The u-blox SAM-M10Q in flight: settings, seeding and ground plane
Getting a GNSS fix on the pad: the SAM-M10Q, assisted
A rocket flight computer has a specific and unforgiving GNSS problem. The board is powered up in a field, often somewhere it has never been before, frequently with a metal rail and a body tube between it and half the sky, and it needs a position before the rocket leaves. A cold receiver with no idea where or when it is can take a quarter of an hour to work that out from scratch. Nobody stands on a launch rail for fifteen minutes.
This is what assisted GNSS is for, and Jupiter does the whole of it: it configures a u-blox SAM-M10Q for flight, fetches current satellite orbits over the cellular link from altimetercloud.com, injects them into the receiver, and seeds the receiver with time and, when it can be trusted, position. This article is the whole process, including the parts that took several attempts to get right.
Before any firmware: the ground plane
Everything below this point assumes the receiver can hear the satellites. That assumption is a hardware one, it is easy to get wrong, and no amount of configuration or assistance recovers from it, so it belongs first.
The SAM-M10Q is a patch antenna module: a 15 by 15 mm patch sitting on top of the receiver. A patch antenna does not radiate on its own. It radiates against the ground plane underneath it, which means the copper on your PCB is not a convenience for routing, it is half the antenna.
u-blox are specific about the optimum. The best radiation pattern comes from a ground plane of about 50 by 50 mm, and below roughly 40 by 40 mm they note the performance decreasing significantly. Nothing should sit within 10 mm of any edge of the module, it wants to be in the middle of that plane rather than at a corner, and no signal traces should run underneath it at all. If a signal has to change layers near the module, keep it at least 20 mm away when it does. They also ask for as much separation as possible between the GNSS and any cellular antenna.
Now the important qualification, because those figures are an optimum and not a threshold. What reduces on a smaller plane is antenna gain and radiation efficiency. That costs signal-to-noise margin; it does not stop the thing working. This is a gradient, not a cliff, and there is no size below which a receiver refuses to fix.
Jupiter has roughly 22 by 35 mm of ground plane to give it. That is well under the recommendation in both dimensions, and it performs well. The airframe sets board width in this hobby and there is no arguing with it, so a rocketry design is going to be under the recommended figure and the question is what you do about it.
What actually causes trouble is rarely falling short of 50 by 50 mm. It is the things that cost nothing to get right and are simply missed: no ground pour at all under and around the module, a pour broken into islands, high-speed signals routed straight through it, or a plane no larger than the module's own 15 by 15 mm footprint, which gives the patch essentially nothing to work against. Those are design decisions rather than physical constraints, and any of them will do more damage than being 20 mm short on a dimension.
So the working rule is: take the recommendation as the direction to push in rather than a bar to clear. Maximise the plane in whatever the airframe allows, keep it continuous and stitched rather than fragmented, keep the module centred across the width you have, keep signals off it and out from under it, and treat the space around it as reserved rather than as somewhere convenient to put a component.
There is a corollary worth stating plainly. The smaller the plane, the less margin there is for anything else to be wrong. On a generous 50 by 50 mm plane a design can absorb a bit of sloppiness elsewhere. At 22 by 35 mm it cannot, so the rules that are free to follow matter more on a small board, not less.
Then there is the noise. The module has a SAW filter and an LNA in front of the receiver, which gives it genuinely good immunity to out-of-band interference including a nearby cellular modem, and it will report jamming and spoofing attempts to the host rather than silently degrading. That is real protection and it is not unlimited. A flight computer puts a cellular transmitter, a fast microcontroller and switching regulators within a few centimetres of an antenna trying to recover a signal that arrived from 20,000 km up at roughly the noise floor. The asymmetry there is worth sitting with: your interference is centimetres away, the signal is not.
The reason this matters to an article about assistance is the failure mode. Bad GNSS hardware does not fail cleanly. It gets a fix on the bench, next to a window, in ten seconds, and then takes four minutes in a field. It tracks nine satellites where it should track fourteen, which still gives a fix, just a worse one that degrades faster when the sky closes in. Occasionally, on a day where the constellation geometry is poor, it does not fix at all, and there is nothing in the logs to distinguish that from bad luck.
Assistance removes the orbit download from the cold start. It cannot recover a signal that never arrived. If the ground plane is wrong, everything below makes the receiver faster at the part that was not the problem.
One related detail, since it comes up further down: the module's backup supply keeps its battery-backed RAM alive, and that RAM is what holds the configuration and the last known state between power cycles. Lose it and every boot is a full cold start with a factory-default receiver. It is one capacitor or one rail, and it is worth getting right.
Bringing the module up
The SAM-M10Q comes out of reset at 9600 baud talking NMEA. At 10 Hz with the sentence set we want, 9600 is not close to enough, so the first job is to move it to 230400.
The awkward part is that the M10 keeps its configuration in battery-backed RAM. On a board that has been powered before, the module may already be at 230400; on a cold one it will be at 9600; and after a backup-supply outage it will be back at 9600 again. Probing for the current rate means writing code that can be wrong, so we do not probe. The baud switch is sent twice, once at 9600 and once at 230400, and the module acts on whichever arrives cleanly. The result is deterministically 230400 from any starting state, and the command that lands on the wrong rate is simply noise the module discards.
Every UBX message is the same shape: two sync bytes, class, id, a little-endian length, the payload, and an 8-bit Fletcher checksum over everything from the class byte onwards. One helper covers the lot.
// Send one UBX frame. The checksum covers class, id, length and payload,
// but NOT the two sync bytes. Getting that span wrong is the classic
// first-day mistake: the module simply ignores you and says nothing.
void ubxSend(HardwareSerial& ser, uint8_t cls, uint8_t id,
const uint8_t* payload, uint16_t len) {
uint8_t head[6] = { 0xB5, 0x62, cls, id,
(uint8_t)(len & 0xFF), (uint8_t)(len >> 8) };
uint8_t ckA = 0, ckB = 0;
for (int i = 2; i < 6; i++) { ckA += head[i]; ckB += ckA; }
for (uint16_t i = 0; i < len; i++) { ckA += payload[i]; ckB += ckA; }
ser.write(head, 6);
ser.write(payload, len);
ser.write(ckA);
ser.write(ckB);
ser.flush();
}
A CFG-VALSET payload is a version byte, a layer mask, two reserved bytes, then one or more key and value pairs. Layer 0x03 means RAM and battery-backed RAM, so the setting survives a reset but not a backup supply outage. The baud switch is then simply sent twice, at both plausible rates.
// CFG-VALSET (0x06 0x8A): version, layers=RAM|BBR, reserved x2,
// then key 0x40520001 (CFG-UART1-BAUDRATE, U4) = 230400.
static const uint8_t baudPayload[] = {
0x00, 0x03, 0x00, 0x00,
0x01, 0x00, 0x52, 0x40, // key, little-endian
0x00, 0x84, 0x03, 0x00 // value, little-endian: 230400
};
// The module keeps its baud in battery-backed RAM, so on boot it is at
// 9600 or at 230400 and we cannot know which. Rather than probe, send the
// switch at both rates. Whichever arrives cleanly is acted on; the other
// is noise the module discards. Deterministic from any starting state.
GPSSerial.end();
GPSSerial.begin(9600, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
delay(50); while (GPSSerial.available()) GPSSerial.read();
ubxSend(GPSSerial, 0x06, 0x8A, baudPayload, sizeof(baudPayload));
delay(50);
GPSSerial.end();
GPSSerial.begin(230400, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
delay(50); while (GPSSerial.available()) GPSSerial.read();
ubxSend(GPSSerial, 0x06, 0x8A, baudPayload, sizeof(baudPayload));
delay(50);
while (GPSSerial.available()) GPSSerial.read();
What not to configure
The single most useful thing learned about the M10 was how much of it to leave alone.
The factory default on the SAM-M10Q already enables exactly the constellation set we want: GPS L1C/A, Galileo E1, BeiDou B1C and GLONASS L1, plus QZSS and SBAS. Sending a CFG-SIGNAL payload to set what is already set is not free. It triggers a GNSS subsystem restart, and it NAKs the whole batch if any single key in it is unrecognised by that firmware build. So the configuration sends no signal configuration at all.
The same applies to the CFG-HW clock keys in the 0x40A4 range. Those are written through UBX-CFG-OTP into one-time programmable memory rather than CFG-VALSET, and are effectively read-only at runtime. The SAM-M10Q reaches 10 Hz without touching them, so there is no OTP burn in this design and no way to brick a module by getting one wrong.
There is a general lesson in that which applies well beyond u-blox: a configuration write you did not need is not neutral. It costs a restart, it can fail as a batch, and it is another thing that can be wrong on a board you cannot reach.
The settings that actually matter
Four writes, and one of them matters far more than the others.
Each write waits for an acknowledgement, and the wait has to be a small state machine rather than a look for the next four bytes. At 10 Hz the module is emitting NAV-PVT and NMEA continuously, so the ACK arrives somewhere inside that traffic and the reader has to walk past whole frames it does not care about without losing sync.
// Wait for UBX-ACK-ACK (05 01) or UBX-ACK-NAK (05 00).
// Steps over any other UBX frame arriving in the meantime, which at 10 Hz
// is most of them.
bool ubxWaitAck(HardwareSerial& ser, unsigned long timeoutMs) {
unsigned long t = millis();
uint8_t state = 0;
while (millis() - t < timeoutMs) {
if (!ser.available()) { yield(); continue; }
uint8_t b = ser.read();
switch (state) {
case 0: if (b == 0xB5) state = 1; break;
case 1: state = (b == 0x62) ? 2 : (b == 0xB5 ? 1 : 0); break;
case 2: state = (b == 0x05) ? 3 : 0; break; // ACK class
case 3:
if (b == 0x01) return true; // ACK-ACK
else if (b == 0x00) return false; // ACK-NAK
else state = 0;
break;
}
}
return false; // timeout: could not verify, which is not the same as failed
}
bool ubxValSet(const uint8_t* payload, size_t len, unsigned long timeoutMs) {
while (GPSSerial.available()) GPSSerial.read(); // clean channel first
ubxSend(GPSSerial, 0x06, 0x8A, payload, len);
return ubxWaitAck(GPSSerial, timeoutMs);
}
The four writes themselves are then short. Note the second one sets two keys in a single message, which is both faster and atomic: the pair is accepted or rejected together.
// Dynamic model: airborne <4g. The one that matters.
static const uint8_t dynModel[] = {
0x00, 0x03, 0x00, 0x00,
0x21, 0x00, 0x11, 0x20, 0x08 // CFG-NAVSPG-DYNMODEL = 8
};
ubxValSet(dynModel, sizeof(dynModel), 2000);
// 10 Hz navigation: two keys, one message.
static const uint8_t rate[] = {
0x00, 0x03, 0x00, 0x00,
0x01, 0x00, 0x21, 0x30, 0x64, 0x00, // CFG-RATE-MEAS = 100 ms
0x02, 0x00, 0x21, 0x30, 0x01, 0x00 // CFG-RATE-NAV = 1
};
ubxValSet(rate, sizeof(rate), 3000);
// Stream NAV-PVT on UART1, one per navigation solution.
static const uint8_t navpvt[] = {
0x00, 0x03, 0x00, 0x00,
0x07, 0x00, 0x91, 0x20, 0x01 // CFG-MSGOUT-UBX_NAV_PVT_UART1 = 1
};
ubxValSet(navpvt, sizeof(navpvt), 2000);
NMEA output on UART1 is disabled before this batch and re-enabled afterwards. That is not cosmetic: with NMEA streaming at 10 Hz, the ACK for each configuration write arrives buried in sentence traffic, and a clean channel makes the difference between reliably reading an ACK and guessing.
Verifying, once
After configuration the firmware reads the key values back with CFG-VALGET and logs any mismatch: navigation rate, talker ID, baud, NMEA output state, and that the GPS and Galileo signals really are enabled. Read-backs use a short 500 ms timeout, because a failure to verify is not the same as a failure to set, and it should not hold up the boot.
The verification result is then cached in NVS and skipped on subsequent boots. Configuration lives in battery-backed RAM, so if it verified once and the backup supply has not dropped, it is still correct; re-verifying every boot spends time on the pad proving something already known. The flag is cleared whenever the GPS loses power, which is exactly when the assumption stops holding.
Where the orbit data comes from
Assisted GNSS works because the slow part of a cold start is not finding the satellites, it is learning where they are. A receiver with no almanac has to decode the broadcast ephemeris off the satellite signal itself, which arrives at 50 bits per second and takes around 30 seconds per satellite under good conditions, longer under a canopy or beside a rail. Hand the receiver current orbits and that entire stage disappears.
The obvious route is u-blox's own AssistNow service, which does exactly this job and does it well. We build our own instead, and the reasons are worth setting out because they are specific to this application rather than a complaint about theirs.
The first is the modem. AssistNow is delivered over HTTPS, and the cellular module in Jupiter has a TLS stack that is not something to lean on for a fetch that has to succeed while somebody is standing on a launch rail. That alone means the request cannot go directly from the device to u-blox: it has to be proxied through a server that can do the TLS properly, and at that point altimetercloud.com is already in the path and already holding the data.
The second is that once you are running a server-side step anyway, the question changes. It is no longer "use a service or build one", it is "proxy somebody else's API, or convert data that is already public". The ephemeris underneath is broadcast by the satellites themselves and published openly by the international GNSS community; it is the same data either way. Converting it ourselves removes a token, a third-party dependency in a boot-critical path, and a rate limit we would have to design around.
The third reason is the one that decided it. A fixed API response is fixed. Building the blob ourselves means we choose what goes in it and what shape it arrives in: three constellations rather than five because the other two earn nothing here, LZMA compression using the identical settings as the flight log codec so the device carries one decompressor instead of two, and chunk sizes chosen to match the modem's HTTP body limit exactly. Every one of those decisions is downstream of constraints on this specific device. A general-purpose service cannot know about any of them, so we would have been post-processing its output anyway.
None of that means AssistNow is a bad service. For a product with a normal TLS stack and no particular opinion about packaging, it is the sensible choice and it saves real work. It simply is not the sensible choice here.
A script on the server pulls the multi-GNSS broadcast ephemeris in RINEX 3 format, the same navigation file the IGS network publishes, and converts each satellite's newest ephemeris into the UBX message the M10 wants: UBX-MGA-GPS-EPH, UBX-MGA-GAL-EPH and UBX-MGA-BDS-EPH. The result is a single binary blob of assistance data, functionally what AssistNow Online would have sent.
Three decisions in that script are worth explaining.
It skips GLONASS and QZSS. GLONASS ephemeris is a state-vector format rather than Keplerian elements, which is a separate conversion for a constellation that adds little once GPS, Galileo and BeiDou are all assisted. QZSS is regional to the Asia-Pacific and does nothing for a UK receiver. Assisting three constellations already gives far more satellites than a fix needs.
It falls through an ordered list of sources. The primary is an auth-free near-real-time file; if that is unreachable, or stale, the script tries independent IGS data centres in turn and takes the first that returns a fresh, well-populated file. Two sanity checks decide "fresh and well-populated": the newest ephemeris in the file must be less than six hours old, which catches a mirror still quietly serving yesterday, and the file must contain at least 20 satellites, which catches the thin window just after 00:00 UTC when a day's file has only started filling. Publishing a near-empty orbit blob is worse than publishing nothing, because the device will accept it, cache it, and stop asking.
Every message's type byte must be 0x01. The first payload byte of each MGA message identifies it as ephemeris, and the receiver silently rejects anything else. The way to validate this is on real hardware, watching UBX-MGA-ACK come back with infoCode 0 for each satellite. There is no substitute for that check, which brings us to the thing that makes assisted GNSS harder to debug than it looks.
Getting it to the device
The blob is around 7 KB of UBX. It is compressed with LZMA, using the same settings as the flight log codec so the device carries one decompressor rather than two, and split into 1000 byte chunks served as gnssglobal.lzma.0, .1, .2 and so on.
The chunk size is a shared constant in two codebases, which is exactly the kind of thing that breaks silently later, so it is worth being explicit about why 1000. The modem's HTTP body length is configured to 1400 bytes, so a chunk plus its HTTP headers has to fit inside that with margin. The device sizes its receive buffer as chunk size times a maximum count. And the last chunk is detected by being shorter than a full one, which means the device never needs to be told how many chunks there are. Change the number on one side only and the download either overflows or never terminates.
The device requests chunks in order, stops on a short chunk or a 404, and inflates the whole stream in one pass into a cache file in LittleFS. On a SIM7080-class modem that is a handful of AT commands.
// Plain HTTP, no TLS: this modem has no certificate store worth the RAM,
// and the payload is public ephemeris data that is useless to tamper with.
sendAT("AT+SHSSL=0", "OK", AT_TIMEOUT);
sendAT("AT+SHCONF=\"URL\",\"http://altimetercloud.com\"", "OK", AT_TIMEOUT);
sendAT("AT+SHCONF=\"BODYLEN\",1400", "OK", AT_TIMEOUT); // 1000 B chunk + headers
sendAT("AT+SHCONF=\"HEADERLEN\",350", "OK", AT_TIMEOUT);
sendAT("AT+SHCONN", "OK", agReq);
for (int idx = 0; idx < MAXCH && !done; idx++) {
char req[52];
snprintf(req, sizeof(req), "AT+SHREQ=\"/ubx/gnssglobal.lzma.%d\",1", idx); // 1 = GET
// ... parse "+SHREQ: <method>,<code>,<len>" ...
if (code == 404) break; // past the last chunk, or none at all
if (code != 200) { ok = false; break; }
if (len < CHUNK) done = true; // short chunk means this was the last
char rc[32];
snprintf(rc, sizeof(rc), "AT+SHREAD=0,%d", len);
// ... read exactly len bytes of body after the +SHREAD header ...
}
sendAT("AT+SHDISC", "OK", 2000);
The modem lesson
This part cost the most time and is the most transferable, so it gets its own section.
Assistance originally arrived over MQTT, chunked into hex fragments in the command channel. That worked and was stable for months. Moving it to plain HTTP was cleaner, simpler, and immediately produced a failure that had nothing to do with GNSS: roughly 30 seconds after each fetch, the telemetry uplink would start failing while the downlink kept working. The symptom was the modem refusing to accept a publish, reporting no prompt for the data, twice on two clean boots at the same interval.
The cause was that the HTTP migration had put three application network services on the modem simultaneously: MQTT over TCP, the UDP telemetry socket, and now HTTP. That is what a starved modem application stack looks like from the outside, and no amount of looking at the GNSS code would have found it.
The fix is blunt and correct: the HTTP session runs alone. Before fetching, the firmware disconnects MQTT and closes the UDP socket; afterwards it restores MQTT and lets the UDP socket reopen lazily on the next send. An orbit fetch is a rare, brief, one-at-a-time operation, and giving it exclusive use of the modem costs nothing that matters.
The general shape of that lesson: when a change to one subsystem breaks a different one, the shared resource between them is the first place to look, not the last. Nothing in the GNSS code was wrong. The GNSS code had simply become a third tenant.
Caching it on LittleFS
The orbit blob is written to a file in LittleFS rather than held in RAM, for two reasons. RAM on this board is contested and a 7 KB buffer held for the whole flight is 7 KB not doing something useful. More importantly, a cache that survives a power cycle is a cache that helps the second boot, and on a launch day a board gets power cycled more than once.
The filesystem is mounted at boot without formatting, and only formatted if that fails. That ordering matters: mounting with format-on-failure as the first attempt will silently erase a perfectly good filesystem after one transient error.
#define ASSISTNOW_CACHE_FILE "/assistnow.ubx"
// Mount WITHOUT formatting first. Only format if the mount genuinely fails,
// otherwise one bad read wipes a cache that was fine.
bool lfsOk = LittleFS.begin(false);
if (!lfsOk) {
Serial.println("[FS] mount failed, formatting");
lfsOk = LittleFS.begin(true);
}
if (!lfsOk) Serial.println("[FS] no filesystem: AssistNow caching disabled");
Writing the cache is unremarkable, but the timestamp beside it is the part that does the work. The file itself carries no age, so the download time goes into NVS next to it. Without that the device has a blob and no way to know whether it is twenty minutes or two days old.
// Write the inflated UBX and stamp the moment it was fetched.
File wf = LittleFS.open(ASSISTNOW_CACHE_FILE, "w");
if (!wf) { Serial.println("[AGNSS] cache open failed."); return false; }
wf.write(raw, rawlen);
wf.close();
prefs.begin("assistnow", false);
prefs.putUInt("cache_ts", espEpoch()); // unix time of this download
prefs.end();
Staleness is then a question the device can actually answer, and the interesting part is what it does when it cannot.
bool agnssCacheStale() {
if (!LittleFS.exists(ASSISTNOW_CACHE_FILE)) return true; // nothing to use
prefs.begin("assistnow", true);
uint32_t ts = prefs.getUInt("cache_ts", 0);
prefs.end();
uint32_t now = espEpoch();
// No clock yet, or a cache saved without a timestamp: the age is UNKNOWN,
// which is not the same as old. Keep what we have. Treating unknown as
// stale would refetch on every boot before time sync, which is every boot
// that matters, and burn the data allowance proving nothing.
if (now == 0 || ts == 0) return false;
return (now - ts) >= agnssTtlSecs();
}
That last decision comes up repeatedly in this design and is worth stating on its own: unknown is not the same as bad. A cache of unknown age is probably fine and is certainly better than no cache, whereas treating every unknown as a failure produces a device that refetches constantly and is worse in every respect than one that occasionally injects something slightly old.
There is a deliberate exception, and it is the no-fix heuristic described further down. When the device has injected assistance and then sits for twenty minutes without a fix, it writes a deliberately old timestamp into the cache rather than deleting the file, which makes the existing staleness path fetch a fresh copy on its next pass. Expiring the cache by ageing it rather than by special-casing it means there is one code path that decides when to fetch, not two.
// Suspect the cache after a long dry spell: age it out rather than delete it,
// so the ordinary staleness check does the work.
prefs.begin("assistnow", false);
prefs.putUInt("cache_ts", espEpoch() - 10UL * 3600UL); // 10 h old
prefs.end();
Injecting it
Injection is not simply writing the cache file at the receiver. The M10's UART receive buffer is finite, and a few kilobytes of MGA messages arriving while the module is also emitting 10 Hz NMEA and 10 Hz NAV-PVT will overflow it. So the sequence is:
- Inject the time seed first, UBX-MGA-INI-TIME-UTC
- Silence NMEA output on UART1
- Pause NAV-PVT streaming
- Enable MGA-ACK, CFG-NAVSPG-ACKAIDING = 1
- Send each UBX frame individually, pacing between them, counting accepted against rejected
- Restore NAV-PVT and NMEA
Time goes first because the orbit predictions are only meaningful against a clock. A receiver that does not know roughly when it is cannot correlate an ephemeris to anything, so if time is not yet available the injection defers rather than proceeding and quietly wasting the data. Accuracy of about a second is ample; MGA-INI-TIME-UTC carries a two second uncertainty window.
The time seed is one 32 byte frame. The clock it uses is the board's master clock, disciplined from GNSS when available and otherwise from NTP or the modem, which is the point: on a cold start the receiver has no GNSS time, so it has to be told the time by something that already knows it.
// UBX-MGA-INI-TIME-UTC: class 0x13, id 0x40, 24 byte payload.
bool injectMgaIniTime(HardwareSerial& ser, uint32_t epoch) {
if (epoch == 0) return false; // nothing worth seeding with
time_t tt = (time_t)epoch;
struct tm t; gmtime_r(&tt, &t);
uint16_t year = t.tm_year + 1900;
uint8_t payload[24] = {
0x10, // type: UTC
0x00, // version
0x00, // ref: none
18, // leap seconds
(uint8_t)(year & 0xFF), (uint8_t)(year >> 8),
(uint8_t)(t.tm_mon + 1),
(uint8_t)t.tm_mday,
(uint8_t)t.tm_hour,
(uint8_t)t.tm_min,
(uint8_t)t.tm_sec,
0x00, // reserved
0, 0, 0, 0, // ns = 0
2, 0, // tAccS = 2 s uncertainty. Ample.
0, 0, // reserved
0, 0, 0, 0 // tAccNs = 0
};
ubxSend(ser, 0x13, 0x40, payload, sizeof(payload));
return true;
}
Frames are parsed out of the cache one at a time by walking the UBX structure, sync bytes, class, id, length, payload, checksum, and each frame's own checksum is verified before sending. A frame that fails its checksum means the cache is damaged rather than the receiver being awkward, and counting those separately turned out to matter when diagnosing a partially written cache file.
// Frame-paced injection. Walk the cache, verify each frame's own checksum,
// send it, then give the receiver a moment. Blasting the whole file at the
// UART overflows the module's receive buffer and loses frames silently.
uint8_t frame[512];
while (f.available()) {
if (f.read() != 0xB5) continue; // resync on the sync bytes
if (f.read() != 0x62) continue;
uint8_t cls = f.read(), id = f.read();
uint8_t lo = f.read(), hi = f.read();
uint16_t len = lo | (hi << 8);
if (len + 8 > sizeof(frame)) break; // not a frame we understand
frame[0] = 0xB5; frame[1] = 0x62;
frame[2] = cls; frame[3] = id;
frame[4] = lo; frame[5] = hi;
f.read(&frame[6], len + 2); // payload plus its checksum
uint8_t ckA = 0, ckB = 0;
for (uint16_t i = 2; i < len + 6; i++) { ckA += frame[i]; ckB += ckA; }
if (ckA != frame[len + 6] || ckB != frame[len + 7]) {
badFrames++; // damaged cache, not a sulky receiver
continue;
}
ser.write(frame, len + 8);
delay(5);
drainMgaAcks(&mgaAccepted, &mgaRejected); // count what landed
}
MGA-ACK is enabled so the receiver reports accept or reject per message, which gives a real number to log: how many satellites' worth of assistance actually landed. As noted above, that number tells you the data was well formed. It does not tell you it was current.
Seeding position, and the trap
This is the part that was wrong for a long time, and the failure is worth describing in full because it is the kind that arms itself.
A position seed tells the receiver roughly where it is, so it only has to search the part of the sky that could contain visible satellites. The obvious source is the last fix, saved to NVS on the previous flight. That is what the firmware used to do, injecting it with a claimed accuracy of 10 km.
The message itself is unremarkable. The field that matters is the last one.
// UBX-MGA-INI-POS-LLH: class 0x13, id 0x40, 20 byte payload.
bool injectMgaPosLLH(HardwareSerial& ser, double lat, double lng,
float alt, double accM) {
int32_t latI = (int32_t)(lat * 1e7);
int32_t lngI = (int32_t)(lng * 1e7);
int32_t altI = (int32_t)(alt * 100.0f); // metres to cm
uint32_t acc = (uint32_t)(accM * 100.0); // metres to cm
uint8_t payload[20] = {
0x01, 0x00, 0x00, 0x00, // type LLH, version, reserved
(uint8_t)latI, (uint8_t)(latI >> 8), (uint8_t)(latI >> 16), (uint8_t)(latI >> 24),
(uint8_t)lngI, (uint8_t)(lngI >> 8), (uint8_t)(lngI >> 16), (uint8_t)(lngI >> 24),
(uint8_t)altI, (uint8_t)(altI >> 8), (uint8_t)(altI >> 16), (uint8_t)(altI >> 24),
// posAcc. This is a PROMISE to the receiver, not a hint. It will trust it.
(uint8_t)acc, (uint8_t)(acc >> 8), (uint8_t)(acc >> 16), (uint8_t)(acc >> 24)
};
ubxSend(ser, 0x13, 0x40, payload, sizeof(payload));
return true;
}
Here is what that produces when it goes wrong. The device seeds 54.3009, -3.1249 while actually sitting at 54.3763, -2.9909. That is 11.8 km away, and the claimed accuracy was 10 km, so the receiver was told with confidence that the truth lay inside a circle the truth was outside. It therefore searched the wrong sky, found nothing, and reported zero satellites for twenty minutes. And because it never got a fix, it never wrote a better last position, so the next boot made exactly the same mistake. It only broke out on a power cycle where a fix happened to land before the seed was applied.
So the cached position seed is gone. The firmware now boots with no position seed at all and waits for something better.
The cell seed stays, and the difference is the whole point. A position derived from the serving tower comes with an accuracy the server has actually computed, 2912 m in the case above rather than a round number someone assumed, and it cannot poison itself the way a cache can: a bad cell fix does not get written back and reused.
There was a second, subtler version of the same bug sitting underneath. The orbit injection ran about two seconds after the cell fix had just seeded a good position, and injected the cached position again on its way past, overwriting it:
[AGNSS] Cell fix 11.8 km from cache - re-seeding GNSS.
[AGNSS] Position seed: 54.375000, -2.994000 (acc 2.9 km) <- correct
[AGNSS] Position seed: 54.300880, -3.124862 (acc 10.0 km) <- undid it, 2 s later
The code had already worked out that the cache was wrong, said so in the log, and then used it anyway two lines later. Worth keeping as a reminder that a diagnostic which correctly identifies a problem is not the same as a code path that acts on it.
Orbits do not need a position seed to be useful. They say where the satellites are, which is the expensive part. The seed only says which half of the sky is worth bothering with, and getting it wrong is worse than not having it.
Keeping it fresh
Ephemeris data goes stale in hours, so the cache carries a TTL of two hours by default, adjustable between one and three. The seed is re-applied on the same two hour cadence, retrying every five minutes if it fails, and stale aiding is purged at two and a half hours if there is still no fix.
Retry timing is deliberately not uniform. The first seed after boot bursts, five attempts four minutes apart, because that is the window where a device is sitting on a pad and the assistance is worth the most. After the burst it settles to every ten minutes, indefinitely.
The last piece is the one that compensates for MGA-ACK being unable to tell you the data was stale. If the device has injected assistance and then goes twenty minutes without a fix, the firmware assumes the cache is the problem, expires it, and lets the normal fetch path pull fresh orbits. That heuristic is capped at three attempts, because otherwise a device sitting forgotten in a drawer with no sky view would refetch forever and spend somebody's data allowance proving that a drawer has no satellites in it.
The whole thing, in one place
The pieces above are spread across a firmware that does a great many other things, so here they are condensed into one sketch that shows the shape end to end: bring the module up, seed the time, fetch the orbit, cache it, inject it, and re-check it on a cadence. Error handling and the modem plumbing are elided where they would obscure the flow. It is a map of the sequence rather than something to paste into a product.
// ---------------------------------------------------------------------------
// Assisted GNSS on a u-blox SAM-M10Q, condensed.
// Shows the sequence, not the error handling. See the sections above for the
// reasoning behind each step.
// ---------------------------------------------------------------------------
#include <LittleFS.h>
#include <Preferences.h>
#define GPS_RX_PIN 14
#define GPS_TX_PIN 13
#define ASSISTNOW_CACHE_FILE "/assistnow.ubx"
#define AGNSS_TTL_SECS (2UL * 3600UL)
HardwareSerial GPSSerial(1);
Preferences prefs;
bool orbitCacheInjected = false;
// --- 1. UBX plumbing -------------------------------------------------------
void ubxSend(uint8_t cls, uint8_t id, const uint8_t* payload, uint16_t len) {
uint8_t head[6] = { 0xB5, 0x62, cls, id,
(uint8_t)(len & 0xFF), (uint8_t)(len >> 8) };
uint8_t ckA = 0, ckB = 0;
for (int i = 2; i < 6; i++) { ckA += head[i]; ckB += ckA; }
for (uint16_t i = 0; i < len; i++) { ckA += payload[i]; ckB += ckA; }
GPSSerial.write(head, 6);
GPSSerial.write(payload, len);
GPSSerial.write(ckA);
GPSSerial.write(ckB);
GPSSerial.flush();
}
bool ubxWaitAck(unsigned long timeoutMs) {
unsigned long t = millis();
uint8_t state = 0;
while (millis() - t < timeoutMs) {
if (!GPSSerial.available()) { yield(); continue; }
uint8_t b = GPSSerial.read();
switch (state) {
case 0: if (b == 0xB5) state = 1; break;
case 1: state = (b == 0x62) ? 2 : (b == 0xB5 ? 1 : 0); break;
case 2: state = (b == 0x05) ? 3 : 0; break;
case 3: if (b == 0x01) return true;
if (b == 0x00) return false;
state = 0; break;
}
}
return false;
}
bool ubxValSet(const uint8_t* payload, size_t len, unsigned long timeoutMs) {
while (GPSSerial.available()) GPSSerial.read();
ubxSend(0x06, 0x8A, payload, len);
return ubxWaitAck(timeoutMs);
}
// --- 2. Bring the module up ------------------------------------------------
void gnssBegin() {
static const uint8_t baud[] = {
0x00, 0x03, 0x00, 0x00,
0x01, 0x00, 0x52, 0x40, 0x00, 0x84, 0x03, 0x00 // UART1 = 230400
};
// Sent at both plausible rates: the module is at 9600 or already at 230400
// depending on whether backup RAM survived, and probing is code that can
// be wrong.
for (uint32_t rate : { 9600UL, 230400UL }) {
GPSSerial.end();
GPSSerial.begin(rate, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
delay(50);
while (GPSSerial.available()) GPSSerial.read();
ubxSend(0x06, 0x8A, baud, sizeof(baud));
delay(50);
}
while (GPSSerial.available()) GPSSerial.read();
static const uint8_t dynModel[] = { 0x00,0x03,0x00,0x00, 0x21,0x00,0x11,0x20, 0x08 };
static const uint8_t rate10hz[] = { 0x00,0x03,0x00,0x00, 0x01,0x00,0x21,0x30, 0x64,0x00,
0x02,0x00,0x21,0x30, 0x01,0x00 };
static const uint8_t navpvt[] = { 0x00,0x03,0x00,0x00, 0x07,0x00,0x91,0x20, 0x01 };
ubxValSet(dynModel, sizeof(dynModel), 2000); // airborne <4g. The one that matters.
ubxValSet(rate10hz, sizeof(rate10hz), 3000);
ubxValSet(navpvt, sizeof(navpvt), 2000);
}
// --- 3. Time seed ----------------------------------------------------------
bool injectTime(uint32_t epoch) {
if (epoch == 0) return false; // nothing worth seeding with
time_t tt = (time_t)epoch; struct tm t; gmtime_r(&tt, &t);
uint16_t year = t.tm_year + 1900;
uint8_t payload[24] = {
0x10, 0x00, 0x00, 18,
(uint8_t)(year & 0xFF), (uint8_t)(year >> 8),
(uint8_t)(t.tm_mon + 1), (uint8_t)t.tm_mday,
(uint8_t)t.tm_hour, (uint8_t)t.tm_min, (uint8_t)t.tm_sec, 0x00,
0,0,0,0, // ns
2, 0, // tAccS = 2 s
0, 0,
0,0,0,0 // tAccNs
};
ubxSend(0x13, 0x40, payload, sizeof(payload));
return true;
}
// --- 4. Cache ---------------------------------------------------------------
bool cacheStale() {
if (!LittleFS.exists(ASSISTNOW_CACHE_FILE)) return true;
prefs.begin("assistnow", true);
uint32_t ts = prefs.getUInt("cache_ts", 0);
prefs.end();
uint32_t now = epochNow();
if (now == 0 || ts == 0) return false; // unknown age is not old age
return (now - ts) >= AGNSS_TTL_SECS;
}
bool cacheWrite(const uint8_t* data, size_t len) {
File wf = LittleFS.open(ASSISTNOW_CACHE_FILE, "w");
if (!wf) return false;
wf.write(data, len);
wf.close();
prefs.begin("assistnow", false);
prefs.putUInt("cache_ts", epochNow());
prefs.end();
orbitCacheInjected = false; // fresh orbit: inject it again
return true;
}
// --- 5. Fetch ---------------------------------------------------------------
// The HTTP session runs ALONE on the modem: MQTT parked, UDP socket closed.
// Three app-network services at once starves the modem's app stack.
bool orbitFetch() {
modemParkMqtt();
modemCloseUdp();
uint8_t* comp = (uint8_t*)malloc(20000);
int clen = httpGetChunks("/ubx/gnssglobal.lzma.%d", comp, 20000, 1000);
bool ok = false;
if (clen > 0) {
uint8_t* raw = (uint8_t*)malloc(65536);
int rawlen = lzmaDecode(raw, 65536, comp, clen);
if (rawlen > 0) ok = cacheWrite(raw, rawlen);
free(raw);
}
free(comp);
modemRestoreMqtt(); // UDP reopens lazily on next send
return ok;
}
// --- 6. Inject --------------------------------------------------------------
void orbitInject() {
if (orbitCacheInjected) return;
if (!LittleFS.exists(ASSISTNOW_CACHE_FILE)) return;
if (!injectTime(epochNow())) return; // orbits are useless without time
// Quiet the UART so the module's receive buffer is not fighting our injection.
static const uint8_t nmeaOff[] = { 0x00,0x01,0x00,0x00, 0x02,0x00,0x74,0x10, 0x00 };
static const uint8_t pvtOff[] = { 0x00,0x01,0x00,0x00, 0x07,0x00,0x91,0x20, 0x00 };
static const uint8_t ackOn[] = { 0x00,0x01,0x00,0x00, 0x25,0x00,0x11,0x10, 0x01 };
ubxValSet(nmeaOff, sizeof(nmeaOff), 1000);
ubxValSet(pvtOff, sizeof(pvtOff), 1000);
ubxValSet(ackOn, sizeof(ackOn), 1000);
while (GPSSerial.available()) GPSSerial.read();
File f = LittleFS.open(ASSISTNOW_CACHE_FILE, "r");
uint8_t frame[512];
int accepted = 0, rejected = 0, bad = 0;
while (f.available()) {
if (f.read() != 0xB5) continue;
if (f.read() != 0x62) continue;
uint8_t cls = f.read(), id = f.read();
uint8_t lo = f.read(), hi = f.read();
uint16_t len = lo | (hi << 8);
if (len + 8 > sizeof(frame)) break;
frame[0] = 0xB5; frame[1] = 0x62;
frame[2] = cls; frame[3] = id;
frame[4] = lo; frame[5] = hi;
f.read(&frame[6], len + 2);
uint8_t ckA = 0, ckB = 0;
for (uint16_t i = 2; i < len + 6; i++) { ckA += frame[i]; ckB += ckA; }
if (ckA != frame[len + 6] || ckB != frame[len + 7]) { bad++; continue; }
GPSSerial.write(frame, len + 8);
delay(5);
drainMgaAcks(&accepted, &rejected);
}
f.close();
// Restore the streams.
static const uint8_t nmeaOn[] = { 0x00,0x01,0x00,0x00, 0x02,0x00,0x74,0x10, 0x01 };
static const uint8_t pvtOn[] = { 0x00,0x01,0x00,0x00, 0x07,0x00,0x91,0x20, 0x01 };
ubxValSet(nmeaOn, sizeof(nmeaOn), 1000);
ubxValSet(pvtOn, sizeof(pvtOn), 1000);
orbitCacheInjected = true;
Serial.printf("[AGNSS] injected: %d accepted, %d rejected, %d corrupt frames\n",
accepted, rejected, bad);
}
// --- 7. Tie it together -----------------------------------------------------
void setup() {
Serial.begin(115200);
GPSSerial.begin(9600, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
if (!LittleFS.begin(false)) LittleFS.begin(true);
gnssBegin();
// No position seed here on purpose. A cached fix of unknown error is worse
// than nothing: see the seeding section above.
}
void loop() {
// Once the clock is known and the network is up, keep the orbit current.
if (clockIsSet()) {
if (cacheStale() && networkReady()) orbitFetch();
orbitInject();
}
// ... flight code ...
}
What it buys
The value of all this is easiest to see by what it removes rather than what it adds. A cold receiver has to do two separate things: acquire the satellite signals, and decode the orbit data those signals carry. Acquisition depends on your sky view and there is nothing a flight computer can do about it. Decoding is the part that takes 30 seconds per satellite at 50 bits per second, needs four satellites before a fix exists, and starts again from nothing every time a satellite drops out mid-download. Assistance removes that second stage entirely.
What is left is acquisition, which is why assisted time to first fix varies with where you are standing rather than with how long the board has been off. A clear pad and an assisted receiver should be quick. The same receiver inside an airframe, beside a metal rail, with half the sky behind a treeline, is working on the part assistance cannot help with.
The other thing it buys is less obvious and possibly more useful: consistency. An unassisted cold start is unpredictable in a way that matters when you are on a launch queue, because it depends on which satellites happen to be overhead and whether their downloads complete. Removing the ephemeris stage removes most of that variance.
What I would do differently
Three things, all of which cost more time than they should have.
An accuracy figure is a promise, not a hint. The 10 km on the cached seed was a round number chosen because it felt safely large. The receiver read it as a constraint and searched accordingly. Any number handed to a system that will act on it needs to be one you can defend, and if you cannot compute it, the honest value is no value at all.
Migrating a transport is not a local change. Moving assistance from MQTT to HTTP was a clean improvement in isolation and broke something a minute later in a subsystem that had not been touched. The modem was the shared resource, and nothing in either codebase mentioned the other.
The worst bugs look like the environment. The cached-seed trap survived as long as it did because its symptom, no satellites for twenty minutes, is exactly what a bad sky view looks like. There is nothing to distinguish them from the outside, which is why it took logging the seed and the eventual fix side by side to see that the two were 11.8 km apart. If a failure mode is indistinguishable from bad luck, it will not be found by noticing it; it has to be instrumented for.


