A Python WWVB / JJY low-frequency time-code transmitter for the Raspberry Pi family. Drives a single GPIO pin to broadcast the same 60 kHz (WWVB) or 40 / 60 kHz (JJY) signal a consumer "atomic" wall clock listens for, so you can keep a clock synced without a real radio fix from Fort Collins, Mt. Otakadoya, or Mt. Hagane.
Supports manual, system-clock, and GPS time sources. Works on the Pi 3, Pi 4, Pi Zero, Pi Zero 2 W (one CPython package), and ships a separate MicroPython firmware for the Pi Pico (RP2040).
Inspired by micooke/wwvb_jjy (an AVR/Arduino implementation).
- How it works
- Bill of materials
- Software installation
- Wiring
- Usage
- Time sources
- Troubleshooting
- Testing without a Pi
- Legal
- License
| Protocol | Carrier | Polarity | Region |
|---|---|---|---|
| WWVB | 60 kHz | starts low | United States |
| JJY40 | 40 kHz | starts high | Eastern Japan |
| JJY60 | 60 kHz | starts high | Western Japan |
Each protocol uses a 60-bit frame, one bit per second. The carrier is
amplitude-modulated — its on/off duration within each second encodes a
0, 1, or MARKER symbol. Full encoding details are in
docs/protocols.md.
The Pi generates the carrier in hardware via the GPCLK peripheral
(pigpio.hardware_clock()), then this package switches it on and off
each second to encode the time-code bits. The Pico generates its carrier
via a PIO state machine — same idea, different silicon.
For a working transmitter you need very little:
| Part | Quantity | Notes |
|---|---|---|
| Raspberry Pi (3, 4, Zero, Zero 2, or Pico) | 1 | Plus its usual power supply / USB cable |
| 220 Ω resistor (¼ W is fine) | 1 | Series current limit from GPIO |
| 28–32 AWG enamelled magnet wire | ~3 m | For winding the loop antenna |
| Small bobbin or ferrite rod | 1 | Pill bottle / film canister / salvaged AM rod |
| Hookup wire + breadboard or perfboard | - | For tidy connections |
Optional: a serial GPS module (NEO-6M / NEO-7M / NEO-8M / NEO-M9N) with TTL UART output, jumper leads, and an SMA-mount GPS antenna.
That's it. No transistors, no op-amps, no licensed transmitter hardware. The 3.3 V GPIO is enough to push a few centimeters to a couple of meters into a nearby clock's ferrite rod.
Tested on Raspberry Pi OS Bookworm (12) with Python 3.11+. Earlier Bullseye (11) works too.
# Update package index
sudo apt update
# Core dependencies — pigpio for hardware-clock generation
sudo apt install -y python3 python3-pip python3-venv git pigpio python3-pigpio
# Enable the pigpio daemon so it starts on boot
sudo systemctl enable --now pigpiod
# Verify the daemon is running
systemctl status pigpiod --no-pager | head -5You should see Active: active (running). If not, check the
Troubleshooting section.
Clone and install in editable mode (recommended — you can pull updates without reinstalling):
git clone https://github.com/haxorthematrix/WWVB-Pi-Python.git
cd WWVB-Pi-Python
# Create a virtual environment (recommended on Bookworm — externally-managed)
python3 -m venv .venv --system-site-packages # system-site-packages so the venv sees apt's pigpio
source .venv/bin/activate
# Install the package
pip install -e .
# (Optional) install GPS support too
pip install -e ".[gps]"
# (Optional) install dev / test dependencies
pip install -e ".[dev]"The --system-site-packages flag is the easiest way to let the venv use
the pigpio Python module installed by apt. Alternatively skip the venv
entirely:
sudo pip3 install --break-system-packages -e .Verify the install:
wwvb-pi --helpYou should see the full option listing.
The Pi Pico is a microcontroller, not a Linux SBC, so the CPython package above doesn't apply to it. Instead:
-
Install MicroPython on the Pico
- Download the latest
.uf2from micropython.org/download/RPI_PICO/ (orRPI_PICO_Wfor the wireless variant). - Unplug the Pico, hold the BOOTSEL button, plug it back in via USB while still holding BOOTSEL.
- A USB drive named RPI-RP2 appears. Drag the
.uf2onto it. The Pico reboots into MicroPython automatically.
- Download the latest
-
Copy the firmware to the Pico
The easiest tool is Thonny (
sudo apt install thonnyor the Windows / Mac installer):- Open Thonny → set the interpreter to "MicroPython (Raspberry Pi Pico)".
- Open
pico/wwvb_pico.pyfrom this repo. - Edit the constants at the top:
PROTOCOL = "wwvb"|"jjy40"|"jjy60"MANUAL_TIME = (year, month, day, hour, minute, second)— UTC for WWVB, JST for JJYUSE_GPS = Trueif you wired a GPS module on UART0
- Save the file to the Pico as
main.py.
Or use
mpremotefrom the command line:pip install --user mpremote mpremote cp pico/wwvb_pico.py :main.py mpremote reset
-
Power-cycle the Pico. It starts transmitting immediately and keeps counting forward from
MANUAL_TIME(or keeps re-syncing from the GPS fix when one is available).
If you're going to drive the transmitter from a serial GPS:
# Python libraries
pip install pyserial pynmea2
# Enable the Pi UART (Pi 3, 4, Zero 2 W). One-time setup:
sudo raspi-config nonint do_serial_hw 0 # enable serial hardware
sudo raspi-config nonint do_serial_cons 1 # disable serial console (frees the UART)
sudo rebootAfter the reboot, your GPS module is reachable at /dev/ttyAMA0 (Pi 3
/ Zero) or /dev/serial0 (recommended alias on all models).
⚠️ Power off the Pi before connecting anything. The 3.3 V GPIO pins are not 5 V tolerant; shorting one to 5 V will kill it.
The carrier-output side is identical across every Pi model — only the GPIO pin number changes. The pattern is:
GPIO carrier ──┬──[ 220 Ω ]──┐
│ │
│ ┌──┴──┐
│ │ │
│ │ L1 │ ferrite-rod or air-core loop antenna
│ │ │
│ └──┬──┘
│ │
└─── GND ─────┘
- R1 = 220 Ω, ¼ W or better. Limits current sourced from the GPIO to ~15 mA (3.3 V / 220 Ω).
- L1 = your hand-wound antenna. See Antenna construction below.
That's the whole transmitter. Everything that follows is just which physical pins map to "GPIO carrier" and "GND" on each board.
Both use the same 40-pin header layout for our purposes. Use GPIO 4 (BCM) = GPCLK0 = physical pin 7.
40-PIN GPIO HEADER (top-down view)
(looking at the board with the header on the right)
3V3 ● ┃ ● 5V (pins 1 / 2)
SDA1 ● ┃ ● 5V (pins 3 / 4)
SCL1 ● ┃ ● GND (pins 5 / 6)
carrier ─── GPIO4 ● ┃ ● GPIO14 (TXD) (pins 7 / 8) ← USE THIS PIN
GND ● ┃ ● GPIO15 (RXD) (pins 9 / 10) ← and this GND
GPIO17 ● ┃ ● GPIO18 (pins 11 / 12)
GPIO27 ● ┃ ● GND
GPIO22 ● ┃ ● GPIO23
3V3 ● ┃ ● GPIO24
GPIO10 ● ┃ ● GND
GPIO9 ● ┃ ● GPIO25
GPIO11 ● ┃ ● GPIO8
GND ● ┃ ● GPIO7
(...)
Connections:
- Pin 7 (GPIO 4) → 220 Ω resistor → one end of antenna coil
- Pin 9 (GND) → other end of antenna coil
Notes:
- The Pi 4 (BCM2711) sources clock from a 750 MHz PLLD; the Pi 3
(BCM2837) from 500 MHz.
pigpiohandles the divider math for you and produces a clean 40 kHz / 60 kHz square wave with sub-Hz error. - Pi 4 runs hot enough under sustained load that a passive heatsink is worth installing if you're going to leave the transmitter on 24/7.
Run:
wwvb-pi --protocol wwvb --pi-model pi4 --gpio 4 -vSame 40-pin header pinout as the Pi 3/4 — the wiring diagram above applies unchanged. Use GPIO 4 (pin 7) and GND (pin 9).
Header on the Zero is unpopulated by default. You'll need to either solder a 2x20 male header on, or use a press-fit "hammer header" which clips on without soldering.
Run:
wwvb-pi --protocol wwvb --pi-model pi-zero --gpio 4 -v # Pi Zero / Zero W
wwvb-pi --protocol wwvb --pi-model pi-zero2 --gpio 4 -v # Pi Zero 2 WPi Zero (single-core) caveat: pigpio's scheduler shares the only ARM core with everything else, which can cause carrier jitter under load. Mitigations:
- Disable HDMI:
sudo /usr/bin/tvservice -o - Don't run a desktop — boot to CLI (
sudo raspi-config→ System Options → Boot) - Set the CPU governor to
performance:echo performance | sudo tee /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
The Pi Zero 2 W has four cores and no such issue.
The Pico is a microcontroller — it runs its own MicroPython firmware (
pico/wwvb_pico.py) rather than the CPython package.
Use GP15 (physical pin 20) for the carrier output.
Raspberry Pi Pico (top-down view, USB on top)
┌─────[USB-C / micro-USB]─────┐
GP0 │ 1 40 │ VBUS
GP1 │ 2 39 │ VSYS
GND │ 3 38 │ GND
GP2 │ 4 37 │ 3V3_EN
GP3 │ 5 36 │ 3V3(OUT)
GP4 │ 6 35 │ ADC_VREF
GP5 │ 7 34 │ GP28
GND │ 8 33 │ GND
GP6 │ 9 32 │ GP27
GP7 │ 10 31 │ GP26
GP8 │ 11 30 │ RUN
GP9 │ 12 29 │ GP22
GND │ 13 28 │ GND
GP10 │ 14 27 │ GP21
GP11 │ 15 26 │ GP20
GP12 │ 16 25 │ GP19
GP13 │ 17 24 │ GP18
GND │ 18 23 │ GND
GP14 │ 19 22 │ GP17
carrier ─── GP15 │ 20 ←─ CARRIER OUTPUT 21 │ GP16
└─────────────────────────────┘
Connections:
- Pin 20 (GP15) → 220 Ω resistor → one end of antenna coil
- Pin 18 (GND) (or any GND pin) → other end of antenna coil
If you also want GPS (recommended for autonomous operation):
- Pin 1 (GP0 / UART0 TX) → GPS module RX
- Pin 2 (GP1 / UART0 RX) → GPS module TX
- Pin 36 (3V3 OUT) → GPS module VCC (most NEO-6M boards run from 3.3 V)
- Pin 38 (GND) → GPS module GND
The Pico's PIO generates a crystal-locked square wave with zero CPU overhead — actually better carrier stability than the Pi 3/4 GPCLK output.
Why "Pi Nano"? There is no Raspberry Pi product called "Pi Nano." This repo interprets that request as the Pi Pico (RP2040), which is the smallest official Raspberry Pi board. If you have a different "nano" board in mind (Arduino Nano, NanoPi, etc.) the toggling pattern in
pico/wwvb_pico.pyports cleanly to anything running MicroPython — just change the pin number.
A resonant antenna at 40 / 60 kHz would be ~5 km long, so we use a small magnetic loop tuned by virtue of being right next to the target clock's ferrite rod. Two practical builds:
- Find a small cylindrical form — a 35mm film canister, a pill bottle, a 3D-printed bobbin, a toilet-paper tube — about 5 cm diameter.
- Wind 60–200 turns of 28–32 AWG enamelled magnet wire neatly around it.
- Strip and tin the two ends.
- Connect one end to the GPIO via the 220 Ω resistor; the other end to GND.
More turns = stronger magnetic field but more inductance. Anywhere between 60 and 200 works. No tuning capacitor needed for the short-range hops we're doing.
┌─────────────────────┐
│ ╱╲ ╱╲ ╱╲ ╱╲ ╱ │ <-- ~100 turns of 30 AWG magnet wire
│ ╲ ╲╱ ╲╱ ╲╱ ╲╱ │ around a small cylindrical form
│ ╲ ╱╲ ╱╲ ╱╲ ╱╲ │
│ ╳ ╲╱ ╲╱ ╲╱ ╲ │
└──┬──────────────────┴───┐
│ │
└─ to 220 Ω ─ GPIO └─ to GND
- Salvage the ferrite rod from a junk AM radio (the long black rod with the variable-cap-tuned coils on it).
- Strip off the existing winding.
- Wind 30–60 turns of magnet wire over the rod's center.
- Lay the rod parallel to the target clock's own ferrite rod — most clocks have theirs horizontally inside the back of the case.
A ferrite-rod antenna couples its field much more efficiently than an air-core loop. Expect a couple of meters of range with ~50 turns and the 3.3 V GPIO drive.
- Lay the antenna within 1–10 cm of the clock's own internal antenna. WWVB / JJY clocks have a ferrite-rod antenna inside, usually along one of the long edges. Some experimenting finds the sweet spot.
- Clocks attempt to sync at fixed times each day (often 2 AM local). To force a sync, look up your clock's manual reset / force-sync button.
- First-time sync takes 1–10 minutes; the clock has to capture two consecutive valid frames.
Full antenna notes: docs/wiring/antenna.md.
For an NEO-6M (or similar) on a Pi 3 / 4 / Zero / Zero 2 W:
Pi pin 4 (5V) ──→ GPS VCC (most NEO boards accept 3.3 V or 5 V — check yours)
Pi pin 6 (GND) ──→ GPS GND
Pi pin 8 (TXD) ──→ GPS RX
Pi pin 10 (RXD) ──→ GPS TX
Then enable the UART (see Optional — GPS module support).
For the Pi Pico see Pi Pico (RP2040) above.
Once wired, verify NMEA is flowing:
sudo apt install -y minicom
minicom -b 9600 -D /dev/serial0You should see streams of $GPRMC,... / $GNRMC,... lines. Press
Ctrl+A, then X to quit. The transmitter cares about RMC sentences
only — once one with status=A (active fix) shows up, the GPS time
source switches over from the system-clock fallback.
wwvb-pi --help
--protocol {wwvb,jjy40,jjy60} signal to broadcast (default: wwvb)
--pi-model {pi3,pi4,pi-zero,pi-zero2,pico,auto}
selects carrier backend (default: auto)
--gpio N BCM pin (default: 4 = GPCLK0)
--time-source {system,manual,gps} where to read "now" from (default: system)
--manual-time ISO8601 e.g. 2026-05-20T13:45:00Z
--gps-port DEV serial device (default: /dev/ttyAMA0)
--gps-baud N GPS baud rate (default: 9600)
--dst WWVB: DST currently in effect
--duration MINUTES stop after N minutes (default: forever)
--dry-run use NullCarrier — no GPIO touched
-v / -vv info / debug logging
Default — broadcast WWVB synced to the Pi's NTP-disciplined system clock:
wwvb-pi --protocol wwvb -vManually set a clock to an arbitrary time (many cheap WWVB clocks will happily accept whatever you transmit — useful for testing or for "shifting" a clock to a different time zone):
wwvb-pi --protocol wwvb --time-source manual \
--manual-time 2026-05-20T13:45:00Z --duration 10 -vSync to a serial GPS module:
wwvb-pi --protocol wwvb --time-source gps --gps-port /dev/serial0 -vJJY for a Japanese clock (eastern Japan = 40 kHz):
wwvb-pi --protocol jjy40 -vJJY for western Japan (60 kHz):
wwvb-pi --protocol jjy60 -vDry-run on a development machine (no GPIO needed — useful for verifying the encoder and timing):
wwvb-pi --dry-run -vv --duration 2For 24/7 operation, install as a systemd unit. Create
/etc/systemd/system/wwvb-pi.service:
[Unit]
Description=WWVB-Pi-Python transmitter
After=network-online.target pigpiod.service
Wants=pigpiod.service
[Service]
Type=simple
ExecStart=/usr/local/bin/wwvb-pi --protocol wwvb -v
Restart=on-failure
RestartSec=10
User=pi
[Install]
WantedBy=multi-user.targetThen:
sudo systemctl daemon-reload
sudo systemctl enable --now wwvb-pi
sudo journalctl -fu wwvb-pi # follow logs| Source | When to use |
|---|---|
system |
Default. Pi has internet → NTP keeps the clock accurate to ~10 ms. |
manual |
Pi has no internet and no GPS. You set the time yourself, the package extrapolates from a monotonic clock thereafter. Drift over hours = the Pi's crystal drift, which is OK for clock-syncing duty. |
gps |
Pi is offline but has a serial GPS module. Most accurate — GPS PPS is sub-microsecond, though our code only uses the NMEA timestamp (~1 second). |
For longest-running unattended deployments, gps is the gold standard.
For most household uses, system + a normal NTP setup is plenty.
Could not connect to pigpiod
sudo systemctl status pigpiod— should beactive (running).- If not, start it:
sudo systemctl start pigpiod. - If pigpio isn't installed:
sudo apt install pigpio python3-pigpio.
hardware_clock(...) failed with code -93
- The chosen GPIO can't drive a hardware clock. Use one of: 4, 5, 6,
20, 21, 32, 42, 43, 44 (BCM). Default is 4 — change with
--gpio.
Clock won't sync
- Move the antenna closer to the clock — within 5 cm of its internal ferrite-rod antenna is ideal.
- Force the clock into manual sync mode (button on the back — see the clock's manual).
- Verify the protocol matches the clock — WWVB clocks won't accept JJY and vice versa.
- DST: if the clock interprets the DST bit, run with
--dstduring northern-hemisphere summer. - Confirm carrier is actually being emitted: probe GPIO 4 with a scope, or short-loop into a sound-card line input and FFT for a 40/60 kHz spike.
Carrier sounds jittery on a Pi Zero
- See the Pi Zero notes above — disable HDMI, drop the desktop, set the
CPU governor to
performance.
Permission denied opening /dev/ttyAMA0
- Add yourself to the
dialoutgroup:sudo usermod -aG dialout $USER, then log out and back in.
Python complains externally-managed-environment
- You're on Bookworm. Either use a venv (see the install steps), or
pass
--break-system-packagestopip.
pip install -e ".[dev]"
pytest15 tests cover the WWVB and JJY encoders and the manual time source. No hardware needed.
You can also smoke-test the CLI on any machine:
wwvb-pi --dry-run -vv --duration 2 \
--time-source manual --manual-time 2026-05-20T13:44:55ZThis uses a NullCarrier that just logs start / high / low /
stop events with timing, so you can verify the second-by-second
modulation is sane.
Low-frequency unlicensed transmission rules vary by jurisdiction. The intended deployment is strict near-field — a few centimeters to a couple of meters at most, using a tiny hand-wound loop driven directly from a 3.3 V GPIO. This is well within typical part-15 (US) and equivalent unintentional-emitter limits in most countries.
Do not:
- attach a long-wire antenna
- add an amplifier
- attempt to reach across a building or a property line
Actual WWVB and JJY transmitters operate at 70 kW (WWVB) and 50 kW (JJY) under government licensing. You are not them.
MIT — see LICENSE.
The encoders and architecture are original; the project takes conceptual inspiration from micooke/wwvb_jjy.