Files
roro9stack/site/content/devlog/roro9stack-gnss/index.md
T
twislaandClaude Sonnet 5.5 6e54658ba4
Site / build (pull_request) Successful in 9s
Site: the devlog, the blog's roro9stack posts in the site's style; Devlog replaces Blog in the navigation
Seven posts imported into site/content/devlog/ with their text unchanged,
links between them pointing to /devlog/, and shortcodes (sign, cast, steps,
asides, folded sections, diagrams, captions) restyled in the site's palette.
The 17 inline SVG diagrams carried <style> blocks and style attributes the
site's Content-Security-Policy refuses: their rules moved to
devlog-diagrams.css, and a diagram's minimum width is a class. An Atom feed
at /devlog/atom.xml.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EhqxQ49eCju4CzKYNjZzwT
2026-10-06 20:51:33 +02:00

20 KiB

+++ title = '''Seventeen satellites, and 47 KB down the back of the sofa''' description = '''roro9stack learns where it is: a GNSS receiver read in the background, a sky view of every satellite overhead, the clock set from space, and Tracks recorded to the SD card. Then a memory hunt that found 47 KB the firmware didn't know it was wasting.''' date = 2026-10-04T23:55:00+02:00

[extra] topics = '''ESP32-S3 · GNSS · Memory''' read_label = '''Read where it went →''' uid = '''gnss: Fix 3D, 17 satellites used''' dek = "Milestone two for roro9stack, my firmware for the M5Stack Cardputer: the GNSS receiver on its LoRa cap. It now knows where it is, what time it is without a network, and which of the satellites overhead it's listening to, and it records where it's been. Along the way the documentation got the pins wrong, a test silenced the receiver until I pulled the plug, and the debug tools from the last post turned out to be eating the memory they were supposed to watch." byline = '''designed by interrogation again: 12 questions, and one answer the code from milestone zero knew better'''

[extra.sign] label = "Bugs fixed by turning it off and on again" note = "Only one. It still counts." count = "1" tone = "red"

extra.cast name = "The receiver" role = "ATGM336H-6N, AT6668" text = "On the Cap LoRa-1262, next to the radio: a multi-constellation GNSS receiver with a ceramic antenna, talking NMEA over a UART at 115200 baud. It also listens, to commands nobody documents where I looked."

extra.cast name = "The satellites" role = "GPS, GLONASS, Galileo, BeiDou, QZSS" text = "Up to 23 in view from my desk, and 18 of them in one Fix. Four constellations at once, colour-coded, because a sky view in one colour is just a scatter plot."

extra.cast name = "The heap" role = "about 340 KB, no PSRAM" text = "Shared by Wi-Fi, TLS, the screen buffer, eight tasks and a receiver. The real antagonist of this post."

extra.cast name = "The window" role = "the only antenna upgrade I own" text = "Indoors the receiver saw one satellite. Moved to the windowsill, it saw seventeen." +++

TL;DR

  • The Cap's GNSS receiver is read in the background by a new GNSS Service, through my own NMEA parser (17 tests, fed with lines captured from the device).
  • A GNSS App shows the position (decimal degrees or degrees-minutes-seconds, plus the Maidenhead locator) and a sky view: every satellite by direction and elevation, coloured by constellation.
  • The clock is set from satellites once there's a Fix, Wi-Fi or not. The Status Bar says G17 with a 3D Fix of 17 satellites.
  • Tracks record to GPX on the SD card in the background, and survive a power cut.
  • Off means off: Settings → GNSS puts the receiver in real standby, with commands found by experiment.
  • From cold, a 3D Fix by the window takes 73 s.
  • Then the memory hunt: with IRC on TLS, the lowest free heap was 12.6 KB, against a 40 KB floor. Measured stacks, no more mDNS and rebuilt TLS buffers bring it to 59 KB. Free memory went from 31 KB to 78 KB.
  • Tagged v0.4.0; the code is on my Gitea.

Why GNSS, and why now

A mesh messenger wants to know where its nodes are, and a device with no battery-backed clock wants the time from somewhere other than Wi-Fi. Both come from the same little receiver on the back of the Cap. It's also the smallest of the hardware milestones: no transmitting, no regulations, just listening to satellites, which are famously patient.

The plan also promised a "radar view". In M2 that means the sky: where the satellites are. The other kind of radar, other people's nodes by distance and direction, needs the mesh, so it waits for M4.

The cast

{{ cast() }}

Designed by interrogation, round three

Twelve questions this time, Q58 to Q69, each with a recommendation to accept or overrule. GNSS on by default, with a Settings switch. Two views, switched with Tab. Tracks started by hand, written as GPX, a point every 5 s once you've moved 5 m. Coordinates in decimal degrees with the Maidenhead locator alongside, because once you've owned a radio you never stop wanting to know your grid square. And the position never leaves the device, at least until the mesh exists and we decide how precisely to share it.

One answer didn't survive contact with the code. Q62 said GNSS would set the clock only when NTP hadn't. But the clock model, written back in milestone zero, already ranks its sources by trust, Mesh below NTP below GNSS. A source can only replace the time if it's at least as trusted as the one that set it, which is right: GNSS time comes from atomic clocks in orbit. The plan now admits the code from three days earlier knew better.

Finding the receiver

M5Stack's page for the Cap says the GNSS UART is on GPIO 8 and 9. Meshtastic, which already supports this exact hardware, says 15 and 13. On the Cardputer ADV, GPIO 8 and 9 are the internal I2C bus the keyboard controller sits on, so the page is wrong, and opening a UART there would have unplugged the keyboard in software.

So, measure. A temporary gnss probe command listened on the candidate pins at two baud rates:

gnss probe: rx 15 tx 13 at 115200: 537 bytes, 16 NMEA lines
  $GNRMC,200231.00,V,,,,,,,041026,,,N,V*1A
  $GNGGA,200231.00,,,,,0,00,25.5,,,,,,*48
  $GNGSA,A,1,,,,,,,,,,,,,25.5,25.5,25.5,1*01
  $GPGSV,1,1,01,25,41,108,17,1*58
  $GLGSV,1,1,00,1*78
gnss probe: rx 13 tx 15 at 115200: 0 bytes, 0 NMEA lines

Receive on 15, transmit on 13, 115200 baud. Indoors, one GPS satellite, no Fix. Good enough to start.

The probe had a flaw I didn't see: to be thorough, it also tried the pins the other way round. For a second, the ESP32 drove the receiver's output line. An hour later, the GNSS Service I'd written read zero bytes. I suspected my code first, naturally, then ran the original probe again: zero bytes there too, on code that had worked. Hot start, cold start: nothing. An ESP32 restart doesn't cut power to the Cap, so the receiver had stayed in whatever sulk it was in since the probe.

Fix: unplug USB, switch off, wait ten seconds, switch on. The receiver came back, and the Service, which had been running all along, read 31,618 bytes and 1,008 sentences without a single bad checksum. Have you tried turning it off and on again: one point to the helpdesk. The milestone plan now says it in so many words: never drive GPIO 15.

NMEA, as this receiver speaks it

{{ diagram(src="gnss-flow.svg", min_width=580, caption="The receiver talks; the GNSS Service listens from the main loop, every 50 ms, which a 512-byte UART buffer covers with room to spare at 450 bytes a second. Commands go back the other way for standby and wake.") }}

NMEA is a text protocol from the 1980s: one comma-separated sentence per line, ending in a checksum. The receiver sends a burst once a second: where it is (RMC, GGA), which satellites it uses (GSA) and which it can see (GSV). TinyGPSPlus, the library the first decision record named, handles the position fine but doesn't keep the satellite list across constellations, and the sky view is made of exactly that. So the parser is my own, about 230 lines, written test-first, and it learned three things from this receiver:

  • One GSA per constellation. The receiver sends a GSA sentence for each system, with a system ID in its last field: 1 for GPS, 2 GLONASS, 3 Galileo, 4 BeiDou, 5 QZSS. That ID matters, because satellite numbers aren't unique across systems: GPS 5 and BeiDou 5 are different satellites in different orbits.
  • One satellite, two bands. The AT6668 is multi-frequency, so a GPS satellite can be listed once for its L1 signal and again for L5. The parser keeps each GSV sequence per constellation and band, then merges them, keeping the stronger signal.
  • Time without a Fix. RMC carries a date and time even when its status says V, not valid: the receiver's own clock, of unknown quality. The parser only trusts it with a Fix.

{{ diagram(src="satellites.svg", min_width=580, caption="Satellites are merged by constellation and number across bands, then marked as used from the GSA of their own system.") }}

Off, by experiment

Q58 said Settings → GNSS Off should really switch the receiver off, if it accepted a command for it. It speaks CASIC's $PCAS commands, but the search for its standby command found mostly military aircraft. So: experiment, with a safety net. If $PCAS12,<seconds> meant "standby for that long", a 5-second value would make the receiver come back by itself even if nothing else worked. Bytes received each second:

t= 1.1 s  +216 bytes
t= 2.3 s  +0
t= 3.3 s  +0
t= 4.4 s  +0
t= 5.5 s  +120
t= 6.7 s  +743
t= 7.8 s  +470

Standby, then back on its own after 5 s. A second test showed any command wakes it within a second, and that 65,535 seconds is accepted. So Off sends $PCAS12,65535 (about 18 hours, renewed every hour just in case), and On sends a hot start, $PCAS10,0, which wakes it and keeps everything it knew. Switched back on, it has a Fix again within seconds.

The GNSS App

{{ figure(src="gnss-app.png", alt="Four Cardputer screens at 2x in a grid. Top left: the GNSS App's Position view, reading 3D Fix, 18 of 21 satellites, then Lat 50.86928 degrees N, Lon 4.25353 degrees E, Locator JO20du, Altitude 52 m, Speed 0.1 km/h, HDOP 0.9, Time 21:45:07 UTC, with r: record a Track and Tab: sky at the bottom; the Status Bar shows G18. Top right: the Sky view, a circle with N, E, S and W, rings for 30 and 60 degrees of elevation, and coloured dots for satellites, filled when used, hollow when not; the legend reads GPS 7/10, GLONASS 5/5, Galileo 1/1, BeiDou 4/5, and 3D Fix. Bottom left: the Position view while recording, REC in orange in the Status Bar and REC 1 point, 13 s, r: stop at the bottom. Bottom right: Settings, with GNSS On and Coordinates Decimal near the end of the list", width=976, height=556, landscape=true, full=true, caption=The Position view, the Sky view, a Track recording, and the two new settings. All captured over Wi-Fi with the Debug Console's screenshot command, and yes, that's my desk.) }}

Position is the receiver's state in a form a human can use: the Fix and how many satellites it uses out of how many it sees, then latitude and longitude, the Maidenhead locator, altitude, speed (and heading once you're moving faster than 1 km/h, below which it's noise), HDOP and UTC. HDOP is the receiver's own estimate of how good the satellite geometry is: under 1 is excellent, which is what four constellations at once buy you. The formatting, the locator and the sky projection are host-tested, with the locator checked against known squares (FN31pr for the ARRL station W1AW, JN58td for Munich).

Sky is the radar: the zenith in the middle, the horizon on the circle, north up. Each satellite is a dot in its constellation's colour, filled if the Fix uses it, hollow if the receiver sees it but doesn't (yet). The legend counts used and in view per constellation. It's the most useless and most satisfying screen in the firmware: nothing on it changes what you do, and you'll watch it anyway.

With GNSS off, the App says so and where to turn it back on, and the Status Bar mark disappears.

{{ figure(src="m2-off.png", alt="The GNSS App with GNSS off: GNSS is off, and under it, Settings > GNSS turns it on. The Status Bar has no G mark", width=480, height=270, caption=Off is off: no G in the Status Bar, and the receiver asleep.) }}

The clock, and a first Fix after zero seconds

The clock had been set by NTP since milestone one. Now the GNSS Service sets it with a Fix and refreshes it every 10 minutes, so the Cardputer knows the time in a field with no Wi-Fi, and logs get the right date.

The first time I checked the time to first Fix, the console said:

gnss: first Fix after 0 s
gnss: clock set

Very impressive, and meaningless: the ESP32 had restarted for an update, but the receiver, still powered by the Cap, had kept its Fix the whole time. A real cold start ($PCAS10,2, forget everything) by the window: 73 seconds to a 3D Fix. Normal for a cold start, and it only happens once per power-up.

Tracks

Press r in the GNSS App and a Track starts: GPX on the SD card, a point every 5 seconds once you've moved 5 metres (haversine, tested), so standing still doesn't fill the card. It keeps recording with the App closed, says REC in the Status Bar, and announces start and stop with a Toast. Tracks are treated like Captures: something you start, so they keep writing past the 90 % card usage where Logs pause.

GPX is XML, and XML has a footer. A Track cut short by a dead battery or a crash would lack its closing tags, and every GPX reader would refuse the whole file. So at boot, the GNSS Service looks for unfinished Tracks and closes them. Tested the honest way: start a Track, then restart the device mid-recording.

gnss: Track /gnss/tracks/20261004-204450.gpx started
debug: restarting now
gnss: closed 1 unfinished Track(s)

The file opened as valid GPX 1.1 on the PC, points and all.

The memory hunt

With everything working, the last "done when" item was memory: free heap above 40 KB with GNSS, Wi-Fi, IRC on TLS and the UI all running. In v0.2.1, before any of the update and debug work, the lowest point had been 79 KB. Now:

irc: status 4, unread 0, heap 33092 min 12632

33 KB free, 12.6 KB at the lowest. GNSS was innocent: it costs a 512-byte buffer and a parser. The culprits were the last post's own features: a task for updates, a task and a log ring for the Debug Console, a bigger stack for checking signatures on the SD card, bigger serial buffers, mDNS, two TCP servers. The tools for watching the device had been eating its memory. Every sysadmin has met a monitoring agent like that.

{{ diagram(src="memory.svg", min_width=580, caption="Free heap with IRC connected over TLS, and the lowest point since boot, in a Debug Build. The lowest point finally clears the floor once the TLS buffers shrink.") }}

Stacks, measured. Every task gets a fixed stack when it's created, and most were sized by guessing generously. FreeRTOS records each task's lowest free stack, so I pushed each through its worst case first: an ECDSA-checked install over Wi-Fi and one from the SD card (a tampered file, so the full check runs but nothing restarts), file transfers, a core dump fetch, an IRC TLS handshake. Then I read the marks:

{% table() %}

Task Stack Peak used Now
main loop 8 KB 3.0 KB 6 KB
update 8 KB 3.4 KB 5 KB
storage 10 KB 3.9 KB 6 KB
irc 8 KB 4.0 KB 6 KB
{% end %}

Then the same worst cases again on the smaller stacks: every task kept at least 1.6 KB free. With the log ring and two serial buffers trimmed too, that bought 15 KB, which wasn't enough: the lowest point was still 18 KB.

mDNS, gone. The device announced itself as roro9stack-2fa4.local, a name that never once worked from my dev box, because the VM reaches the Cardputer through a routed network that mDNS doesn't cross. 7.5 KB back, and pushes go to the IP the Firmware page shows, as they always had.

TLS, rebuilt. The big one. Arduino-ESP32 ships its ESP-IDF libraries prebuilt, with one configuration for every board, and that configuration gives each TLS connection a 16 KB receive buffer and a 16 KB send buffer, for life. A TLS record can be 16 KB, so receiving needs it. Sending IRC lines doesn't. Those sizes are compiled into the libraries, so changing them means rebuilding the framework. pioarduino calls that a hybrid compile: list the settings in platformio.ini, and the build regenerates the libraries from ESP-IDF first (ADR 0006):

{% code(caption="platformio.ini: receive stays 16 KB, send drops to 4 KB, and buffers come and go as needed.") %}

custom_sdkconfig =
    CONFIG_MBEDTLS_ASYMMETRIC_CONTENT_LEN=y
    CONFIG_MBEDTLS_SSL_IN_CONTENT_LEN=16384
    CONFIG_MBEDTLS_SSL_OUT_CONTENT_LEN=4096
    CONFIG_MBEDTLS_DYNAMIC_BUFFER=y
    CONFIG_MBEDTLS_DYNAMIC_FREE_CONFIG_DATA=y
    CONFIG_MBEDTLS_DYNAMIC_FREE_CA_CERT=y

{% end %}

The first attempt downloaded ESP-IDF, installed 31 Python packages, configured everything, and died on Missing partition table file /work/default_8MB.csv: in this mode the project must own its partition table. It's now in the repository, checked byte for byte against the one in the device's flash, because moving the app slots under a firmware that updates itself would be a creative way to brick it. The second attempt took 3 minutes 56 seconds, and later builds are back under a minute.

A rebuilt framework is a lot of change underneath, so before trusting it I checked the regenerated configuration still had everything the firmware leans on: rollback, core dumps to flash, the 5-second task watchdog, task statistics, the certificate bundle. All there, plus a surprise: the rebuild follows the board definition, so support for PSRAM the Cardputer doesn't have is off too. Then the whole stress set again. Result: 78 KB free with IRC connected, 59 KB at the lowest, and 46 KB under the heaviest pile-up I could manage all at once.

And IRC can stop now. Testing all this, I noticed IRC could only really be stopped while connected: /quit did nothing while it was retrying or waiting for Wi-Fi, and opening the App reconnected anyway. Now /quit and a new irc stop work in any state, a stopped IRC stays stopped until you type a line, and stopping it gives its 40-odd KB back.

Smaller bruises

  • A UI driven blind. My screenshots come from the frame the UI composes off-screen, and the UI doesn't redraw while the display is off. So a screenshot taken a minute after the last key press shows whatever the screen showed a minute ago, and the first key after that only wakes the screen. For a while I thought my key presses were going to the wrong app. They were going nowhere.
  • The wrong firmware said "confirmed". My test loop waited until the device reported its update confirmed, then measured. Once, the new build had failed to compile, so the old firmware, never replaced, happily said "confirmed", and I took careful screenshots of an App that didn't exist yet. The loop now waits for the version it pushed.
  • One wrong include. The GNSS App's first build failed with dozens of identical errors about an incomplete type. One missing header. Compilers have never learned to say it once.

By the numbers

{% table() %}

Commits from v0.3.0 to v0.4.0 12
Lines added about 1,650
Tests 324, 31 of them new
Release firmware 1.58 MB of 3.3 MB (47 %)
NMEA from the receiver about 450 bytes a second, one Fix a second
Cold start to a 3D Fix, by the window 73 s
Most satellites in one Fix 18 of 21, HDOP 0.9
Free heap with IRC on TLS 31 → 78 KB
Lowest free heap 12.6 → 59 KB
First build with the rebuilt framework 3 min 56 s
{% end %}

Where it stands

{% steps() %}

  1. M0: the skeleton, and M1: Wi-Fi, IRC and Wi-Fi Tools. v0.1.0 to v0.2.1, the first post.

  2. Updates and debugging over the air. v0.3.0, Look, no cables.

  3. M2: GNSS, with Position and Sky views, the clock from satellites, Tracks, and the memory to run it all with IRC. v0.4.0, this post.

  4. Next, M3: the LoRa radio on the same Cap, with a scanner, plus notes and a file browser. The first time anything on this device transmits, so the Region setting from milestone zero finally gets to say no to something.

  5. Then M4 and M5, the mesh, and with it the other radar: nodes by distance and direction. {% end %}

{% signoff() %} The Cardputer knows where it is, what time it is, and which satellites are listening, and it has its memory back. Next, it learns to talk. {% end %}