Twelve Days Inside the AE4JC VHF Scanner: What We Built, Why, and How

September 2026

The video above is this morning’s test: an Android phone running an app I’ve been building, scanning the VHF band through an RTL-SDR dongle (affiliate link), switched through an SDR switch to my IC-705 for transmit, and setting that same IC-705’s frequency over Bluetooth the moment it finds something worth working. It’s the first time I’ve run all three together, mobile, in one session; each piece was proven on its own first, TX-safe scanning two days ago, Bluetooth control the day after that. Twelve days ago none of this existed, and I haven’t written a line of the code myself. Here’s what was built, why, how you can get the app, and how you could try something like this yourself.

Where this actually started

This goes back at least three weeks earlier, wrapping up a POTA rove from DC to the Gulf Coast. VHF was the weak spot of the whole trip: only one contact in four days. This gave me the idea of using an SDR for the next long drive. But it wasn’t an entirely new thought. Back in June I ran a 128-response survey on what new hams actually start with, and an RTL-SDR dongle already showed up in that data as one of the cheapest ways into VHF, roughly $40 all in with an antenna and adapters, in the same rough price band as the Baofeng-style handhelds that dominate what new Technicians actually buy, just listen-only instead of two-way. So when I finally sat down to fix my own VHF gap, the cheapest option I already knew about was the obvious one to build around.

Not with a working app, though. With this:

“Attempting to AI vibe code an SDR scanner app.” No code of my own, no plan beyond that. Two days later there was something to show:

That’s a quote-tweet of the first post, not a new one; the same thread continuing. So is the next one, the following night, on a real antenna and battery instead of a USB cable at my desk:

RepeaterBook Connect, which I already subscribe to, sells a feature close to what I wanted: look up a repeater and push its frequency, offset, and tone straight to a radio. What I actually needed was to go straight from a frequency my own app found to that same radio, not another lookup tool. The goal of the app is to find activity quickly and reliably while traveling, and tune a radio to that frequency, with offset and CTCSS, at the push of a button. That’s the actual use case, not a general-purpose scanner. The scanning half of that already works without knowing anything ahead of time; the app listens; the push-button offset and tone half still depends on repeater data I’ve imported by hand for one state, not a live lookup anywhere I’d actually be traveling, so the travel story is the direction, not yet the whole truth on the road today. It’s also scoped to VHF and up on purpose; HF would need a different app.

How it’s actually built, and why the builder changed partway through

For the first five days, one AI built all of it. A cloud coding agent inside Grok, xAI’s own Grok Bot, wrote the app from a running list of fixes and requests I gave it, running on Cursor’s infrastructure early on (the GitHub branch it created is still named cursor/vhf-rtl-sdr-scanner-d007). I wasn’t reviewing code line by line; I was watching the app run and reporting what it did wrong, closer to managing than programming:

Claude Code joined on day six, at first only as a reader: reviewing Grok’s diffs against the real source, reading the field videos I sent frame by frame, running the app’s own test suite rather than trusting a “tests pass” claim on its own. Grok was still the one writing the app.

Then Grok got stuck. I’d asked for the scan logic stripped down to something simple and reliable: find a signal, park on it, play audio, and several rounds of Grok’s own attempts at that kept not producing reliable audio. I asked Grok to hand the same task to Claude, in parallel, with the same request to simplify it. Claude’s first attempt at the rewrite worked, on the one measurement I had to check it against at the time. Worth naming the confound rather than hiding it: by then Grok and I had already worked through what “simplify” actually meant across several failed rounds, so Claude wasn’t starting from the same blank prompt Grok had. Some of what looked like a model difference may have been a better-specified problem by the time it reached a second model.

Concretely, that rewrite changed how the app decides a signal is real: the old code picked the single loudest sample and chased it, which chased noise as often as signal. The new code measures actual energy across the whole channel instead. Tested against a repeated noise-only calibration signal, the old detector produced two to six false parks per hop; the new one produced zero on the same test. That’s a real result on the specific failure mode I was chasing, false parks on noise, not a full sensitivity comparison; I didn’t separately measure whether the new detector still catches real signals as well as the old one did. Two days later, on September 24, I made the call, relayed through Grok chat and confirmed on both sides: Claude’s version works very well, keep going with that branch. Claude’s build became the only one still shipping. Grok’s role became what Claude’s had been three days earlier: a second reader, not the primary builder. We still collaborate; the primary development path just moved.

One overnight session from that same stretch shows why having two independent readers is worth keeping, even after the roles flipped. I sent a video of the app losing the 147.090 repeater net it should have held. Grok’s own copy of that video turned out to be a stale, shorter clip pulled by mistake, and Claude’s read of a partial, lower-resolution copy reached a diagnosis anyway. Both agreeing on a bad or partial input isn’t real confirmation by itself; it’s a coincidence waiting to be checked. The part that actually settled it was Grok going back and finding the correct, full-length clip and reading it again independently, landing on the same root cause a second time against the right evidence: the app’s “is this signal still here” check compared loudness measured two different ways, on two different scales, so a real, already-parked signal kept losing to its own confirmation test. That correction step, not the first coincidental agreement, is the actual control in this process.

The same discipline applies to the numbers in this article. A few days into the classic build, a different review found something worse than a wrong number: two test assertions still checked slider defaults the app had stopped using days earlier, and a third assumed a timing behavior a later fix had since changed. That gap existed because Gradle’s own test runner had a separate, unrelated compile break blocking it for a stretch, so only a faster, separately built toolchain could actually execute tests, and it hadn’t been run against every build in that window. “Compiles clean” had quietly stopped meaning “tests pass” until someone ran the suite again and found it. All three assertions got fixed, and the lesson stuck.

The milestone arc

By September 23 it could decode APRS packets off the air for the first time:

By September 26 it could survive being paired with an SDR switch and a real transmitting IC-705, which is a harder test than anything receive-only. An SDR switch exists specifically to protect a receive-only dongle like this one from a nearby transmitter, but the app itself still had to survive the RF environment around it, and the first attempt didn’t: scanning under RF overload from the nearby transmitter threw an exception the recovery code didn’t catch. A pulled crash log traced it to one unprotected Thread.sleep() in the scan loop’s error handler, the only one in that file not already guarded against exactly this. Fixed, tested against a fresh crash reproduced before the patch and gone after it, and the next attempt worked:

What’s live today: the IC-705 talks back

A reader asked for a quick schematic of the current setup after seeing this morning’s video, so here’s the whole thing, drawn out:

AE4JC VHF Scanner system schematic: a shared antenna feeds an SDR Switch, which routes to a receive-only RTL-SDR dongle over one RF path and a transmitting IC-705 over another; the Android phone reads the RTL-SDR over USB-OTG and sends CI-V commands to the IC-705 over Bluetooth LE

At least three separate links, not one, and probably a fourth I haven’t drawn: the RF path (antenna through the SDR Switch to whichever radio is active), the USB-OTG path (raw IQ samples from the RTL-SDR into the phone), the Bluetooth CI-V path (commands only, phone to radio, nothing coming back the other way yet), and however the switch itself senses that the IC-705 has keyed up, which I haven’t dug into and didn’t want to draw as fact without checking. In operation, the switch’s job is to disconnect the RTL-SDR’s antenna feed while the IC-705 is transmitting; the SDR Switch was already part of the setup on the very first transmit test, the crash that day came from the app’s own error-recovery code, not from missing RF protection, so don’t read the switch as a fix I added after getting burned. It was there from the start; the app’s own reliability under RF overload wasn’t.

On September 27 the app picked up the ability to set the IC-705’s own frequency over Bluetooth CI-V:

That single day landed the code for frequency, then repeater duplex offset and CTCSS tone on top of it, VFO control over CI-V, not full memory-channel programming, over a bespoke Bluetooth LE link built directly for this app, rather than Hamlib, the usual default for desktop CAT control, since the target here was an Android app talking CI-V over Icom’s undocumented Bluetooth interface, not a serial port, with the command bytes read from Icom’s own published IC-705 CI-V Reference Guide (IC-705_ENG_CI-V_1_20200721.pdf) rather than guessed. One real find along the way, from my own 232-row Alabama RepeaterBook export, not a national statistic: 27 percent of those rows share an output frequency with at least one other row in the same file, real channel reuse across separated sites, not dirty data. One concrete case: W4WYN in Tuscaloosa and W4IAX in Mobile both sit on 147.300 MHz. The app used to silently pick whichever row came first in the file; now it either resolves a tie by comparing to my phone’s coarse location or shows me the list and makes me choose, and it will not send a guessed duplex or tone to a live transmitter on a tie it can’t resolve.

What changed. The frequency and offset commands worked the moment they shipped. The CTCSS tone commands didn’t, for two more builds. Tone sends four back-to-back Bluetooth writes, three of them sharing the same command byte; the fix held a 50-millisecond gap before the next queued write fires, instead of firing the instant the previous write’s Bluetooth-level acknowledgment landed.

The working theory, not a captured trace. I didn’t record a Bluetooth-level log of what the radio actually received, so this is inferred from behavior, not proven byte by byte: a Bluetooth write acknowledgment likely only confirms the radio’s Bluetooth stack received the bytes, not that its CI-V command parser finished acting on them, and three same-numbered commands fired with no gap may have outrun that parser. It’s the explanation that matches what actually fixed it, not a certainty.

What was actually checked. After the fix, tone showed on live, once, on my own IC-705, watched by me. That’s a real on-air result, and it’s also a single trial on one radio, not a repeated bench test or an independent tester. The regression suite, 218 of 218 tests, stayed green through the change, which shows the surrounding logic wasn’t broken; it says nothing for or against this specific fix, since a plain JVM test can’t reach Bluetooth transport timing at all. The signed build’s jarsigner -verify check confirms the file wasn’t corrupted before it reached my phone, nothing more.

Against known practice, as an analogy, not a documented Icom requirement. Treating a low-level delivery acknowledgment as different from “the receiver finished acting on it” is a standard idea in app-level protocol pacing generally; I’m not claiming Icom’s own documentation calls for this specific delay. Where this project’s testing falls short of a real test pyramid, worth saying plainly: the JVM suite covers pure logic, my own radio covers one end-to-end path, and there’s no middle layer, no Android emulator or instrumentation test, so anything touching Bluetooth, the UI layout, or the actual RTL-SDR hardware is checked by a real device once or not checked automatically at all.

How you can use it

The app isn’t on the public Play Store, and the source isn’t in a browsable public repo right now; that part of “open source” is still a plan, not a fact on the ground today. What’s real: it ships through Google Play’s Internal Testing track, which caps out at 100 testers and requires you to accept a Play invite from a Google account. If you’re a licensed ham and want in while a slot is open, DM me your Google account email on X.

Before you do, know what it actually needs: an Android phone with USB-OTG, an RTL-SDR dongle for the receive side, and, only if you want the part of this piece about talking back to a radio, an IC-705 with Bluetooth CI-V. Everything up through repeater lookup and digital-mode identification works receive-only. You’re also accepting that this is a single-author, closed-source build whose safety checks are the ones described in this piece, not an independently audited one, and that the Bluetooth CI-V path can set a real transmitter’s frequency, offset, and tone. To be specific about what “checked” means here: the CI-V code that actually writes those commands has been tested by running it and watching the radio, not read line by line by anyone besides me for correctness. That’s a real gap for code that reaches a transmitter, worth knowing before you accept the invite.

How you could try something like this yourself

The real prerequisite isn’t a tool choice; it’s already knowing what correct behavior looks like. I could tell the app was wrong about a repeater net or a duplex tone because I know what a real radio is supposed to do; without that, an AI-directed build can ship confident wrongness just as fast as a correct one, especially on anything that reaches a transmitter. Treat what follows as one project’s retrospective hypotheses, not a validated playbook:

What’s still open, and what I’d still want proven

Every “confirmed live” claim in this piece means confirmed once, on my own IC-705, watched by me. Nothing here has been checked by an independent tester, captured with a Bluetooth sniffer, or run against a second radio. That’s the real scope of a hobby project built this fast, and it’s the lens to read the rest of this list through:

The version number alone has climbed from 0.1.13 to 0.1.110 in twelve days. The frequency half of the path I set out to build (find a signal, send it to a real radio) works on every setup I’ve tested; the offset and tone half works too, qualified exactly as above: once, on one radio, watched by me. What’s next is less about a new feature and more about turning what’s still a working theory or a single trial into something measured, starting with that 5 MHz question the next 440 repeater will settle.

Share: X Facebook Email