Aiming an OTA Antenna With Data: Prometheus + Grafana on an HDHomeRun FLEX 4K
- 7 minutes read - 1333 wordsAiming an over-the-air TV antenna is traditionally a two-person job with a lot of yelling: one person on the roof rotating the mast, one inside shouting “better… worse… BETTER… no, worse again.” The feedback loop is slow, the signal bar in the tuner’s web UI updates lazily, and you can never quite tell whether that last nudge helped.
I wanted a tight feedback loop: a live graph, updating every few seconds, of the exact signal metrics that matter — so I could rotate the antenna and watch a line move in real time. My tuner is an HDHomeRun FLEX 4K, and it turns out you can scrape per-tuner signal quality straight into Prometheus and chart it in Grafana. This post is how I built that, the one exporter that actually does the job, and the surprise ending where the data explained a “broken” channel that wasn’t broken at all.
What actually matters for aiming #
First, the RF literacy, because it changes what you graph. The HDHomeRun reports three per-tuner numbers:
- Signal Strength (
ss) — how much RF power is hitting the tuner. Intuitive, and mostly a distraction. You can have tons of strength and still not decode. - Signal Quality (
snq) — signal-to-noise ratio. Higher is better; this is the one to maximize. - Symbol Quality (
seq) — symbol error rate. This must sit at 100 (error-free). The moment it drops below 100 you get pixelation and dropouts.
So the aiming rule is: keep Symbol Quality pegged at 100, and maximize Signal Quality. Signal Strength is context, not the target. A dashboard built for aiming should make Symbol Quality impossible to miss.
The environment #
Everything runs on fileserv (192.168.1.21), which already had a monitoring stack: Prometheus (host networking, :9090, --web.enable-lifecycle so POST /-/reload works), Grafana 11.2.2 on :3000, Alertmanager, and node_exporter. The right move was to extend that stack, not stand up a new one. The tuner is an HDHomeRun FLEX 4K (HDFX-4K, 4 tuners, ATSC 1.0/3.0) at 192.168.1.51, and it’s already my Jellyfin Live TV source — so a hard constraint was don’t disrupt Live TV and leave tuners free.
The idle-tuner problem, and picking the right exporter #
Here’s the trap. The HDHomeRun’s HTTP status.json only reports signal fields for tuners that are actively tuned. Idle tuners report nothing. So a naive json_exporter scraping status.json shows you blank graphs exactly when you’re trying to aim — unless something is already streaming.
I compared two community exporters:
camalot/hdhomerun-exporter (Python) — exposes tuner counts, not per-tuner
ss/snq/seq. Rejected.mdlayher/hdhomerun_exporter (Go) — speaks the libhdhomerun control protocol directly (not HTTP), and reads all tuners regardless of lock state. It exposes exactly what I need:
hdhomerun_tuner_signal_strength_ratiohdhomerun_tuner_signal_to_noise_ratiohdhomerun_tuner_symbol_error_ratiohdhomerun_tuner_info{tuner,channel,lock}
Critically, it’s read-only — it never tunes a tuner, so it can’t disrupt Live TV. That made the decision easy.
The one wrinkle: mdlayher’s exporter ships no release tags and no prebuilt image. Rather than pull a moving master, I built it from a pinned commit so the deploy is reproducible:
# Build mdlayher/hdhomerun_exporter from a pinned commit.
# Speaks the libhdhomerun control protocol (TCP/UDP 65001) and reports
# per-tuner ss/snq/seq for ALL tuners regardless of lock -- exactly what
# antenna aiming needs.
FROM golang:1.26 AS build
ARG EXPORTER_SHA=3592c57
RUN git clone https://github.com/mdlayher/hdhomerun_exporter . \
&& git checkout ${EXPORTER_SHA} \
&& CGO_ENABLED=0 go build -o /hdhomerun_exporter .
The exporter uses a blackbox-style ?target= pattern and listens on :9137. I ran it ephemerally against the live device first to confirm it returns metrics even with idle tuners:
curl -s 'http://127.0.0.1:9137/metrics?target=192.168.1.51' | grep '^hdhomerun_'
It worked — and it caught the live state neatly: tuner 2 locked to 8vsb:605000000 (RF 36, my Jellyfin stream) reading ss=0.82, snq=1.0, seq=1.0; the other three idle and reading 0. Read-only, non-disruptive, exactly as advertised.
Wiring it into Prometheus #
A blackbox-style scrape job, at a 5-second interval so the graph is responsive enough to aim against:
- job_name: hdhomerun
scrape_interval: 5s
metrics_path: /metrics
static_configs:
- targets: ['192.168.1.51']
relabel_configs:
- source_labels: [__address__]
target_label: __param_target
- source_labels: [__param_target]
target_label: instance
- target_label: __address__
replacement: hdhomerun-exporter:9137
Then curl -X POST http://localhost:9090/-/reload to pick it up without restarting Prometheus.
The dashboard, tuned for aiming #
The Grafana dashboard (“HDHomeRun FLEX 4K — Antenna Aiming,” file-provisioned) uses a PromQL join so panels show only locked tuners, labelled by channel — no clutter from idle tuners reading zero. Three time-series panels in priority order:
- Symbol Quality — the one to keep pinned at 100.
- Signal Quality — the one to maximize.
- Signal Strength — context only.
Plus an all-tuners panel for the full picture.
The aiming workflow #
Idle tuners report nothing, so before aiming you have to lock the marginal channels you actually care about — the weak ones decide antenna placement, not the strong ones. A small helper script holds channels on free tuners (via port 5004 /auto/), and Ctrl-C releases them:
./tune.sh 4.1 24.1 # hold your marginal channels on FREE tuners
Then: open Grafana, rotate the antenna slowly, and watch the lines. Keep Symbol Quality at 100, nudge Signal Quality up. Because the helper only ever grabs free tuners, Live TV (Jellyfin on .10) is never interrupted.
A compose-era landmine #
Worth flagging for anyone on an older Docker toolchain: this host only has standalone docker-compose v1.29.2, which throws KeyError: 'ContainerConfig' when recreating a container on the Docker 29 engine. Adding Grafana’s provisioning mounts triggered exactly that. The workaround is to remove the leftover renamed container and up -d fresh — safe here because the data lives in a named volume (grafana-data), so no dashboards or settings were lost.
The plot twist: “channel 7 doesn’t work” #
With the dashboard live, I went hunting for why a couple of channels wouldn’t play. The data told a story I did not expect.
107.1 (KMGH / ABC): I tuned it and it locked beautifully — Signal Quality 100, Symbol Quality 100 on RF 593 MHz, better than most of my lineup. And yet the stream returned 0 bytes, while a clear channel on the exact same frequency (102.1 KWGN) streamed several megabytes fine. The lineup JSON gave it away: "DRM":1, HEVC video / AC4 audio. This is NextGen TV (ATSC 3.0) with DRM. No amount of antenna aiming fixes it — the reception is already perfect; SiliconDust DRM channels simply can’t be decrypted by Jellyfin (or Plex/Emby). KUSA (NBC) 109.1 is in the same boat. That’s an industry restriction, not a setup problem.
7.1 (KMGH ATSC 1.0): at first it was absent from my lineup entirely, and I nearly wrote it off as discontinued. But after one antenna position change it came in rock-solid — Symbol Quality pinned at 100, dead steady across repeated samples, and Jellyfin happily streamed it. The catch: 7.1 broadcasts on 177 MHz — high-VHF (RF channel 7), not UHF like the 593/605 MHz channels that make up most of the market. That one fact explained everything. My antenna position was UHF-favorable; a small tweak brought VHF back. That reorientation also jumped my lineup from 56 → 79 channels.
The lesson the graphs taught me: not every “dead” channel is a reception problem. One was DRM (perfect signal, undecryptable), one was a VHF-vs-UHF antenna-orientation tradeoff. Without the per-channel signal data, I’d have spent a weekend rotating a mast trying to “fix” a channel whose signal was already flawless.
Takeaways #
- Graph Symbol Quality and Signal Quality, not Signal Strength. Aim to keep symbol errors at zero and maximize SNR.
- Use an exporter that reads all tuners regardless of lock (mdlayher’s, via libhdhomerun) — a
status.jsonscraper goes blind on idle tuners. - Pin builds to a commit when upstream has no release tags, so your deploy is reproducible.
- A 5-second scrape turns aiming into a live feedback loop — no more walkie-talkie guessing.
- Data disambiguates failures. DRM lockouts and VHF/UHF tradeoffs look identical to “bad antenna” until you can see per-channel signal quality.