Introduction#
I now have a stratum 1 time server in the “IT cupboard” under the stairs: a Raspberry Pi with a GPS receiver bolted on, holding its clock to within a few hundred nanoseconds of the satellites. This post covers how I got there, including the bits that went sideways.
Why bother? A few of the devices on my network care about time far more than the average kettle. There are five Global Meteor Network cameras and an allsky camera, and a meteor detection is only useful if its timestamp is right. Stations elsewhere triangulate the same image, so a camera a second out is worse than no camera at all.
Until now everything has been pulling time from the public NTP pool over the internet. That works fine, right up until the connection has a wobble, and it always felt a bit lazy for a house full of Raspberry Pis.
A quick bit of terminology before going further:
- Stratum 0 is the reference clock itself, here the GPS constellation.
- Stratum 1 is a machine directly attached to a stratum 0 source. That’s the Pi.
- Stratum 2 is anything syncing from a stratum 1 server, which is where the cameras will end up.
The original plan was to recycle an old Pi 3 from the bird box camera, which is retiring in favour of an IP camera. Then it dawned on me that I already had a Pi 4 in the same cupboard doing one small, quiet job and very little else. Why not put it to work? Turns out it was the better choice anyway, for reasons that come up when we get to serving time to the rest of the network.
The Hardware#
The shopping list was refreshingly short, and nothing needed soldering (for a change, phew!):
- Raspberry Pi 4 Model B, already in service and mostly idle
- Uputronics GPS/RTC Expansion Board: a u-blox M8 receiver, PPS output on GPIO18 (pin 12), GPS serial on GPIO14/15, an RV-3028 real-time clock and an SMA antenna socket
- An active GPS antenna, 3–5 V, 28 dB gain, on a 5 m SMA lead
The Uputronics board is just the right thing. It sits on the Pi’s 40-pin header like any HAT and does three jobs: it tells the Pi what time it is (over serial), exactly when each second starts (the PPS pin) and keeps the time across reboots and power cuts (the RTC).
One thing to check before fitting it to a Pi 4: the board sits directly over the SoC, and a Pi 4 runs a lot warmer than a Pi 3. If your Pi lives in a case with a chunky heatsink or fan, the HAT may not fit, or it may trap the heat. Worth a look at vcgencmd measure_temp under load once it’s all together.
Where to Put the Antenna#
The received wisdom is that a GPS antenna wants a clear view of the sky, so my plan was the attic (tiles are fine for GPS, foil-backed insulation is not). The 5 m lead wouldn’t reach from the cupboard, so I’d pencilled in an extension of LMR-240 coax (about 0.27 dB/m loss) rather than the thinner RG174 (about 0.9 dB/m).
Before buying anything, though, I tested the antenna in a temporary spot nearer the Pi. More on the results in a moment, but the short version is that the extension cable may well never get bought.
Two Signals: NMEA and PPS#
It’s worth understanding what the GPS actually gives you, because the whole setup hangs off it. The receiver produces two separate signals, and neither is any good on its own.
NMEA is text over the serial port: sentences like $GNRMC that carry the date, time, position and fix status. It tells you which second it is, but the sentence turns up some tens of milliseconds after that second started, and the delay wobbles.
PPS (pulse per second) is a single electrical edge on a GPIO pin, aligned to the start of each UTC second to within tens of nanoseconds. It tells you exactly when a second starts, but not which one.
Put them on a timeline and the division of labour is obvious:
UTC second N UTC second N+1
│ │
▼ PPS edge (±tens of ns) ▼ PPS edge
┃ ┃
┃──── ~50 ms ────▶ $GNRMC,…N… arrives ┃
┃ on serial (wobbly) ┃Chrony, the NTP daemon doing the work here, uses both. NMEA is configured as a reference clock that’s never selected for timekeeping; its only job is to label each pulse with the right second. PPS is then locked to NMEA and does all the precise work.
gpsd sits in between: it reads the serial port, decodes the NMEA, and passes the time to chrony through a shared-memory segment. The PPS pulse goes straight to the kernel via the pps-gpio driver and appears as /dev/pps0.
Configuring the Pi#
Is the Board Alive?#
Before touching any config I wanted proof that the Pi could see the board. With I²C enabled (raspi-config → Interface Options → I2C), i2cdetect should show two devices:
sudo apt install i2c-tools
i2cdetect -y 1 0 1 2 3 4 5 6 7 8 9 a b c d e f
40: -- -- 42 -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- 52 -- -- -- -- -- -- -- -- -- -- -- -- --0x42 is the u-blox receiver and 0x52 is the RV-3028 RTC. Both present, so on we go.
config.txt#
Four lines in /boot/firmware/config.txt do the heavy lifting:
enable_uart=1
dtoverlay=disable-bt
dtoverlay=pps-gpio,gpiopin=18
dtoverlay=i2c-rtc,rv3028,backup-switchover-mode=1enable_uartanddisable-btgive the GPS the Pi’s proper UART (the PL011) rather than the flaky mini UART, by taking it back from Bluetooth.pps-gpiocreates/dev/pps0from the pulse on GPIO18.i2c-rtc,rv3028brings up the RTC. Thebackup-switchover-modeparameter lets the RTC run from the board’s backup supply when the Pi loses power; without it the RTC can forget the time in a power cut, which rather defeats the point. Check the Uputronics documentation for the mode they recommend.
Then in raspi-config, under Interface Options → Serial Port, say no to a login shell and yes to the serial hardware, and reboot.
Most guides also tell you to systemctl disable hciuart. On current Raspberry Pi OS that just earns you Unit hciuart.service does not exist, which is harmless: Bluetooth now attaches through the kernel, and the overlay has already turned it off. The check that matters is where /dev/serial0 points:
ls -l /dev/serial0lrwxrwxrwx 1 root root 7 Nov 5 14:20 /dev/serial0 -> ttyAMA0ttyAMA0 is the full UART. If it says ttyS0, the disable-bt overlay hasn’t taken.
The RTC#
With a real clock on board, the fake one that Raspberry Pi OS ships with can go:
sudo apt purge fake-hwclock
sudo hwclock -rThe first read may show nonsense until the RTC has been set once. That happens later, after chrony has a lock.
Talking to the GPS#
The Baud Rate Surprise#
The obvious first test is to read the serial port directly and watch the NMEA scroll past:
sudo cat /dev/serial0What I got instead was a screenful of this:
� I`c)�d 4icb}�c NIc)i "!�n�!��Ġ#H4 ac�cD���̠#H4 ic�bc���̠#H4 ic�bcThat’s not a dead receiver; it’s the right data at the wrong speed. The giveaway is the repeating fragments (#H4 ic over and over), which is what a regular, structured stream looks like when it’s sampled at the wrong baud rate. Most u-blox modules default to 9600, but this board ships at a different rate. Rather than guess, try a few:
for b in 9600 38400 115200; do
echo "=== $b ==="
sudo stty -F /dev/serial0 $b raw -echo
sudo timeout 3 cat /dev/serial0
doneThe winner was 115200, and out came proper sentences:
$GNRMC,142301.00,A,…
$GNGGA,142301.00,…The A after the time in $GNRMC means a valid fix; V means it’s still searching. If you get nothing but garbage at every rate, check /boot/firmware/cmdline.txt for a leftover console=serial0,115200, which means a login console is fighting the GPS for the port.
A bonus of 115200 over 9600: each sentence arrives sooner after the second it describes, which comes in handy later.
A while later I noticed a piece of card in the bottom of the GPS/RTC Expansion Board box said that the baud rate needs to be set to 115200… oh well!
gpsd#
sudo apt install gpsd gpsd-clients pps-tools/etc/default/gpsd:
DEVICES="/dev/serial0"
GPSD_OPTIONS="-n -s 115200"
USBAUTO="false"-n tells gpsd to start reading the receiver straight away rather than waiting for a client to connect, which chrony needs. -s 115200 pins the speed so gpsd doesn’t have to hunt for it on every start.
sudo systemctl enable --now gpsd
sudo ppstest /dev/pps0source 0 - assert 1762352581.000000412, sequence: 41 - clear 0.000000000, sequence: 0
source 0 - assert 1762352582.000000398, sequence: 42 - clear 0.000000000, sequence: 0One pulse a second. Don’t panic if ppstest shows nothing at first: the u-blox only pulses once it has a fix.
How Good Is the Sky View?#
cgps -s gives a live view of the fix and every satellite in sight. The targets I set myself were a steady 3D fix, 8 or more satellites in use, and signal strengths of 35–45 dB-Hz. Here’s what the temporary antenna position managed overnight:
┌───────────────────────────────────────────┐┌────────────────Seen 24/Used 16──┐
│ Time 2026-11-06T08:29:22.000Z (18)││GNSS S PRN Elev Azim SNR Use│
│ Latitude (redacted) ││GP 5 5 57.0 292.0 27.0 Y │
│ Longitude (redacted) ││GP 7 7 53.0 69.0 32.0 Y │
│ Alt (HAE, MSL) (redacted) ││GP 9 9 19.0 88.0 24.0 Y │
│ Speed 0.13 km/h ││GP 11 11 20.0 211.0 15.0 Y │
│ Track (true, var) 38.6, -0.5 deg ││GP 14 14 14.0 146.0 25.0 Y │
│ Climb -2.46 m/min ││GP 20 20 36.0 271.0 33.0 Y │
│ Status 3D DGPS FIX (11 secs) ││GP 21 21 74.0 196.0 32.0 Y │
│ Long Err (XDOP, EPX) 0.42, +/- 1.6 m ││GP 30 30 71.0 150.0 34.0 Y │
│ Lat Err (YDOP, EPY) 0.67, +/- 2.5 m ││GL 3 67 25.0 94.0 25.0 Y │
│ Alt Err (VDOP, EPV) 1.11, +/- 5.0 m ││GL 4 68 72.0 46.0 33.0 Y │
│ 2D Err (HDOP, CEP) 0.78, +/- 3.7 m ││GL 5 69 43.0 298.0 37.0 Y │
│ 3D Err (PDOP, SEP) 1.36, +/- 6.5 m ││GL 13 77 35.0 41.0 23.0 Y │
│ Time Err (TDOP) 0.75 ││GL 14 78 48.0 123.0 38.0 Y │
│ Geo Err (GDOP) 1.55 ││GL 15 79 12.0 168.0 30.0 Y │
│ Speed Err (EPS) +/- 18.0 km/h ││GL 19 83 10.0 226.0 11.0 Y │
│ Track Err (EPD) n/a ││GL 20 84 32.0 278.0 20.0 Y │
│ Time offset 0.089743645 s ││GP 15 15 4.0 277.0 0.0 N │
│ Grid Square (redacted) ││GP 16 16 2.0 14.0 0.0 N │
│ ECEF X, VX (redacted) ││GP 18 18 11.0 332.0 19.0 N │
│ ECEF Y, VY (redacted) ││GP 27 27 0.0 35.0 0.0 N │
│ ECEF Z, VZ (redacted) ││SB120 33 27.0 199.0 0.0 N │
│ ││SB123 36 25.0 151.0 34.0 N │
│ ││SB136 49 29.0 173.0 39.0 N │
│ ││GL 21 85 25.0 328.0 0.0 N │
└───────────────────────────────────────────┘└─────────────────────────────────┘I’ve redacted the position fields, for obvious reasons. Don’t be fooled by the non-zero speed and climb either: the Pi hasn’t moved. That’s normal noise for a stationary receiver. The Time offset is gpsd’s own measure of how late the serial data arrives, separate from the figure chrony works with.
Set against my targets:
| Measure | Target | Result |
|---|---|---|
| Fix | 3D | 3D DGPS (SBAS corrections in use) |
| Satellites used | 8+ | 16 of 24 visible (GPS + GLONASS) |
| Signal strength | 35–45 dB-Hz | Mostly 20s to high 30s, a few weak ones in the teens |
| Time dilution of precision (TDOP) | under 1 | 0.75 |
The signal strength is below my target, but that target was a comfortable margin rather than a requirement. TDOP is the number that matters for timing: it measures how good the satellite geometry is for working out the time, and under 1 is excellent. A fixed timing receiver only needs a handful of satellites to keep the pulses coming, so this spot is more than adequate. The proof of the pudding, as we’ll see, is in the logs.
Chrony#
sudo apt install chronyOn Raspberry Pi OS this replaces systemd-timesyncd, which is what you want.
Keep Your Changes in conf.d#
Rather than editing /etc/chrony/chrony.conf, I put my additions in drop-in files. Debian’s stock config already reads them, which is easy to confirm:
grep -E '^(confdir|sourcedir|rtcsync)' /etc/chrony/chrony.confsourcedir /run/chrony-dhcp
sourcedir /etc/chrony/sources.d
rtcsync
confdir /etc/chrony/conf.dThat gives me three things for free. Package upgrades never stop to ask about a modified chrony.conf. It’s obvious at a glance what’s mine and what’s the default. And rtcsync is already on, so the kernel copies the system time to the RTC every 11 minutes.
One trap: despite the name, sources.d won’t do here. It only accepts server, pool and peer lines, in files ending .sources, and chrony rejects a refclock there. conf.d takes any directive, in files ending .conf.
I split mine into two files by purpose. First, the reference clocks:
# /etc/chrony/conf.d/gps.conf
refclock SHM 0 refid NMEA offset 0.2 delay 0.2 noselect
refclock PPS /dev/pps0 lock NMEA refid PPS preferSHM 0is the shared-memory segment that gpsd writes to.noselectmeans chrony never uses NMEA for timekeeping; it’s only there to number the seconds. Theoffset 0.2is a deliberate first guess at how late the sentences arrive, which gets tuned properly in the next section.PPS /dev/pps0is the pulse.lock NMEAties each pulse to the second NMEA says it is, andprefermakes it the source of choice.
Second, who may ask this server for the time (the subnets here are placeholders; use your own, as narrow as you can):
# /etc/chrony/conf.d/serve.conf
allow 192.168.10.0/24
allow 192.168.20.0/24I left the default pool lines alone. If the GPS ever drops out, chrony falls back to the internet.
To see what chrony makes of everything once it’s all merged, -p prints the parsed configuration and exits without starting a second daemon:
sudo chronyd -p | grep -E 'refclock|allow'
sudo systemctl restart chronyGetting the Lock#
A few minutes after the restart:
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
#? NMEA 0 4 377 16 -147ms[ -147ms] +/- 100ms
#* PPS 0 4 377 17 +419ns[+1455ns] +/- 236ns
^- (pool server) 2 7 377 87 +233us[ +235us] +/- 18ms
^- (pool server) 2 7 377 86 -603us[ -602us] +/- 7795usThe * next to PPS is the one you’re waiting for: it means PPS is the selected source, and the Pi is now a stratum 1 server.
A few things worth knowing about how to read this:
- Reach is an octal shift register of the last eight polls.
377is binary11111111, all eight good. When I first looked, PPS showed3(binary00000011, only two samples so far), and chrony won’t select a source until it has a few. Patience. - Last sample shows the offset now, with the raw measurement in square brackets. The
+/- 236nsis the error bound, and sub-microsecond is what we’re after. - The pool servers have dropped to
^-. They’re still being polled, but only as fallbacks. - NMEA shows
#?permanently, which is correct for anoselectsource. Its-147ms, though, is telling us something.
Tuning the NMEA Offset#
That -147ms on the NMEA line means my offset 0.2 guess was too big. It doesn’t stop anything working, since PPS does all the precise timing. But lock NMEA needs the two sources to agree to within half a second to number the pulses correctly, and a smaller error leaves more margin. It’s also nice to have it right.
One sample isn’t enough to tune from, so after letting it run for a while I asked sourcestats for chrony’s averaged view:
chronyc sourcestats | grep NMEANMEA 9 7 127 -0.975 10.686 -147ms 233usThe columns that matter here are the last two: an average offset of −147 ms, with a standard deviation of only 233 µs. The NMEA delay is late, but very consistently late, which is exactly what you want from something you’re about to correct with a constant.
The sum is straightforward. A negative offset means the corrected NMEA time lands ahead of the clock, so the real delay is smaller than the guess:
real delay = configured offset + measured offset
= 0.2 s + (−0.147 s)
= 0.053 sSo the NMEA sentence actually arrives 53 ms after the pulse at 115200 baud. In it goes:
sudo sed -i 's/offset 0.2 /offset 0.053 /' /etc/chrony/conf.d/gps.conf
sudo systemctl restart chronyA couple of minutes later:
#? NMEA 0 4 7 9 +114us[ -588us] +/- 100ms
#* PPS 0 4 7 10 +113ns[ -702us] +/- 330nsFrom −147 ms to +114 µs. PPS also came back after only three polls (reach 7) this time, quicker than the first lock, because the RTC and chrony’s saved frequency estimate gave it a head start.
One thing that briefly alarmed me straight after the restart:
NMEA 2 0 18 +0.000 2000.000 -78us 4000msA standard deviation of 4000 ms! With only two samples (the NP column), chrony hasn’t fitted a line through anything yet, and the 2000.000 and 4000ms are placeholders rather than measurements. Ten minutes later they were real numbers.
Proving It Works#
A lock at the moment you look is nice. A lock that held all night is better.
The Journal#
Chrony logs every change of selected source, so a quick grep shows any dropouts:
journalctl -u chrony --since yesterday | grep -Ei 'selected|unsynchron|can.t synchron'(--since "yesterday 20:00" looks reasonable but journalctl won’t parse it; use a full date like "2026-11-05 20:00" if you need a time.)
Nov 05 20:02:03 chronyd[680]: Selected source (pool server)
Nov 05 20:15:42 chronyd[3449]: Selected source (pool server)
Nov 05 20:16:24 chronyd[3449]: Selected source PPS
Nov 05 20:34:25 chronyd[6793]: Selected source (pool server)
Nov 05 20:35:07 chronyd[6793]: Selected source PPSThe number in brackets is chronyd’s process ID, so each change of PID is a restart: boot, then the restart with the refclock config, then the one after tuning the offset. Each time, PPS took over within about 40 seconds. After 20:35 there’s nothing at all, which means twelve hours on PPS without a single dropout from the temporary antenna position.
Turning On the Logs#
For proof over days rather than hours, chrony can log every sample. One more line in gps.conf (Debian’s stock config already sets logdir /var/log/chrony):
log tracking refclocksAfter a restart, two files appear in /var/log/chrony/. Debian ships a logrotate rule for them, so they won’t fill the SD card. All times in them are UTC.
refclocks.log: One Line per Raw Sample#
2026-11-06 08:35:25.999998 PPS 13 N 1 2.424000e-06 1.965172e-06 5.500e-08
│ │ │ │ │ │ │ └ assumed dispersion
│ │ │ │ │ │ └ error after chrony's filtering
│ │ │ │ │ └ raw local clock error from this sample (s)
│ │ │ │ └ 1 = PPS sample, 0 = not
│ │ │ └ leap status (N = normal)
│ │ └ driver poll sequence number
│ └ refid
└ timestamp of the sample, by the local clockThis sample says the local clock was 2.4 µs out at that pulse, a little high because chrony had restarted a minute earlier. The .999998 in the timestamp shows the same thing from the other side: the pulse marks an exact second, and the local clock read it 2 µs early.
The NMEA lines show the offset tuning at work. The raw column is the uncorrected delay, the next is after the 0.053 s offset:
2026-11-06 08:35:26.054597 NMEA 13 N 0 -5.462427e-02 -1.624728e-03 5.500e-08
raw: −54.6 ms after offset: −1.6 mstracking.log: One Line per Clock Update#
2026-11-06 08:35:28 PPS 1 11.828 0.008 -4.004e-07 N 1 3.291e-07 4.211e-07 1.000e-09 1.016e-05 4.510e-04
│ │ │ │ │ │ │ │ │ │ └ max error since last update
│ │ │ │ │ │ │ │ │ └ root dispersion
│ │ │ │ │ │ │ │ └ root delay
│ │ │ │ │ │ │ └ correction still being slewed in
│ │ │ │ │ │ └ std dev of offset
│ │ │ │ │ └ sources combined
│ │ │ │ └ offset of system clock (s)
│ │ │ └ ± error on frequency (ppm)
│ │ └ frequency correction (ppm)
│ └ stratum of reference
└ referenceThe frequency figure is my favourite number in the whole exercise. 11.828 ppm is how far the Pi’s crystal is off. Left to its own devices that would be:
11.828 × 10⁻⁶ × 86,400 s/day ≈ 1.02 s of drift per dayChrony corrects it continuously, and the value drifts slowly with the temperature in the cupboard.
Three consecutive lines caught the handover from the pool to the GPS after a restart:
| Time (UTC) | Reference | Stratum | Offset |
|---|---|---|---|
| 08:34:32 | pool server | 3 | +46.8 µs |
| 08:35:12 | PPS selected | 1 | +416 µs (one-off correction of the pool’s error) |
| 08:35:28 | PPS | 1 | −0.4 µs |
That middle line is quite telling: the pool had the clock 416 µs out, which is perfectly good by internet standards and over a thousand times worse than the GPS.
A Gap Checker#
PPS logs a sample every second, so any gap longer than that means the lock dropped. This prints any it finds:
sudo awk '$3=="PPS"{split($2,t,":"); s=t[1]*3600+t[2]*60+t[3];
if (p!="") {d=s-p; if (d<0) d+=86400; if (d>1.5) printf "gap %s %s %.0fs\n",$1,$2,d}
p=s}' /var/log/chrony/refclocks.logIt copes with midnight, though not with a gap that spans a log rotation. Silence after a week, and the attic extension cable stays on the shelf.
Serving the Network (and Managing Expectations)#
Here’s where the Pi 4 earns its place over the Pi 3. Once clients start syncing from the server, their own chronyc sourcestats will look disappointing next to the server’s, and it’s worth knowing why before you go hunting for a fault.
On the server, PPS shows a standard deviation of a couple of hundred nanoseconds. That’s a GPS edge going more or less straight into a GPIO interrupt. A client isn’t measuring the GPS, though; it’s measuring NTP packets, and every sample includes the round trip across the network:
PPS edge ──▶ GPIO ──▶ server kernel clock server sees this (~ns)
client ──▶ NIC ──▶ switch ──▶ [router, if another VLAN] ──▶ server NIC ──▶ kernel
◀──────────────────────── and back again ◀─────────────────────────
client sees this (~µs)Switch queueing, interrupt latency on both ends and, for clients on another VLAN, a hop through the router’s CPU all show up as jitter in the client’s figures. Expect tens of microseconds rather than hundreds of nanoseconds.
This is the Pi 3’s weak spot. Its Ethernet chip is on the USB bus, which polls in fixed time slots, so packets sit waiting a variable amount of time in both directions. That adds yet more jitter. The Pi 4 has proper native Ethernet, so a good chunk of that goes away.
There’s also one limit no statistic will show you. NTP assumes the trip out and the trip back take the same time; any asymmetry turns up as a constant offset, not as scatter. On a home LAN that’s also in the tens of microseconds.
None of which matters for my purposes. The meteor cameras need millisecond-level accuracy, so tens of microseconds is plenty in hand. For a better single number for how a client is really doing, chronyc tracking on the client shows an RMS offset, which is smoother than the per-sample scatter in sourcestats.
What’s Next?#
The time source itself is done. What’s left is mostly plumbing:
- A host firewall on the Pi. nftables accepting UDP 123 only from the subnets in
serve.conf, on top of the router’s rules, so a mistake in one layer doesn’t open up anything else on the box. - A router rule for the IoT VLAN, allowing NTP to the server and nothing more.
- Pointing the cameras at it, then watching their
chronyc trackingto see what the network actually costs. - A week of logs through the gap checker, to decide once and for all whether the antenna stays where it is.
One more idea for anyone running a service that genuinely needs a correct clock (certificate signing, for example). The RTC means the Pi boots with roughly the right time, but “roughly” isn’t always good enough. A systemd drop-in can make that service wait until chrony is properly synced:
[Unit]
After=chrony.service
Wants=chrony.service
[Service]
ExecStartPre=/usr/bin/chronyc waitsync 30 0.01That waits for up to 30 checks (about five minutes) for the clock to be within 10 ms. With the RTC on board it normally passes within seconds of boot.
As usual, all antenna-related plans are subject to the vagaries of what the British weather does to GPS signals in a cupboard. So far, it seems to be doing just fine.