A GPS/PPS stratum-1 NTP server on a Pi 3B
I already selfhost so much, why not selfhost time itself!?!?.
Precise time is extremely important, especially if you're homelabbing and use a central syslog server. Without synchronised clocks, how can you possibly know if event A from server A happened before or after event B from server B ?
For this reason I already had all my servers set up to use my opnsense cluster for NTP. This was never going to be the most precise time and I knew that. But what mattered more was that the at least all servers had the same time, so even if my network was completely offline for a while and stuff started drifting, it was all equally wrong.
Then, recently, I took 2 weeks off and was thinking about what absolutely overkill and unnecessary project I could do next. This is when building a gps based NTP server came to mind again and I figured that sounds like a lot of fun!
I was initially really stoked to see how cheap all of this can start but quickly realized that I won't be satisfied with the cheap stuff since most of these modules don't give me access to the pps signal that I would need to actually get a more precise absolute time than what I can currently already achieve via the ordinary internet.
After a bit of searching around I landed on the SparkFun NEO-M9N. It is a bit pricy but it looked like it will be able to offer everything I could ever ask for from such a module.

I will connect this thing to a raspberry-pi 3B since it has ethernet and I wouldn't want to run a NTP server on my Pi-zero 2w's over wifi.
After wiring everything up, I enabled uart on my pi and told it that GPIO-Pin-18 is now a pps device by placing the following into /boot/firmware/config.txt.
dtoverlay=pps-gpio,gpiopin=18
Then it was time to see if it even works.
A quick ppstest /dev/pps0 showed the following:
trying PPS source "/dev/pps0"
found PPS source "/dev/pps0"
ok, found 1 source(s), now start fetching data...
source 0 - assert 1785533365.000001884, sequence: 412151 - clear 0.000000000, sequence: 0
source 0 - assert 1785533366.000002093, sequence: 412152 - clear 0.000000000, sequence: 0
source 0 - assert 1785533367.000001654, sequence: 412153 - clear 0.000000000, sequence: 0
source 0 - assert 1785533368.000002361, sequence: 412154 - clear 0.000000000, sequence: 0
source 0 - assert 1785533368.999999005, sequence: 412155 - clear 0.000000000, sequence: 0
source 0 - assert 1785533369.999997941, sequence: 412156 - clear 0.000000000, sequence: 0
source 0 - assert 1785533370.999998596, sequence: 412157 - clear 0.000000000, sequence: 0
source 0 - assert 1785533371.999998521, sequence: 412158 - clear 0.000000000, sequence: 0
source 0 - assert 1785533372.999997822, sequence: 412159 - clear 0.000000000, sequence: 0
Great! The signals work. I set up gpsd and started it as a systemd service so that Chrony can actually access the relevant data in the next step.
Up to the chrony.conf now.
Here we have to make sure that some things are correct.
The two relevant lines in my config are:
refclock SHM 0 refid NMEA offset 0.0 precision 1e-3 poll 3 noselect
refclock PPS /dev/pps0 refid PPS lock NMEA prefer poll 3
This tells chrony to read from the shared memory segment number 0 for the NMEA source, which is important in order to know which second currently is.
The second line then tells chrony to read a pps (pulse-per-second) signal from /dev/pps0, assign it the ID "PPS", then lock NMEA tells it to lock the absolute time to whatever the source with the ID NMEA claims it to be. This is extremely important since the PPS signal alone only says that a second has passed, but not which second. Only in combination with the NMEA source does the pps signal start to shine.
I then also added some generic NTP servers with nts support as a fallback.
Time...(pun intended) to check what chrony thinks of all of this.
.-- Source mode '^' = server, '=' = peer, '#' = local clock.
/ .- Source state '*' = current best, '+' = combined, '-' = not combined,
| / 'x' = may be in error, '~' = too variable, '?' = unusable.
|| .- xxxx [ yyyy ] +/- zzzz
|| Reachability register (octal) -. | xxxx = adjusted offset,
|| Log2(Polling interval) --. | | yyyy = measured offset,
|| \ | | zzzz = estimated error.
|| | | \
MS Name/IP address Stratum Poll Reach LastRx Last sample
===============================================================================
#? NMEA 0 3 377 5 +65ms[ +65ms] +/- 1000us
#* PPS 0 3 377 4 +618ns[ +623ns] +/- 687ns
Great, we are syncing from the pps signal with a max error of +/- 687ns. Most of the error comes from the oscillator of the pi but unless I start using PTP, everything under a few μs is more than enough.
Me being me, I obviously needed some proper monitoring of all of this too because where is the fun in ultra precise time if you can't even show off with it? For this I'm using SuperQ's Chrony exporter and his dashboard (19186).

According to this, my maximum clock error is within a few μs, but this is only half of the truth since this is the new stratum 1 timesource itself. I need to check how this looks on a real client that syncs from this. For this lets check what my main proxmox server says.

This is really great, in the top right graph we can see that the maximum actual error/offset is within +/- 20μs in the worst case and mostly stays below +/- 10μs.
To close this first technical article on my blog I would like to show you this image of my poor raspberry-pi, you can see the wired up GPS module in the right, but that Pi is doing a lot more than just being my local time server. Maybe I'll go into detail for some of the many other wires in a future blog post.
