Passive RDS Clock-Time receiver for FM via RTL-SDR, with multi-source consensus for GPS-denied environments
Project description
rdsclock — Passive RDS Clock-Time Receiver
A pure-Python passive receiver for the RDS (Radio Data System) Clock-Time data stream broadcast by FM radio stations. Built for GPS-denied, NTP-unavailable scenarios where an operator needs a non-GPS source of UTC and cannot emit RF.
The package is composed of small, audit-friendly modules: DSP primitives, the RDS block / group layers, a synthetic-signal generator, a multi-source time-consensus engine, and a continuous "recon" mode that hops across multiple FM stations and aggregates their clocks into a single, robust time estimate with an explicit uncertainty.
Quick Start
make setup # creates .venv and installs the package (editable)
make test # runs the unit and integration test suite
make demo # synthetic 3-station multi-channel showcase (no SDR)
make recon-offline # replay recon over eter/ recordings
make recon # passive recon LIVE with an RTL-SDR
All sub-commands are also available through the CLI:
rdsclock generate build/test.iq --time 2026-05-17T12:00 --snr 25
rdsclock decode build/test.iq -v
rdsclock live --freq 95.5 --duration 10
rdsclock multi --freqs 92.0,98.3,106.8 --mode auto
rdsclock recon --start 87.5 --end 108.0 --max-stations 5
rdsclock scan --start 87.5 --end 108 --step 0.1
rdsclock play --freq 102.4 # live FM audio (needs [audio] extra)
rdsclock play --file build/capture.iq --fs 250000
rdsclock plot build/capture.iq --out spec.png # MPX spectrum (needs [plot] extra)
rdsclock plot wide.iq --kind waterfall --fs 2400000
Optional dependency groups (install with pip install 'rdsclock[audio]' etc.):
| Extra | Brings in | Enables |
|---|---|---|
audio |
sounddevice |
live FM audio playback |
plot |
matplotlib |
MPX spectrum and waterfall PNGs |
dev |
pytest, ruff | running the test/lint suite |
Architecture
flowchart TB
subgraph pkg[src/rdsclock/]
cli[cli.py<br/>CLI entry point]
decoder[decoder.py<br/>IQ → groups → StationInfo]
recon[recon.py<br/>continuous passive receiver]
synth[synth.py<br/>synthetic IQ generator]
channelizer[channelizer.py<br/>wide-band → N narrow]
consensus[time_consensus.py<br/>multi-source consensus]
dsp[dsp.py<br/>DSP primitives]
rds_blocks[rds_blocks.py<br/>block layer + CRC]
rds_clock[rds_clock.py<br/>MJD ↔ datetime · 4A]
rds_groups[rds_groups.py<br/>PS / RT / CT]
rtl_tcp[rtl_tcp.py<br/>rtl_tcp client]
audio[audio.py<br/>FM audio<br/>optional]
plot[plot.py<br/>spectrum / waterfall<br/>optional]
end
tests[tests/<br/>≈ 140 unit & integration tests]
docs[docs/<br/>architecture · threat model · quickstart]
cli --> decoder
cli --> recon
cli --> synth
cli --> audio
cli --> plot
decoder --> dsp
decoder --> rds_blocks
decoder --> rds_groups
recon --> decoder
recon --> rtl_tcp
recon --> consensus
synth --> rds_blocks
synth --> dsp
channelizer --> dsp
channelizer --> decoder
rds_groups --> rds_clock
rds_groups --> rds_blocks
The tree itself lives under src/rdsclock/; see
docs/ARCHITECTURE.md for the data-flow
description of each module.
What FM MPX Looks Like
| Synthetic capture | Real local capture |
|---|---|
Generated by rdsclock.synth.synthesize_fm_iq (no broadcast content). |
Spectrum derived from a local FM capture. The plot is a derivative work; the source IQ is not redistributed. |
The two plots show the FM-demodulated baseband: on the left an entirely
synthetic stream produced by the package, on the right a real-world
capture analysed by the same rdsclock plot renderer. The annotated
bands are the four standard FM-MPX components:
| Band | Component |
|---|---|
| 0 – 15 kHz | Mono audio (L+R) |
| 19 kHz | Stereo pilot tone |
| 23 – 53 kHz | Stereo (L–R) double-sideband subcarrier |
| 57 kHz (±~2 kHz) | RDS BPSK on the 3rd harmonic of the pilot |
The receiver locks the RDS subcarrier through the pilot
(estimate_pilot_19khz × 3), which is robust against the typical
RTL-SDR ppm drift. Reproduce either figure with:
# Synthetic
rdsclock generate build/test.iq --snr 30
rdsclock plot build/test.iq --out spectrum.png # needs the [plot] extra
# From your own local capture (no broadcast content shared, plot only)
rdsclock live --freq 89.0 --duration 30 --save build/local.iq
rdsclock plot build/local.iq --out spectrum.png
RDS Group 4A — Bit Layout (IEC 62106-2:2021, Figure 11)
The 34-bit Clock-Time payload (MJD 17 + Hour 5 + Minute 6 + LTO sign 1 + LTO magnitude 5) is distributed across data words B, C and D (MSB-first per word):
| Field | Block B | Block C | Block D |
|---|---|---|---|
| MJD | [1:0] (MSB) |
[15:1] (LSB) |
— |
| HOUR | — | [0] (MSB) |
[15:12] (LSB) |
| MINUTE | — | — | [11:6] |
| LTO sign | — | — | [5] |
| LTO mag | — | — | [4:0] |
The equivalent bit-extraction formula is:
MJD = ((B & 0x0003) << 15) | (C >> 1)
HOUR = ((C & 0x1) << 4) | (D >> 12)
MINUTE = (D >> 6) & 0x3F
SIGN = (D >> 5) & 0x1
MAG = D & 0x1F
The Modified Julian Day epoch is 1858-11-17 00:00 UT. The
hh:mm field is UTC; local time = UTC + sign · magnitude · 30 min.
Passive Time-Receiver Mode (recon)
recon implements a continuous, fully passive time receiver designed
for environments where:
- GPS may be jammed or spoofed,
- NTP is unavailable (no internet),
- the operator cannot emit RF of any kind,
- the broadcast language is unknown (so PS/RT are not used).
Operating Principle
- Acquisition — a quick band scan locates strong FM stations and tests each one for valid RDS Group 4A (Clock-Time).
- Maintenance — the watchlist is hopped through, collecting one CT observation per station per cycle.
- Consensus — the multi-source median of the observed times becomes the operator's UTC reference. Each station carries a trust score that decays when it diverges from the median (Hampel outlier rule).
- RF fingerprinting — per-station features (CFO, RSSI, PI) are recorded for later analysis. Automated shift detection is on the roadmap.
- Holdover — between Clock-Time messages the receiver extrapolates UTC from a local monotonic clock disciplined by an estimated ppm drift. Uncertainty grows linearly with the age of the most recent CT.
- Operator display —
UTC 2026-05-17 04:23:18 ±2s N=3 trust=HIGH.
Quick Run
# Live
rdsclock recon --start 87.5 --end 108 --max-stations 5 --rescan-min 10
# Offline (no SDR, replays recordings or any directory of .iq files)
rdsclock recon --from-dir eter/
See docs/operator-quickstart.md for an operator-oriented walkthrough
and docs/THREAT_MODEL.md for the security threat model.
Hardware
- RTL-SDR USB dongle (tested: RTL2838 with R820T2 tuner).
rtl_tcp -a 127.0.0.1(loopback only; do not expose to the network without a firewall — the protocol has no authentication).- An FM-band (VHF II ~88–108 MHz) antenna.
Testing
make test # all tests, including real-recording validation
make test-fast # skip 'slow' and 'real_sdr' tests
make coverage # generate htmlcov/
The synthetic round-trip (tests/test_decoder_synthetic.py) is the
load-bearing correctness check: it generates IQ from a known clock,
runs the full pipeline, and asserts the decoded clock matches.
Real-world validation against a live RTL-SDR (tests/test_real_recordings.py)
is gated by the real_sdr marker and skips automatically when no
rtl_tcp daemon is reachable. These tests do not assume any specific
station or date — they verify pipeline invariants on whatever signal
the local antenna picks up.
Status
- 0.1.0 — first public release. Pre-1.0; the CLI and on-disk formats may change.
- ~140 tests passing across synthetic, integration and live-capture suites; line coverage above 80% (tracked by SonarCloud).
Legal Note
The receiver is passive — it never transmits and never emits RF, so receiving broadcast FM is lawful in Poland and across the EU. However, the captured IQ data and any decoded audio should be kept local:
- Polish Prawo komunikacji elektronicznej (Dz.U. 2024 poz. 1221) preserves the confidentiality of electronic communications and restricts dissemination of received content.
- The audio embedded in an FM capture is generally a copyrighted work.
Local recording for technical or amateur-radio use is widely accepted;
public redistribution of those recordings typically requires
permission from the rights holders. The synthetic generator shipped
with the package produces copyright-free IQ files for tests and
demos. See docs/datasets.md for details and
SECURITY.md for the security checklist. None of
the above is legal advice.
Licence
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rdsclock-0.1.1.tar.gz.
File metadata
- Download URL: rdsclock-0.1.1.tar.gz
- Upload date:
- Size: 87.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3a356c9147b475e150190bf1436504f34e44fa14a7581aefcbc9dc3ea428e19
|
|
| MD5 |
3a2d9f1a1c583cc6cc2bf754591b5aaf
|
|
| BLAKE2b-256 |
1ece349536f6a59b5441ba6da304219483274c03b6262b195c2c5558873d40ce
|
Provenance
The following attestation bundles were made for rdsclock-0.1.1.tar.gz:
Publisher:
publish.yml on mateusz-klatt/rdsclock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rdsclock-0.1.1.tar.gz -
Subject digest:
a3a356c9147b475e150190bf1436504f34e44fa14a7581aefcbc9dc3ea428e19 - Sigstore transparency entry: 1563109825
- Sigstore integration time:
-
Permalink:
mateusz-klatt/rdsclock@e62d8daa90b60e17dc3e5cfa541e4402dab5f292 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/mateusz-klatt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e62d8daa90b60e17dc3e5cfa541e4402dab5f292 -
Trigger Event:
push
-
Statement type:
File details
Details for the file rdsclock-0.1.1-py3-none-any.whl.
File metadata
- Download URL: rdsclock-0.1.1-py3-none-any.whl
- Upload date:
- Size: 55.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
266093c0f9bf061d205b3c6f4bd97d6cc39902d3b24786ea8e69d4c6aa8185fe
|
|
| MD5 |
36c128faaf64108da4fbf85737f6826f
|
|
| BLAKE2b-256 |
ac2a033486fc514e9381232be7d90e833b7af1602d34cf46acc55b85d39fc226
|
Provenance
The following attestation bundles were made for rdsclock-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on mateusz-klatt/rdsclock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rdsclock-0.1.1-py3-none-any.whl -
Subject digest:
266093c0f9bf061d205b3c6f4bd97d6cc39902d3b24786ea8e69d4c6aa8185fe - Sigstore transparency entry: 1563109885
- Sigstore integration time:
-
Permalink:
mateusz-klatt/rdsclock@e62d8daa90b60e17dc3e5cfa541e4402dab5f292 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/mateusz-klatt
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e62d8daa90b60e17dc3e5cfa541e4402dab5f292 -
Trigger Event:
push
-
Statement type: