OpenBSD 6.9 time server with Meinberg clocks

Tick. Tock. Tick. Tock.§

Time is important. Debugging between machines will become a pain if the timestamps are incorrect.

With OpenBSD, a serial port and a Meinberg clock module, like the DCF600HS (or older COM52HS), you can create your own stratum 1 time server, receiving the DCF77 longwave time signal!

If you do not have a good serial port, a USB FTDI adapter will do as well. Not sure if there is a big difference between different USB serial chips or vendors. YMMV.

Step 1: Prepare the Hardware§

You need a serial port (preferrably not USB) of your machine wired to a Meinberg module with a straight cable. The module itself probably needs its antenna attached and pointing to Mainflingen.

Well, unless you are there on vacation, then you just need to hold the module at the right angle. Tested that for you! ;)

Step 2: Temporary Timedelta§

While the Meinberg Standard Time String is, well, standard on all Meinberg clocks, it is not always on the same serial setup.

In my case, my COM52HS outputs a 9600 Baud, 7-databit, 2 stop-bit signal with even parity.

To make OpenBSD aware of the clock, we need to attach a serial line discipline to it. Specifically, msts(4):

# dmesg | grep ucom
ucom0 at uftdi0 portno 1
# ldattach -7e2s 9600 msts cuaU0 # attach the line discipline
# sysctl hw.sensors.msts0   # check hw.sensors node for first msts sensor
hw.sensors.msts0.percent0=100.00% (Signal), OK
hw.sensors.msts0.timedelta0=0.005555 secs (MSTS), OK, Mon Aug  2 00:38:11.004

I attached the line discipline with ldattach(8) on cuaU0, which is the call out device of ttyU0, which is provided by ucom0 attached to the uftdi0 driver. Phew.

The hw.sensors framework has timedelta sensors, which is what you are seeing here. This timedelta0 shows that the last received msts(4) string contained a timestamp which was ahead of the local clock by 5.555 milliseconds.

In an unsyncronized state, the offset might be a lot more than that.

Step 3: Make it persistent§

Since we now know that it works, it's time to make it permanent.

# tail -n 5 /etc/ttys
ttyTZ   none                            network

# Clocks
ttyU0 "/sbin/ldattach -7e2s 9600 msts" unknown on softcar

This line in ttys(5) calls the ldattach(8) command on attach. Note that we used ttyU0 instead of cuaU0 here because of a note in ldattach(8). (Both work, YMMV.)

Reboot and the msts(4) sensor should still exist. Yay!

Step 4: NTP§

Now that we have a persistent timedelta sensor, it's time to configure ntpd(8), better known as OpenNTPD.

# cat /etc/ntpd.conf
# To get a baseline correct time, configure a few NTP server.
#server                # Stratum 1, PTB also provides the source for the DCF77 senders in Germany.
#server # Stratum 1 hosted by the uni stuttgart.
server                       # Internal, synced to the above and more.
server             # Cloudflare's time service. Pretty good latency, stratum 3. I use this as fallback.

# We want to attach msts0 with the standard refid DCF, correct it with a known 1ms delay.
# Given a weight of 2, this has higher priority than a single other clock. (which have weight 1)
sensor msts0 refid DCF correction 1000 weight 2

# To get more safety that we are indeed not too far from the truth,
# configure some constraints that certain services should always work.
# *sigh*
constraint from ""              # quad9 v4 without DNS
constraint from "2620:fe::fe"          # quad9 v6 without DNS
constraints from ""      # intentionally not

# Now that we are certain we have decent time, lets share it.
listen on *                            # Bind to all interfaces. Make sure you want this.

# rcctl enable ntpd
# rcctl start ntpd
# # wait some time

Step 5: Profit§

Now that you have waited a while, it's time to check the results.

# ntpctl -s all
2/2 peers valid, 1/1 sensors valid, constraint offset -1s, clock synced, stratum 1

   wt tl st  next  poll          offset       delay      jitter
    1 10  3  110s  766s         8.585ms    11.372ms     2.557ms
    1 10  2  383s  772s        13.309ms     5.773ms     8.913ms

   wt gd st  next  poll          offset  correction
msts0  DCF
 *  2  1  0    8s   15s        -1.485ms     1.000ms

Even though the clock is attached via a shoddy USB serial cable, ntpd(8) seems to like it more than NTP servers. Yay! With a real PCI/PCIe serial port, the offset should be smaller as there is a lot less jitter.

Whew. Now you just need to point any NTP client to your box and.. TADA!


There are a couple of ways to improve this setup.

  1. Using a hardware serial port will result in better timekeeping, as mentioned above. Curiosity will probably get the best of me and I'll build a latency testing setup. Or maybe I'll just graph drift with multiple devices attached to the same clock.

  2. Adding another time source. Whether it is another Meinberg clock or a nmea(4)-compatible GNSS receiver, you will get a lot more reliable time source if you have more than one.

  3. Not using OpenBSD but Linux. Ouch! :( This is because OpenBSD has ntpd(8) but not chrony.

chrony lets you compensate local oscillator drift by using a temperature sensor. It also has some improvements in the NTP protocol which improves accuracy. Cloudflare runs it, seems to work well for them.

I wish someone would port it to OpenBSD and integrate the sensor framework. Maybe I will. Or I'll attempt to make ntpd(8) do some of those tricks. I've also been wondering if I can make this janky setup more reliable using software.

Seemingly all timedelta sensors update once every second, ntpd(8) polls every 15 seconds. Maybe if I change it to poll every second and average more values, I'll get a less jumpy clock offset.

We'll see.