Skip to main content

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

License: Apache 2.0 Python 3.12+ Tests Sonar Quality Gate Coverage

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
Synthetic FM MPX spectrum Real FM MPX spectrum
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

  1. Acquisition — a quick band scan locates strong FM stations and tests each one for valid RDS Group 4A (Clock-Time).
  2. Maintenance — the watchlist is hopped through, collecting one CT observation per station per cycle.
  3. 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).
  4. RF fingerprinting — per-station features (CFO, RSSI, PI) are recorded for later analysis. Automated shift detection is on the roadmap.
  5. 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.
  6. Operator displayUTC 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

Apache License, Version 2.0.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

rdsclock-0.1.0.tar.gz (79.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

rdsclock-0.1.0-py3-none-any.whl (59.5 kB view details)

Uploaded Python 3

File details

Details for the file rdsclock-0.1.0.tar.gz.

File metadata

  • Download URL: rdsclock-0.1.0.tar.gz
  • Upload date:
  • Size: 79.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for rdsclock-0.1.0.tar.gz
Algorithm Hash digest
SHA256 61183cc69749fb6de4c069933d3df0b86c7783a3550c76283dcf5b0f38daea85
MD5 091ab15f5652da61572cce6225f6cb57
BLAKE2b-256 f184e6c9cc8cdeac07d6856af4998a11c81443edea78c8d2a38cd92638699d79

See more details on using hashes here.

Provenance

The following attestation bundles were made for rdsclock-0.1.0.tar.gz:

Publisher: publish.yml on mateusz-klatt/rdsclock

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file rdsclock-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: rdsclock-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 59.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for rdsclock-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6ed4d06f1559457334fda118e5659c3e8333361914719a336f381bf21b0ce96d
MD5 37f8ddefa5247d523194afb2b704d47b
BLAKE2b-256 1bea0ad22bc176896bac4da067e16be117b353fa670e22aa5198a0d8af4e9586

See more details on using hashes here.

Provenance

The following attestation bundles were made for rdsclock-0.1.0-py3-none-any.whl:

Publisher: publish.yml on mateusz-klatt/rdsclock

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page