Here is the artifact to start with. On a HackRF Pro with the stock telescopic whip antenna, we now type one command, wait a couple of minutes, and get told where the tower is:
FalconEye --scan --scan-bands 4,66 --scan-output /tmp/b466 \
-g 40 -a "driver=hackrf"
1 cell(s) found
1: DL 2147.5 MHz, PCI 201, 75 PRB, 2 ports, band 4
Suggested: FalconEye -a "driver=hackrf" -f 2147500000 -p 201 -c 75 -g 40.0
That is the same PCI 201 at 2147.5 MHz we had been tuning to by hand for weeks. The difference is that nobody told the program where to look. It walked 1,350 candidate channels across two overlapping bands, found one carrier, confirmed it twice, and wrote a JSON file and a CSV file. Getting from there to here took a fork, a crew, and one very convincing ghost.
What changed since the last cast
The receiver work from the earlier posts got its closing exam. A thirty-minute endurance run went through the harness with bounded-memory checks, and the focused reliability suite grew from 27 tests to 48. Along the way the harness caught real defects, and a few of them were funny in hindsight:
- The MIB plausibility filter only accepted the sample rates for a 75-PRB carrier. A 20 MHz, 100-PRB cell would have been thrown away as a “false lock” with only a quiet debug line. Fixed, with a test that fails without the fix.
- An early rule banned cell ID 0 outright as a false lock. PCI 0 is a perfectly legal cell. The ban died, and confirmation now relies on the MIB and consistency checks instead of superstition.
- The endurance runner sampled the memory of the wrong process. It was carefully measuring timeout rather than FalconEye. The leak-slope check looked great for exactly the wrong reason.
- Two of the release scripts vanished from a commit because a .gitignore rule for underscore-prefixed paths also swallowed the CI directory. Files that pass every test and are not in the repo are the best kind of trap.
Meanwhile the project picked up a new name and a new promise. The HackRF-focused fork is Falcon-GSG, a nod to the folks who make the radio. It stays AGPL-3.0-or-later like upstream FALCON, every earlier contributor is credited, and the whole point is that anyone can take it and modify it the way we did.
Why a scanner at all
FALCON has always assumed you already know the centre frequency. That is fine in a lab with one known carrier, and useless the moment you ask a simpler question: what is around here? Providers use different bands, different cell IDs, and different downlink and uplink pairs. We wanted a finder that sweeps a chosen set of bands, detects cells, ranks them, and lets a human pick one to hand to the existing sniffer. The rules we set first still hold: receive only, no automatic sniffing, JSON and CSV output, TDD bands hidden for now, and relative power only, because a HackRF is not a calibrated meter and we refuse to print dBm we cannot defend.
We also rolled back a roadmap that had run ahead of itself. The finder was not even in production yet, and the ideas queued behind it were multiplying. Stability came first, so those ideas went onto a side branch labelled brainstorming, and the finder got built properly, one reviewed task at a time.
The arithmetic of a haystack
The scan library is boring on purpose. There is one band table with 44 rows, cross-checked against the bundled srsLTE tables and against an independent reading of the 3GPP band definitions. Frequencies are computed in integer units of 100 kHz, so there is no floating-point fuzz to argue about, and a test walks every EARFCN of every band and round-trips it. The presets for the Canada and US region come to 5,610 candidate channels on the 100 kHz raster. At the quarter of a second we measured for an empty channel, an exhaustive sweep of that set is about 25 minutes.
Those timing numbers came from a spike before we wrote any product code: a retune costs roughly 9 ms, a short sync-only probe of an empty channel costs roughly 247 ms, and that probe found the live cell every time we tried it at a false-positive rate near one percent. Measure first, then design. The design that fell out of it is the two-phase scan in the figure: probe cheaply everywhere, confirm expensively only where something answered.
The first live run found nothing
Everything passed on the bench. The scan engine had unit tests against a synthetic radio, golden files for the output, a hardware-free command-line test, and clean sanitizer runs. Then we pointed it at the sky. The first real sweep of band 4 walked 450 channels in about two minutes and reported zero cells. The cell we can decode by hand at that exact frequency was sitting right there, and the scanner drove straight past it.
Two things were wrong. The probe was running the full decode path on every channel instead of the light sync-only probe we had measured. And on an empty channel, running out of time was being reported as a radio failure, which turned nearly every quiet channel into a scary red error: 428 of 450 in one run. We had also been stopping and restarting the HackRF’s receive stream on every retune, which the timing spike never did. The fix was to copy the recipe that had worked: start the stream once, retune between channels, treat “nothing here” as an answer instead of an error, and keep real radio failures as real failures. The next sweep found the cell in 107 seconds with a single error.
The ghost at 2147.3
Then we tried both overlapping bands together, 1,350 channels, and got the most educational failure of the night. The scanner announced one cell: a confident PCI 0, 15 PRB, 1 antenna port, at 2147.3 MHz. Our real cell, twenty decibels stronger and 200 kHz higher, was missing from the list.
It is a good ghost. As best we can reconstruct it, the strong tower leaks into its neighbour channel, so the scanner found a faint sync signal at 2147.3 MHz before it ever reached 2147.5, because it walked the band in frequency order. That leaked signal decoded into a MIB that passed the checksum and the plausibility filter, one confirmation was enough to accept it, and then the scanner did the sensible-looking thing: it pruned every channel within the ghost’s bandwidth. The pruning ate the real cell.
A neighbour’s echo passed every check we had, because every check we had asked whether the decode was plausible, not whether it was the strongest thing in the room.
The fix is the design in Figure 1. Probe everything first and record how strong each detection is. Confirm the strongest first. Never accept a single confirmation, so two agreeing MIB decodes are now mandatory even when the detector is confident. Only a fully confirmed cell may prune its neighbours, and anything detected inside a confirmed carrier is parked and listed as a warning in the JSON instead of being silently dropped. The rerun returned exactly one cell, PCI 201, with two confirmations and zero errors in 358 seconds for both bands. A repeat of band 4 alone matched it in 129 seconds. The ghost is still there. It just has a label now.
The full sweep, and a band that could not make up its mind
With the ghost labelled we ran the whole Canada and US preset: 5,610 channels in 24.6 minutes with zero radio errors. It found two carriers. One was the familiar PCI 201 at 2147.5 MHz. The other was PCI 201 again at 1955.0 MHz in band 2, a 100 PRB carrier that the scanner detects and confirms but that the HackRF path cannot fully decode yet, so the tool says so instead of pretending.
The full sweep also exposed a small identity crisis. The frequency 2147.5 MHz is a legal downlink in bands 4, 10 and 66, each with its own channel number. Band 4 alone reported EARFCN 2325, but the full sweep labelled the same cell as band 66, because whichever overlapping candidate happened to confirm first won. The result depended on the order of confirmation, which is the kind of bug you only meet on the long run. The rule is now deterministic, lowest band wins, every field comes from that band’s candidate, and the result carries a note such as also valid in bands 10,66. Re-running bands 4, 10 and 66 together returns one cell, band 4, EARFCN 2325, every time.
Pulling the cable
A scanner that runs for twenty-five minutes will eventually meet a loose cable, so we tested it on purpose. Midway through a band 4 sweep the operator pulled the HackRF’s USB cable. FalconEye segfaulted about four tenths of a second later. The backtrace was instructive: the radio’s driver library crashes inside its own error-logging path when we ask it to retune a device that has already vanished, and our retry logic, added for the first-probe hiccup, was politely retuning the dead device.
We cannot fix the driver, so we fixed our manners. After any radio failure the scanner now does a cheap health check that cannot crash before it is allowed to retune. If that fails, or the failure repeats, the backend is marked lost with a structured error, nothing ever touches the device again, the partial results are kept, and the exit status says what happened. A fake radio in the tests reproduces the contract by aborting the test if anyone retunes after loss. We then repeated the pull-the-cable test live: clean stop, partial results, no crash. (The first live attempt did not count. The scan finished before the cable came out.)
A tab with a mouse
The command line is fine for us, but the point of a finder is that an operator can use it without one. The mockup we published to ourselves had a setup panel, a progress ribbon, a ranked results table, a details panel, and a notes panel. The real thing was built in three layers so the logic stays testable without a screen:
- The controller is a widget-free Qt object that owns a worker thread, runs the same scanner, and reports progress and cells through queued signals. Cancel keeps partial results, a disconnect becomes a Failed state with a message, and the radio is released after every ending. The radio itself sits behind an injected factory, so tests use the synthetic backend. Codex wrote this layer before its usage ran out.
- The widgets are a setup panel with region presets and per-band channel counts, a live time estimate, a progress display, the results table, a selected-cell panel with a copyable command, and a notes list. Kimi built them, plus an offscreen test that drives the real widgets through the whole workflow: choose a region and see 5,610 or 11,650 channels, start, watch progress, get ranked cells with 2147.5 MHz reported as band 4 EARFCN 2325, select a row, open it in the Downlink view, cancel midway, survive a simulated disconnect, save and compare files, and confirm the radio is free afterwards.
- The wiring adds a Scan action to the toolbar like its siblings. Scanning and sniffing are mutually exclusive: Start is disabled while a scan runs, the scan is disabled while sniffing, and the radio is fully released first. Open in Downlink view fills the Radio panel with the cell’s frequency, cell ID, PRB and ports and switches view. It never starts sniffing. That stays the operator’s button.
Then the operator sat down with a real HackRF and started clicking, and the mouse found two bugs the offscreen test had not. First, the scan quietly used the radio settings captured when the window was built, not what was typed into the Radio panel, because those values only reach the sniffer settings when sniffing starts. A scan could have run with automatic gain and empty device arguments while the panel said gain 40. Now the values are read fresh on the interface thread every time a scan starts. Second, Save JSON and Save CSV silently did nothing when the Output name field, scrolled out of sight in the setup panel, was empty. Its only feedback was a note buried in the notes list. Save now asks where to write, and every save says where it went.
The first GUI sweep covered four bands, 1,750 channels, in 451 seconds with zero errors. It found one cell, PCI 201 at 2147.5 MHz, band 4, EARFCN 2325, 75 PRB, two ports, and parked the usual neighbour-channel ghosts as notes. Everything checked out live: progress, the ranked table, the disabled sniffer button, Open in Downlink view, and both saved files.
Unexpected fun
- The vape charger. Partway through a session the cell that had locked earlier refused to lock. Nothing in the software had changed. A vape was charging on a neighbouring USB port. Locks came back after we unplugged it, re-seated the HackRF, and swapped the 2.4 GHz-tuned antenna for the telescopic whip that ships with the radio. We never isolated which of the three did it. USB noise near a small antenna is not a metaphor, it is a test condition, and it now has a line in the testing notes.
- “You might be doing it too quickly.” The operator’s own field advice, watching a lock attempt, was that it usually takes a few seconds to lock and get the MIB. The dwell time per channel is now a command-line option, not a buried constant.
- The crew ran out of fuel, repeatedly. The build was run by three agents: Claude as the manager and reviewer, Kimi and Codex as the workers, all talking through a message board on the LAN. Each task went red, green, then an independent review before it merged. Both workers hit their usage windows at inconvenient moments. One died mid-task with its finished tests sitting uncommitted in a worktree. We rebuilt in its own build directory, checked its numbers, and merged. Timers on the manager’s side became the team’s alarm clocks.
- Reviewers who were right. One of the manager’s own worked examples in a task brief had an EARFCN paired with the wrong frequency. The reviewing agent said so, and it was correct. A golden test file still carries a similar slip that is on the cleanup list.
- A branch that jumped the fence. A fast-forward accidentally carried master onto an agent’s branch. It was docs-only and local, and it got a proper rollback commit and a preserving branch rather than a heroic rewrite.
- Band 6 does not exist. The operator ticked “bands 4, 5, 6 and 7” and the estimate said 1,750 channels, which matched no arithmetic in the room. The band table has no band 6, so the fourth chip was band 8: 450 + 250 + 700 + 350 channels. The estimate was right. The operator was one chip off, and the empty bands made a fine test of the “nothing here” path.
- The fuel gauge, round two. Kimi hit its five-hour window twice in one day, once seventeen minutes into a job. Codex is out of credits until the 29th. The manager’s job became checking their half-finished work, fixing a link error one of them left behind, and committing it. A worker that stops at a green checkpoint is a good worker.
Perks
The scan library is small, deterministic, and testable without a radio. There is a synthetic backend that can inject cells, timeouts, disconnects, and cancellation, so most of the logic never touches hardware. There are now sixteen focused scan tests including the offscreen GUI workflow, a passing reliability suite of forty-nine, a scan-library line-coverage gate that measures 86.7 percent against its 80 percent bar, and clean AddressSanitizer, UBSan, and ThreadSanitizer runs over the scan tests. The command-line change kept every existing short flag working and pinned it with a regression test. Ctrl-C cancels the scan cleanly and still writes the partial results. The README now carries a plain receive-only note. The code lives on a server of our own, because the whole point is to know where our own code is, and there is a public read-only copy that anyone can clone.
What is still open
- The neon restyle. The Scan tab is deliberately plain so a later theme pass can change the look in one place. It will come when the crew is back.
- Decoding 100 PRB carriers on the HackRF. The scanner finds and confirms them, and the tab disables Open in Downlink view for them with a note.
- USB overflow and timeout handling and a long memory soak are parked on purpose, until real runs show us a problem worth the tests.
- Relative power varied from run to run for the same cell, so it stays a ranking hint and is documented that way.
- A plan for TDD bands, and the same second-receiver boundary for Msg3 that closed the last post.
The line we reached is a useful one: from a known carrier to an unknown sky, using the same receiver, with the ghosts labelled and a button to press. The fish finder works. The next job is making it look like it does.
Get the code
Falcon-GSG is public and read-only at git.substratelabs.ca/substratelabs/falcon-gsg. Clone it with git clone https://git.substratelabs.ca/substratelabs/falcon-gsg.git. It contains the HackRF receive path, the scan library, FalconEye --scan, the GUI Scan tab, the evidence files from the live runs, and the README with the build recipe. It stays AGPL-3.0-or-later and every upstream contributor is credited in CREDITS.md. It is a passive receiver: it does not transmit, and you are responsible for following your local rules on receive-only radio use. Issues and merge requests are switched off on the public copy for now. To reach us, use the contact page on this site.
References
- FALCON: An accurate real-time monitor for client-based mobile network data analytics, Robert Falkenberg and Christian Wietfeld.
- LTESniffer, SysSec KAIST.
- LTE-Cell-Scanner, JiaoXianjun / Evrytania lineage.
- srsRAN 4G, the srsLTE lineage FALCON is built on.