I also got a heatsink for the Raspberry Pi so the fan on the PoE HAT would not need to run as much. Once everything was assembled, there was about 2 mm of clearance between the fan and the GPS HAT.

I ordered almost everything from Adafruit because they are close by so shipping is fast.


The Ultimate GPS HAT required soldering the included header. I have seen other GPS HATs that do not require soldering if that is a problem. I opted for this one because I had the tools, this is what Adafruit had, and rather wanted to try soldering.

Here is a component overview with everything that went into the build. The heatsink has already been applied.

Connect the stacking and 2x2 headers then attach the PoE HAT to them with the 15 mm standoffs to verify the PoE HAT works. The pins on the stacking header will go through the PoE HAT.

Use the standoffs included with the PoE HAT on top of the HAT and attach the GPS HAT on top of PoE and use the PoE HAT’s screws to secure everything together.

The PoE HAT was a bit finicky to use. The first time I connected the PoE HAT, everything was fine. I then disconnected everything for pictures, reconnected everything, soldered the header to the GPS HAT, and then the PoE HAT didn’t power on the Pi. I disconnected everything yet again, connected only the PoE HAT, and it worked again. I connected the GPS HAT a second time and it still worked so I don’t know what happened before.


All of the steps assume Raspbian 10 (Buster). Adjust the packages to install to your distro of choice if you are not using Raspbian. The configuration of the software should remain very similar.

Install everything needed now at once. I opted for chrony but NTP works well and needs only small translations of the chrony options. Contrary to all other documentation I have seen on this, you do not need to compile anything from scratch.

$ sudo apt install chrony gpsd gpsd-clients pps-tools

Serial Port

The serial port is used to receive the NMEA 0183 data from the GPS device. Logging in from the serial port must be disabled but the serial hardware kept enabled for communication.

Run sudo raspi-config, choose “Interface Options”.

$ sudo raspi-config
┌─────────┤ Raspberry Pi Software Configuration Tool (raspi-config) ├──────────┐
│                                                                              │
│       1 System Options       Configure system settings                       │
│       2 Display Options      Configure display settings                      │
│       3 Interface Options    Configure connections to peripherals            │
│       4 Performance Options  Configure performance settings                  │
│       5 Localisation Options Configure language and regional settings        │
│       6 Advanced Options     Configure advanced settings                     │
│       8 Update               Update this tool to the latest version          │
│       9 About raspi-config   Information about this configuration tool       │
│                                                                              │
│                                                                              │
│                                                                              │
│                                                                              │
│                                                                              │
│                     <Select>                     <Finish>                    │
│                                                                              │

Then choose “Serial Port”.

┌─────────┤ Raspberry Pi Software Configuration Tool (raspi-config) ├──────────┐
│                                                                              │
│    P1 Camera      Enable/disable connection to the Raspberry Pi Camera       │
│    P2 SSH         Enable/disable remote command line access using SSH        │
│    P3 VNC         Enable/disable graphical remote access using RealVNC       │
│    P4 SPI         Enable/disable automatic loading of SPI kernel module      │
│    P5 I2C         Enable/disable automatic loading of I2C kernel module      │
│    P6 Serial Port Enable/disable shell messages on the serial connection     │
│    P7 1-Wire      Enable/disable one-wire interface                          │
│    P8 Remote GPIO Enable/disable remote access to GPIO pins                  │
│                                                                              │
│                                                                              │
│                                                                              │
│                                                                              │
│                                                                              │
│                     <Select>                     <Back>                      │
│                                                                              │

Select “No” to disable the login shell over serial port and then “Yes” to keep the serial port hardware enabled. Press Tab to select “Finish”. The utility will prompt you to reboot so do that.

Once the Pi comes back up, it is time to test the GPS HAT. Set the mode for /dev/serial0 to raw, 9600 baud, 8 bits, no modem control signals, and one stop bit. See the stty(1) man page for more information on everything.

$ sudo stty -F /dev/serial0 raw 9600 cs8 clocal -cstopb

Now check /dev/serial0 to verify NMEA data appears.

$ cat /dev/serial0

There should be output regardless if there is a fix or not. Once there is a fix, more of the GPS data will be filled in with much less “mostly comma” output. This HAT has a LED that will blink every second when it is searching and slow to blinking every 15 seconds when it has a fix.

I was able to get a fix within a few minutes while indoors, 20 feet from a window, and without attaching the external antenna.

One Pulse Per Second (1PPS)

Use the pps-tools package to monitor the 1 Pulse Per Second GPS sends to get exactly when each GPS second begins.

Edit /boot/config.txt to enable kernel support for PPS. /boot/overlays/README documents the available overlays and pps-gpio is listed in there. The Adafruit GPS HAT uses GPIO pin 4 so add that to config.txt.


Reboot to use the overlay.

When the Pi boots up, test the signal is received with ppstest.

$ sudo ppstest /dev/pps0
trying PPS source "/dev/pps0"
found PPS source "/dev/pps0"
ok, found 1 source(s), now start fetching data...
source 0 - assert 1629489053.004789031, sequence: 98 - clear  0.000000000, sequence: 0
source 0 - assert 1629489054.004796073, sequence: 99 - clear  0.000000000, sequence: 0
source 0 - assert 1629489055.004806575, sequence: 100 - clear  0.000000000, sequence: 0


The gpsd package includes a systemd socket unit file, gpsd.socket, for systemd to automatically start GPSD when something connects to the default GPSD port of 2947. This is useful for regular GPS use but can be frustrating when debugging. My advice is to stop (sudo systemctl stop gpsd.socket) and disable (sudo systemctl disable gpsd.socket) the unit file if you need to debug GPSD. Leaving gpsd.socket disabled will be fine for timekeeping because gpsd.service will be used instead.

Edit /etc/default/gpsd to set DEVICES to use the serial port to receive GPS NMEA data and the pps0 device to receive the kernel’s PPS. Also set GPSD_OPTIONS to tell GPSD to immediately talk to the device and not wait for a client to connect.

DEVICES="/dev/serial0 /dev/pps0"

Restart GPSD to use those devices and enable it to start automatically at boot.

$ sudo systemctl restart gpsd.service
$ sudo systemctl enable gpsd.service

Run gpsmon or cgps -s to connect to GPSD and display the GPS information. Check for PPS data and PPS lines to appear in the packet log. Note, if the terminal is 24 lines tall, you will only see a single line of the packet log and it will be difficult to spot the PPS line.

$ gpsmon
tcp://localhost:2947          NMEA0183>
│Time: 2021-08-22T21:53:45.000Z Lat:  XX XX' XX.XXXX0" Non:  XX XX' XX.XXXX0" W│
└───────────────────────────────── Cooked TPV ─────────────────────────────────┘
└───────────────────────────────── Sentences ──────────────────────────────────┘
│Ch PRN  Az El S/N ││Time:      215345.000       ││Time:      215345.000       │
│ 0  XX  XX XX  41 ││Latitude:     4XXX.XXXX N   ││Latitude:  4XXX.XXXX        │
│ 1  XX  XX XX  37 ││Longitude:   07XXX.XXXX W   ││Longitude: 07XXX.XXXX       │
│ 2  XX XXX XX  28 ││Speed:     0.26             ││Altitude:  1XX.X            │
│ 3  XX XXX XX  36 ││Course:    94.750           ││Quality:   1   Sats: 08     │
│ 4  XX XXX XX  27 ││Status:    A       FAA: A   ││HDOP:      1.17             │
│ 5  XX  XX XX  21 ││MagVar:                     ││Geoid:     -34.2            │
│ 6  XX XXX XX   0 │└─────────── RMC ────────────┘└─────────── GGA ────────────┘
│ 7  XX XXX XX   0 │┌────────────────────────────┐┌────────────────────────────┐
│ 8  XX XXX XX   0 ││Mode: A3 Sats: 23 24 10 18  ││UTC: 215345.000RMS: 7.3     │
│ 9 XXX  XX XX   0 ││DOP: H=1.17  V=0.91  P=1.48 ││MAJ: 4.5       MIN: 4.0     │
│10  XX  XX XX  40 ││TOFF:  0.319084138          ││ORI: 164.5     LAT: 4.4     │
│11  XX 1XX XX  37 ││PPS:  0.000759500           ││LON: 4.1       ALT: 24      │
└────── GSV ───────┘└──────── GSA + PPS ─────────┘└─────────── GST ────────────┘
(72) $GPGRS,215344.000,1,-5.93,-0.14,0.83,36.9,3.35,9.75,-0.36,-2.94,,,,*72
(51) $GPGST,215344.000,7.4,4.4,3.9,150.3,4.3,4.0,23*40
------------------- PPS offset:  0.000759500 ------
(75) $GNGGA,215345.000,4XXX.XXXX,N,07XXX.XXXX,W,1,08,1.17,1XX.X,M,-34.2,M,,*4E
(52) $GPGSA,A,3,23,18,24,15,10,,,,,,,,1.48,1.17,0.91*0B
(48) $GLGSA,A,3,69,78,68,,,,,,,,,,1.48,1.17,0.91*12
(71) $GNRMC,215345.000,A,4XXX.XXXX,N,07XXX.XXXX,W,0.26,94.75,220821,,,A*5E
(35) $GNZDA,215345.000,22,08,2021,,*45
(71) $GPGRS,215345.000,1,-5.51,0.95,0.27,37.1,2.82,10.6,-2.84,-3.96,,,,*57
(51) $GPGST,215345.000,7.3,4.5,4.0,164.5,4.4,4.1,24*49
------------------- PPS offset:  0.000758989 ------

Note the “PPS offset” lines and the PPS time offset in the display. That means GPSD is receiving everything and can send the data to the desired timekeeping daemon.


Before configuring chrony, run ntpshmmon as root to find the shared memory (SHM) sources that GPSD is already populating. Despite its name starting with “ntp”, ntpshmmon is part of the gpsd package.

$ sudo ntpshmmon
ntpshmmon version 1
#      Name Seen@                Clock                Real                 L Prec
sample NTP0 1629680609.372849180 1629680608.363601521 1629680608.000000000 0 -20
sample NTP2 1629680609.373066773 1629680609.041970056 1629680609.000000000 0 -30
sample NTP0 1629680609.385268612 1629680609.384725334 1629680609.000000000 0 -20
sample NTP2 1629680610.042570932 1629680610.041973357 1629680610.000000000 0 -30

The precision (Prec) field is the precision of the clock scaled with log(2). The lower the value in the precision column, the more accurate that clock is.

Usually the NMEA clock is at NTP0 and 1PPS is at NTP1. With this HAT the 1PPS appears at NTP2 (note the more accurate precision of -30 vs -20).


Edit /etc/chrony/chrony.conf to use the clocks from GPSD. I kept the pool line to allow chrony to synchronize with hosts over the internet until it can determine the accuracy of the PPS clock. The precision is because chrony cannot read the precision from the shared memory segments. The delay is because the GPS NMEA data is read over a relatively slow serial port and the larger delay allows greater range for overlap when reading the two clocks. I will adjust it later once its offset is known.

refclock SHM 0 refid GPS precision 1e-1 delay 0.1
refclock SHM 2 refid PPS precision 1e-7

Restart chrony.

$ sudo systemctl restart chrony.service

Now wait a while and check periodically to see if chrony picked the PPS clock. When it does, “PPS” clock will have the prefix be #*. Any other sources mixed in with the chosen source will have a prefix of “+” while a prefix of “-“ or “x” are excluded sources.

I added the -n argument to make chronyc not try to resolve the IPs to display the output faster.

$ chronyc -n sources
210 Number of sources = 6
MS Name/IP address         Stratum Poll Reach LastRx Last sample
#x GPS                           0   4   377    17   +387ms[ +387ms] +/-  150ms
#* PPS                           0   4   377    18   -592ns[-1062ns] +/-  851ns

After waiting for sample times to settle, I used the offset under the “Last sample” column to set an offset for the GPS NMEA clock in the config to bring it closer to the PPS clock and prevent chrony from classifying it as a “falseticker” (the “x” in “#x”).

Edit /etc/chrony/chrony.conf to set an offset and then restart chrony.service.

refclock SHM 0 refid GPS precision 1e-1 offset 0.370

Note the addition of offset to add a fixed offset to that clock and the removal of delay because the clock is approximately correct with an offset.

Serving Time

If you are not interested in using the Raspberry Pi as a time server then you are done. If you are interested, continue onwards.


Edit /etc/chrony/chrony.conf to enable other clients to connect to chronyd.

# If you also have IPv6 on the subnet.
allow 2600:8394:a947:bf::/64

Restart chrony to enable serving time.

$ sudo systemctl restart chrony.service


I use pfSense as a router and first set the Raspberry Pi to have a fixed IP address and then added that IP address as an NTP server on the LAN interface. My Fedora laptop automatically picked up the new NTP server but a different Raspberry Pi running Debian and a FreeBSD server did not.

$ chronyc -n sources
MS Name/IP address         Stratum Poll Reach LastRx Last sample
^? ...
^*                   1   9   377   469    -11us[ -114us] +/- 1260us

That is almost one millisecond accuracy over WiFi. The other Raspberry Pi averages 300 µs accuracy over Ethernet.