Fixing the Adafruit Ultimate GPS with USB-C Linux Driver

2023 Sep 30 - Brian Kloppenborg

In my previous post on the Adafruit Ultimate GPS with USB-C, I explored an issue related to the PPS signal being connected to the Ring Indicator (RI) pin, but undetectable by ppscheck or gpsd. I’m happy to report, that I’ve figured out the problem and implemented a solution to the problem.

I’ve submitted the relevant changes to the Linux kernel, but if you need a working driver earlier, visit

Adafruit GPS module providing PPS signals to chrony with an average of 9 microseconds error relative to the system clock.

What was the problem?

The Adafruit Ultimate GPS uses a Silicon Labs CP2012N USB to serial (UART) adapter to convert the RS-232 signals from the GPS module to USB. Unfortunately, neither the driver from the manufacturer nor the driver in the Linux kernel implemented the relevant functions to detect changes in the modem status lines.

What specifically was missing?

According to the data sheet the CP2102N can, in fact, read signals from the modem status lines; however, this feature is not enabled by default. Instead, it must be explicitly activated by the driver or interfacing application. After much digging, I found that these signals are provided by EMBED_EVENTS as described in AN571: CP210x Virtual COM Port Interface. Once enabled, these events are presented as two to four characters that are interleaved with the rest of the data stream. Each event begins with a user-selected escape character and is followed by pre-defined values (see Section 5.28 of AN571 for further information). Unfortunately, this way of presenting the data means that every character coming from the device must be checked. Once a modem status change is detected, it needs to signal the kernel using IOCTL mechanisms to be usable by either ppscheck or gpsd.

Having known nothing about how the Linux kernel handles USB or serial devices, this was a bit complicated to figure out. I ended up spending quite a lot of time reading through source code from the GPSD, Linux Kernel, and Fortian Inc repositories. I paid particular attention to these files:

Normally a PPS signal takes this path:

  1. PPS signal from the GPS receiver is sent to the DCD pin.
  2. The USB to serial adapter detects this change and sends it to the host, when polled, in the form of a USB Request Block (URB).
  3. Most USB device drivers I’ve inspected override the default usb_serial_generic_process_read_urb function by replacing the function pointed to by usb_serial_driver.process_read_urb. This new function processes the packets and looks for state changes on the DCD pin. When a change is detected, the driver calls usb_serial_handle_dcd_change found in generic.c.
  4. The primary job of usb_serial_handle_dcd_change is to get the TTY line discipline for the port and call the tty_ldisc_ops.dcd_change function which registers the PPS event with the kernel.

Interestingly, the ONLY file in the Linux kernel source that populates tty_ldisc_ops.dcd_change is pps-ldisc.c:

$ cd linux
$ grep -r "\.dcd_change" *
drivers/pps/clients/pps-ldisc.c:	pps_ldisc_ops.dcd_change = pps_tty_dcd_change;

This means that you can safely call usb_serial_handle_dcd_change for any PPS signal, even if it doesn’t appear on the DCD pin.

What changes were required to the stock cp210x driver?

In the end, the stock cp210x driver had most of what was needed. The only modifications were as follows:

One quick note about gpsd, ppscheck, and gpsmon

While testing my driver modifications, I found an interesting difference between gpsmon /dev/ttyUSB0 and sudo gpsmon /dev/ttyUSB0. The former attempts to get time using only TIOCMIWAIT. Running

gpsmon --debug 10 --logfile gpsmon_normal.log /dev/ttyUSB0

and searching the log

grep "PPS" gpsmon_normal.log
gpsmon:PROG: KPPS:/dev/ttyUSB0 checking /sys/devices/virtual/pps/pps0/path, /dev/ttyUSB0
gpsmon:INFO: KPPS:/dev/ttyUSB0 running as 1000/1000, cannot open /dev/pps0: Permission denied(13)
gpsmon:WARN: KPPS:/dev/ttyUSB0 kernel PPS unavailable, PPS accuracy will suffer
gpsmon:PROG: PPS:/dev/ttyUSB0 thread launched
gpsmon:PROG: TPPS:/dev/ttyUSB0 ioctl(TIOCMIWAIT) succeeded, time: 1696189330.000203832,  state: 0
gpsmon:PROG: TPPS:/dev/ttyUSB0 Clear, cycle: 1696189330000203, duration: 1696189330000203 @  1696189330.000203832
gpsmon:PROG: PPS:/dev/ttyUSB0 Clear cycle: 1696189330000203, duration: 1696189330000203 @  1696189330.000203832

whereas launching it will sudo will cause gpsmon to use KPPS + PPS_CANWAIT or KPPS + TIOCMIWAIT.

sudo gpsmon --debug 10 --logfile gpsmon_sudo.log /dev/ttyUSB0
grep "PPS" gpsmon_sudo.log
gpsmon:PROG: KPPS:/dev/ttyUSB0 checking /sys/devices/virtual/pps/pps0/path, /dev/ttyUSB0
gpsmon:INFO: KPPS:/dev/ttyUSB0 RFC2783 path:/dev/pps0, fd 5
gpsmon:INFO: KPPS:/dev/ttyUSB0 pps_caps 0x1133
gpsmon:INFO: KPPS:/dev/ttyUSB0 have PPS_CANWAIT
gpsmon:INFO: KPPS:/dev/ttyUSB0 kernel PPS will be used
gpsmon:PROG: PPS:/dev/ttyUSB0 thread launched
gpsmon:INFO: KPPS:/dev/ttyUSB0 kernel PPS timeout Connection timed out(110)

Because the Adafruit module puts PPS on the RI line and my original implementation of the driver only accounted for TIOCMIWAIT, neither sudo gpsmon and the systemd instance of gpsmon couldn’t read the PPS signal! The solution was quite simple: add a call to usb_serial_handle_dcd_change. There is more discussion on this topic on the gpsd GitLab.

Wrapping it up

As a person who has never dove this deep into the Linux kernel device drivers, this took a lot longer to figure out than I anticipated. Nevertheless, I got it working!

As stated above, the patches have been submitted to the Kernel, so it should eventually make it into your distribution. If you need support earlier, you can get a copy of the driver along with build and installation instructions here: