Commit Graph
100 Commits
Author SHA1 Message Date
twislaandClaude Sonnet 5.5 148611ff4c Updates: the Debug Build message fits on one line of the screen
"A Debug Build keeps its console: update it from your PC" ran off the
edge of the release page. It now reads "Debug Build: update from the PC".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 17:01:04 +02:00
twislaandClaude Sonnet 5.5 325e7755ad R1 plan: the CI timings, measured from the jobs' own start and end times
The first full run took 11.4 minutes, not 17: that was the waiting time.
A pull request takes about 8.7, a release build alone 5.5, a push to main
1.1.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 17:01:04 +02:00
twisla df2aaddbc8 Merge pull request 'Updates from Gitea: check, list and install releases from the device (#6)' (#55) from gitea-updates into main
CI / build (push) Successful in 14m4s
Reviewed-on: #55
2026-10-06 14:29:01 +00:00
twislaandClaude Sonnet 5.5 e2f00e93c2 CI: a branch's pushes run nothing, its pull request runs once
CI / build (pull_request) Successful in 8m43s
A branch with an open pull request ran twice per push: once for the push,
once for the pull request. Pushes to main still run the tests and publish
the badges; every other branch is tested by its pull request.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 16:17:52 +02:00
twislaandClaude Sonnet 5.5 d490a18b9a Updates from Gitea, step 5: the daily check's announcement, the docs, ADR 0009
The announcement was cut at the notification's 48 bytes; it now reads
"v0.11.0 is out: see Settings > Firmware". README: Updates from Gitea
and its limits. ADR 0009: the device trusts the two ISRG roots. R1.md:
what was built and the checks on the device, with what wasn't checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 16:16:32 +02:00
twislaandClaude Sonnet 5.5 4ef41347ab Updates from Gitea, step 4: the Firmware page shows the project's releases
Latest release (checked on Enter, or c), Older releases (the last ten,
newest first), a release page with the tag's message, and an install
dialog; going back to an older release asks differently. A failed check
someone asked for shows a Toast. A Debug Build shows the latest release
and says it can't install it: it would take its console away.

Tried on the device: a check, the list, the release page, the dialog
(Cancel is the default; Back cancels), and the install from the screen
with its progress screen, then the restart into the release build and its
confirmation.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 16:06:49 +02:00
twislaandClaude Sonnet 5.5 510ce42a99 Updates from Gitea, step 3: install from Gitea, and IRC steps aside for it
A release downloads straight into the inactive slot through the existing
install path: the signature is checked after 160 bytes, before anything
is written, the hash at the end. Tried on the device against the real
server: a download cut short, a flipped byte in the signature and one in
the image are each refused with the running firmware untouched; the real
v0.10.0 installed, restarted, and confirmed itself on Probation.

A TLS connection to Gitea peaks at about 52 KB of heap whether or not the
certificate is verified. With IRC connected (66 KB free) a check left 3 KB
and a download 836 bytes. A check, list or install a person asks for now
makes IRC step aside (holdForUpdate) and come back after: the lowest free
heap during a full download with IRC connected is 38 KB. The daily check
never interrupts IRC; with IRC up it waits. A TLS connection starts with
80 KB free (it was 55).

Debug Builds get test knobs: update probe <host>, update damage cut|flip,
update pretend <version>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 15:51:10 +02:00
twislaandClaude Sonnet 5.5 5754ae5b57 Updates from Gitea, step 2: the connection (check and list work on the device)
The roots (ISRG X1 and X2), an HTTPS client that reads the answer as a
stream, GiteaReleases (the latest and a list of ten), the Update
Service's requests, install from Gitea through the existing install path,
the daily check's schedule, the Check for updates setting, and console
commands: update check | list | status | install <tag>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 15:20:58 +02:00
twislaandClaude Sonnet 5.5 b0e8226943 Updates from Gitea, step 1: the model (host-tested)
A streaming JSON scanner (a 33 KB list of releases costs a few hundred
bytes), the release reader built on it, HTTP response heads and chunked
bodies, URLs, and the decisions: which release is an update, whether to
announce it, and which download URLs the device takes. Tested against the
real answers of git.twis.la.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 15:11:58 +02:00
twislaandClaude Sonnet 5.5 6b7e90765f R1 plan: updates from Gitea, the design round (Q162-Q174)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 15:08:33 +02:00
twisla 3120c63b4b Merge pull request 'CI: coverage and badges; tests on a push, builds on a pull request' (#51) from coverage into main
CI / build (push) Successful in 1m7s
Reviewed-on: #51
2026-10-06 12:39:25 +00:00
twislaandClaude Opus 5.5 873af31e21 R1 plan: pull requests, merged by rebase then a merge commit (Q161)
CI / build (push) Successful in 1m6s
CI / build (pull_request) Successful in 8m17s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 14:37:44 +02:00
twislaandClaude Opus 5.5 16345ed6b3 CI: the badges are published from main only
CI / build (push) Successful in 1m5s
CI / build (pull_request) Successful in 8m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 14:23:08 +02:00
twislaandClaude Opus 5.5 86ddb87f54 CI: a push runs the tests, a pull request also builds the firmware
CI / build (push) Successful in 1m6s
Rebuilding both firmwares on every push was more than anyone looked at.
A push now runs the host tests with their coverage (under two minutes);
a pull request adds the release firmware and the Debug Build, and is how
changes reach main; a tag still does everything before it releases.
Pull requests from forks don't run. scripts/ci.sh takes 'tests' or
'builds' for one half; coverage.sh now fails when a test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 14:19:56 +02:00
twislaandClaude Opus 5.5 5ecd5d003f Coverage of lib/ by the host tests, and badges in the README
CI / build (push) Successful in 9m9s
scripts/coverage.sh builds the host tests with coverage counters and
reports with gcovr: 94.6% of the 3,193 lines of lib/ today (lib/SD and
src/ have no host tests and aren't counted). CI runs it on every push,
puts the figure in the job's summary, and on main publishes a coverage
badge and a latest-release badge to the branch 'badges'. The README shows
them next to Gitea's own badge for the workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 13:56:08 +02:00
twislaandClaude Opus 5.5 edc140e30c Merge branch 'ci': CI on every push, a signed release on every tag
CI / build (push) Successful in 8m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 13:51:19 +02:00
twislaandClaude Opus 5.5 6459ca5446 R1 plan: CI and releases are in place
CI / build (push) Successful in 8m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 13:51:18 +02:00
twislaandClaude Opus 5.5 a94c14f000 R1 plan: CI as built on the container runner
CI / build (push) Successful in 8m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:52:43 +02:00
twislaandClaude Opus 5.5 db7e6ccc18 CI: jobs run in a container, PlatformIO directly in it, the toolchains in a volume
CI / build (push) Successful in 8m5s
The runner now gives each job a container. The workflow asks for
python:3.12-slim, installs git, a compiler and PlatformIO, and mounts the
roro9stack-pio volume as the cache; the scripts skip their own docker run
when RORO_NO_DOCKER says they're in the build container already. A tag
from before the framework was rebuilt gets the stock framework libraries
back before it builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:39:37 +02:00
twislaandClaude Opus 5.5 ababfaf993 Release build: tags from before Firmware Updates carry no public key to check against
CI / build (push) Failing after 12m51s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:25:20 +02:00
twislaandClaude Opus 5.5 6b6bb975f5 R1 plan and ADR 0008: CI signs releases; README section on CI
CI / build (push) Successful in 8m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:19:00 +02:00
twislaandClaude Opus 5.5 1fade6287b CI: tests and builds on every push, a signed release on a tag (#5)
CI / build (push) Successful in 11m26s
The workflow runs on the runner's host and builds in the project's Docker
image through scripts/ci.sh, as on a developer's machine. A tag v*, or a
run by hand for an older tag, builds that tag's sources, signs the Update
File with the key held in the repository's secrets, checks the signature
against the public key in the sources, and publishes a Gitea release with
the .ota, the factory image, the ELF and checksums.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 12:06:57 +02:00
twislaandClaude Opus 5.5 661227cd2d CI: try the runner's label as it is registered
CI / probe (push) Successful in 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 11:58:53 +02:00
twislaandClaude Opus 5.5 c481bb5191 CI: a first workflow, to see what the runner gives a job (#5)
CI / probe (push) Successful in 19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 11:56:10 +02:00
twislaandClaude Opus 5.5 9f65c75f01 Merge branch 'notes': the Notes App
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 10:11:31 +02:00
twislaandClaude Opus 5.5 a35d82c654 F1 plan: Notes ships as v0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 10:11:31 +02:00
twislaandClaude Opus 5.5 e8a654a15f Notes: plain text notes on the SD card, with an editor that saves by itself (#19)
The Notes App lists the files of /notes by their first line, newest first:
n starts a note, Enter opens it, r renames its file, d deletes it after
asking, s sorts by name. A new note's file is named after its first line.

The editor wraps at spaces, 38 columns by 8 rows; Fn+arrows move through
the wrapped text, Ctrl+A and Ctrl+E go to the ends of the line. There is no
save key: the note is written five seconds after the last key, on Back, on
leaving the App, when the screen turns off and before the device powers
off. A save writes a temporary file and puts it in the note's place; a save
cut short is put back, or offered, the next time.

A note is up to 16 KB, held in one buffer reserved when it's opened: the
file is read straight into it and typing never makes it grow. A failed
allocation aborts on this device, and with IRC connected the largest free
block is about 31 KB: a first version that copied the note once on loading
restarted the device when a full note was opened with IRC connected.
Editing files of any size is #47.

The Storage App's text viewer gets `e`, which edits a text file up to 16 KB
with the same editor unless the file is read-only.

439 host tests. Checked on the device: docs/milestones/F1.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 10:08:10 +02:00
twislaandClaude Opus 5.5 c868977f1c A key sent through the Debug Console could turn the screen "off" for a tick
The `key` command stamps the power timer from millis(); the power tick
then compared with its pass's older time, and the unsigned difference read
as 49 days without a key. The screen state went Off for one tick, and the
next key was swallowed as a wake-up: about one remote key in twenty-five.
Keys from the keyboard pass the loop's own time and were never affected.
The same shape as #46. PowerPolicy::update now treats a stamp from the
future as "just now", with a test.

rdbg.py: piped lines written while it was still connecting stayed in
Python's read buffer until the next line arrived (readline() behind
select()). It reads the descriptor directly now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 10:08:10 +02:00
twislaandClaude Opus 5.5 4ab873e9f8 F1 plan: the Notes design round (Q141-Q150)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 09:29:38 +02:00
twislaandClaude Opus 5.5 50fcf6b7a3 Merge branch 'f1': the Storage App
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 09:03:50 +02:00
twislaandClaude Opus 5.5 80d68ccfd7 F1 plan: the Storage App ships as v0.9.0; the SNTP panic is #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 09:03:49 +02:00
twislaandClaude Opus 5.5 e14eb304b5 Wi-Fi: SNTP could be started twice at a join, and ESP-IDF asserts on that
At every join the DNS and NTP setup ran twice in the same pass: the
"check now and then" timer compared this pass's time with a stamp taken
from millis() a moment later, and the unsigned difference underflowed.
Starting SNTP is only queued for the network task, so when the second run
looked before the first had been carried out, it queued a second start:
"Operating mode must not be set while SNTP client is running", a panic
nine seconds after boot. Rare (once in the dozen or so boots of this
branch's testing), there since v0.7.0. Rollback caught it: the update
was on Probation and the device went back to the build before.

The service now remembers that it started SNTP instead of asking
esp_sntp_enabled(), and the timer compares signed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 08:52:54 +02:00
twislaandClaude Opus 5.5 b7aed8e91c F1 steps 2-6: the Storage App, its viewers, and Maintenance moved in (#3)
The Storage App browses the SD card: folders first with sizes and dates,
three sorts, one item at a time with a clipboard (c, x, v), rename, delete
after counting what's inside, new folder, details. A listing holds 256
entries and says when a folder has more.

FileOps does the card's work for the App and the console alike, one
operation at a time on the storage task in turns of about 150 ms, so Logs
and Captures are still written during a long copy. A copy shows progress,
can be cancelled (what it wrote is taken back) and compares sizes after.
The read-only rules are checked there: the firmware's top-level folders,
/gemini/cache, and files being written (a Track, a Capture, an upload,
today's IRC Logs). A listing reads the folder straight from FatFs: through
the Arduino File, 329 entries took over two seconds.

Viewers by type: text read a screen at a time whatever the file's size
(logs open at the end), a hex dump, a Capture's packets as the LoRa Scanner
lists them, a Track's summary, and an Update File checked as an install
would check it, without writing anything. Tab shows any file as hex or text.

Settings > Storage is gone: usage, Storage Clean-up and Erase are the App's
Maintenance, behind a warning. The Storage Warning points there.

The Clock sets the system time whatever its source, so files are dated
correctly with a GNSS Fix alone (Q137).

Console: cp, mv, mkdir, du, cancel; rm takes folders and follows the rules;
ls shows dates; Debug Builds get `sd fill`.

424 host tests. Checked on the device: docs/milestones/F1.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 08:45:43 +02:00
twislaandClaude Opus 5.5 7ab8f043d0 F1 step 1: names, read-only rules and the packed listing (host-tested)
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
2026-10-06 07:37:36 +02:00
twislaandClaude Opus 5.5 63bae576ef F1 plan: the Storage App's design round (Q128-Q140)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 07:34:43 +02:00
twislaandClaude Opus 5.5 77ace09c64 Merge branch 's1': the main loop rests, and GNSS can pause for the radio
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 06:42:33 +02:00
twislaandClaude Opus 5.5 c4465675a0 S1 plan: the resting loop and the GNSS pause ship in v0.8.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 06:42:33 +02:00
twislaandClaude Opus 5.5 70fb37ebb5 The radio's noise: the GNSS receiver costs 8 dB; a setting pauses it (#20)
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
2026-10-06 03:41:15 +02:00
twislaandClaude Opus 5.5 06a593293d The main loop rests between passes (#40)
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
2026-10-06 03:08:02 +02:00
twislaandClaude Opus 5.5 9078ab9c39 Merge branch 's1': the System App, and traffic counted per service
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 01:32:01 +02:00
twislaandClaude Opus 5.5 06aa3fe28b S1 plan: the System App ships in v0.8.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 01:32:01 +02:00
twislaandClaude Opus 5.5 e36aa50922 S1 #11: the System App: tasks, memory, network and system, live
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
2026-10-06 01:28:26 +02:00
twislaandClaude Opus 5.5 70bb2a4137 S1 #11: bytes read and written, counted per network service
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
2026-10-06 01:10:55 +02:00
twislaandClaude Opus 5.5 1df94b684a S1 #11: the System Monitor's design round (Q117-Q127)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 01:04:41 +02:00
twislaandClaude Opus 5.5 00f69e8d53 S1 plan: fixed IPv4 shipped in v0.7.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 00:55:32 +02:00
twislaandClaude Opus 5.5 f834095ee3 Merge branch 's1': fixed IPv4 addresses, DNS and NTP servers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 00:48:41 +02:00
twislaandClaude Opus 5.5 acf7697bdc S1 #7 step 4: fixed IPv4, DNS and NTP in Settings
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
2026-10-06 00:04:53 +02:00
twislaandClaude Opus 5.5 3e279738b6 S1 #7 step 3: the Wi-Fi Service applies IP, DNS and NTP settings
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
2026-10-05 23:52:22 +02:00
twislaandClaude Opus 5.5 bdd027cb50 S1 #7 steps 1-2: IPv4 checks, the IP setting per Saved Network, DNS and NTP settings
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
2026-10-05 23:39:07 +02:00
twislaandClaude Opus 5.5 7e8882d9cb Merge branch 's1': the SD driver's ready test, and sd card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 23:25:55 +02:00
twislaandClaude Opus 5.5 7b2cf88a0a ADR 0007: link the upstream report and the issue that follows it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 23:16:17 +02:00
twislaandClaude Opus 5.5 370f067fbf sd card: what the card says it is, from its CID register
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
2026-10-05 23:15:29 +02:00
twislaandClaude Opus 5.5 3f2650c56e SD driver: a dummy byte before the ready test; say why a write failed (#21)
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
2026-10-05 22:55:02 +02:00
twislaandClaude Opus 5.5 3ee7ae1097 lib/SD: Arduino-ESP32 3.3.12's SD library, as it comes
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
2026-10-05 22:51:59 +02:00
twislaandClaude Opus 5.5 60cec80fa4 Merge branch 'm3': the LoRa radio, receive only, and the LoRa Scanner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 21:40:43 +02:00
twislaandClaude Opus 5.5 4744163683 M3 done: the listening hour heard nothing; a reference node is needed for M4
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
2026-10-05 21:40:30 +02:00
twislaandClaude Opus 5.5 b1de0f8804 M3 step 5: Sweep, the band's signal strength as bars and a waterfall
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
2026-10-05 20:28:46 +02:00
twislaandClaude Opus 5.5 2fce79aebd M3 step 4: the LoRa Scanner App, Sniffer and Captures
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
2026-10-05 20:14:58 +02:00
twislaandClaude Opus 5.5 594748e99a M3 step 3: the Radio Service, receive only
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
2026-10-05 19:49:46 +02:00
twislaandClaude Opus 5.5 30a827070b Gemini: count the App's per-line tables in the page budget
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
2026-10-05 19:19:52 +02:00
twislaandClaude Opus 5.5 a0e3868934 Debug Console put: verify the card's copy, never zero-fill after a failed write
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
2026-10-05 19:13:25 +02:00
twislaandClaude Opus 5.5 3242475699 M3 step 2: Meshtastic header and presets, pcap with LoRaTap (host-tested)
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
2026-10-05 18:59:23 +02:00
twislaandClaude Opus 5.5 fed065a6f9 M3 step 1: RadioLib and lora probe
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
2026-10-05 18:52:38 +02:00
twislaandClaude Opus 5.5 6485f277b5 M3 plan: radio bring-up, receive only (Q89-Q104)
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
2026-10-05 18:44:25 +02:00
twislaandClaude Opus 5.5 0a2f46b428 Merge branch 'g1': a Gemini client, with Saved Pages for offline reading
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 01:49:13 +02:00
twislaandClaude Opus 5.5 dafbdaddf6 G1 done: Saved Pages read offline, checked by hand
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 01:49:12 +02:00
twislaandClaude Opus 5.5 fe886e2c1d G1: accept a ~12 KB heap dip during a fetch with IRC connected (Q86 revised)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 01:47:59 +02:00
twislaandClaude Opus 5.5 9ca9a16de8 Gemini: read big pages from the card as you scroll (Q88)
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
2026-10-05 01:44:01 +02:00
twislaandClaude Opus 5.5 fd306d5013 G1 steps 5-6: input prompts, bookmarks, downloads, Saved Pages
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
2026-10-05 01:24:29 +02:00
twislaandClaude Opus 5.5 10f3ff7a4c G1 step 4: the Gemini App (rendering, links, history, address line)
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
2026-10-05 01:07:23 +02:00
twislaandClaude Opus 5.5 7b8d9391a4 G1 step 3: the Gemini fetcher (TOFU, redirects, floors, pages via the card)
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
2026-10-05 00:58:01 +02:00
twislaandClaude Opus 5.5 babb1d114e G1 step 2: Gemini parsers (host-tested), and the memory decision (Q86)
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
2026-10-05 00:29:55 +02:00
twislaandClaude Opus 5.5 2d384f5cff G1 step 1: gemini get, and two TLS connections measured
`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
2026-10-05 00:27:05 +02:00
twislaandClaude Opus 5.5 dc9b0e14ce G1 design: Gemini client plan, glossary (Capsule, Saved Page)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-05 00:18:01 +02:00
twislaandClaude Opus 5.5 dc4b49755a Merge branch 'm2': GNSS, and the memory back
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-04 23:42:41 +02:00
twislaandClaude Opus 5.5 fff28af961 Rebuild the framework with smaller TLS buffers (ADR 0006)
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
2026-10-04 23:32:29 +02:00
twislaandClaude Opus 5.5 4e439410cd Stop IRC by hand; remove mDNS
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
2026-10-04 23:17:48 +02:00
twislaandClaude Opus 5.5 db265fb757 Trim task stacks and buffers by measurement: +15 KB with IRC up
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
2026-10-04 23:02:35 +02:00
twislaandClaude Opus 5.5 578a39d3c1 M2: record cold-start time to first fix and the heap measurement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-04 22:52:07 +02:00
twislaandClaude Opus 5.5 d7952612dd M2 step 6: Tracks, recorded to GPX in the background
`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
2026-10-04 22:46:57 +02:00
twislaandClaude Opus 5.5 463ed2dda8 M2 step 5: GNSS App, with the Position and Sky views
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
2026-10-04 22:38:06 +02:00
twislaandClaude Opus 5.5 d408bada07 M2 step 4: GNSS mark in the Status Bar; the clock follows the Fix
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
2026-10-04 22:29:20 +02:00
twislaandClaude Opus 5.5 988253ef02 M2 step 3: GNSS Service, with standby and the clock from a Fix
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
2026-10-04 22:26:53 +02:00
twislaandClaude Opus 5.5 c9b1ecb772 M2 step 2: NMEA parser (host-tested)
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
2026-10-04 22:05:01 +02:00
twislaandClaude Opus 5.5 f812c62a3d M2 step 1: gnss probe finds the receiver on RX 15 / TX 13 at 115200
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
2026-10-04 22:05:01 +02:00
twislaandClaude Opus 5.5 9c30d47982 M2 design: GNSS plan, glossary (GNSS Service, Fix, Track), ADR 0001 note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-04 22:00:09 +02:00
twislaandClaude Opus 5.5 634a20c339 key command: del and tab
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
2026-10-04 21:27:40 +02:00
twislaandClaude Opus 5.5 2c63e9fd6e Merge branch 'ota': Firmware Updates, Debug Builds, Safe Mode
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-04 18:14:26 +02:00
twislaandClaude Opus 5.5 388e847cd4 Debug Console: files, screenshots and Update from SD over Wi-Fi
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
2026-10-04 03:18:23 +02:00
twislaandClaude Opus 5.5 14ff13f634 Safe Mode, crash reports, and a watched main loop
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
2026-10-04 03:07:56 +02:00
twislaandClaude Opus 5.5 0fb7f4e9d5 Debug Builds: the console over Wi-Fi (Debug Console, TCP 2323)
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
2026-10-04 02:44:14 +02:00
twislaandClaude Opus 5.5 5b199c2436 sd put: copy a file to the SD card over USB serial
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
2026-10-04 02:31:19 +02:00
twislaandClaude Opus 5.5 8ec4e9e449 ADR 0003: the bootloader does roll back; Arduino had validated the image
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
2026-10-04 02:15:38 +02:00
twislaandClaude Opus 5.5 7afe6b7d17 OTA: keep new images on Probation; Arduino validated them before setup()
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
2026-10-04 02:12:45 +02:00
twislaandClaude Opus 5.5 45542dcbff OTA: the firmware rolls itself back; the bootloader doesn't
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
2026-10-03 21:48:39 +02:00
twislaandClaude Opus 5.5 de5931210f OTA: clean refusal report in ota_push.py; shorter signature error
The device refuses at the header and hangs up mid-transfer; the push
client now says so instead of crashing on the reset. The error fits a
Toast (47 characters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-03 21:43:03 +02:00
twislaandClaude Opus 5.5 08b59eb203 OTA: answer once the announced image size has arrived
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
2026-10-03 21:39:35 +02:00
twislaandClaude Opus 5.5 fa62a07993 README: Firmware Updates over Wi-Fi and from the SD card
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-03 21:37:29 +02:00
twislaandClaude Opus 5.5 21b3d9e422 OTA steps 3-5: Update Service, Probation and Rollback, Update from SD
- 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
2026-10-03 21:35:33 +02:00
twislaandClaude Opus 5.5 90cb6ef9b2 OTA step 2: signing key, Update File builder, push client
- 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
2026-10-03 21:31:39 +02:00