~/writing/jankgps-ts2phc-gpsd-demux
ts2phc and gpsd cannot share a serial port
One USB serial port, two programs that both need exclusive access. ts2phc wants RMC-only for PTP clock discipline; gpsd wants everything else. I wrote a demuxer that gives each one a synthetic port shaped exactly the way it expects.
A u-blox NEO-M9N shows up on Linux as one USB serial device, /dev/ttyACM0. That single port is the whole problem. Two programs both want all of it, exclusively, at the same time.
ts2phc is the linuxptp tool that disciplines a NIC's PTP hardware clock from a GPS. It reads NMEA off the serial port to learn the time of the second, timestamps the PPS pulse for the exact instant. gpsd I want for everything else: satellite count, fix quality, dilution of precision, feeding a chrony SHM refclock and a Prometheus exporter. Both open the port O_EXCL. Run one or the other. Not both.
There's a second, sharper constraint that kills the obvious workarounds. ts2phc wants the serial port to carry RMC sentences and nothing else. Point it at the full NMEA spread gpsd likes (GGA, GSV, VTG, ZDA) and it gets unhappy. A naive tee of the raw port doesn't fix it; it'd feed ts2phc a stream it doesn't want.
[global]
ts2phc.nmea_serialport /dev/ttyACM0
ts2phc.nmea_baudrate 115200
# ts2phc wants RMC and nothing else on this port.
[enp2s0]
ts2phc.master 0
ts2phc.pin_index 0
ts2phc.extts_polarity risingStop sharing the port, start owning it
One process owns /dev/ttyACM0 outright and hands each consumer a synthetic port shaped the way it expects. ts2phc gets a virtual serial port with only RMC. gpsd gets the full NMEA set. Neither touches the real device, so the exclusivity fight disappears. There's one opener, and it's mine.
The u-blox speaks two protocols on that one port: NMEA (comma-delimited ASCII) and UBX, the binary protocol with richer data and a tighter frame. They're interleaved on the same byte stream. Demuxing them is simpler than it sounds; the two framings start with distinguishable bytes. UBX frames begin with sync pair 0xB5 0x62; NMEA sentences begin with $. The reader is a small state machine over the byte stream:
b := d.buf[d.pos]
switch {
case b == ubx.SyncA: // 0xB5, start of a UBX frame
if err := d.readUBX(); err != nil {
return err
}
case b == '$': // start of an NMEA sentence
if err := d.readNMEA(); err != nil {
return err
}
default:
d.pos++ // junk byte between frames, skip it
}UBX frames go to a handler that decodes NAV-PVT (position, velocity, time in one message) and the rest of the nav messages. NMEA passes through. From there the daemon fans the data out to both consumers, each in its preferred shape.
A PTY that only ever says RMC
For ts2phc, the synthetic port is a PTY. Open a pair, symlink the slave end to a stable path, and point ts2phc's config at that path instead of the real device. ts2phc opens the symlink, gets the slave, reads it like an ordinary serial port. The daemon writes to the master.
master, slave, err := pty.Open()
// ...
os.Remove(linkPath) // clear any stale link
os.Symlink(slave.Name(), linkPath) // /run/jankgps/ts2phc -> /dev/pts/NThe config change is a one-liner: serial port is now the symlink, not the device.
ts2phc.nmea_serialport /run/jankgps/ts2phcThe daemon writes exactly one sentence type to that PTY. NAV-PVT arrives, it synthesizes an RMC and writes it to the master. Real NMEA comes through, it forwards to the PTY only if it's RMC. Everything else gets dropped on this path. ts2phc sees a clean, RMC-only port. That's the diet it asked for.
// TCP (gpsd) gets everything; the PTY (ts2phc) gets only RMC.
if h.tcp != nil {
h.tcp.Broadcast(sentence)
}
if h.pty != nil && isRMC(sentence) {
_ = h.pty.Write(sentence)
}gpsd gets the firehose over TCP
gpsd doesn't need a serial device. It can read NMEA from a TCP source. So the other export is a plain TCP listener on :2948 that broadcasts every sentence to every connected client and drops clients that stop reading. gpsd connects as a source, and from there the usual chain works unchanged: gpsd to a chrony SHM refclock, gpsd to a Prometheus exporter.
Synthesize, don't just forward
Because the daemon decodes UBX NAV-PVT, it can build NMEA sentences itself rather than passing through whatever the module emits. The NMEA the module sends and the NMEA each consumer receives are decoupled. I can run the module in a mostly-UBX config for the richer data and still hand ts2phc and gpsd well-formed NMEA derived from the binary messages. The serial port stops being a fixed pipe and becomes something I shape per consumer.
Metrics, including ts2phc grading itself
Since the daemon's already decoding every nav message, exposing GPS metrics is nearly free: satellites tracked, fix type, dilution of precision, all the NAV-SAT and NAV-PVT fields, on a Prometheus endpoint alongside node_exporter and the chrony exporter.
The part I like is that the daemon also watches ts2phc grade itself. ts2phc logs its servo state to the journal: clock offset, frequency correction, NMEA delay. Rather than parsing PTP internals, the daemon tails journalctl -u ts2phc -f and scrapes those lines into metrics:
offsetRegex = regexp.MustCompile(`([^ ]+) offset\s+(-?\d+)\s+s\d\s+freq\s+(-?\d+)`)
nmeaDelayRegex = regexp.MustCompile(`nmea delay: (\d+) ns`)So the same daemon feeding ts2phc its time also reports, in ns, how well ts2phc thinks it's doing. The thing producing the input and the thing measuring the output are the same process. The whole timing chain is observable from one scrape target.
The antenna cable delay (fixed propagation time from antenna down the coax to the receiver) is a configurable offset on the PPS path, defaulting to 38 ns. You measure it once for your install and forget it.
What it is and is not
The README calls this "a pretty crappy ublox demuxer," and there's some truth in that. The newer u-blox config interface (CFG-VALSET) is thinly documented and took some trial and error. But the shape is right. When two programs both demand exclusive ownership of one device, and one of them is picky about the exact format it's fed, you don't pick a winner and you don't tee raw bytes. You own the device once and give each consumer a tailored view: a PTY speaking only RMC for the timing daemon, a TCP firehose for gpsd, a metrics endpoint watching the whole chain from outside.
The code is on GitHub.