lib/files: path parts; names checked for rename and new folder; why
something can't be renamed, moved or deleted (the Gemini cache, a file
being written or a folder holding one, the firmware's top-level
folders), and why it can't go into a folder; the viewer for a file by
its name, with a sniff for text. FileList keeps a folder's entries
packed, 256 at most, the first 256 by name whatever order the card lists
them in, and sorts by name, date or size with folders first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Debug Builds: `lora noise test` changes one thing at a time, Sweeps the
band, and reports the floor under each condition; it runs on the device
by itself, since one condition pauses Wi-Fi (not saved, so a restart
brings it back). Result, at 125 kHz: -117 dBm with the antenna switched
off, -106 with the GNSS receiver in standby, -98 with it running. The
receiver's serial line isn't it (one sentence a second changes nothing),
and neither are the main loop, the CPU frequency, Wi-Fi, the screen or
the radio's own regulator, all within 1 dB.
Settings > "Pause GNSS for LoRa", off by default: the receiver waits in
standby while the radio listens or sweeps, except during a Track, and
has a Fix again about 7 s after. The GNSS App says it's paused.
11 dB remain between the antenna with GNSS quiet and the chip alone,
untouched by anything that can be switched from the firmware.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
It made 50,000 passes a second and kept core 1 100 % busy at rest. Keys
are buffered by the keyboard controller, the consoles and the radio have
their own tasks, and no Service ticks more often than every 50 ms, so
the loop now rests 5 ms after a pass with the screen on and 20 ms with
it off; never during a serial file transfer. Safe Mode's loop too.
Screen off: 50 passes a second and core 1 at 1 %; screen on: 167 and
10 %. The chip settles 4 C cooler (34.3 against 38.3). GNSS, Gemini, an
upload, the Sweep and the radio's interrupt all checked at the new pace.
`tasks` shows the loop's passes; Debug Builds: `loop spin on|off`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Five views, Tab between them: Overview (each core's load, memory,
traffic, battery, two minutes of load), Tasks (share of a core over the
last second, lowest free stack, flagged under 512 bytes; `s` sorts),
Memory (free heap against the floors of Q86), Network (bytes per
service, and what's moving now), System (what `info` prints, plus
battery, card, radio, GNSS). It samples once a second and keeps history
only while open.
The arithmetic is host-tested, including the trap found on the device: a
task's run-time counter only moves when it's switched out, so the task
that samples (the main loop, alone on its core) gets what's left of its
core. `tasks` now samples across a second of normal running instead of
inside its own wait. The main loop uses 100 % of core 1 at rest (#40).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
A Counted<> wrapper around the network clients adds what goes through
their buffer read and write to a per-service counter (IRC, Gemini, Debug
Console, Updates); the single-byte calls and print() end up there, so
each byte counts once. `net` prints the totals. For TLS it's the plain
text the service sees.
Checked on the device: a Gemini fetch counts 164,986 in (a 164,970-byte
page and its 16-byte header) and 42 out (the URL and CRLF).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Enter on a Saved Network opens its page instead of asking to forget it:
"IP address" switches between Automatic and Fixed, with an address, a
prefix and an optional gateway. Fixed starts from what the network is
giving the device; the draft is checked and applied on leaving the page,
so a half-typed address is never used. "DNS and NTP" holds the two DNS
servers, "Always use my DNS" and the two NTP servers. Enter on Status
shows the connection's details and where each value came from. Address
fields take digits and dots only; refusals show as Toasts.
Checked on the device through the screens: Fixed 10.39.39.13 applied and
reverted to Automatic, a prefix of 99 refused. Measurements in
docs/milestones/S1.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Joining a Saved Network uses its Fixed address, mask and gateway, or
DHCP. DNS comes from Settings on Fixed networks and when "Always use my
DNS" is on; NTP servers come from Settings, after any that DHCP offered.
Both are re-checked every 30 s, since a DHCP renewal puts DHCP's DNS back
and clears the NTP slots it didn't fill. `wifi status` shows what's in
use, where it came from, and which NTP servers answered; `wifi ip`,
`wifi dns`, `wifi ntp`. Debug Builds: `wifi ip ... try <s>` reverts
unless kept.
On knbg-guests (10.39.39.0/24, gateway .1): Fixed .12 and .13 both reach
the internet through 9.9.9.9; a wrong gateway on trial cut the device off
and came back by itself; back to DHCP; both NTP servers answer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The plan and decisions Q105 to Q116 (docs/milestones/S1.md). lib/net:
strict IPv4 parsing, prefix and mask, and the checks a Fixed setting must
pass, each refusal with its reason. A Saved Network is Automatic or Fixed
(address/prefix and an optional gateway), kept with it in flash. Settings:
two DNS servers (9.9.9.9, 1.1.1.1), "Always use my DNS", two NTP servers
(pool.ntp.org, time.cloudflare.com). Host-tested: 383 tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Type, size, and the identity register read by our SD driver (CMD10):
manufacturer, OEM, product name, revision, serial and date, decoded by a
host-tested parser. Needed for the upstream report of #21: nothing else
here could read the card's identity. This one is a Samsung 8 GB SDHC
from June 2013.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The card "refused" a write about once in 2,000 multi-block writes: three
1.7 MB uploads in ten. Measured with a driver that records where it gives
up: every time, all blocks were accepted, and the status check after Stop
Tran came back as 0xFF or 0x1F. The driver tests for ready with the first
byte after selecting the card, which reads 0xFF before the card has
signalled busy, so CMD13 went out mid-programming. A dummy byte first, as
in ChaN's reference driver, and one after Stop Tran.
30 uploads in a row since, each read back by SHA-256, ten with the radio
listening: no fault. 10 MHz made no difference; the card stays at 20 MHz.
`info` shows the driver's write faults; `put` prints the step and the
card's answer when one happens. ADR 0007.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
A project library named SD takes the framework's place at link time.
Unchanged here (Apache-2.0), so that the next commit shows exactly what
roro9stack changes in it, and a later framework update can be compared.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Outside, on battery, an hour on LongFast with a Capture running: 0
packets, 0 headers. The noise is no lower than at the desk and its peaks
follow the device, so about 15 dB of the floor is the Cardputer's own
(issue #20). With IRC on TLS and the radio listening, 52.6 KB free. One
"Done when" item is half met: Sweep was never tried against a known
transmitter. The card's refused writes are issue #21.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Tab in the LoRa Scanner sweeps 863-870 MHz in 100 kHz steps (the
strongest of three RSSI readings at each, at 125 kHz), shown as bars with
peak hold over a waterfall, the Sniffer's frequency marked (Q98). The
Sweep pauses the Sniffer and keeps its packets; Tab resumes it (Q99).
Status Bar: SW. The floor, top and peaks are host-tested; `lora sweep
on|off|dump` prints them on the console.
At the desk: about 607 ms a pass, a flat floor at -100 to -102 dBm
(15 dB above the chip's own) and a steady carrier at 863.2 MHz.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The Sniffer lists packets newest first (time, RSSI, SNR, and for
Meshtastic the sender, receiver and hops), with the clear header and a
hex dump on Enter (Q96); `p` picks an EU868 preset, kept in Settings
(Q95). `c` starts a Capture: pcap with LoRaTap in /captures/lora, its own
Clean-up category, recorded by a small Service so it carries on with the
App closed (Q97, Q100). The Status Bar shows L while listening, bright on
each packet, and CAP while capturing (Q101). StorageService gains raw
appends for binary files. Debug Builds get `lora inject` to test all of
this with no transmitter in range: a Capture made on the device reads
back in TShark field for field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
One task owns the SX1262 and does all its SPI behind the card's bus lock;
the main loop posts requests and DIO1 only wakes the task. It listens
while a client asks (App, Capture, Console) and sleeps otherwise, with a
ring of the last 32 packets (9.8 KB, freed when idle). No transmit path.
The Cap's antenna switch (expander P0) is set on the main loop, which
owns the I2C bus. `lora probe` now runs on the radio task and checks the
DIO1 interrupt with a receive timeout; `lora status`, `lora rx on|off`,
`lora preset`, and `lora custom` for other LoRa settings.
Measured: DIO1 works (timeout after 105 ms); four 1.7 MB uploads and
Gemini pages to the card while listening, no radio or card errors; task
stack peak 2.0 KB. Twenty minutes on LongFast and LoRaWAN: no packets,
and a noise floor of -83 to -94 dBm at the desk.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Found in M3 while checking floors with the radio on: a windowed page left
1.5 to 3 KB less than the 40 KB steady floor (Q86), radio or not. The
budget counted TextBuffer's index (8 B/line) but not the App's tables,
which also grew by doubling. Now 16 B/line, and the App sizes them
exactly. The FAQ (1051 lines, windowed): 38.5 -> 39.6 KB after, radio
asleep; 37.1 -> 39.0 KB with the radio listening.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Found by M3's shared-bus test: when the card refused a write, the retry
closed the file (losing up to 3 KB of earlier chunks still in the write
buffer), then truncate() extended it back with zeros. The checksum only
covered the received bytes, so `put` reported success with 3 KB of zeros
on the card. Now a retry gives up if the card lost data, and the finished
file is read back and must hash the same before it's renamed.
The card refuses a write about once in five 1.7 MB uploads, with the
radio asleep as often as listening.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The 16-byte clear header (hops away, channel hash, relay node), the EU_868
presets and their frequency slots, and the channel hash, all checked
against Meshtastic's source. Captures are pcap with LoRaTap v0, read back
with TShark 4.2.5; packet RSSI is plain dBm, as Wireshark reads it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The probe finds the SX1262 (TCXO 1.8 V works) and measures the antenna
path: the Cap's PI4IOE5V6408 at 0x43 must drive P0 high, or the receiver
is deaf (-111.9 dBm flat vs -87 to -94 dBm with P0 high). DIO2 makes no
difference to reception. Receive only; the radio is left asleep.
RadioLib 7.8.1 + probe: +23.6 KB flash, +656 B static RAM (release).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The Radio Service owns the SX1262 and shares the SPI bus with the card;
the LoRa Scanner shows packets (Sniffer) and the band (Sweep). Nothing in
M3 can transmit. Notes and the File Browser move to issues #19 and #3.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
A page larger than memory allows is now windowed instead of cut: one
pass over its file (cache or Saved Page) counts lines, indexes every
64th (offset, and the preformatted state there in bit 31: about 1 KB
for a 1 MB page) and loads the first window. Scrolling near either end
reads the next or previous window on the Gemini task; the line on top
of the screen stays put, the scrollbar follows the whole page, and Tab
at a window's edge pages on instead of wrapping. Window budgets count
the memory the old window gives back.
Display pages alternate between two cache files so the one on screen
is never overwritten by the next fetch; jobs use a third.
On the device, Cosmos with IRC connected: 226 of 419 lines at first,
then lines 192-419, back to 64 and 0 while scrolling. `key space` added
to the console's key command.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Everything touching the network or the card is a job on the Gemini
task (a missing card can block 5 s, past the main loop's watchdog):
about:start is composed from /gemini/bookmarks.gmi and the Saved Pages
(by capsule, newest first); file:// opens a Saved Page; s, S, r, d, b
and downloads report one line to the URL bar, S with progress Toasts.
A Saved Page is the cached body with a first line "> Saved from <url>
on <date>": it shows as a quote and gives relative links their base;
inside one, links to other Saved Pages open the saved copy, others go
online or say "Not saved, and offline". Input prompts (11 masked)
request the same URL with the answer as its query.
Fixed on the way: re-wrapping indented lines rebuilt the text from its
rows, inserting a space where a long word had been cut; refreshing
loaded the whole Saved Page next to a TLS connection (heap down to 436
bytes), now it reads one line and every fetch checks the 55 KB floor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Gemtext as Q75 has it: headings bold (# in the accent colour), lists
with a middle dot, quotes muted, links as » labels, preformatted lines
unwrapped and scrolled sideways together; other text/* as is. Pages are
wrapped once for each line's first row (4 bytes a line) and only the
lines on screen are wrapped again to draw. Tab and Shift+Tab move
between links, Enter follows (relative links resolved), Back or Delete
go back to where the page was scrolled, g opens the address line.
Non-Gemini links say so in the URL bar; a changed certificate opens a
dialog; errors and refusals get a page with a "Try again" link; a page
only partly in memory says why at its end.
A status message timed with millis() after the loop's clock read was
cleared before it was drawn (unsigned wrap): now a signed comparison,
as for the toasts in M0.
Verified on the device: start page, Project Gemini, its relative news/
link, Back with the scroll restored, and the YouTube link refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
GeminiService fetches on a short-lived task and hands the App a
GeminiPage: header, the body as lines in 4 KB chunks (TextBuffer: no
large block, no doubling copies), the final URL after up to 5
redirects. Certificates are pinned on first use per host and port; a
change comes back as its own outcome with both fingerprints. No fetch
starts below 55 KB free (Q86).
With a card, the body streams to /gemini/cache/page.gmi in 1 KB pieces
while the connection is open, then loads into RAM once its memory is
back (Q87); StorageService::runAndWait (moved from the Debug Console)
keeps every card access on the storage task. Without a card: RAM, with
the steady and transient floors.
Measured with IRC connected: Cosmos (31.6 KB) went from 4.6 KB to the
whole page on the card and 20 KB on screen; lowest free heap 19.5 KB
in transfer, 43 KB once loaded. `gemini get` and `gemini trust` on the
console. 14 Gemini tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
lib/gemini: URLs split per RFC 3986 appendix B and resolved per section
5.2 (all 32 reference examples of 5.4 pass, with gemini:// for http://),
the request form (no fragment, lower-case host, never an empty path),
query encoding for input prompts, Saved Page paths; the response header
(status, category, MIME type and parameters, 1024-byte meta limit);
gemtext's line types; and display text for the Latin-1 fonts. 12 tests.
Q86, decided after step 1: free heap stays above 40 KB in steady state
and 20 KB during a handshake; a fetch won't start below 55 KB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
`gemini get <url>` fetches on a short-lived task (a handshake would trip
the main loop's watchdog; an idle client should cost no stack) and
reports the header, size, certificate fingerprint and heap. First page:
geminiprotocol.net, 20 text/gemini, 1,184 bytes in 0.7-1.1 s.
With IRC connected over TLS, a fetch dips to about 24 KB free during its
handshake, about 36-40 KB after it; nothing leaks. Antenna is down, so
the default aggregator becomes Cosmos.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
custom_sdkconfig makes pioarduino regenerate the ESP-IDF libraries:
asymmetric TLS buffers (16 KB in, 4 KB out) and dynamic buffers that
free handshake-only data once connected. The rebuild also follows the
board: no PSRAM, 8 MB flash. The project now carries the partition
table the hybrid build needs (identical to the framework's: the app
slots must not move under updates over the air). Generated files are
ignored.
Debug Build, GNSS on, IRC on TLS: 78 KB free and a 59 KB low (M2 began
at 31 KB and 12.6 KB; the floor is 40 KB). The full stress set passes
on it: refused installs over Wi-Fi and from SD, put/get, screenshot,
core dump decode, Probation; under all of it at once, the low is 46 KB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
IrcService::disconnect() stops the session from any state: QUIT if
connected, then no more retries. /quit goes through it (before, it only
stopped a connected session; while waiting for Wi-Fi or retrying it did
nothing), and so does the new `irc stop` command. Stopped by hand,
opening the IRC App no longer reconnects; typing a line does.
mDNS is gone: it never crossed the dev box's routed network, and it
cost about 7.5 KB of RAM. Pushes go to the IP shown in Settings ->
Firmware, which drops its Name row. OTA Q54 records the change.
On the device, Debug Build: 105.6 KB free with Wi-Fi (was 98); with IRC
on TLS 54 KB free (was 46); after `irc stop`, back to 99 KB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Peak stack use was measured through each task's worst case (an
ECDSA-checked install over Wi-Fi and from SD, get/put, a core dump
fetch, an IRC TLS handshake); stacks are now peak plus about 2 KB: loop
8 -> 6 KB, update 8 -> 5, storage 10 -> 6, irc 8 -> 6. The Debug Build's
console ring goes 6 -> 4 KB, serial TX 2 -> 1 KB, the GNSS UART buffer
1 KB -> 512 B.
On the device, with IRC on TLS: 46 KB free (was 31), an 18 KB low (was
9.4). The re-run of every worst case left at least 1.6 KB of stack free
in each task.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
`r` in the GNSS App (or `gnss track start|stop`) records a Track to
/gnss/tracks/YYYYMMDD-HHMMSS.gpx: a point every 5 s once moved 5 m
(lib/gnss/track, 7 tests: haversine, the rule, GPX text, the file name).
It keeps recording with the App closed, shows REC in the Status Bar, and
announces start and stop with a Toast. It needs a card and the time;
switching GNSS off stops it. Tracks are written like Captures: past 90 %
card usage, until full. Storage Clean-up gets a GNSS tracks category.
A Track cut short by a reset or power loss has no GPX footer; the GNSS
Service closes such files at the next boot.
Verified on the device: a recorded Track and one interrupted by a reset
both parse as GPX 1.1 on the PC.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Position: the Fix line (or how long it has been searching), latitude and
longitude in decimal degrees or degrees-minutes-seconds (Settings ->
Coordinates), the Maidenhead locator, altitude, speed and course, HDOP
and UTC. Sky: the satellites by azimuth and elevation, coloured by
constellation, filled when used in the Fix, with used/in-view counts.
Tab switches. lib/gnss/geo_format holds the formatting, the locator and
the sky projection, host-tested (6 tests; locators checked against
FN31pr and JN58td).
Verified on the device by a window: 3D Fix from GPS, GLONASS, Galileo
and BeiDou.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Q61: a muted G while searching (or the receiver is silent), G with a 2D
Fix, G and the satellite count with a 3D Fix; nothing when GNSS is off.
Verified on the device by a window: 3D Fix, 9 of 11 satellites used
(GPS, GLONASS, BeiDou), HDOP 1.3, Status Bar "G9", and "gnss: clock set".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The GNSS Service reads the receiver (UART 15/13, 115200, 1 KB buffer)
from its 50 ms tick and feeds the NMEA parser. With a Fix it sets the
clock (GNSS is the most trusted TimeSource) and refreshes it every 10
min. Settings gets GNSS On/Off and the coordinate format (Q64).
Off puts the receiver in standby with $PCAS12,65535, renewed hourly; On
wakes it with a hot start, $PCAS10,0. Both measured on the device: the
output stops within a second, and a command wakes it within a second.
Commands: gnss status (Fix, satellites per constellation, bytes and
sentences), gnss nmea on|off, gnss send <sentence>, gnss restart. The
probe is gone; never drive GPIO 15 (the receiver's output): the first
probe's swapped-pin attempt silenced it until a power cycle.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
lib/gnss: RMC, GGA, GSA and GSV into one GnssState: Fix type, position,
altitude, speed, course, HDOP, UTC time (trusted only with a Fix) and the
satellites in view across constellations. GSV sequences are kept per
constellation and signal band and merged per satellite with the stronger
SNR; GSA's system ID marks which satellites are used. 16 tests, using
lines captured from the device.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
A temporary `gnss probe` command listens on both pin orders at 115200
and 9600. Only RX 15 / TX 13 at 115200 carries NMEA, as Meshtastic's
board file says; M5Stack's GPIO 8/9 are the keyboard's I2C bus. The
output format (NMEA 4.10, GSA system IDs, GSV per signal) is recorded in
docs/milestones/M2.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The serial and Debug Console `key` command could type characters but not
erase them, so text typed into a field by mistake couldn't be cleared
remotely. `key del` and `key tab` inject Delete and Tab.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
New commands everywhere: ls, rm and install <path> (Update from SD
without the Firmware page), all as Storage Service jobs. In Debug
Builds the console task answers get and put (one storage job per
transfer, file kept open, TCP flow control: about 300 KB/s, against
55 KB/s over serial) and screenshot (the RGB332 frame the UI composes
into). rdbg.py turns those into files and PNGs.
A failed put closes the connection: the rest of the file had been
parsed as commands. Card writes are retried 3 times after closing,
truncating to the last good byte and reopening, since FATFS keeps a
file in error after one failed write (seen once at 1.3 MB on this card).
onStorage() decides with one compare-and-swap whether the job or the
timeout wins, so an abandoned job can't touch a returned stack frame.
Verified on the device: screenshot; a get round trip byte-identical;
4 puts in a row; a tampered .ota refused by install; a good one put,
installed from SD, confirmed on Probation. The retry path itself has
not fired since it was added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Every build now records at boot which version runs and, after a crash
restart, which one crashed (even across a Rollback). The core dump
summary (task, PC, reason, backtrace) is printed and raised as a
Notification; `crash` shows it later. After 3 crash restarts in a row
the firmware starts in Safe Mode: clock, Wi-Fi, Update Service and Debug
Console only (SafeMode, 2 host tests). A normal restart or a minute up
resets the count.
The main loop is now on the task watchdog (enableLoopWDT): Arduino only
watched core 0's idle task, so a stuck loop hung the device for good.
The Update Service restarts into an installed update by itself if the
main loop hasn't after 90 s.
Debug Builds: `coredump get` and `reset` are answered by the console's
own task; rdbg.py crash decodes the backtrace and rdbg.py coredump runs
esp-coredump, against ELFs archived by version and digest in .pio/elves.
The StorageService mutex is now made in the constructor: Safe Mode never
starts that Service, and `info` crashed on the null mutex, 29 times in a
row before the fix was pushed into Safe Mode over Wi-Fi.
Verified on the device: crash report and full core dump decoded over
Wi-Fi; Safe Mode at exactly 3 crashes, left by `reboot`; a hung loop
caught by the watchdog in 5 s; `reset` from the console task. ADR 0005.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
cardputer-adv-debug (-DRORO_DEBUG, version +debug) adds a Debug Console:
after a token line, a client gets the last 6 KB of console output, live
lines (ESP-IDF logs included) and the serial commands. The socket task
only queues lines; the main loop runs them. Release builds compile none
of it. The token lives in ~/.config/roro9stack/debug-token, created by
_docker.sh and passed into the container.
All output now goes through `console`, which never waits for USB: a host
that was attached but not reading stalled the main loop up to 2 s per
line. New commands everywhere: info (slots with their versions from NVS,
since the framework stamps its own into each image), tasks, reboot,
boot other, log level, help. scripts/rdbg.py is the client; flash.sh
--debug builds it; CI builds both variants. ADR 0004.
Verified on the device: USB-flashed, then updated over Wi-Fi to a Debug
Build that confirmed on Probation; both slots hold Debug Builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
scripts/sd_put.sh <file> [card path] sends a file (by default into
/updates, for Update from SD) without taking the card out. The serial
driver drops bytes once its receive buffer is full, so the transfer is
stop-and-wait: 1 KB chunks, each acknowledged once the Storage Service
has written it, into a 2 KB receive buffer. The device checks the
SHA-256 before renaming <path>.part into place, and gives up after 5 s
of silence or a card job that never returns. FileReceiver holds the
logic, with 12 host tests. About 55 KB/s: 1.6 MB in under 30 s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Verified on the device: a crashing update pushed over Wi-Fi died once,
and the next boot was the previous firmware, with the "Update ... failed"
Toast. bootGuard() stays as a second line.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Arduino-ESP32's initArduino() marks a PENDING_VERIFY image valid unless
the sketch overrides the weak verifyRollbackLater(). Every update was
therefore VALID before bootGuard() or Probation ever ran (otadata read
back state 0x2 on a crash-looping test build), and nothing rolled back.
The bootloader was never the problem. Override it to return true, so
Probation decides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
A deliberately crashing update looped forever on the device: the
prebuilt bootloader ignores ESP_OTA_IMG_PENDING_VERIFY despite the
app-side rollback config. UpdateService::bootGuard() now runs first in
setup(): it counts starts on Probation in NVS and, on the second
unconfirmed start, marks the image invalid and reboots into the
previous one. Confirming (or the Wi-Fi rollback) resets the counter.
ADR 0003 records the limit: a crash in the first milliseconds still
needs USB.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The Arduino network client treats a half-closed connection as closed,
so the device's reply after the sender's EOF was lost. The header
already carries the image size: UpdateParser::complete() lets the
device finish and answer while the connection is open. ota_push.py
half-closes only if no answer comes within 3 s, for older firmware.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- EcdsaVerifier (mbedTLS, embedded public key) and EspOtaSink (writes
the inactive app slot, esp_ota_end validates the image, then sets
the boot partition)
- UpdateService: listens on TCP 3232 (and mDNS roro9stack-<id>) while
Wi-Fi is Connected; streams into UpdateParser; replies OK/ERR to the
sender; remembers the pending version so a Rollback is reported
after the reboot
- Probation (host-tested): confirm after the first frame + 30 s + Wi-Fi
(if configured); roll back if configured Wi-Fi never connects in 3 min
- Main loop: full-screen progress while receiving; restart once
installed, waiting up to 60 s for Text Entry to end
- Settings > Firmware: version, Probation status, push address and
name, and the .ota files in /updates on the SD card to install
- StorageService.runJob() runs work on the storage task (SD installs)
- wifi status prints IP and running version; RORO_TEST_CRASH test hook
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- scripts/ota_keygen.sh: ECDSA P-256 key pair; the private key goes to
~/.config/roro9stack/ (0600), the public key to keys/ and
src/platform/ota_public_key.h; .gitignore refuses *key.pem
- scripts/make_ota.py: wraps firmware.bin into a signed .ota (openssl)
- scripts/ota_push.py: sends it over TCP 3232, prints the device's answer
- scripts/flash.sh --ota <host>: build, sign, push
Checked: a generated .ota has the documented layout and its signature
verifies with openssl against the committed public key.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
lib/ota: a 160-byte header (magic, format, image size and SHA-256,
version, ECDSA signature over the first 80 bytes) then the image.
UpdateParser checks the header and signature before writing anything,
hashes the image as it streams into an UpdateSink, and only finishes
the sink when the hash matches. Downgrades are flagged, not refused.
Includes a dependency-free SHA-256 and semver comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
IRC: join after NickServ login, keyed auto-join, SASL->NickServ
fallback, nick regain; Alt scrolls and Up/Down recall sent lines; /j.
Wi-Fi Tools: sort and filter the network list, and scan logging to CSV.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- lib/wifi (host-tested): scan_log (CSV field quoting, header/row, a
once-per-interval throttle) and network_list_view (sort by signal /
channel / name, filter open-only / hide-hidden / strong-only)
- ScanEntry moved to lib/wifi so both are testable on the PC
- Wi-Fi Tools networks view: s sort, o/h/w filters, l toggles logging
to /wifi/scans/<date>.csv (header + one row per AP per logged scan,
throttled 30 s, needs the clock for timestamps); foreground-only
- Wi-Fi scan logs replace probe-request logs as a clean-up category
- Serial: cat <path>, and key <char> injects a character
Verified on the device: CSV written with header, timestamps, BSSID,
channel, RSSI, security and SSID; hidden networks marked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- Alt + ; / Alt + . scroll the Buffer back and forward
- Up / Down (Fn + ; / Fn + .) browse the last 30 sent lines, shell-style,
returning to the draft past the newest (InputHistory, host-tested)
- /j is short for /join
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- With NickServ, auto-join waits for the logged-in reply (900) or 2 s
at most, so registered-only IRC channels let us in
- A failed SASL login falls back to NickServ (its own password, or the
SASL account and password)
- IDENTIFY names the account explicitly, and once logged in on a
fallback nick, REGAIN takes ours back from a stale session
- Auto-join entries take keys ("#private key, #public"); keys from
/join are reused when rejoining; keyed channels go first in JOIN
- Serial irc dump: whole Buffers, and which login is configured
(never the secrets)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The IRC App edited the live config while the IRC task could be reading
it to connect. The App now edits a copy; applyConfig() validates it and
swaps it in under the lock, and the task connects from a snapshot.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- lib/wifi (host-tested): channel occupancy (neighbour spill, signal
weighting, quietest of 1/6/11) and a signal tracker (history, lost
detection, click interval)
- WifiService: scan results carry BSSID, channel and security; a scan
can target one channel for quick tracker refreshes; endListScans()
turns the radio back off when Wi-Fi is disabled
- Wi-Fi Tools App: networks nearby, channel occupancy bars, signal
tracker with clicks (m to mute)
- M1 plan: Monitoring-mode views and captures deferred
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- Chat: header (Buffer n/N, unread elsewhere, topic or connection
state), wrapped hh:mm <nick> lines (own in accent, Mentions green,
info grey), input line; Tab cycles Buffers, Fn+Up/Down scrolls back,
Enter sends, Back leaves while the IRC Service keeps running
- /settings: server form (host, port, TLS, self-signed pinning, nick,
SASL, NickServ, auto-join); Save & reconnect restarts the session
- Opening the App starts the IRC Service; viewing a Buffer clears its
unread count; IrcSession gains a revision counter for redraws
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- IrcService: networking on its own task, TLS with the built-in CA
bundle (or trust-on-first-use pinning when self-signed is allowed),
plain TCP when TLS is off; connects only while Wi-Fi is Connected,
marks pauses for Monitoring, reconnects with backoff, pings a quiet
server, writes Logs, raises Notifications for Mentions
- Session: forget /quit once disconnected (it was handled every loop);
the server Buffer never counts as unread (MOTD showed as [4])
- Status Bar: unread count
- Frame buffer 16 -> 8-bit colour (M1 Q46): min free heap with IRC on
TLS went from 51 KB to 79 KB
- Serial: irc start / say / dump
Verified on the device against irc.libera.chat:6697: certificate
checked, joined #roro9stack-test, sent a message, quit cleanly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
lib/irc: IrcMessage parse/serialize, base64, ReconnectPolicy
(5 s .. 5 min), IrcConfig (validated, persisted) and IrcSession:
registration with SASL PLAIN or NickServ, nick fallback, PING, Buffers
capped at 50 lines with unread counts, Mentions, CTCP ACTION/VERSION,
topics, /names on request only, others' join/part/quit hidden, user
commands, and rejoining after a pause. No networking: the session
returns lines to send, Log entries and Notifications.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- lib/storage_model (host-tested): FAT-safe names, daily Log paths,
dates from Log/Capture file names, CleanupPlan by category and age,
byte formatting
- StorageService does all card I/O on its task: queued Log lines are
written in batches each second (dropped while Logs are paused),
plus file listing and deletion jobs
- Settings > Storage moves into StoragePage: usage, Clean up
(category, age with size preview, confirmation), Erase SD card
- Clock: local date for Log names
- Serial: log <text>, sd list
Verified on the device: lines land in /irc/dev/#test/2026-10-02.log
with folders created as needed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- lib/wifi (host-tested): SavedNetworks (up to 8, validated, hidden
flag, persisted) and WifiController (joins the strongest Saved
Network, tries hidden ones in turn, backoff 10/30/60 s, connect
timeout, Monitoring override, radio off without Saved Networks)
- WifiService carries out the controller's actions, sets the EU
country code, and syncs the Clock over SNTP without touching the TZ
- Wi-Fi On/Off setting; Settings > Wi-Fi page: status, add from a scan
or a hidden network, forget
- Status Bar: W + signal bars, W? while searching, MON while monitoring
- Serial: wifi add / wifi status (replaces the step 1 heap probe)
Verified on the device: joins the test network ~5 s after boot, clock
set over NTP, ~129 KB free heap while connected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The deep+ dependency finder broke the framework's WiFi -> Network
include on the device build; only the native test env needs it.
The probe serial command (probe <ssid>TAB<password>) measures heap
with Wi-Fi connected and one TLS connection; credentials never touch
the repo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- lib/apps_model (host-tested): SettingsMenu (rows, readable values,
choices, validation messages) and SetupWizard (names, Region
confirmation, timezone; saves only when finished)
- AppManager: modal Apps that Home/Back can't leave (setup wizard)
- SettingsApp: all settings plus Storage (usage, erase SD behind a
dialog) and About (version, node id, battery, memory, uptime) pages,
replacing the temporary Diagnostics App
- SetupApp: first-boot wizard, opened modally until SetupDone
- Node id and default names derived from the MAC like Meshtastic
- Widget demo is now hidden (About, then w)
- lib_ldf_mode = deep+ so libraries see each other's headers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
The firmware accepts burst, key <name>, sound on|off and short|normal
over serial, so UI behaviour can be reproduced on the device without
the keyboard. scripts/serial_log.sh records (and drives) it in a named
container that flash.sh removes before uploading.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
An Off screen turns on dimmed for the Toast's duration so the user can
see what beeped. It isn't user activity: the timeouts aren't restarted,
and a key pressed meanwhile reaches the App.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- ToastQueue compared times unsigned: a Toast stamped a few ms after the
main loop read the clock looked expired, so Toasts were skipped or
shown late and bursts chained wrongly
- Notifier now beeps and flashes when each Toast appears instead of when
the Notification arrives, so a burst alerts once per Toast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
Repartitions the whole card (f_fdisk + f_mkfs) on the storage task.
Temporarily triggered from the diagnostics screen by typing FORMAT and
Enter; moves to Settings > Storage in step 7.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
lib/services holds the host-tested logic:
- Settings: typed, validated, persisted, SettingChanged events,
transmit gated on a confirmed Region
- BatteryEstimator: LiPo curve, median smoothing against TX sags
- ClockModel: source priority GNSS > NTP > mesh, relative ages,
Europe/Brussels local time via POSIX TZ
- StorageMonitor: warning once per boot at 80%, Logs stop at 90%,
Captures stop with < 2 MiB left
- PowerPolicy / PowerButton: dim/off timeouts, wake key swallowed only
when the screen was off, G0 long press
src/services wires them to the hardware (NVS, battery ADC, SD on the
shared SPI bus with the LoRa CS held high, backlight, deep sleep), and
main.cpp is a temporary diagnostics screen for the hardware checks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
lib/core is hardware-independent and unit-tested on the host:
- EventBus: fixed-size thread-safe queue, delivered on the UI task by
dispatch(); drops newest when full and counts drops
- Service / ServiceManager: cooperative ticking at per-service intervals,
no catch-up bursts, safe across millis() wraparound
- App / AppManager: one foreground App, Home always to Launcher, Back
offered to the App first, hidden Apps, redraw requests
- KeyEvent: logical keys for the upcoming input layer
Firmware main loop now ticks services and dispatches events.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
- pioarduino platform 55.03.312 (Arduino-ESP32 3.3.x / ESP-IDF 5.5),
board m5stack-stamps3, M5Cardputer 1.1.1
- native env with Unity for host-side tests
- scripts/ci.sh and scripts/flash.sh run PlatformIO in a container
- version injected from git describe
- boot stub shows name, version and pressed keys
- GPL-3.0 license
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT