No description
  • Python 53.2%
  • Shell 42.8%
  • HTML 4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-22 07:29:08 +01:00
bin Sky view: time-weight the per-direction mean instead of accumulating 2026-08-17 17:37:36 +01:00
docs Reserve CPU0 for the PPS interrupt; raw jitter sigma 1360->759 ns 2026-08-02 21:34:53 +01:00
etc Sky view: log per-direction signal history and plot it 2026-08-17 15:34:36 +01:00
web Pi hardware monitoring: vcgencmd collector + temp-vs-freq page 2026-08-01 13:35:47 +01:00
.gitignore Remove stray __pycache__, add .gitignore 2026-08-01 14:35:13 +01:00
README.md GNSS telemetry: tDOP, elevation profile, orbit data, raw observables 2026-08-11 22:26:01 +01:00
setup.sh GNSS telemetry: tDOP, elevation profile, orbit data, raw observables 2026-08-11 22:26:01 +01:00
verify.sh Verify: check chrony's live HW timestamping, not the boot journal 2026-08-19 17:12:26 +01:00

rpi-ntp-server

Repeatable process for building a stratum-1 NTP server from a blank microSD card, a Raspberry Pi 4, and a Waveshare NEO-M8T GNSS Timing HAT. Going from blank card to verified server takes about 20 minutes plus GPS lock time. (A Pi 5 also works — the config block covers both models automatically, and on a Pi 5 the setup additionally serves PTP from the NIC's hardware clock and hardware-timestamps NTP packets.)

How it works: the NEO-M8T sends NMEA sentences over the Pi's UART (GPIO14/15 → /dev/ttyAMA0) and a pulse-per-second signal on GPIO18 (→ /dev/pps0). gpsd reads both and feeds them to chrony, which disciplines the system clock to the PPS edge (tens of microseconds accuracy or better) and serves NTP to the LAN.

On the Pi 4 the setup disables Bluetooth: the only timing-grade UART (the PL011) is wired to the Bluetooth radio by default, and the mini-UART alternative is unusable for a refclock because its clock varies with the core frequency. An NTP server has no use for Bluetooth, so it loses.

0. One-time hardware prep

Only needed the first time you assemble the hardware — not per SD card.

  1. Set the HAT's jumpers to the B (Pi ↔ GNSS) position so the module talks to the Pi's GPIO UART, not the micro-USB port.
  2. Screw the GNSS antenna onto the u.FL/SMA pigtail and place it with sky view — a windowsill works, outdoors or roofline is better. Antenna side without the sticker faces up.
  3. Seat the HAT on the Pi 5's 40-pin header.

1. Flash the SD card

Use Raspberry Pi Imager on your workstation:

  1. Device: Raspberry Pi 4 → OS: Raspberry Pi OS Lite (64-bit) (Bookworm) → your SD card.
  2. In Edit settings before writing:
    • Hostname: ntp-pi (or your choice)
    • Enable SSH, public-key auth only; paste your public key
    • Set username (e.g. andy) — skip the password if using keys
    • Wi-Fi: leave unconfigured if using Ethernet (recommended for an NTP server)
    • Locale/timezone as appropriate (NTP itself is UTC; this only affects logs)
  3. Write, then move the card to the Pi and power on.

Give it a static IP or a DHCP reservation on your router — clients will reference this address.

2. Provision

SSH in and run the setup script:

ssh andy@ntp-pi.local
sudo apt-get install -y git
git clone https://code.a10r.net/beezly/rpi-ntp-server.git
cd rpi-ntp-server
sudo ./setup.sh
sudo reboot

setup.sh is idempotent — re-run it any time. It:

  • installs gpsd, gpsd-clients, chrony, pps-tools
  • adds a model-conditional block to /boot/firmware/config.txt: on a Pi 4, enable_uart=1 + dtoverlay=disable-bt (frees the PL011 UART from Bluetooth); on a Pi 5, dtparam=uart0=on; on both, dtoverlay=pps-gpio,gpiopin=18,capture_clear
  • strips any serial console from cmdline.txt so nothing else claims the UART, and disables the hciuart Bluetooth service on a Pi 4
  • installs etc/default/gpsd (fixed devices /dev/ttyAMA0 + /dev/pps0, -n so it polls without clients)
  • installs etc/chrony/conf.d/gps-pps.conf (NMEA as the labelling source, PPS as the preferred refclock, LAN allow rules, monitoring logs)
  • installs Netdata from the official repo (web dashboard; the Debian archive build lacks the chrony collector) with a bounded metrics-DB retention (etc/netdata/netdata.conf)
  • adds national-lab stratum-1 NTP servers (NPL, PTB, Netnod) as cross-check and fallback sources (etc/chrony/conf.d/external-servers.conf)
  • on a Pi 5 only: installs linuxptp, serves PTP from the NIC's hardware clock, and enables hardware timestamping of NTP packets (see PTP grandmaster)
  • installs log2ram (RAM-backed /var/log, synced to the card hourly and at shutdown), caps the journal at 32 MB, and rotates chrony's logs hourly (etc/logrotate.d/chrony) — SD-card wear protection for an appliance that runs unattended for years
  • installs the clock-accuracy collector (bin/clock-stats.py) that feeds the status page's charts
  • enables gpsd and chrony

The reboot is required for the device-tree (UART/PPS) changes.

3. Verify

After reboot, wait a few minutes for a GPS fix, then:

cd rpi-ntp-server
./verify.sh

All checks should pass. Useful commands while waiting:

Command What you should see
cgps -s 3D fix, satellites in view
sudo ppstest /dev/pps0 one assert line per second
chronyc sources -v #* PPS selected, #? NMEA marked noselect
chronyc tracking Reference ID = PPS, offsets in the µs range

From another machine: ntpdate -q ntp-pi.local or add server ntp-pi.local iburst prefer to its chrony/ntp config.

4. Receiver tuning (automatic)

gnss-tune.service runs bin/tune-receiver.sh at every boot: it sets the M8T's stationary dynamic model, configures GPS+GLONASS (Galileo is deliberately disabled — enabling it on TIM 1.10 firmware corrupted the receiver clock model until all measurements were rejected; SBAS is disabled as useless for timing), and starts a survey-in (≥ 1 h, to 5 m), after which the receiver holds its position fixed and produces PPS from as little as one satellite — the "T" in M8T. Check survey progress / result with (gpsd must release the port — polls routed through a running gpsd don't get answered):

sudo systemctl stop gpsd.socket gpsd
sudo env UBXOPTS="-P 18 -w 6" ubxtool -f /dev/ttyAMA0 -s 9600 -p TIM-SVIN
sudo systemctl start gpsd.socket gpsd

Two related tweaks are part of the build: force_turbo=1 pins the CPU clocks (frequency scaling adds jitter to the PPS interrupt timestamp), and eee-off.service disables Energy-Efficient Ethernet, whose sleep/wake transitions add jitter to served packets. On clients, use chrony's xleave option against this server for best results.

PTP grandmaster (Pi 5 / CM5 only)

The Pi 5's RP1 Ethernet controller has a PTP hardware clock (PHC) with hardware packet timestamping — the Pi 4's NIC has neither. On a Pi 5 or CM5, setup.sh adds two things on top of the NTP service:

GPS PPS ──GPIO18──▶ chrony (system clock)
                       │
                       ├─ hwtimestamp eth0 ──▶ NTP, hardware-timestamped
                       │
                       └─ phc2sys ──▶ NIC PHC ──▶ ptp4l ──▶ PTP on the LAN
  • etc/chrony/conf.d/hwtimestamp.conf makes chrony timestamp NTP packets in the NIC instead of the kernel, removing network-stack scheduling jitter from what clients see.
  • phc2sys.service steers the PHC from the GPS-disciplined system clock (note: the reverse of the distro unit's direction, which assumes a PTP client), and ptp4l.service (etc/linuxptp/ptp4l.conf) serves it as a master-only grandmaster with hardware timestamps. The PHC runs on the PTP (TAI) timescale; ptp4l announces the UTC offset.

phc2sys runs with -S 1 (step whenever more than a second out) and this is not optional. Its default is to step once at startup and slew from then on, and the RP1's PHC does not survive an eth0 link flap: a 39-second link-down window during boot on 2026-08-10 left the hardware clock 777,778 s out, whereupon phc2sys pinned itself at the maximum 64000 ppm slew — a correction that would have completed some time in the 25th century — while ptp4l went on announcing itself as a grandmaster serving that time. It ran that way for 20 hours. The netdata alarm below was CRITICAL throughout, which is what alarms are for; there was simply nobody subscribed to it.

Which PHC eth0 timestamps against (CM5)

On the CM5 IO board this needs pinning down explicitly. The board wires a PHY interrupt that the Pi 5 Model B does not (ethernet-phy@0 in bcm2712-rpi-cm5l-cm5io.dtb against ethernet-phy@1 in bcm2712-rpi-5-b.dtb), and with CONFIG_BCM_NET_PHYPTP=y the BCM54213PE PHY then registers a PHC of its own. Being probed first it takes provider index 0, demoting the MAC's gem-ptp-timer to ptp1:

ptp0 → …ethernet-ffffffff:00   (MDIO PHY)  = bcm_phy_ptp
ptp1 → eth0                    (MAC)       = gem-ptp-timer

The PHY timestamper never returns a TX timestamp, so ptp4l cycles MASTER → FAULTY → LISTENING indefinitely ("timed out while polling for tx timestamp") and chrony falls back to TX-only timestamping. It is also reached over MDIO rather than memory-mapped registers, which costs about four orders of magnitude in transfer quality:

phc2sys PHY (ptp0) MAC (ptp1)
delay 214,423 ns 37 ns
steering rms 2,19512,854 ns 2541 ns

eth0-hwtstamp.service runs bin/set-hwtstamp-provider.sh before ptp4l, phc2sys and chrony start, selecting the PHC whose sysfs parent is the net device itself — the MAC's. The selection is runtime-only and does not survive a reboot, which is why it is a unit and not a one-off.

Selecting a provider needs ethtool ≥ 7.0 (--set-hwtimestamp-cfg); trixie ships 6.14.2, which can only show it. setup.sh therefore builds 7.x as /usr/local/sbin/ethtool-7, leaving the distro binary alone. Drop that once Debian stable ships ≥ 7.0.

Accuracy: the grandmaster is bounded by the system-clock → PHC transfer over PCIe (sub-µs), not by the ~150 ns GPS discipline.

Disciplining the PHC directly from the PPS with ts2phc, for tens-of-ns accuracy, is not possible on this hardware — a hoped-for benefit of moving to a CM5 that does not survive contact with the driver. ts2phc needs an external-timestamp channel to capture the PPS edge, and the MAC's clock advertises none:

$ cat /sys/class/ptp/ptp1/n_external_timestamps   # gem-ptp-timer
0
$ ls /sys/class/ptp/ptp1/pins/                    # (empty)

The only PHC here with n_external_timestamps=1 is the PHY's, whose sole pin is SYNC_OUT (an output) and whose TX timestamping is broken anyway — see above. Whatever RP1 exposes in silicon, Linux offers no PPS-in path to either clock today, so the software steering chain stands.

Check it:

ethtool -T eth0                          # hardware-transmit/receive listed;
                                         # provider index = the MAC's PHC
sudo pmc -u -b 0 'GET PORT_DATA_SET'     # portState MASTER
sudo phc_ctl /dev/ptp1 cmp               # PHC vs system: ~37 s (TAI-UTC;
                                         # printed as CLOCK_REALTIME minus
                                         # PHC, so it reads about -37 s).
                                         # ptp1 on the CM5, ptp0 on a
                                         # Model B -- check which is which
chronyc serverstats                      # NTP still served as before

A Linux PTP client needs linuxptp with the distro-default direction: ptp4l -i eth0 -s plus stock phc2sys (PHC → system clock).

Monitoring: ptp-monitor.service (bin/ptp-monitor.py) tails phc2sys/ptp4l and polls pmc, feeding a ptp dashboard section (steering error rms/max, PHC frequency correction, port state, missed TX timestamps) with alarms (etc/netdata/health.d/ptp.conf) for steering error above 500 ns/2 µs, loss of MASTER state, and TX timestamp timeouts. The status page grows a "PTP: serving (grandmaster), NIC clock within N ns" row — and, above 10 µs of steering error, a "NIC clock unsteered — do not use" row instead, because a grandmaster that is up and wrong is worse than one that is down. phc2sys logs 10-second rms/max summaries (-u 10) rather than per-second lines, keeping the capped journal quiet. The end-to-end truth remains a client-side measurement: a linuxptp client's own master_offset against this server includes the network path.

Board LEDs

The two onboard LEDs are repurposed as a status display you can read without logging in:

LED Shows
PWR solid = every check passed; slow 2 s/2 s = only survey-in outstanding (converging); fast 500/500 = something needs a human
ACT lit, blinking dark for 100 ms once per GPS PPS pulse

Together they read at a glance as: is the second still arriving (ACT) and is everything else still true (PWR).

PWR needs setting explicitly — the CM5 IO board ships it with trigger none and brightness 0, i.e. dark. Both are set by dtparam lines in the managed config.txt block, and ACT is taken off SD-card activity so pps-led.service can own it.

PWR as a health indicator

health-led.timer runs the same verify.sh a human would, once a minute, and bin/health-led.sh turns PWR solid only when every check passes. Anything else leaves it blinking — at one of two rates, because "not ready yet" and "broken" are different situations and should not look alike:

PWR Meaning
solid all checks pass
slow, 2 s on / 2 s off only the GNSS survey-in check fails — the receiver is still converging
fast, 500 ms anything else failed

tune-receiver.sh restarts survey-in at every boot, so a freshly rebooted box is legitimately not right yet for up to an hour, and that is not a fault. It is not nothing either: mid-survey the receiver is still solving for position every epoch rather than running fixed-position time mode, and PPS jitter roughly doubles (measured 2026-08-11: ~100 ns mid-survey against a ~46 ns settled baseline). So it is shown, logged as converging: rather than verify:, and distinguished from a real fault. Making the two look identical would train you to ignore the light.

Rate changes compare delay_on as well as the trigger — the trigger is already timer in both blinking states, so comparing only that would silently ignore a change of rate.

Blinking is the default, not the alarm state, and that asymmetry is the point: config.txt sets dtparam=pwr_led_trigger=timer so the LED blinks from the moment the kernel brings it up (the timer trigger's own defaults are already 500 ms on/off, so no delays need setting), and it takes a passing run to make it solid. A health timer that never fires, a script that dies, or a box that never finishes booting all leave the light blinking. Solid is a positive assertion, never an absence of bad news — the only safe polarity for an indicator meant to be believed.

The check costs ~2.4 s per run, about a 4% duty cycle (verify.sh's timeout values are ceilings, not durations). It is niced with idle I/O priority. It logs the names of failing checks and stays silent when passing, because once a minute into a 32 MB journal on log2ram's tmpfs would otherwise be pure noise; for the same reason the unit sets LogLevelMax=notice to drop systemd's own per-run start/stop lines.

The script exits 0 even when the checks fail — its job is to set the LED, and it did. So systemctl --failed will not show ill health; the LED and the logged FAIL lines are the signal.

Re-selecting the timer trigger restarts its blink phase, so a run that finds the box still unhealthy leaves the trigger alone rather than rewriting it — otherwise the LED would stutter once a minute instead of blinking evenly.

ACT is driven by bin/pps-led.sh from ppstest's event stream: this kernel has no PPS LED trigger (nor a netdev one), and because the overlay sets capture_clear both edges are timestamped, so the LED mirrors the real pulse instead of an invented blink. It idles lit and blanks for the pulse (PPS_LED_INVERT=0 in the unit reverses this), so a dark LED means the second stopped arriving — the failure worth noticing reads clearly against a lit background, and a stopped daemon leaves it dark too.

The daemon opens /dev/pps0 a second time alongside chrony's refclock, which is permitted and does not perturb kernel timestamping; userspace is confined to CPUs 02, so its once-a-second wakeup stays off the PPS interrupt's isolated CPU3. It is cosmetic, so the unit runs it at Nice=10 with idle I/O priority.

Monitoring

The chrony config enables full logging to /var/log/chrony/:

File Contents Watch for
tracking.log every clock update: offset, frequency, stratum offset drifting out of the µs range
refclocks.log every raw PPS and NMEA sample gaps (signal loss), rising jitter
statistics.log per-source regression stats growing std-dev on PPS
measurements.log fallback NTP server measurements disagreement with GNSS time

These logs are rotated hourly, not daily (etc/logrotate.d/chrony, which replaces the package's config, plus an hourly override on logrotate.timer). At 1 Hz, refclocks.log alone runs ~85 MB/day, and /var/log is log2ram's 128 MB tmpfs: with daily rotation the rotated copy sat next to the growing live one, the filesystem hit 100%, and chrony's log writes then failed silently — 0-byte logs and no measurement history at all, for two days, with nothing in the journal to say so (2026-08-09 to 2026-08-11). verify.sh now checks the headroom and that tracking.log is non-empty. The trade is history: roughly the last six hours of per-sample data, compressed, rather than a day.

log2ram itself flushes tmpfs to disk only on a clean stop or on its own timer, so that timer's period is the window an unclean shutdown destroys. Upstream ships it daily (23:55), which put up to 24 hours of measurement history at risk of a power cut — precisely the evidence you want after one. log2ram-daily.timer.d/hourly.conf caps that at an hour, at ten past, so it lands after logrotate has compressed and syncs the small files rather than the large live ones. The unit keeps its upstream name, so "log2ram-daily" now runs hourly. sudo log2ram write forces a sync on demand — worth running before anything that risks the power.

Watchdog

A headless time server that wedges is off the air until somebody notices. systemd pets /dev/watchdog0 (the BCM2835 timer) every 30 s against a 1 min hardware timeout, and resets the board if PID 1 stops responding; RebootWatchdogSec covers the shutdown path, where the units that would otherwise notice a hang have already stopped.

Raspberry Pi OS enables all of this by default, which is exactly why system.conf.d/watchdog.conf pins it and verify.sh asserts it: a distro default that changed underneath us would otherwise disappear silently. The timeout is deliberately not tightened — a spurious reset on a momentarily busy box costs more than a slightly slower recovery, and the reset is itself unclean, bypassing log2ram's flush and forfeiting up to an hour of logs.

logchange 0.1 additionally writes a syslog warning whenever chrony has to adjust the clock by more than 100 ms — after startup that should never happen on this hardware, so any occurrence is worth an alert.

Live queries via chronyc: tracking (current offset/stratum), sources -v and sourcestats -v (per-source state), serverstats (NTP packets served, per-client stats with clients).

SSD wear

Installing nvme-cli is enough to light up netdata's go.d nvme collector (it shells out via the setuid ndsudo), giving 16 charts without configuration — endurance used, data units read/written, temperature, unsafe shutdowns, media errors, thermal throttling.

Worth watching because the write load is not the time service. Sampled idle — SSH plus NTP/PTP, nothing else — the disk takes roughly 515 KiB/s, essentially all of it netdata's own metrics database (/var/cache/netdata, tier-0 retention 256 MiB), with ext4's jbd2 journal thread committing behind it. Nothing in the timing path writes continuously: /var/log is log2ram tmpfs so chrony's per-sample logs and the journal stay in RAM until the periodic sync, swap is on zram, and / is noatime.

Beware the drive's lifetime average as a guide to any of this — a second-hand SSD carries someone else's history. Sample nvme.device_nvme0_io_transferred_count over a window instead, or diff Data Units Written (1 unit = 512,000 bytes). If the writes ever need to approach zero, netdata's dbengine is the only real lever, at the cost of history across reboots.

GNSS telemetry and spoofing/jamming detection

gnss-monitor.service (bin/gnss-monitor.py) taps gpsd's raw stream read-only (chrony untouched) and feeds a gnss dashboard section: u-blox spoofing-detection state, RF jamming indicator and noise floor, antenna supervisor, satellites and C/N0 per constellation, survey-in state, receiver clock accuracy, PPS quantisation error, leap-second announcements, and the median offset of the NTP comparison servers (a systematic drift there implicates the GNSS). Alarms in etc/netdata/health.d/gnss.conf cover spoofing indicated, sustained jamming, satellite loss, loss of fixed-position timing mode, antenna faults, pending leap seconds, and GNSS-vs-NTP divergence.

Four charts exist to answer questions that otherwise need a manual ubxtool poll at the moment you least want one:

Chart From Answers
gnss.dop NAV-DOP tDOP — the geometry term behind time_accuracy. A jitter excursion that tracks tDOP is the sky; one that doesn't is the receiver or antenna
gnss.satellites_tracked NAV-SAT tracked vs used — the gap is what the elevation mask is discarding
gnss.cno_by_elevation NAV-SAT whether low-elevation signals really are worse here, i.e. whether the mask still earns its keep
gnss.orbit NAV-ORB ephemeris/almanac/AOP held — why a boot was slow, without inferring it

The C/N0-by-elevation pair is the one that settles arguments. The 12° CFG-NAV5 mask was set because low-elevation multipath at the old antenna site stalled survey convergence. Measured after the antenna moved on 2026-08-11: 37.1 dB-Hz below 15° against 37.2 dB-Hz from 1545°. When those converge the mask is discarding satellites on geometry alone, and is worth re-testing rather than inheriting.

gnss.orbit also reads aop, the AssistNow Autonomous predictions. It sits at 0 and will stay there: AOP data lives in battery-backed RAM, this is a ROM module (MOD=NEO-M8T-0) with no flash to fall back on, and no V_BCKP backup is fitted — so it is lost on exactly the power-cut restarts where it would have helped. Fit backup power before enabling it.

Undervoltage and thermal throttling are alarmed separately in etc/netdata/health.d/rpi.conf. With force_turbo=1 pinning the clocks, a marginal supply shows up as throttling rather than as a quiet slowdown, and rpi.throttled_sticky latches an event that happened while nobody was watching.

Raw observables (post-processed position)

The NEO-M8T is the raw-data variant: it can emit RXM-RAWX carrier phase and pseudorange, which a plain M8 cannot. bin/log-raw-observables.sh captures those for a chosen number of hours so they can be post-processed with RTKLIB (convbin → RINEX, rnx2rtkp against a nearby CORS station) into a centimetre-level position. That position can then be programmed as a fixed TMODE2 position, removing survey-in altogether — no hour of degraded jitter after every boot, no boot-to-boot position variation.

It is deliberately not enabled in tune-receiver.sh. At 1 Hz with ~25 satellites RXM-RAWX is ~800 bytes per epoch plus SFRBX, against a 115200-baud UART shared with the NMEA the refclock depends on — 1015% of the link for a measurement wanted once, not forever. It logs to /var/lib/rpi-ntp-server/raw on the NVMe, since a 24-hour capture is far larger than log2ram's 128 MB tmpfs, and disables itself on exit.

Status page

ntp-status.timer renders a curated, public-safe status page every minute (stratum, offsets, error bound, satellites, receiver mode, external-server agreement, and a live server-synced clock). It contains no internal IPs, versions, or security telemetry.

Three charts go with it, drawn as inline SVG by the generator itself. Client-side charting is not an option: the public hostname is allowed only the status page and the clock-sync endpoint, so browser code cannot reach netdata's data API at all. Server-side rendering also keeps the page one self-contained file with no chart library.

Chart Window Says
Offset from UTC 24 h mean line inside the min/max envelope — how tightly the clock is held, and whether it drifts
Cross-check vs national labs 7 d the only measurement here not marked by its own homework; bounded by internet path asymmetry (hundreds of µs), so it can confirm the absence of a gross error, nothing finer
Allan deviation τ = 10 s … ~4 h stability against averaging time; falling to the right means the error averages away as white noise, a floor or upturn would mean drift

The data behind them comes from clock-stats.service (bin/clock-stats.py), which polls chronyc every 10 s and pushes integer nanosecond gauges to netdata's statsd, plus a rolling 24-hour overlapping Allan deviation written to /var/lib/rpi-ntp-server/clock-stats.json. Netdata's own chrony collector cannot be used for this: its offsets pass through the data API as seconds rounded to seven decimals, so a clock living at ±100 ns comes back quantised to a three-level staircase of -1e-7 / 0 / 1e-7. Gaps in the data are drawn as gaps — bridging a collector outage with a straight line would render it as perfect behaviour.

A Caddy front proxy on the Pi owns :19999 and routes by Host header (etc/caddy/Caddyfile): requests arriving via the public hostname get the status page at / and can reach only /status.html and /api/v1/alarms (the clock-sync probes) — the netdata dashboard and API are unreachable through the public name. Any other Host (LAN IP, localhost) passes straight through to netdata, which is bound to 127.0.0.1:19998. The external reverse proxy therefore needs nothing but reverse_proxy <pi>:19999 — no path rules.

Web dashboard (Netdata)

setup.sh installs Netdata; browse to http://ntp-pi:19999. Its chrony collector autodetects the daemon on 127.0.0.1:323 — no configuration needed — and charts stratum, current/RMS offset, frequency error, and root delay/dispersion under the chrony section, alongside system health (CPU, temperature, network). The dashboard is read-only but visible to the whole LAN by default; bind it to localhost via /etc/netdata/netdata.conf if you'd rather tunnel in.

If you later want long-term history and alerting instead, the Prometheus chrony_exporter or Telegraf's chrony input plugin read the same data.

Keeping it current

unattended-upgrades installs from all four archives this box draws from, not just Debian security, and reboots at 04:00 when an upgrade requires it.

File Does
20auto-upgrades arms the apt-daily timers
52rpi-ntp-unattended origins, reboot window, kernel retention

Both failure modes here are silent, which is why verify.sh asserts against the effective configuration rather than the files:

  • The apt-daily timers ship enabled on a stock image but install nothing until APT::Periodic says so. A box can look automated and quietly fall behind for months — this one had both timers running and unattended-upgrades not even installed, sitting on an uninstalled caddy security update while serving that proxy to the internet.
  • An Origins-Pattern entry that matches no archive is a no-op. The strings come from apt-cache policy; confirm with unattended-upgrade --dry-run --debug | grep 'Allowed origins', which prints what will actually be upgraded.

Upgrading everything, including the kernel, is defensible here for two specific reasons. Nothing in this repo pins a kernel-version-specific detail — the PPS interrupt is found by name and the PTP clock by sysfs parent, both of which already survived a hardware change that renumbered them. And verify.sh runs every minute driving the PWR LED, so a broken upgrade shows as a blinking light within 60 seconds rather than as mysteriously bad time.

Old kernels are not removed (Remove-Unused-Kernel-Packages "false"). Hardware timestamping and PPS on this platform have a regression history (raspberrypi/linux#5904), so the previous kernel is the rollback path; the disk space is worth nothing by comparison.

Automatic-Reboot-WithUsers is true on purpose. The safer-sounding alternative is worse: one forgotten SSH session blocks every reboot indefinitely, leaving an upgraded kernel installed and never running.

Documentation

  • docs/performance.md — measured accuracy, the error budget, and the reasoning behind non-obvious configuration decisions.
  • docs/roadmap.md — staged upgrade path: enclosure, IRQ pinning, MSF/DCF77 radio time, LF carrier-phase tracking, ionospheric modelling, disciplined oscillators, multi-receiver ensembles, and the Pi 5 hardware-timestamping route.

Rebuilding / disaster recovery

The SD card holds no unique state worth backing up — the whole point of this repo. To rebuild: repeat steps 13 with the same hostname and IP reservation, and clients never notice. The chrony drift file regenerates itself within an hour.

Troubleshooting

  • No /dev/pps0 — the overlay didn't load; check the # BEGIN rpi-ntp-server block survived in /boot/firmware/config.txt and that you rebooted.
  • /dev/ttyAMA0 exists but no NMEA (gpspipe -r silent) — check the HAT jumpers are on B, and that cmdline.txt has no console=serial0 entry.
  • PPS test times out — the M8T only emits PPS with a satellite fix; check cgps -s first, improve antenna placement.
  • chrony shows PPS but won't select it — the NMEA and PPS sources must agree; if NMEA's offset is wildly off, adjust the offset value in /etc/chrony/conf.d/gps-pps.conf (measure with chronyc sourcestats -v).