An inventory and band-survey web UI for an rtl_433 receiver: it subscribes to the receiver's MQTT event stream, records every transmitter that decodes, and serves the result as a browsable table. It can also retune the same dongle for a time-boxed survey of other bands (315 / 868 / 915 MHz) to answer "is there anything over there worth a second receiver?", then always puts it back.
(Both screenshots are of the service running against invented devices, not a real receiver's inventory — see the privacy note below.)
flowchart LR
RX["rtl_433 receiver<br/>-M level -M protocol<br/>-F mqtt -F http://0.0.0.0:8433"]
MQ["MQTT broker"]
SC["rtl433-scanner<br/>(this service)"]
DB[("SQLite /data/scanner.db<br/>devices · packets · bands · scans")]
UI["Web UI :8434<br/>devices · device detail · band scan"]
HA["Home Assistant<br/>(optional, MQTT discovery)"]
RX -- "events JSON" --> MQ
MQ -- "rtl_433/events" --> SC
SC -- "GET /cmd?cmd=center_frequency&val=…" --> RX
SC --> DB
DB --> UI
SC -- "rollup entities + scan state" --> MQ
MQ --> HA
HA -- "rtl433scan/cmd/start_scan" --> MQ
Two connections to the broker, one thread each: a subscriber for the event stream and the command topic, a publisher for discovery, state and acknowledgements. The only channel to the receiver is rtl_433's own HTTP command API.
A 433 MHz receiver is indiscriminate. This service records everything it decodes — your own sensors, the neighbours' weather stations and doorbells, the tyre-pressure sensors of cars driving past — with an identifier, a first- and last-seen time, packet counts, signal strength and the last decoded payload. That is the point of the tool: you cannot decide what to adopt without first seeing what is out there. It is also a database about people who did not ask to be in it.
So: keep it on a private network, do not expose port 8434 to the internet, and think about
retention (PACKETS_DAYS / PACKETS_MAX) before you widen it. Nothing here is
transmitted; the receiver only listens. Check what your local rules say about logging
radio traffic before running it anywhere but your own home.
- Inventory. Every decoded event updates one row keyed
model|id|channel: first and last seen, packet count, every field name the device has ever sent, its last payload, the protocol number, and running RSSI / SNR / noise statistics. Devices first heard in the last 24 h are highlighted, so a new arrival is obvious. - Device detail. The full field set, an SNR sparkline over recent packets, which bands it has been heard on, and the most recent raw payloads.
- Labels and adoption. Give a device a role name and a note. The page renders the JSON line you would paste into the allowlist of whatever rtl_433 → Home Assistant bridge you run, and you can then tick it as adopted so it drops out of the "still to triage" view. This service deliberately never creates a per-device entity itself — that would put the whole neighbourhood into your home automation system.
- Band survey. A named preset retunes the receiver, optionally changes the sample rate and enables extra decoders, listens for N minutes per step, and reports how many devices and packets each band produced. Presets ship for 315 MHz (US TPMS and remotes), 868.3 MHz (EU ISM), 915 MHz (US ISM utility meters) and a combined survey of all three.
- Home Assistant surface (optional). MQTT discovery publishes rollups only — devices
heard in 24 h, new in 24 h, packets in 24 h, awaiting adoption, receiver frequency,
health, last publish, and a
scanningbinary sensor carrying the preset, step and end time as attributes. Availability is a retained LWT plusexpire_after. Scans can be started and stopped over MQTT, by preset name only — never a free-form frequency. - Health.
/api/statereportsreceiver_ok(the/cmdAPI answered on the last poll) andmqtt_ok(the subscriber is connected); the container healthcheck asserts both, so a listener that came up with no receiver behind it is unhealthy rather than quietly idle.
With one dongle, a scan silences the 433 MHz sensors for its duration — anything that
depends on them will read unavailable until it ends. The design is therefore
time-boxed, auto-restoring, and visible:
- a scan is a preset from
presets.json, never a caller-supplied frequency; SCAN_MAX_MINUTESis a hard ceiling and truncates any longer plan;- restore means home frequency + home sample rate + disabling every decoder the scan
enabled, then reading the frequency back to confirm. Five retries, then health goes to
restore_failedand it retries every minute forever — a receiver parked on 315 MHz is the one failure this must never be quiet about; - if the process dies mid-scan,
/data/scan-active.jsonrecords that, and start-up restores when it finds the marker or the receiver off-frequency.SIGTERMrestores within the 30 s stop grace period; - rtl_433's setters are not persisted, so restarting the receiver is itself a restore.
Python 3.12 in a python:3.12-slim-bookworm container, zero pip dependencies: the
standard library's http.server and sqlite3, threads, and a small vendored MQTT 5.0
client (app/mqtt_min.py). The image is a COPY and nothing else, so a build cannot fail
on a wheel — it builds on a Raspberry Pi as happily as on a laptop.
It needs an rtl_433 (25.12 or newer) started with -M level -M protocol — so events carry
rssi/snr/noise and the decoder number — publishing to MQTT, and with
-F http://0.0.0.0:8433 so the scan engine can retune it. That HTTP command port is
unauthenticated: keep it on a trusted network.
cp .env.example .env && chmod 600 .env # then edit: receiver, broker, credentials
docker compose up -d --buildThen open http://<host>:8434/. data/ is created next to the compose file and holds
scanner.db, an editable copy of presets.json, audit.jsonl (every scan start/stop and
label change) and the scan marker.
| Variable | Default | What it is |
|---|---|---|
RTL433_HTTP |
http://rtl433-receiver:8433 |
rtl_433's HTTP command API. Required. |
MQTT_HOST / MQTT_PORT |
mqtt-broker / 1883 |
The broker. Host required. |
MQTT_USER / MQTT_PASS |
rtl433scan / — |
Its own broker account: read the events topic, read/write rtl433scan/# and homeassistant/+/rtl433scan/#. |
EVENTS_TOPIC |
rtl_433/events |
The receiver's JSON event stream. |
SLUG |
rtl433scan |
Prefix for this service's own MQTT topics and entity ids. |
DISCOVERY_PREFIX |
homeassistant |
Point it elsewhere to exercise discovery without Home Assistant seeing it. |
HOME_FREQ / HOME_SAMPLE_RATE |
433920000 / 250000 |
What a scan restores to. Must match the receiver's own CLI defaults. |
SCAN_MAX_MINUTES |
60 |
Hard ceiling on any scan. |
PACKETS_MAX / PACKETS_DAYS |
200000 / 7 |
Packet history retention. |
PUBLISH_INTERVAL |
300 |
Home Assistant publish cadence, seconds; entities expire at 3x. |
PORT |
8434 |
HTTP port inside the container. |
Without Docker: pip-free, so RTL433_HTTP=… MQTT_HOST=… DATA_DIR=./data python3 app/scanner.py works anywhere with Python 3.12.
GET /api/state (health), GET /api/devices?since=SECS&sort=…, GET|POST /api/device/<key> (label, note, adopted), GET /api/scan (current, history, presets),
POST /api/scan {"preset": name}, DELETE /api/scan, GET /api/receiver (read-only
passthrough of rtl_433's own getters), GET /api/audit.
python3 -m unittest tests.test_db # from the project rootSix offline tests covering the rollups, the retention cap, new-since, scan attribution and labels. No radio and no broker needed.
| Path | What it is |
|---|---|
app/scanner.py |
Entry point: config, shared state, threads, signal handling. |
app/ingest.py |
MQTT subscriber — the event stream and the command topic. |
app/db.py |
SQLite inventory: devices, packets, bands, scans, retention. |
app/scan.py |
The band-scan engine and the restore path. |
app/receiver.py |
Thin client for rtl_433's /cmd HTTP API. |
app/hapub.py |
MQTT publisher: Home Assistant discovery and state. |
app/web.py, app/ui.py |
The HTTP server, and the pages as module strings. |
app/mqtt_min.py |
Vendored dependency-free MQTT 5.0 client. |
seed/presets.json |
Band-scan presets, copied to /data on first start and editable there. |
tests/ |
Offline unit tests. |

