Incline Village Time Lab

Two builds: a GPS-disciplined stratum-1 NTP server for the LAN, and a 60 kHz WWVB
micro-transmitter so radio-controlled watches sync to the house clock instead of gambling
on Fort Collins skywave (marginal on the west slope of the Sierra, worse in a 3-story
townhouse). Total budget lands around $150–190, well under the $300 cap.

1. Parts list

Prices verified July 2026. Note: the 2026 DRAM shortage pushed all ≥2 GB Pi prices up
sharply; 1 GB SKUs are price-protected and are all you need for NTP.

Part Price Source
Raspberry Pi 4B 1GB (or Pi 5 1GB, $45) $35 raspberrypi.com, pishop.us
Adafruit Ultimate GPS HAT (PA1616D/MT3333, PPS→GPIO4, uFL, RTC) $29.95 adafruit.com/product/2324
External active GPS antenna, 28 dB, 5 m SMA, magnetic base $21.50 adafruit.com/product/960
SMA→uFL adapter pigtail ~$4 Adafruit #851 or Amazon
Adafruit QT Py ESP32 Pico $14.95 adafruit.com/product/5395
Adafruit DRV8833 H-bridge breakout (antenna driver) $5.95 adafruit.com/product/3297
60 kHz ferrite rod antenna ×1–2 (salvage from a Canaduino/ES100 WWVB receiver kit, or bare loopstick) $5–15 (varies; uncertain) Amazon/AliExpress/universal-solder.ca
Misc: 32 GB microSD, USB-C PSU, CR1220, small enclosure, hookup wire ~$35 anywhere

Total: ~$150–165. GPS HAT choice: the Adafruit is US-stocked and cheap; the
Uputronics GPS/RTC HAT
(£47.99, u-blox M8) is the upgrade path — the M8 supports fixed-position timing/survey-in
mode for better single-sat holdover, but it ships from the UK and the MT3333’s PPS is
already good to tens of ns; your system noise floor is the Pi, not the receiver.
Adafruit stock was thin (2 units) at check time — Digi-Key/Mouser carry ADA2324 too.

Antenna in snow country: don’t rely on the HAT’s patch antenna indoors. Run the 5 m
active antenna to a top-floor south-facing windowsill, or better, magnet-mount it on any
steel under a roof eave/gable vent where snow sheds. GPS L1 tolerates dry snow; a foot of
wet snow on top of the antenna will hurt, so under-eave beats exposed-roof here. You need
timing, not position — 4+ sats with mediocre geometry is plenty.

2. Stratum-1 NTP server

Use the wired Ethernet port. Wi-Fi adds hundreds of µs of asymmetric jitter and wastes
the whole exercise.

Architecture: gpsd parses NMEA (chrony has no NMEA parser); PPS goes straight to
chrony via kernel PPS (pps-gpio), bypassing gpsd for the precision path. Fewer moving
parts where it matters.

/boot/firmware/config.txt:

enable_uart=1
dtoverlay=disable-bt        # frees PL011 (ttyAMA0) from Bluetooth on Pi 4
dtoverlay=pps-gpio,gpiopin=4

Remove console=serial0,... from cmdline.txt. Install gpsd pps-tools chrony, set
DEVICES="/dev/ttyAMA0" and GPSD_OPTIONS="-n" in /etc/default/gpsd.

/etc/chrony/chrony.conf:

refclock SHM 0 refid NMEA offset 0.500 delay 0.2 noselect
refclock PPS /dev/pps0 refid PPS lock NMEA prefer
makestep 1.0 3
allow 192.168.0.0/16        # your LAN
local stratum 10 orphan     # keep serving sanely if GPS drops

The NMEA offset is the serial-sentence latency (~0.5 s for MTK @ 9600 baud); measure it
with chronyc sourcestats against a pool server and tune until NMEA offset ≈ 0, or the
PPS lock can’t pair samples. NMEA stays noselect — it only labels which second the
pulse belongs to.

Expected accuracy: system clock within ~1–5 µs of UTC (kernel PPS on a Pi; the MT3333
PPS itself is ±10 ns class). Wired LAN clients: 50–200 µs. That’s stratum-1 with a
straight face.

Verify:

ppstest /dev/pps0            # one assert/second
chronyc sources -v           # PPS selected '*', NMEA '?'/noselect
chronyc sourcestats          # PPS std dev should be low-µs or better
chronyc tracking             # Ref ID PPS, stratum 1, RMS offset in µs

Pool vs private: you can join pool.ntp.org (needs a stable public IP or DNS, set a
low netspeed; expect a constant packet trickle). For a home lab, keep it private — point
DHCP option 42 / router NTP at the Pi and be done. Nothing stops you doing both later.

3. WWVB micro-transmitter

Prior art (current as of mid-2026)

How the signal works

WWVB is 60 kHz AM, one symbol/second: carrier drops ~17 dB at the top of each second and
recovers after 0.2 s (bit 0), 0.5 s (bit 1), or 0.8 s (frame/position marker). 60 symbols
= one frame/minute: minute, hour, day-of-year, 2-digit year, DUT1, leap-second/leap-year
and DST flags, all UTC (the watch applies its own TZ). The ESP32 does this with LEDC PWM:
60 kHz carrier into the DRV8833, gate/attenuate the output low for the symbol’s low
period. No DDS needed for a watch’s crappy receiver bandwidth.

Feed it house time

Point the firmware’s NTP at the Pi, not the pool — in WatchTower change the NTP server in
the config (compile-time define / config header) to timelab.lan or the Pi’s static IP.
End-to-end story: GPS → chrony → ESP32 → 60 kHz → watch. If the Pi is down, WatchTower
would silently fall back only if you left a pool server configured — don’t, for purity.

Legality (brief)

Intentional radiators below 490 kHz fall under 47 CFR §15.209: field strength limit
2400/F(kHz) µV/m at 300 m → 40 µV/m at 300 m for 60 kHz. A milliwatt-class ferrite
loop with feet of range is orders of magnitude under that. Compliant by construction;
don’t bolt on an amplifier to cover the house.

Practical range

Near-field magnetic coupling, ~1/r³: one rod ≈ nightstand-radius (under a foot,
reliably), two orthogonal rods ≈ 6 ft. So: transmitter on the nightstand, watch next to
it overnight. Casios attempt auto-receive between roughly midnight and 4 a.m.; for
testing, force a manual receive (Citizen: press-and-hold lower-right; Casio: hold the
lower-left “Adjust” region per model) rather than waiting for 2 a.m.

4. Assembly & bring-up (one weekend)

Saturday AM — stratum-1:

  1. Flash Raspberry Pi OS Lite (64-bit), enable SSH, static IP or DHCP reservation.
  2. Solder HAT headers, seat HAT, CR1220 in, uFL→SMA pigtail out, antenna to the window.
  3. config.txt/cmdline edits above; reboot. Milestone: cat /dev/ttyAMA0 shows NMEA,
    fix LED blip, $GPRMC has valid time.
    (Cold fix may take a few minutes.)
  4. ppstest /dev/pps0Milestone: asserts once/second.
  5. gpsd + chrony config; calibrate NMEA offset. Milestone: chronyc tracking shows
    PPS, stratum 1, RMS offset < 10 µs after ~30 min settle.

Saturday PM — LAN:

  1. Point router/DHCP NTP at the Pi; verify a laptop syncs (chronyc clients on the Pi).

Sunday — WWVB:

  1. Wire QT Py → DRV8833 → ferrite rod per WatchTower’s schematic; flash with NTP server
    set to the Pi.
  2. Milestone: nearby AM/SDR sanity check — a cheap SDR or even another WWVB receiver
    module shows the 60 kHz keying.
  3. Force manual receive on a watch 6 in away. Milestone: watch syncs, reads house
    time.
    Then the real test: leave it on the nightstand — watch shows successful
    auto-sync at ~2 a.m.
    (check the watch’s “last received” indicator in the morning).

5. Five-minute demo (NTP-enthusiast neighbor edition)

  1. chronyc tracking — Ref ID PPS, stratum 1, offset in single-digit µs. Let it sink in.
  2. chronyc sourcestats -v — PPS std dev; compare against the NMEA line to show why PPS.
  3. ppstest /dev/pps0 live — the heartbeat itself.
  4. chronyc clients — the whole house chimes off this box.
  5. Finale: grab the Casio, force a receive next to the ESP32, watch it snap to house
    time. “NIST is 900 miles away; my WWVB is nine inches away.”

6. Peering the two units through the shared wall

316 Ski Way (Erik Fair, next door) shares a wall with us, and he’s an NTP devotee — so the
right topology isn’t “one shared server” (single point of failure, whose garage?), it’s
two independent GPS stratum-1s that peer. NTP symmetric-active peering is built for
exactly this: each box keeps the other honest, and if one GPS loses sky view under snow load
the other carries the LAN. It’s a two-node time ensemble across the property line, which is a
far more interesting thing to show him than a lone box.

Physical link (best → easiest):

chrony peer line (each side points at the other, both peer not server):

# on our box
peer 192.168.x.y minpoll 2 maxpoll 4   # Erik's stratum-1
# on his box, symmetric:
peer 192.168.x.z minpoll 2 maxpoll 4   # ours

With two real refclocks peering, chrony’s selection algo can flag a falseticker if either
GPS drifts — you get mutual sanity-checking for free. Stretch: PTP (linuxptp) over the Cat6
for sub-µs cross-wall sync, with NTP as the fallback layer.

Is he already running one? (ask, don’t scan)

The correct and best move is social: “Thinking of standing up a stratum-1 off GPS — are you
already running one we could peer through the wall?”
is possibly the strongest opening line
you could pick with this specific neighbor, and it’s how you end up with a two-node cluster
instead of a scan log.

Do not port-scan his network. You won’t be on the same LAN across two condos by default,
and he’s an infosec guy who will notice an uninvited nmap — exactly the wrong first
impression. If and only if you’re already sharing a LAN (post-peering), a host’s stratum is
visible non-invasively: chronyc -h <ip> tracking, ntpq -p <ip>, or ntpdate -q <ip> — a
stratum-1 with a .GPS./.PPS. refid is unmistakable. But that’s for after you’re peering by
mutual consent, not for recon. Ask first.

7. Stretch goals