A self-contained mobile weather radar project built from a $5 salvaged RV satellite dome, a cheap SDR, and open source software. No internet required, no subscription, no NWS data dependency — your own radar returns, updated locally.
Built in Kokomo, Indiana. I nearly got hit by a tornado that NEXRAD couldn't see coming in time. That's the whole reason this exists.
Why This Exists
Every storm chaser on the road is looking at NEXRAD data that's 4-6 minutes old, from a radar station that might be 100+ miles away. At close range the NWS beam overshoots low-altitude storm features entirely, because of Earth curvature and beam elevation. When a supercell is 10 km away and moving at 40 mph, 6-minute-old data from a distant station isn't situational awareness. It's history.
I got close enough to a tornado once that NEXRAD flat out didn't have it — not "showed it late," didn't have it at all at the range I needed. That's what started this. This project generates local radar returns from a vehicle-mounted dish, in the 10-30 second range instead of minutes, at whatever range the hardware config supports.
Professional mobile Doppler radar trucks (DOW — Doppler on Wheels) do something similar. They cost $500,000+. This is built from salvage and hobbyist SDR gear.
Status note: the active-transmit side of this project is still not fully settled — see Licensing for where that actually stands. Pluto/AD9363 is the only backend that transmits (continuous FMCW — see Only Pluto Actually Transmits); HackRF is supported as a receive-only auxiliary device with no transmit code path at all, so it carries no transmit-related legal exposure. I have not gotten a licensed authority to sign off on the FMCW/SS classification. Receive-only operation and the simulation mode remain the zero-risk way to run this today.
What It Looks Like Running
Live PPI radar display served as a web page, accessible on any device on your local network. Offline Indiana county and road map overlay. Alert feed with tiered severity and multi-sweep confirmation. GPS position tracking with manual fallback. Transit mode that locks the dish forward while driving and resumes full sweeps when stopped. Multi-sweep storm cell tracking with a projected cone of uncertainty, similar in spirit to how NHC draws a hurricane track cone, but derived live from this radar's own recent heading consistency.
Try It Without Any Hardware
The repo includes portable_radar_simulator.html — a fully self-contained, single-file browser demo. No Flask server, no Python, no hardware. It runs the same alert-escalation logic and storm-track/cone-of-uncertainty math as the real backend, driving two synthetic drifting storm cells across the scope so you can see the whole system (INFO → CAUTION → WARNING → DANGER, transit mode, the tracking cone) work end to end. Just open the file in a browser. This is the easiest way to evaluate the project before building any hardware.
The full Python stack does the same thing automatically if it doesn't find an SDR — everything degrades to a live simulation rather than crashing, so you can run and test the whole software stack (alerts, tracking, the web UI) with zero RF hardware at all.
How It Works
Theory of Operation
The Winegard Carryout Anser GM-5000 is an automatic RV satellite dome — a motorized prime focus parabolic dish in a weatherproof radome, originally designed to find and track TV satellites. The idea is to repurpose it as a scanning X-band radar antenna.
The Eagle Aspen LNB inside operates at 11250 MHz local oscillator frequency. Injecting a signal at 850 MHz into the LNB's IF port causes it to upconvert and radiate at 10.4 GHz through the dish — the same X-band frequency range used by weather radar. Rain, hail, and debris reflect a portion of that energy back, and the LNB downconverts the echo back to 850 MHz for the SDR to capture.
TX injection frequency math:
LNB LO (11250 MHz) - Target (10400 MHz) = 850 MHz
Inject 850 MHz -> dish radiates 10.4 GHz
Only Pluto Actually Transmits
This project went through a real back-and-forth on whether HackRF could run a legally-defensible pulsed mode — specifically, an idea where the radar pulse itself was the Morse-coded station callsign, so the required identification and the ranging pulse would be the same transmission. It doesn't work, for a reason that turned out to be a hard physical/regulatory contradiction rather than an engineering problem to solve: §97.119(b)(1) caps CW identification speed at 20 WPM, which puts a minimum legal dit length around 60ms. Round-trip time to a real target at these ranges is on the order of 200 microseconds — roughly 300x shorter than the shortest dit you're legally allowed to send. HackRF is also half-duplex (one shared RF front end), so it's completely deaf for the full length of every dit it transmits. There's no rotation speed, no re-timing, no clever slicing of the callsign that resolves this — the regulation and the physics pull in opposite directions no matter how you arrange it.
So Pluto/AD9363 is the only backend that transmits anything in this project. It runs a continuous FMCW chirp (full-duplex, so it can transmit and listen at the same time) and dechirps the return in software (build_fmcw_chirp / dechirp_range_profile in sdr.py).
HackRF is kept as a receive-only auxiliary device — there is no TX code path for it anywhere in sdr.py, so there's no transmit-related legal exposure running it at all. It's useful for confirming the LNB signal chain is actually live (RadarSDR.get_signal_power()), general spectrum monitoring, or as a second receiver alongside a separately-transmitting Pluto. It does not, and structurally cannot, produce ranged returns on its own — ranging needs a known TX timing reference to measure delay against, and HackRF never transmits here. get_range_profile() returns an empty list for the HackRF backend rather than fabricating range data from an untimed listen window, or silently falling back to fake simulated storms while the UI still claims a real SDR is connected — both would be actively misleading about what a receive-only radio can and can't do.
If neither board is found, everything falls back to simulation mode. There's no menu, no config flag to flip — RadarSDR.connect() figures out what's plugged in.
Signal Chain (as designed)
SDR TX port (850 MHz)
-> F-to-SMA adapter
-> MAIN F port on dome
-> LNB upconverts to 10.4 GHz
-> dish radiates into sky
-> rain/hail/debris returns echo
-> dish captures echo
-> LNB downconverts to 850 MHz
-> SEC F port (dedicated receive path)
-> SDR RX port
The MAIN port handles TX injection. The SEC port (second LNB output, active simultaneously) provides a clean dedicated receive path. A bias tee on the SEC coax powers the LNB. No shared TX/RX path on the dish side, no switching, no backfeed concern — that isolation is a property of the LNB's two physical outputs, independent of which SDR/timing mode you're running.
Hardware
The Dome
Winegard Carryout Anser GM-5000
| Spec | Value |
|---|---|
| Dish type | Prime focus parabolic, polished aluminum |
| Dish diameter | ~18-22 inches |
| Gain at 10 GHz | ~32-33 dBi |
| Beamwidth | ~4-5 degrees |
| Azimuth | Belt driven motor |
| Elevation | Manual set and lock |
| Wind rating | 35 mph maximum |
| Weight | ~16 lbs |
| Includes | 25 ft power cable, 20 ft RG6 coax |
LNB: Eagle Aspen 501353
| Spec | Value |
|---|---|
| Input band | 12.2-12.7 GHz |
| LO frequency | 11250 MHz |
| IF output | 950-1450 MHz |
| Outputs | MAIN + SEC (simultaneous) |
| LNB bias power | External, supplied via coax |
Motor Control Port
The dome has a thin plastic knockout panel on the base housing. Pop it out with a flathead screwdriver — it's factory-designed to be removed, same idea as the score-line insert between a milk jug handle and body. The RJ-25 RS-485 control jack is immediately behind it.
RJ-25 pinout (pin side up, cable end toward you):
Pin 1: GND
Pin 2: T/R-
Pin 3: T/R+
Pin 4: RXD-
Pin 5: RXD+
Pin 6: Not connected
Baud rate: 57600, 8N1
SDR Hardware
Pluto/AD9363 (Zynq7020, libiio/pyadi-iio) is required for actual radar operation — it's the only backend that transmits. HackRF One is optional and receive-only; see Only Pluto Actually Transmits above for why.
- Pluto/AD9363, via libiio — full-duplex, continuous FMCW mode. Also works cleanly from Android/Termux over Ethernet without USB permission headaches. This backend is newer and less field-tested than the original HackRF path was — treat it as needing real-hardware verification before you trust it (see the
UNVERIFIED AGAINST REAL HARDWAREnotes insdr.py). - HackRF One, via SoapySDR — receive-only, auxiliary. No TX code path exists for it at all.
RadarSDR.get_signal_power()gives you a raw receive-power reading (confirming the LNB chain is live, general spectrum monitoring), not ranged returns — it can't produce those without transmitting.
If no SDR is detected at all (or you just want to evaluate the software), everything falls back to simulation mode automatically — synthetic storm cells that drift and behave like the standalone HTML demo above, so the rest of the stack (alerts, tracking, display) is fully testable without any RF hardware.
HackRF One Port Clarification
The HackRF has two SMA connectors, and people commonly misidentify them:
- ANT — the RX connection used by
get_signal_power(). - CLKOUT — a 10 MHz reference clock output only. Not an RF signal port.
Shopping List
Essential
| Item | Notes | Price |
|---|---|---|
| Winegard Carryout Anser GM-5000 | Salvage yards, RV surplus, Facebook Marketplace | $5-40 |
| HackRF One or Pluto/AD9363 (Zynq7020) | Pluto required for actual radar transmission; HackRF works but is receive-only auxiliary. See SDR Hardware. | ~$300 / varies |
| F-to-SMA adapters (x2) | Female F to Male SMA | ~$10 |
| DTECH RS232-to-RS485 converter | Specifically DTECH brand | ~$12 |
| RJ-25 6-pin phone cord | Must be 6-conductor, NOT standard RJ-11 | ~$5 |
| USB-to-Serial cable | Any brand | ~$10 |
| Torx screwdriver set | T10, T15 for inner dome | ~$8 |
Recommended
| Item | Notes | Price |
|---|---|---|
| RTL-SDR V4 | Passive RX-only monitoring; built-in bias tee for the LNB | ~$35 |
| 10 dB SMA attenuator | Inline on TX path (once TX is legally sorted), protects SDR from close-range reflections | ~$5 |
Bias Tee (choose one)
Option A -- RTL-SDR V4: Connect to SEC port, enable bias tee in software. Powers the LNB automatically.
Option B -- Internal build: Solder a 100uH inductor and 100pF capacitor inline at the LNB coax junction inside the dome. A couple dollars in parts, permanently integrated.
Option C -- External: RTL-SDR brand bias tee inline on the SEC coax. ~$12.
Software Stack
Architecture (what's actually in this repo)
portable_radar/
|-- run.py Entry point, elevation prompt, launches stack
|-- config.py All settings in one place
|-- app.py Flask web server, /api/telemetry endpoint
|-- radar_scanner.py Main scan loop thread (local SDR path), alerts, storm tracking
|-- nexrad_scanner.py NEXRAD internet radar backend (see NEXRAD Mode below)
|-- motor.py RS-485 motor control, paced sweep, position verify
|-- sdr.py Pluto (FMCW, transmitting) + HackRF (receive-only aux) backends, auto-detected, + simulation fallback
|-- gps.py NMEA GPS parser (with checksum validation), speed detection, transit mode
|-- noaa_wx.py NOAA Weather Radio SAME alert decoder, own RX-only hardware, optional (NOAA_ENABLED in config.py)
|-- simplify_maps.py One-time GeoJSON optimization (run once before first use, or again if you swap in new map data)
|-- test_nexrad_decoder.py Offline test for the NEXRAD Level-II decoder
|-- portable_radar_simulator.html No-hardware standalone browser demo (see above)
|-- requirements-nexrad.txt Python deps for NEXRAD mode + the web server (waitress)
|-- README_TERMUX_NEXRAD.md NEXRAD mode setup/config details
|-- static/
| |-- indiana_counties.geojson
| |-- indiana_roads.geojson
| |-- manifest.json PWA manifest (Add to Home Screen support)
| |-- service-worker.js PWA shell/map caching, see PWA section below
| |-- icon-192.png
| `-- icon-512.png
`-- templates/
`-- index.html Live radar PPI display
Session recording/replay and a LoRa alert bridge were planned but got sidestepped for now — they're not part of the current stack. This list reflects what's actually here, not a roadmap.
Installation (Linux/Ubuntu/Debian)
# System dependencies sudo apt install python3-pip python3-numpy \ soapysdr-tools soapysdr-module-hackrf # Python dependencies pip install flask pyserial waitress # If you're running the Pluto/FMCW path, also: pip install pyadi-iio # If you're running NEXRAD internet radar mode, see requirements-nexrad.txt # (no compiled scientific packages needed for that path) # Clone this repo git clone https://github.com/Koakno/Small-portable-weather-radar-Dome cd Small-portable-weather-radar-Dome/portable_radar # Optimize map files (run once before first use, or again if you swap in # new/different map data -- safe to re-run, it's a no-op on files it's # already processed) python3 simplify_maps.py # Run python3 run.py
Installation (Termux / Android)
pkg install python python-numpy pip install --break-system-packages pyserial flask waitress python3 run.py
Confirmed working in Termux without any hardware attached (SDR/motor/GPS all gracefully fall back to simulation/manual mode). Note: if you're replacing an older copy of the project folder in Termux, make sure it's actually deleted and not just moved to your file manager's recycle bin — Termux can end up still seeing stale files otherwise.
The web server runs on waitress (a production-grade WSGI server), not Flask's built-in dev server. This matters if you're exposing the display remotely (Cloudflare Tunnel, ngrok, etc.) — the dev server can't reliably keep up with concurrent/proxied connections and requests will silently queue up and stall. run.py will fall back to the dev server with a warning if waitress isn't installed, but that fallback is only meant for quick local-network testing.
Configuration
Edit config.py before running. These are the real constant names as they exist in the code:
SERIAL_PORT = "/dev/ttyUSB0" # RS-485 adapter port GPS_PORT = "/dev/ttyACM0" # GPS device port MANUAL_LAT = 40.4864 # Fallback if no GPS MANUAL_LON = -86.1336 TARGET_SWEEP_SECONDS = 20.0 # Set to your motor's real full-360 rotation time MOVEMENT_SPEED_THRESHOLD_MPH = 2.0 # Above this, dish locks forward (transit mode) PLUTO_URI = "ip:192.168.2.1" # Only used if a Pluto is what gets detected
Running
On startup you'll be prompted for the current dish elevation (this dome has no elevation motor, so it's always manual):
Elevation Configuration
========================
Elevation motor: NOT DETECTED (manual mode)
Enter current dish elevation (degrees):
5 deg - shallow, best long range storm detection
15 deg - moderate, good general use
30 deg - steep, useful close to a storm
Elevation > 10
[OK] Fixed elevation: 10 deg
Azimuth correction: -4.0 deg applied to all returns
The software applies an automatic azimuth correction to compensate for the offset dish geometry coupling elevation into azimuth. It will also print which SDR backend it found (Pluto/HackRF/simulation) — only Pluto actually transmits and ranges; HackRF connects receive-only. Open your browser to http://localhost:5000.
Features
Live Radar Display
- Canvas-based PPI (Plan Position Indicator) radar scope
- Standard NWS-style reflectivity color scale (cyan -> green -> yellow -> orange -> red -> magenta)
- Offline Indiana county boundaries and road overlay (no internet required), culled to the visible viewport by full geometry bounding box, not a single test vertex
- Real-time sweep line animation
- Telemetry polled every 200ms
Alert System
Tiered alert feed, generated once per full sweep. Every tier except TRACK requires the same alert type to qualify on 2 consecutive sweeps before it's surfaced, and won't re-fire for 30 seconds after it last did — this cuts down single-sweep noise spikes from painting the alert feed with flickering false positives.
| Tier | Trigger |
|---|---|
| INFO | Any precipitation detected |
| CAUTION | Storm core in range |
| WARNING | Supercell-scale signature (high density of strong returns) |
| DANGER | Extreme-strength return within 5 km |
| ROTATION | Velocity couplet between adjacent azimuths (opt-in, Pluto only — see below) |
| TRACK | Multi-sweep storm heading/speed, with a widening cone of uncertainty (informational, not debounced — this reflects current motion, not a threshold event) |
Transit Mode
GPS speed is monitored continuously. Above MOVEMENT_SPEED_THRESHOLD_MPH (default 2 mph):
- Dish locks to a fixed forward azimuth
- Full sweep suspended
- Forward-looking returns still captured and displayed
- Mode indicator on the web display switches to
TRANSIT
Below threshold, full azimuth sweeps resume automatically, alternating direction each sweep (0->360, then 360->0) so the dish is scanning continuously instead of idling while it slews back to a fixed start position.
Storm Track / Cone of Uncertainty
Tracks the centroid of the strongest return cluster across sweeps, derives a heading and speed, and projects it forward 5/10/15/20 minutes as a widening cone drawn directly on the PPI display. The cone width is driven by how consistent the storm's recent heading has actually been — a steady bearing narrows it, a wobbling one widens it — rather than a fixed value.
Rotation / Velocity Couplet Detection
Local hardware (Pluto) path — opt-in, off by default (config.DOPPLER_ENABLED = False). Everything else described in this README for the local radar path is reflectivity-only — how strong a return is, not how fast it's moving. This is the one piece that gets actual velocity, and it's what a real tornado-vortex-signature detection needs: a sharp opposite-sign velocity swing between adjacent beam positions at the same range, the same couplet signature NEXRAD and Doppler-on-Wheels trucks look for.
It works by capturing a short burst of consecutive FMCW chirps at one fixed azimuth and measuring the phase progression chirp-to-chirp at each range bin — that phase shift is directly proportional to radial velocity, the same principle every automotive FMCW radar uses to get range and speed from one waveform. Each azimuth's velocity-by-range gets compared against the previous azimuth's; a swing past config.DOPPLER_COUPLET_THRESHOLD_MPS with opposite sign gets logged as a ROTATION alert, run through the same 2-sweep-confirm / 30s-cooldown debounce as every other alert type.
This uses a deliberately different, much shorter chirp than normal ranging (DOPPLER_CHIRP_DURATION_S, 150µs by default vs. the 1ms ranging chirp) — a real, unavoidable tradeoff, not a bug: a shorter chirp gives a wider unambiguous velocity window before it aliases, at the direct cost of usable range, since the round-trip time to a target has to stay well under the chirp duration for the math to hold. At the defaults that's roughly ±109 mph unambiguous velocity out to about 7 km (DOPPLER_MAX_RANGE_KM) — short range compared to normal reflectivity scanning, but rotation detection is inherently a close-range feature anyway (see Detection Range and the beamwidth discussion in Why This Exists — the same close-range advantage that beats NEXRAD's overshoot problem also means a much tighter beam footprint for actually resolving a rotating column into separate inbound/outbound returns).
This is genuinely unverified against real hardware, more so than the rest of the FMCW path — the DSP has been tested against synthetic targets of known range and velocity (recovered within ~1% error), and the couplet logic has been tested against synthetic adjacent-azimuth scenarios, but the sign convention, the actual achievable SNR, and the DOPPLER_COUPLET_THRESHOLD_MPS default (15 m/s, picked as a reasonable starting point, not validated against any real observed rotation) all need real-target bench testing before you'd want to trust what this reports. Confirm against a target of known velocity before relying on it in the field.
Both the Python simulation fallback and portable_radar_simulator.html include a rotating cell so this is actually demoable without hardware — one of the standalone demo's three simulated storm cells produces a real velocity couplet as the sweep crosses it, and the Python fallback does the same when DOPPLER_ENABLED = True.
NEXRAD internet mode — always on, not gated by DOPPLER_ENABLED. That flag only controls the local SDR's FMCW-burst velocity workaround described above; a real NEXRAD Level-II volume already carries a genuine VEL moment measured by the ground radar itself, so nexrad_scanner.py runs the same adjacent-radial couplet check against real Doppler data on every decoded volume, no opt-in required and no chirp/range tradeoff to think about. It's arguably the more trustworthy of the two rotation paths, since it's real measured velocity rather than an FMCW phase-progression approximation.
A ROTATION alert (from either path) also gets a spinning marker directly on the PPI display at the couplet's location (curl direction matches rotation_sense, CW or CCW) plus a matching ↻/↺ icon next to it in the sidebar alert feed — deliberately more attention-grabbing than plain text, since a couplet is exactly the kind of thing you don't want to miss on a quick glance at the scope. A short legend explaining the symbol sits in the sidebar next to the reflectivity color key.
NEXRAD Internet Radar Mode
Set RADAR_SOURCE = "nexrad" in config.py to run entirely off public NEXRAD Level-II data instead of the local dish — no SDR, motor, or dome hardware required at all. This is what the demo at radar.koakno.com runs, and it's meant for people who like the display/alert/tracking software but don't have (or want) the RF hardware. See README_TERMUX_NEXRAD.md for the full configuration reference (site selection, observer-vs-radar-site map centering, range settings).
Architecture note: the actual volume decode — HTTP fetch from AWS, gzip/bz2 decompression, then a pure-Python struct.unpack parse of the binary radial data — is real CPU work with no compiled/vectorized fast path (nexrad_scanner.py deliberately has zero NumPy/SciPy/Py-ART dependency, see the module docstring). Running that inline in the same process as the web server starves Python's GIL and stalls /api/telemetry responses for however long a volume takes to decode — invisible on localhost, but it turns into request pileups and a UI that won't finish loading once there's real network latency in the path (e.g. over a Cloudflare Tunnel). The decode now runs in its own multiprocessing.Process — a genuinely separate OS process with its own interpreter and GIL — and only the final compact result (already-reduced returns/velocity/alerts, not the raw radial data) crosses back over a queue. The web server's threads never wait on it.
Running as an App (PWA)
The web UI is installable as a standalone app via static/manifest.json and static/service-worker.js — open the page in Chrome (desktop or Android) and use "Add to Home Screen" / "Install". It launches without browser chrome, in landscape, with its own icon. The service worker caches the page shell and the (large, mostly-static) map overlay files so reloads are fast; it deliberately never caches /api/telemetry — that always hits the network live, so the display can't get stuck showing stale/frozen data.
Detection Range — Set Your Expectations Honestly
config.MAX_RANGE_KM is set to a conservative 20 km default. Earlier project descriptions (and some early coverage) floated 40-60 km figures — those assumed close to ideal free-space propagation to a strong reflector, and don't hold up as well at ground level once you account for Earth curvature and terrain/foliage clutter cutting into the effective radar horizon, the stock Eagle Aspen LNB's noise figure and gain, and how low in the atmosphere the precipitation you actually care about tends to sit versus your beam elevation.
20 km is a deliberately conservative starting point, not a hard physical ceiling in either direction. A higher-gain LNB or an added amplifier stage will genuinely extend real detection range — if you tune your setup and get more (or less), change MAX_RANGE_KM to match what you're actually seeing rather than trusting a marketing-style number.
Deployment
North Alignment
Azimuth 0 on the dome corresponds to approximately North, assuming the dish is mounted facing the front of the vehicle. Align your vehicle to North before deploying using a compass app on your phone — a built-in vehicle compass typically only indicates the nearest 45-degree cardinal direction, which isn't precise enough at this beamwidth.
Elevation and Azimuth Coupling
The Carryout Anser's offset dish geometry means changing elevation also shifts the effective beam azimuth. The software compensates automatically — enter your current elevation at startup and the azimuth correction is applied to every logged return. Default coupling coefficient is 0.4 degrees of azimuth correction per degree of elevation.
Vehicle Mounting
The dome mounts to a roof rack via its standard base. Orient so the MAIN/SEC F connectors face rearward — this places the motor's natural 0 degree position toward the front of the vehicle. Set elevation before mounting using the degree markings on the dome base. Stow the dome (lay flat) when driving at highway speed — it's rated for 35 mph maximum wind loading.
Field Deployment Checklist
- Park, orient vehicle North using a compass app
- Set dish elevation using base markings, lock in place
- Connect power, coax (MAIN and SEC), RS-485 (RJ-25)
- Run
python3 run.py, enter elevation when prompted - Open a browser to
localhost:5000 - Monitor the PPI display and alert feed
Licensing
Read this section before building the TX side of this project. None of this is legal advice — I'm not an attorney. Verify independently before you transmit.
Transmitting on the 3 cm amateur band (10.0-10.5 GHz) requires a Technician class amateur radio license at minimum. Holding a license is not the whole story, though — the emission type matters.
Per 47 CFR 97.305(c)(6)(ii), the 3 cm band authorizes: MCW, phone, image, RTTY, data, SS (spread spectrum), and test emissions. Plain pulse is not on that list for this band — compare that to the neighboring 5 cm and 1.2 cm bands, which do explicitly authorize pulse. That's why this project doesn't transmit plain pulse at all anymore.
FMCW / chirp (the sdr.py Pluto backend, the only backend that transmits). A swept-frequency chirp plausibly reads as SS (spread spectrum) or data, both explicitly authorized on this band. This is the path the project actually uses — "plausibly reads as" isn't the same as confirmed, so verify your specific waveform with your local club's microwave/EME people, ARRL's Technical/Regulatory staff, or a communications attorney before you key up.
The idea of encoding the pulse itself as the Morse-coded station callsign (a possible path for the old HackRF pulsed design) turned out to be a dead end, on two separate grounds:
- Regulatory: even if the beacon-purpose argument (§97.3(a)(9) / §97.203(d), framing the transmission as observing propagation/backscatter rather than as radiolocation) has merit, wrapping a legally-required Morse ID around a signal whose underlying function is still radiolocation may not change how the FCC classifies the transmission. This was never resolved either way.
- Physical, independent of the legal question: §97.119(b)(1) caps CW identification speed at 20 WPM, which sets a hard minimum dit length around 60ms. Round-trip time to a real target at these ranges is on the order of 200 microseconds — about 300x shorter than the shortest dit you're legally allowed to send, and HackRF (half-duplex) is completely deaf for the full length of every dit it transmits. No rotation speed, re-timing, or slicing of the callsign resolves this; the legal speed floor and the physical timing requirement pull in opposite directions no matter how it's arranged.
Because of the second point specifically, this isn't a "someday, if the legal question resolves favorably" feature — it doesn't become viable no matter how the regulatory question is eventually answered. HackRF's transmit code has been removed from this project entirely as a result; see Only Pluto Actually Transmits. HackRF remains supported as a receive-only auxiliary device, which carries no transmit-related legal exposure at all.
Until the FMCW/SS classification is confirmed:
- Receive-only operation (HackRF's role in this project now, or an RTL-SDR/similar) sidesteps the transmit question entirely and is a reasonable way to experiment with the signal chain and software today.
- The simulation mode / standalone HTML demo requires no transmission and no license at all — it's the safest way to evaluate the software right now.
General ham licensing background, if you're starting from zero:
- 35 question multiple choice exam for Technician class
- ~$15 exam fee
- Study free at HamStudy.org
- Most people pass with 1-2 weeks of casual study
- Also enables APRS position reporting and VHF/UHF voice comms with other chasers
Safety
This system is for observing severe weather from a safe distance. Never attempt to intercept or approach a tornado. Always maintain a viable escape route. Monitor NWS warnings alongside your own radar data — your radar provides local situational awareness, NWS provides the authoritative forecast and warning.
The dome is rated to 35 mph wind loading. Stow it when driving at highway speed. Do not operate during hail.
Do not transmit until you've actually resolved the licensing question above for your specific setup. See Licensing.
Recent Fixes
A round of community testing surfaced several real bugs that are now fixed: azimuth sweep double-sampling the 0°/360° boundary, the DANGER alert threshold not matching its own displayed wording, a motor command reply that could be misread as a failure, a serial read that could stall a full second waiting on a silent controller, GPS fix status that never cleared after a lost fix, missing NMEA checksum validation, a hardcoded range scale baked into five places in the display instead of following MAX_RANGE_KM, hardcoded N/W hemisphere labels, map feature culling that tested a single vertex instead of a bounding box, and the SDR connection never being explicitly closed on shutdown. Thanks to everyone who dug into the code and reported these.
Adding NEXRAD mode surfaced a separate round of remote-access bugs, all specific to running this over a Cloudflare Tunnel rather than plain LAN: the web server has switched from Flask's built-in dev server to waitress, since the dev server can't reliably handle tunnel/proxy connection patterns and requests would silently queue up and stall; simplify_maps.py's point-thinning pass wasn't actually reducing anything on real map data (the size was almost entirely oversized coordinate precision and unused feature properties, not redundant points — it now rounds coordinates and strips properties the frontend never reads, cutting the bundled Indiana map files roughly in half); and the NEXRAD volume decode — a genuinely CPU-heavy pure-Python parse with no compiled dependency — has moved out of the main process into its own multiprocessing.Process, since running it inline was starving the GIL and stalling the web server's request threads for however long a decode took.
Credits
SaveItForParts — whose Carryout-Radio-Telescope project reverse engineered the GM-5000 RS-485 motor control protocol and proved the concept of using this dome as an RF imager. This project would not exist without that work.
- Carryout-Radio-Telescope
- Carryout-Rotor
- YouTube: Winegard Carryout teardown
- YouTube: Microwave imaging with hacked TV dish
Resources
| Resource | Link |
|---|---|
| GNU Radio | gnuradio.org |
| RTL-SDR drivers | rtl-sdr.com |
| HamStudy (license exam) | hamstudy.org |
| NWS Indianapolis SKYWARN | weather.gov/ind/skywarn |
| Spotter Network | spotternetwork.org |
| 47 CFR Part 97 (amateur radio rules) | ecfr.gov, Title 47, Part 97 |
Built in Kokomo, Indiana -- tornado country. If this helps one person get better warning of an incoming storm it was worth building.
This project is hands-on and human-designed -- the hardware repurposing, the RF signal chain, the mounting, all of it came out of nearly getting caught by a tornado NEXRAD didn't catch in time. I used AI assistance to help write, debug, and clean up the software side, and to help write this README. It did not design the hardware or the underlying RF approach. I'm flagging that plainly instead of pretending otherwise, same as I'm flagging the licensing issue above -- I'd rather this be accurate than impressive.