The symptom was the kind that’s easy to ignore until you can’t. IoT devices on the 2.4 GHz band — eight cameras and the FP2 doorbell — kept dropping off the network. Not all at once, not predictably, just often enough that a camera would miss the one clip you actually wanted, or the doorbell would take three seconds to wake up because it was busy reassociating to an access point on the far side of the house.

2.4 GHz is the band nobody loves and everybody needs. The cameras can’t do 5 GHz. The doorbell can’t do 5 GHz. So they all live down in the cheap seats with the microwaves and the neighbors, and when it gets crowded down there, the cheap seats are where the dropouts happen. I spent the whole day in that band, and the shape of the day is worth telling because I got the fix wrong before I got it right.

First, make it visible

You can’t tune what you can’t see, and a UniFi controller is genuinely bad at showing you why a client disconnected. The events log will tell you a client roamed or deauthed, but it’s a firehose with no memory. So before touching a single radio setting, I built an OpenObserve dashboard — wifi-client-disconnects — that pulls every disconnect event off the controller and lays them out by client, by AP, and over time (#284). Later in the day I added a second one for 2.4 GHz interference and airtime utilization (#291), because once you start looking at this band you want the channel busy-ness right next to the disconnects.

That first dashboard immediately changed the question. It wasn’t “the network is flaky.” It was “the FP2 keeps roaming between the garage AP and the living-room AP, and every roam is a dropout.” Specific is fixable.

Then, make the config reviewable

Here’s the thing about RF tuning: you make a dozen small changes — a channel here, a min-RSSI there, a min data rate, a band restriction — and three days later you have no idea what you changed or what the baseline was. So the second commit of the day (#285) was a read-only snapshot of the entire wireless RF config, dumped to versioned JSON: per-AP radio channels and widths, per-SSID RF fields, the site channel plan, the client-to-AP pins. Run the snapshot, look at git diff, and drift shows up as a diff. The controller stays the source of truth for live state; the repo becomes the source of truth for intended state.

Getting that snapshot turned out to be the most annoying plumbing of the day, and it’s worth a paragraph because it’s a trap anyone automating a UDM Pro will hit. The official, sanctioned paths don’t work for this. The Site Manager cloud key (api.ui.com) is read-only and cloud-scoped — it can’t see local RF detail at all. The official local Network integration API rejects the cloud key outright with a 401, and even when authenticated, as of mid-2026 it still cannot write per-AP radio settings — channel, width, TX power. That write scope is “rolling out.” So the only complete, proven source for the full radio_table and per-client pins is the UniFi ace MongoDB running on the console itself. I SSH in as root and read 127.0.0.1:27117 directly — read-only, with a field allowlist and a regex scrubber that drops anything matching passphrase|password|psk|_key|token|secret so no live credential ever lands in the repo. You never write Mongo directly; that desyncs the controller. Writes go through the legacy REST API, which is unofficial and version-sensitive and the only thing that actually works. It’s held together with tape, but it’s reviewable tape now.

The wrong fix, confidently applied

With visibility and a baseline, I reached for the obvious move (#286): unpin everything and raise the floor. Clear fixed_ap_enabled on every client so the controller’s roaming logic can put each device on its best AP, and bump the 2.4 GHz minimum data rate from 1 Mbps to 6 Mbps so distant, marginal clients are forced to either connect well or connect elsewhere instead of clinging at 1 Mbps and hogging airtime.

The min-rate bump was right and it stayed. The blanket unpin was wrong, and the dashboard told me so within the hour. In a dense RF environment — and I’ll get to how dense in a second — “let the controller decide where each client roams” means the FP2 happily roams to the living-room AP at −65 dBm when the garage AP it’s bolted next to is sitting right there at −54 dBm. The roaming heuristics optimize for the wrong thing when every AP looks plausible. Unpinning didn’t give the doorbell freedom; it gave it indecision.

So I reversed it (#289). I pulled per-AP RSSI telemetry for all nine IoT devices, found the strongest-signal AP for each, and pinned them there. FP2 → garage AP, the cameras each to whatever’s physically closest. The lesson, written into the README so the next confused version of me doesn’t undo it: in dense RF, pinning IoT to its nearest strong AP beats letting it roam. Roaming is a feature for laptops that move. A doorbell does not move.

Forty-nine

Then the channel report (#290), which is where the title comes from. I wrote a passive RF report tool — no active scan, just reads the neighbor table each AP already maintains — that counts how many other access points each of my APs can hear on channels 1, 6, and 11. The garage AP, the one the troublesome doorbell lives on, was parked on channel 11. On channel 11 it could hear forty-nine other access points. Forty-nine. Channel 6, by contrast, had twelve.

That’s not a homelab problem. That’s an apartment-density problem. Forty-nine APs on channel 11 is forty-nine neighbors and their landlords’ default-config routers all shouting in the same narrow slice of spectrum, and there is no setting in my controller that turns them off. So I did the only thing I could: moved each of my six APs off its busiest channel onto the cleanest available 1/6/11. Garage and living room to 6, office and patio to 1, hallway and pantry to 11. I also consolidated the 2.4 GHz SSIDs down to just two (#287) — every network that could go 5 GHz-only did, so the 2.4 airtime is spent on devices that actually need it — and disabled a hidden “Wireless Device Adoption” SSID that was quietly beaconing for no reason (#288).

The part I can’t fix

After all of it — the dashboards, the config-as-code, the pins, the channel re-plan, the SSID diet — the band is better. The FP2 stays on the garage AP. The cameras hold. But the 2.4 GHz airtime is still busier than I’d like, and I want to be honest about why: most of what’s left is external. Forty-nine neighbors don’t go away because I moved to channel 6; they just stop being the loudest thing I’m fighting. Some interference you engineer around. Some you just have to name, write down in the README as “residual saturation is largely external neighbor interference,” and stop pretending a TX-power tweak will solve.

What I actually walked away with isn’t a fixed band — it’s a fixed workflow. The RF config is now a thing I can diff, a thing I can review, a thing where a bad idea like the blanket unpin leaves a commit I can point at and revert. The next time a camera drops, I won’t be guessing in a web UI. I’ll run the snapshot, look at the diff, pull the RF report, and find out whether the problem is mine — or whether it’s just channel 11 being channel 11 again.


Sidebar, from tonight’s research sweep: the whole fleet is on a single Wazuh agent version (4.14.5) with zero disconnects this run, including the usually-flaky site02-kvm01 — the cleanest agent state in a while. The only high/critical alerts in the last 24h were the 17 known-benign internal OpenObserve ingest floods that local rule 100533 already suppresses. And a small reminder from Krebs’ record-breaking June Patch Tuesday: there’s a VS Code zero-day that steals GitHub tokens. Not our stack, but there’s an active GitHub PAT living on this desktop, so “keep your editor and agent tooling patched” is advice I’m taking personally.