RF noise floor monitor for RTL-SDR, with Home Assistant integration via MQTT.
Periodically samples configurable frequency bands, estimates the noise floor using Welch's PSD method, stores readings in SQLite, and publishes them to Home Assistant via MQTT autodiscovery. Runs as a systemd timer on Debian/Ubuntu Linux.
RTL-SDR v3/v4 (USB)
│
SDR++ headless server ──── SoapyRemote TCP (default :55123)
│
noise_floor_monitor.py
│
├── SoapySDR Python bindings (driver=remote)
│ └── Capture IQ samples per band
│
├── DSP: Welch PSD → Nth-percentile bin → dBFS
│
├── SQLite ~/.noise_monitor/noise_floor.db
│
├── MQTT → Home Assistant
│ ├── Autodiscovery: homeassistant/sensor/rf_conditions/…/config
│ └── Data: noise_monitor/{band_label}/noise_dbfs
│
└── matplotlib PNG ~/.noise_monitor/plots/noise_floor.png
Metric: 10th-percentile power bin from Welch's averaged PSD, in dBFS (dB relative to ADC full scale).
Welch's method averages many overlapping FFT segments, producing a stable power spectral density estimate even from a short capture. Taking the 10th-percentile bin ignores narrowband signals (carriers, birdies, broadcast stations) and tracks only the background noise floor of the band.
A rising noise floor typically indicates new local RFI — switching power supplies, solar inverters, motor drives, LED dimmers, or nearby electronics. By comparing multiple bands simultaneously you can distinguish broadband RFI (all bands rise together) from interference on a specific frequency range.
Because dBFS is relative to full ADC scale, readings are only comparable across
runs at the same gain setting. Keep sdr.gain fixed.
| Item | Notes |
|---|---|
| RTL-SDR v3 or v4 | v4 recommended; direct sampling mod required for HF |
| SDR++ (headless) | Must have SoapyRemote server enabled |
| Linux host (LXC, VM, or bare metal) | Debian 12 or Ubuntu 22.04+ |
| MQTT broker | Mosquitto, EMQX, etc. |
| Home Assistant | Optional — MQTT integration required for autodiscovery |
| Mode | Range | Notes |
|---|---|---|
| Normal (via tuner) | ~24 MHz – 1766 MHz | Default for VHF/UHF bands |
| Direct Q-branch sampling | ~500 kHz – 14 MHz | RTL-SDR v3/v4 with mod; auto-enabled by this script |
Bands below sdr.direct_sampling_threshold_hz (default 24 MHz) automatically
switch the device to Q-branch direct sampling mode. No manual configuration is
needed — just add the band to config.yaml.
Note: Direct sampling quality degrades above ~14 MHz on most dongles. The 20m HF band (14.2 MHz) is at the practical upper limit. Results may vary depending on your specific hardware revision.
SDR++ must be running in headless/server mode with SoapyRemote enabled on the host machine that has the RTL-SDR dongle attached.
- Install SDR++ and enable the Remote Source module
- Start SDR++ — no GUI client connection is needed
- Confirm the SoapyRemote server is listening:
You should see device info (manufacturer, tuner type, sample rate range, etc.)
SoapySDRUtil --probe="driver=remote,remote=YOUR_SDR_HOST:55123"
The noise monitor opens and closes the SDR device for each band capture. Do not run it while an SDR++ client is actively streaming the same device — only one SoapySDR client can hold the device at a time.
git clone https://github.com/your-username/SDR_NoiseMon.git
cd SDR_NoiseMonNote: Git does not preserve execute permissions across all platforms. After cloning on Linux, make
setup.shexecutable manually:chmod +x setup.sh
cp config.example.yaml config.yaml
cp .env.example .envEdit config.yaml — at minimum, verify the bands and MQTT device name.
Edit .env — set your SDR++ server host, MQTT broker address, and credentials.
chmod +x setup.sh
./setup.shsetup.sh will:
- Install system packages via apt (SoapySDR, numpy, scipy, matplotlib)
- Install
paho-mqttandpython-dotenvvia pip - Set up RTL-SDR udev rules for non-root USB access
- Probe the SoapySDR remote server to verify connectivity
- Install and enable the systemd service + timer
# Check the timer is active
systemctl status noise-monitor.timer
# Run a sample pass immediately and watch the output
systemctl start noise-monitor.service
journalctl -u noise-monitor.service -fSuccessful output looks like:
2026-03-21T14:00:03 INFO Config: config.yaml | SDR: driver=remote,...
2026-03-21T14:00:03 INFO DB: /home/user/.noise_monitor/noise_floor.db
2026-03-21T14:00:03 INFO MQTT connected → 192.168.1.12:1883
2026-03-21T14:00:04 INFO MQTT autodiscovery published: 5 band sensor(s) + timestamp
2026-03-21T14:00:04 INFO Sampling MW_AM @ 1.000 MHz ...
2026-03-21T14:00:05 INFO MW_AM: -58.34 dBFS
2026-03-21T14:00:05 INFO Sampling 40m_HF @ 7.100 MHz ...
2026-03-21T14:00:06 INFO 40m_HF: -62.11 dBFS
...
2026-03-21T14:00:12 INFO Sample pass complete: 5/5 band(s) OK.
2026-03-21T14:00:12 INFO Plot → /home/user/.noise_monitor/plots/noise_floor.png
After the first successful run, entities appear automatically in HA under Settings → Devices & Services → MQTT → RF Conditions:
| Entity | Topic | Unit |
|---|---|---|
sensor.mw_am_noise_floor |
noise_monitor/MW_AM/noise_dbfs |
dBFS |
sensor.40m_hf_noise_floor |
noise_monitor/40m_HF/noise_dbfs |
dBFS |
sensor.20m_hf_noise_floor |
noise_monitor/20m_HF/noise_dbfs |
dBFS |
sensor.vhf_wx_noise_floor |
noise_monitor/VHF_WX/noise_dbfs |
dBFS |
sensor.2m_ham_noise_floor |
noise_monitor/2m_HAM/noise_dbfs |
dBFS |
sensor.rf_monitor_last_sample |
noise_monitor/last_sample |
timestamp |
Entity names and topic labels are derived from the label field in config.yaml.
Add or rename bands and re-run --discover to update HA.
MQTT discovery payloads are retained (retain=True), so HA normally picks them
up automatically on restart. If entities go missing, re-publish manually:
python3 noise_floor_monitor.py --discoverUse an HA history graph card or ApexCharts card with the sensor entities to plot noise floor trends over time. Example Lovelace YAML:
type: history-graph
title: RF Noise Floor
hours_to_show: 48
entities:
- entity: sensor.mw_am_noise_floor
- entity: sensor.40m_hf_noise_floor
- entity: sensor.20m_hf_noise_floor
- entity: sensor.vhf_wx_noise_floor
- entity: sensor.2m_ham_noise_floor| Key | Default | Description |
|---|---|---|
sdr.remote_host |
(required) | IP/hostname of SDR++ server |
sdr.remote_port |
55123 |
SoapyRemote TCP port |
sdr.soapy_args |
(built from host/port) | Override with a raw SoapySDR driver string |
sdr.gain |
30 |
RF gain in dB; "auto" for AGC (not recommended) |
sdr.num_samples |
256000 |
IQ samples captured per band |
sdr.nperseg |
1024 |
Welch segment length (power of 2) |
sdr.noise_percentile |
10 |
PSD percentile used as noise floor estimate |
sdr.direct_sampling_threshold_hz |
24000000 |
Bands below this auto-enable Q-branch mode |
mqtt.broker |
(required) | MQTT broker IP/hostname |
mqtt.port |
1883 |
MQTT broker port |
mqtt.topic_prefix |
noise_monitor |
Prefix for all data topics |
mqtt.discovery_prefix |
homeassistant |
HA MQTT discovery prefix |
mqtt.device_name |
RF Conditions |
Device name shown in HA |
mqtt.device_id |
rf_conditions |
Unique device identifier |
storage.db_path |
~/.noise_monitor/noise_floor.db |
SQLite database location |
storage.plot_dir |
~/.noise_monitor/plots |
PNG and CSV output directory |
storage.plot_hours |
48 |
Rolling window for plots |
| Variable | Description |
|---|---|
SDR_HOST |
IP/hostname of SDR++ server |
SDR_PORT |
SoapyRemote port (default 55123) |
MQTT_BROKER |
Broker IP/hostname |
NOISE_MQTT_USER |
MQTT username |
NOISE_MQTT_PASSWORD |
MQTT password |
# Normal operation (sample + plot + publish) — what the timer runs:
python3 noise_floor_monitor.py
# Override SDR++ host for testing without editing config:
python3 noise_floor_monitor.py --sdr-host 192.168.1.50
# Verbose output:
python3 noise_floor_monitor.py --debug
# Regenerate plots from existing DB without sampling:
python3 noise_floor_monitor.py --plot
# Publish MQTT autodiscovery only (no sampling):
python3 noise_floor_monitor.py --discover
# Explicit config file location:
python3 noise_floor_monitor.py --config /etc/noise_monitor/config.yaml# Timer status (next scheduled run):
systemctl status noise-monitor.timer
# Last service run status and exit code:
systemctl status noise-monitor.service
# Trigger an immediate run:
systemctl start noise-monitor.service
# Follow logs live:
journalctl -u noise-monitor.service -f
# Recent logs:
journalctl -u noise-monitor.service -n 100
# Disable the timer:
systemctl disable --now noise-monitor.timerData is stored in SQLite at the path configured in storage.db_path.
CREATE TABLE noise_floor (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL, -- ISO-8601 UTC (e.g. 2026-03-21T14:00:04Z)
band TEXT NOT NULL, -- label from config.yaml
freq_hz INTEGER NOT NULL,
noise_dbfs REAL NOT NULL, -- 10th-pctile Welch PSD bin in dBFS
gain_db REAL NOT NULL, -- -1.0 if gain was "auto"
sample_rate INTEGER NOT NULL
);# Most recent reading per band:
sqlite3 ~/.noise_monitor/noise_floor.db \
"SELECT ts, band, printf('%.2f', noise_dbfs) dBFS
FROM noise_floor
GROUP BY band
HAVING ts = MAX(ts);"
# Last 24 hours for a specific band:
sqlite3 ~/.noise_monitor/noise_floor.db \
"SELECT ts, printf('%.2f', noise_dbfs) dBFS
FROM noise_floor
WHERE band = '2m_HAM'
AND ts >= datetime('now', '-24 hours')
ORDER BY ts;"
# Daily average per band:
sqlite3 ~/.noise_monitor/noise_floor.db \
"SELECT date(ts) day, band, printf('%.2f', avg(noise_dbfs)) avg_dBFS
FROM noise_floor
GROUP BY day, band
ORDER BY day DESC, band;"WARNING: SoapySDR probe failed
- Confirm SDR++ is running on the remote host with SoapyRemote enabled
- Check firewall — port 55123 must be reachable from the monitor host
- Test manually:
SoapySDRUtil --probe="driver=remote,remote=HOST:PORT" - Verify only one client is connected at a time
Direct sampling output can be affected by:
- USB noise on the dongle itself (use a USB extension cable)
- Lack of an HF antenna (a simple wire helps significantly)
- DC offset at exactly 0 Hz (normal — the 10th-percentile estimator avoids this)
- Verify MQTT integration is enabled in HA with the correct discovery prefix (
homeassistantby default) - Run
python3 noise_floor_monitor.py --discoverto re-publish retained config payloads - Check broker connectivity:
mosquitto_pub -h BROKER -u USER -P PASS -t test/ping -m hello
config.yaml is missing a bands: list. Copy config.example.yaml and edit it.
Pull requests welcome. Useful additions:
- Support for other SoapySDR-compatible devices (HackRF, SDRplay, etc.)
- Additional noise floor estimators (e.g. minimum statistics)
- InfluxDB / Prometheus export alongside SQLite
- Docker container definition
- Web UI for the SQLite data
Please open an issue before starting a large feature to discuss the approach.
MIT License. See LICENSE for details.