TL;DR
A $35 RTL-SDR dongle and the coffee-shop Pi at Muse & Co now publish live block-level train, vehicle, and pedestrian signals to ncfayetteville.com/train and the business-analytics pages. The biggest lesson: the first detector design — measuring audio-band RMS off rtl_fm — was blind to the very signals it was meant to catch, because at low SNR the FM discriminator can't capture and audio RMS sits at the noise floor regardless of carrier presence. Fix: switch to rtl_fm's IF-stage squelch (-l 65, calibrated against NOAA Weather Radio as a known positive control), plus a 30-min watchdog restart on the supervisor service because the R820T tuner silently wedges between mux cycles otherwise. BLE counting via the Pi's onboard Bluetooth turned out to be the unplanned win (25–40 unique devices per 5-min window even at midnight); train prediction is scaffolded but waiting on 30+ days of clean detector data, and a one-block sensor honestly reports as one block — not downtown-wide.
What if we could give downtown Fayetteville a useful heads-up before the train crossings get blocked?
Not a perfect system. Not a city-funded ITS platform. Not a proprietary railroad feed. Just a practical, community-built approximation using cheap radios, a Raspberry Pi, and some careful signal detection.
The project started with a pretty simple goal:
Can we provide a 10+ minute warning that one of the downtown rail crossings is likely to become blocked?
The working public page is here: ncfayetteville.com/train.
The rough idea was to combine a few local signals into something useful:
- Train detector events from an RTL-SDR listening for rail-related radio activity.
- Crossing reports from the community.
- Amtrak schedule data, where available.
- TPMS-based vehicle counting or other passive RF methods to approximate traffic volume downtown.
That last part is where things get fun — and where the project turned out to teach us a lot more than the train problem itself.
Bill of materials
For anyone starting from zero, this whole rig is about $115 in hardware plus an optional keyboard.
| Item | Price | Why it's on the list |
|---|---|---|
| RTL-SDR USB dongle | $50 | The radio. Picks up everything from CSX defect detectors at 160 MHz to TPMS tire-pressure broadcasts at 315/433 MHz. |
| Raspberry Pi 3 Model B+ | $50 | Plenty of headroom for the Pi-side signal-processing pipeline. A Pi 4 works too but isn't necessary; the 3B+ runs the whole stack at low CPU. |
| Miuzei case with fan, heat sinks, and 5V/3A PSU | $15 | The case + active cooling matter once you're leaving the Pi running 24/7 in a storefront window where temps cycle. The PSU is in the box so you don't end up powering an SBC off a phone charger that can't sustain peak draw. Also keeps loose components off the GPIO header — important when the install lives somewhere non-technical staff might touch it. |
| Total | ~$115 | One-time spend, no subscription. |
| Optional: Rii mini wireless keyboard | ~$20 | Not required but a huge QoL win for kiosk-style installs — see Tools that made this easier below. |
If you already have a Pi sitting on a shelf, your incremental cost is just the $50 RTL-SDR. That's the path most of us actually walked — half-day build, parts you probably have lying around, and a working community sensor at the end of it.
What actually got built
A Raspberry Pi 4 already lives at Muse & Co (300 Hay Street block) running a kiosk, an IP camera relay, and a label printer. A single RTL-SDR Blog v3 dongle plugged into a free USB 2.0 port, with the included telescoping dipole extended to 44 cm per leg (quarter-wave at 161 MHz), placed near a south-facing window for a line-of-sight shot toward Wade NC ~9 miles south.

Three radios sharing one physical box:
- The RTL-SDR, time-multiplexed between 160.5900 MHz (CSX defect detector listening) for 180 seconds, then 315 / 433.92 MHz (
rtl_433for TPMS + key-fob + garage-door decodes) for 120 seconds, on repeat. - The Pi's onboard Bluetooth radio, passively scanning for BLE advertisements as a pedestrian-presence proxy.
- (Optionally) a second USB Wi-Fi adapter in monitor mode for probe-request counting.
All of it ships observations to a Cloudflare Worker (downtown-guide.wemea-5ahhf.workers.dev) that stores them in D1 and surfaces them on the public train page and the business-analytics dashboards.
The whole sensor stack is open-source in the Muse_and_Co repo under muse-display/pi-setup/sdr/ — service files, install script, deploy-from-laptop helper, env example, README with the calibration procedure.
What worked surprisingly well: BLE for foot traffic
This was the unplanned win. The Pi has an onboard Bluetooth chip already; we put it in passive-scan mode and it sees about 25–40 unique BLE-emitting devices in any given 5-minute window at midnight on a Sunday. Phones, watches, Find-My beacons, BT headphones, the occasional keyless-entry car fob. Daytime numbers should be 2–3× that.

Privacy posture matters here. MAC addresses are never written to disk. They're hashed with a salt that rotates every 5-minute epoch, so the same device walking past at 11:05 and again at 11:10 produces two different hashes — you can't correlate a person across windows. The only thing that leaves the sensor is the count of distinct hashes in the bucket. ECPA §2511(2)(g)(i) covers unencrypted radio reception of public broadcasts; the rotating-salt design keeps us out of NC G.S. § 75-65 territory.
The signal is good enough that I'm now more excited about BLE counting than the original train-detector idea.
What worked: TPMS and key-fob events for vehicle activity
The rtl_433 decoder catalog covers ~200 device protocols. We just listen on 315 + 433.92 MHz and classify each decode:
- TPMS sensor IDs → unique vehicles passing
- Key-fob locks/unlocks, garage-door remotes → vehicle arrival/departure events near a building
- Everything else (weather stations, etc.) → bucket as "antenna liveness" — a small but non-zero count that proves the antenna is alive when TPMS counts go to zero
The clever thing isn't the radio; it's the antenna-liveness sanity counter. Without it you can't distinguish "no cars passed" from "antenna unplugged." That telemetry-of-telemetry pattern paid off the second TPMS counts dropped to zero at 1 AM — a glance at sdr_misc_pi confirmed the antenna was fine; nobody was just driving.
What didn't work: measuring RMS of FM-demodulated audio
Here's the embarrassing one.
The original detector design listened to 160.5900 MHz (the CSX road channel — also where defect detectors broadcast their "Detector, MP 200.5, no defects, total axles 200…" voice messages) and measured the RMS amplitude of rtl_fm's audio output. The theory: when a defect detector fires, the audio gets loud, RMS jumps, you trigger an event.
It seemed reasonable. We tuned an adaptive baseline (rolling 30-second median × 2.5 with a noise floor), kept getting either constant false-positives (when the threshold was too low) or zero events (when it was too high). We deployed both extremes. Two hours of zero events later, an Amtrak Silver Meteor passed through and the detector did nothing.
That's when I built a proper test. The Pi's antenna at 160.5900 MHz reads RMS ≈ 7400 when the channel is empty. Then I pointed rtl_fm at 162.5500 MHz — NOAA Weather Radio, a continuous 1000-watt broadcast from a transmitter ~50 miles away. NWS is very loud. Same antenna, same gain, just a different frequency:
160.59 MHz (CSX, empty): median RMS = 7400
162.55 MHz (NWS, 1000W): median RMS = 7817
The audio RMS at a known-loud transmitter is the same as the audio RMS at an empty channel. The whole detection metric is blind.
Why audio-RMS fails (the actual physics)
FM receivers exhibit a thing called capture effect: when the carrier-to-noise ratio at the IF stage is high enough (~10 dB), the discriminator "captures" onto the dominant signal and outputs clean audio. Below capture threshold, the discriminator outputs broadband noise that looks indistinguishable from the noise floor.
A 25-watt defect-detector transmitter at 9 miles, picked up indoors on a stock dipole, lands in the 5 dB SNR range. That's below capture. The discriminator can't separate carrier from noise; its audio output stays at the post-demod noise floor whether anything is broadcasting or not. Audio-band RMS is mathematically incapable of telling the two states apart.
rtl_power confirmed the antenna is receiving the band — there's a measurable 5 dB peak above noise floor — we just can't see it after the audio stage.
What worked: IF-stage squelch via rtl_fm -l
The fix is to do the discrimination before the FM demodulator. rtl_fm has a -l <N> parameter that gates on raw IF carrier-to-noise; when the IF signal exceeds the threshold, audio bytes come through. When it doesn't, rtl_fm emits zero bytes. We detect "carrier present" by select()-ing on the pipe with a 100 ms timeout: bytes arrived = carrier; timeout = silence. Sustained ≥ 3 seconds = real broadcast.
Calibration uses NWS as the positive control: sweep squelch levels, find the lowest one where NWS opens the squelch but the empty CSX channel doesn't.
L=60 NWS=149944 CSX=74898 # both pass; squelch leaks noise
L=65 NWS=149796 CSX=0 # robust NWS margin, CSX rejects — sweet spot
L=70 NWS=137314 CSX=0 # tighter — NWS still passes but on the edge
L=75 NWS=0 CSX=0 # too tight; even NWS blocked
At the 300 Hay Street block, the answer is -l 65. (We initially deployed at -l 70 because that was the first level that cleanly rejected empty-channel noise. After several hours of zero detector events made it clear we were on NWS's pass-through edge, we dropped to -l 65 for more margin.) It's location-dependent — a quieter RF environment might use 60, a noisier one 75 — but the procedure is portable. Anywhere NWS broadcasts, you have a free calibration reference.
And then we got zero events for five more hours
After the IF-squelch fix, the detector ran silent for another five hours. CSX averages ~18 trains/day past this milepost, so we should have caught 2-4 by then.
The first instinct was "calibration's still off, drop squelch further." The actual problem was nastier. Stopping the supervisor and re-running the NWS positive control showed zero bytes at every squelch level — NWS, which broadcasts continuously at 1000 W from ~50 miles away, was suddenly inaudible. The receiver chain was wedged. Same R820T-tuner-stuck symptom we'd hit earlier in the day: rtl_test sees the dongle, rtl_fm returns nothing.
The wedge happens silently between mux cycles. Each detector→TPMS→detector→TPMS rotation releases and re-claims the dongle. Somewhere in that handoff (frequency retune? USB hotplug race?) the tuner gets stuck. Our rtlsdr-reset.sh recovery script — which does a modprobe purge plus USB unbind/bind — only ran at service start via ExecStartPre, so within a single multi-hour supervisor run, a wedge meant silent zero-output for hours.
The empty-channel test masks the wedge perfectly. "0 bytes at 160.59 MHz at -l 70" is the expected output when the channel is empty. The only way to distinguish "channel empty" from "dongle wedged" is to test against a known-live signal — i.e., NWS. Liveness probes against known-good positive controls aren't just for calibration. They're for runtime health detection too.
The fix is a one-line kludge that's the right tradeoff for an unattended kiosk Pi:
# muse-sdr-supervisor.service
RuntimeMaxSec=1800
systemd kills the supervisor every 30 minutes. Restart=always brings it back. ExecStartPre re-runs rtlsdr-reset.sh. Cost: ~3 seconds of downtime per 30 min (0.16%). The principled alternative — a runtime NWS-probe-as-liveness-check inside the supervisor — adds significant complexity. Sometimes the right answer is to cycle the receiver every half hour and move on.
We also dropped squelch from -l 70 to -l 65 for more margin while we were in there.
Other things that broke along the way
A short, honest list of operational scars from this build:
- The DVB kernel driver kept re-grabbing the dongle. Linux's stock
dvb_usb_rtl28xxuclaims the same hardware that SDR userland wants. Amodprobeblacklist only prevents boot-time autoload; if the module is in memory when a USB renumeration happens, the kernel happily re-binds the device. We now force-unload 5 related modules and do a USB unbind/bind on every supervisor start. That happens insidertlsdr-reset.sh, run asExecStartPrevia systemd. - The R820T tuner can wedge.
rtl_testwould see the dongle and report success, butrtl_fmandrtl_sdrreturned zero bytes. The cure was an unbind/bind cycle at the USB layer — same hook above. - Bluetooth was soft-blocked by
rfkill. An admin had disabled BT on this Pi months ago to save power.rfkill unblock bluetoothas anExecStartPreon the BLE counter service makes it stick through reboots. - Cloudflare's edge cache stomped our "Train approaching" alerts. The global cache middleware applied 5-minute
max-ageto GET responses. Route-levelCache-Control: no-storegot overridden because middleware ran later. Fix: add the realtime endpoints to anoCachePathsallowlist before the cache wrapper. - 5-minute sensor windows colliding on the same hourly D1 key. The original
traffic_observationsschema keyed on(date, hour, source, venue)— fine for daily aggregates. With 12 five-minute windows per hour, 11 of every 12 got UPSERT-overwritten silently. Added awindow_start_minutecolumn and agranularity_secondscolumn so 5-minute, hourly, and daily rows can coexist.
When "always 100/100" means your calibration is wrong
The first day after the BLE counter went live, the foot-traffic score saturated at 100/100 from about 7am onward. Mesa. Flat top. No headroom for anything that might actually be busy.
We'd set MAX_RAW_FOR_CALIBRATION = 80 — the unique-device count that maps to score=100 — based on overnight data showing 25-40 devices. Reasonable starting guess, completely wrong. Quiet Monday morning rush hits 132 unique devices in a 5-minute window. With a ceiling of 80, anything past mid-morning clipped.
The diagnostic moment
The give-away wasn't the chart shape — it was the comparison to the prediction model. Our Downtown Guide rates each day of the week against historical patterns. Monday in 95°F heat was scoring 12/100 ("Very Quiet"): day-of-week baseline 16, knocked down to 12 by the weather modifier.
Sensor says 100. Prediction says 12. ∆ = 88.
That's not "sensor is wiser than the prediction" — that's the sensor reading a saturated metric. The prediction model is the right anchor for sensor calibration: "What does the model think today should be? Does the sensor agree?"
Re-scaling to give the metric a useful dynamic range
| Scenario | Raw devices | Old score (MAX=80) | New score (MAX=400) |
|---|---|---|---|
| Late night (residents + parked cars) | 25 | 31 | 6 |
| Current quiet Monday morning | 132 | 100 (clipped) | 33 |
| Moderate weekday rush | ~300 | 100 | 75 |
| 4th Friday peak / festival | ~400+ | 100 | 100 |
Bumped the ceiling to 400. That gives ~4× headroom over what we've actually observed, so quiet days read quiet and real peak events (festivals, parade routes, 4th Friday art walks) can still pin the meter.
Always store the raw count alongside the derived score
This is the lesson that paid off the most.
The D1 schema has both raw_value (unique devices in the window) and score (0-100 derived). When we re-tuned the calibration, fixing the historical chart was one UPDATE:
UPDATE ble_pi
SET score = MIN(100, CAST(ROUND(raw_value / 4.0) AS INTEGER));
147 rows, milliseconds. The 24h timeline redrew with a sane shape (~5-10 overnight, rising to ~30 in the morning) instead of waiting for fresh data to age in the saturated mesa. Distribution went from min=21 max=100 avg=53 to min=4 max=33 avg=11 — and that 11 closely matches the prediction's 12.
If we'd thrown away the raw count and only stored the score, this rescore would have been impossible. Months of bad data, no way back.
General rule: when you derive a normalized score from a sensor measurement, always also persist the raw input. Calibration WILL be wrong at least twice; you want the option to recompute.
Make the ceiling an env var
The MAX_RAW_FOR_CALIBRATION ceiling is now BLE_MAX_RAW in muse-sdr.env. The next "oh, that's clipping" moment is one env edit and a service restart, not a code change + redeploy.
Calibration ceilings should always be env vars on a system like this. The whole point of community sensing is that you start with a bad guess and iterate; making each retune a friction-free knob turn keeps the iteration loop tight.
What it looks like now
| Metric | Before | After |
|---|---|---|
| BLE score (current 5-min window) | clipped at 100 | 24 (96 devices) |
| BLE score distribution (147 rows) | min=21, max=100, avg=53 | min=4, max=33, avg=11 |
| Match against prediction model | 100 vs 12 (∆=88) | 11 vs 12 (∆=1) |
| Headroom for true peak | none — saturated on a quiet Monday | 4× — 400 devices = 100 |
The dashboard goes from "everything is always 100" to "this matches what we'd expect for the day's prediction." On the next genuinely busy day, when prediction and sensor diverge, the divergence will be informative — instead of getting lost in the saturation.
Operational lessons that generalize
A few of these have applications well beyond this project:
- When measurement and reality disagree, distrust the measurement. Two hours of zero detector events during a known Amtrak passage isn't "the trains weren't passing." It's "we are blind to the signal."
- A known-good positive control is worth more than any amount of theorizing. NWS at 162.55 MHz turns out to be the universal SDR calibration source: continuous, regulated, high power, present nearly everywhere in the US. If your detector can hear NWS, it can probably hear what you actually want. Use it for runtime liveness too — "the channel is empty" and "the receiver is wedged" produce identical zero-byte outputs, and only a positive control can tell them apart.
- Antenna-liveness telemetry pays for itself the first time you ask "is anything actually working?" A small
sdr_misc_picounter that picks up random weather sensors is the cheapest way to tell "no signal" from "no traffic." - The hard part of SDR isn't the radio. It's the kernel driver fights, USB state machines, systemd unit ordering, and
rfkillsoft-blocks. Plan for the recovery hooks before you plan for the signal processing. - Privacy by design beats privacy by policy. Rotating-salt MAC hashes for BLE/Wi-Fi counts mean we couldn't track a person across windows even if we wanted to. Make the data shape itself enforce the boundary.
Tools that made this easier

The single biggest QoL upgrade for this build was a tiny wireless keyboard with a built-in touchpad. The Pi lives behind the window display, so every time we needed to poke at it — restart the supervisor, tail a log, check rtl_fm settings — we were either SSHing in from a laptop on the cafe's WiFi or wrestling a full-size USB keyboard and mouse through the storefront.
The fix was a Rii mini wireless keyboard. It's roughly the size of a TV remote, has a usable thumb-board, a trackpad, and a single 2.4 GHz USB dongle. We keep it under the counter; when something needs hands-on attention we walk over, plug the dongle into the Pi, and we're typing in seconds. No second monitor, no clearing counter space for a full keyboard, no fighting tangled cables in a display window where every cable is visible to customers.
Anyone doing a kiosk-shaped Pi build — digital signage, sensor station, photo frame, whatever — should default to this form factor. The price is comparable to a basic wired keyboard, the footprint is a fraction, and the touchpad alone removes the "where am I going to put a mouse" problem in cramped installations.
What's still TBD
- Prediction. Right now the system detects; it doesn't predict. With enough detector events (target: 30+ days of clean data) we can layer a median-inter-arrival model that says "given the last hit was N minutes ago and median spacing is M minutes, next train is ~M-N minutes away." Foundation is in place; data isn't yet.
- Multi-location calibration. One sensor at one block is "300 Hay Street block activity," not "downtown activity." More sensors → less location bias.
- Correlation with BestTime baseline. The foot-traffic prediction model already has a ground-truth baseline (BestTime visit counts). After 14 days of BLE data we'll know whether the rotating-salt counts correlate (Spearman > 0.4 = useful signal, drop confidence if not).
- Quieter freight hours. CSX runs ~18 trains/day on the A-line through Fayetteville. Once the detector hit-rate stabilizes, we'll know which time-of-day patterns are real versus calibration artifacts.
What's live now

The whole stack is running and visible:
/trainpage — live alerts (community + sensor), defect-detector timeline, 24h/30d histograms, day-of-week × hour heatmap./business-analytics/foot-trafficand/business-analytics/dashboard— live block-level sensor card with foot-traffic and vehicle-flow scores, plus a 24h activity chart.
Public APIs (no auth, cached 30–60 s):
GET /api/trains/detector-events/recent?hours=24GET /api/trains/detector-events/histogramGET /api/trains/detector-events/heatmapGET /api/trains/detector-events/statsGET /api/signals/currentGET /api/signals/timeseries?source=ble_pi&hours=24GET /api/trains/alerts
If you want to read the underlying design rather than the API: the system architecture is in🔥 1 docs/TRAIN_PREDICTION_SYSTEM.md, and the Pi-side calibration procedure is in the sdr/README.
The whole experiment cost about $35 in hardware (just the RTL-SDR; the Pi was already there) and 2 evenings of build time, plus an embarrassing half-day debugging the audio-RMS dead-end before figuring out we needed IF-stage squelch.
That's the unglamorous shape of community sensing. Cheap parts, careful calibration, honest scoping in the UI ("300 Hay Street block — not downtown-wide"), and a willingness to admit when the obvious approach turns out to be blind to the signal you're chasing.
We get a heads-up before the crossings block. Eventually.
1 Comment
Log in to comment