+++
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](/devlog/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](/devlog/roro9stack-ota/) 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](https://git.twis.la/twisla/roro9stack/src/tag/v0.4.0).
## 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](https://git.twis.la/twisla/roro9stack/src/tag/v0.4.0/lib/gnss/src/nmea_parser.cpp) 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](https://git.twis.la/twisla/roro9stack/src/tag/v0.4.0/lib/gnss/src/nmea_parser.cpp#L193-L233), 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,` 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](https://git.twis.la/twisla/roro9stack/src/tag/v0.4.0/src/services/gnss_service.cpp#L42-L58) 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](https://git.twis.la/twisla/roro9stack/src/tag/v0.4.0/src/services/gnss_service.cpp#L139) 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](/devlog/roro9stack-ota/)'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](https://git.twis.la/twisla/roro9stack/src/tag/v0.4.0/docs/adr/0006-framework-rebuilt-for-smaller-tls-buffers.md)):
{% code(caption="[platformio.ini](https://git.twis.la/twisla/roro9stack/src/tag/v0.4.0/platformio.ini#L23-L32): receive stays 16 KB, send drops to 4 KB, and buffers come and go as needed.") %}
```ini
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](/devlog/roro9stack/).
2. ~~Updates and debugging over the air.~~ v0.3.0, [Look, no cables](/devlog/roro9stack-ota/).
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 %}