Skip to content

haxorthematrix/WWVB-Pi-Python

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

WWVB-Pi-Python

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).


Contents

  1. How it works
  2. Bill of materials
  3. Software installation
  4. Wiring
  5. Usage
  6. Time sources
  7. Troubleshooting
  8. Testing without a Pi
  9. Legal
  10. License

How it works

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.


Bill of materials

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.


Software installation

Raspberry Pi OS prerequisites

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 -5

You should see Active: active (running). If not, check the Troubleshooting section.

Install the Python package

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 --help

You should see the full option listing.

Pi Pico — flash MicroPython firmware

The Pi Pico is a microcontroller, not a Linux SBC, so the CPython package above doesn't apply to it. Instead:

  1. Install MicroPython on the Pico

    • Download the latest .uf2 from micropython.org/download/RPI_PICO/ (or RPI_PICO_W for 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 .uf2 onto it. The Pico reboots into MicroPython automatically.
  2. Copy the firmware to the Pico

    The easiest tool is Thonny (sudo apt install thonny or the Windows / Mac installer):

    • Open Thonny → set the interpreter to "MicroPython (Raspberry Pi Pico)".
    • Open pico/wwvb_pico.py from 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 JJY
      • USE_GPS = True if you wired a GPS module on UART0
    • Save the file to the Pico as main.py.

    Or use mpremote from the command line:

    pip install --user mpremote
    mpremote cp pico/wwvb_pico.py :main.py
    mpremote reset
  3. 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).

Optional — GPS module support

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 reboot

After the reboot, your GPS module is reachable at /dev/ttyAMA0 (Pi 3 / Zero) or /dev/serial0 (recommended alias on all models).


Wiring

⚠️ 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.

Universal schematic

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.

Pi 3 / Pi 4

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. pigpio handles 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 -v

Pi Zero / Pi Zero 2 W

Same 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 W

Pi 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.

Pi Pico (RP2040)

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.py ports cleanly to anything running MicroPython — just change the pin number.

Antenna construction

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:

Air-core loop (easiest)

  1. Find a small cylindrical form — a 35mm film canister, a pill bottle, a 3D-printed bobbin, a toilet-paper tube — about 5 cm diameter.
  2. Wind 60–200 turns of 28–32 AWG enamelled magnet wire neatly around it.
  3. Strip and tin the two ends.
  4. 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

Ferrite-rod loopstick (best range)

  1. Salvage the ferrite rod from a junk AM radio (the long black rod with the variable-cap-tuned coils on it).
  2. Strip off the existing winding.
  3. Wind 30–60 turns of magnet wire over the rod's center.
  4. 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.

Placement

  • 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.

GPS module wiring

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/serial0

You 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.


Usage

CLI reference

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

Examples

Default — broadcast WWVB synced to the Pi's NTP-disciplined system clock:

wwvb-pi --protocol wwvb -v

Manually 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 -v

Sync to a serial GPS module:

wwvb-pi --protocol wwvb --time-source gps --gps-port /dev/serial0 -v

JJY for a Japanese clock (eastern Japan = 40 kHz):

wwvb-pi --protocol jjy40 -v

JJY for western Japan (60 kHz):

wwvb-pi --protocol jjy60 -v

Dry-run on a development machine (no GPIO needed — useful for verifying the encoder and timing):

wwvb-pi --dry-run -vv --duration 2

Running as a system service

For 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.target

Then:

sudo systemctl daemon-reload
sudo systemctl enable --now wwvb-pi
sudo journalctl -fu wwvb-pi          # follow logs

Time sources

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.


Troubleshooting

Could not connect to pigpiod

  • sudo systemctl status pigpiod — should be active (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 --dst during 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 dialout group: 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-packages to pip.

Testing without a Pi

pip install -e ".[dev]"
pytest

15 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:55Z

This uses a NullCarrier that just logs start / high / low / stop events with timing, so you can verify the second-by-second modulation is sane.


Legal

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.


License

MIT — see LICENSE.

The encoders and architecture are original; the project takes conceptual inspiration from micooke/wwvb_jjy.

About

Python WWVB / JJY low-frequency time-code transmitter for Raspberry Pi (3, 4, Zero, Zero 2 W, Pico). Manual, system-clock, or GPS time sources.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages