# Building a Spectrum Analyser w/ nRF24L01

> Which channel works best for your Zigbee?

By Zsolt Bizderi · Published 2026-10-07
Canonical: https://ambientnode.uk/spectrum-analyser-nrf24l01

The 2.4 GHz band can get saturated easily when your Wi-Fi, countless Bluetooth devices, the neighbour's baby monitor, and your entire Zigbee mesh are all crammed into the same 85 MHz of spectrum. When your smart home gets laggy, the usual advice is to move your Zigbee to a quieter channel. But how can you determine what is actually a quieter channel? Obviously, the answer completely depends on your environment, Channel 20 may work for some but is oversaturated for others.

This post is about building a self-contained 2.4 GHz spectrum analyser using an ESP32, a pocket device to carry around the house, point at the band, and have it tell you the best channels to use for Wi-Fi and Zigbee in order to avoid overlap.

![](/media/d6d46413-a7ac-4664-9e82-5b54baee9421/745.webp)

***

## How it works

The nRF24L01+ is not a spectrum analyser by any means and it was never meant to be one, it's just a cheap digital transceiver designed to transmit small packets. It has no FFT and no calibrated power measurement, but it does have a Received Power Detector. This is a single bit that goes high when there's a signal stronger than about -64 dBm on the channel you're listening to, and stays low otherwise. So per-channel, you get "busy" or "quiet" and this build piggybacks off of that feature.

One bit on its own is pretty useless, but if you sample the same channel thirty times in a row and count how many come back busy, you can start building a pattern. Then, repeat this across all 126 channels and we get an approximate map of the band usage.

The nRF24 divides the band into 126 channels spaced 1 MHz apart, where channel *n* sits at 2400 + *n* MHz. So channel 0 is 2400 MHz and channel 125 is 2525 MHz. Sweep and count all of them, and stream that array of 126 numbers out a few times a second. The ESP32 hosts its own Wi-Fi access point and a small web page that visualises the spectrum and gives you a channel recommendation.

***

## The hardware

* **ESP-WROOM-32:** driving the radio over SPI, and running a Wi-Fi access point plus a web server plus a WebSocket stream.
* **nRF24L01+PA+LNA with antenna:** The PA is a power amplifier for transmitting, which we don't care about here, but the LNA is a low-noise amplifier on the receive path, and that is the feature we use for this build as it makes the radio more sensitive.
* **nRF24L01+ breakout adapter with 3.3V regulator:** Needed in order to power it at 5V straight off the ESP32 and delivers a stable 3.3V to the radio.

***

## What it can and can't do

As mentioned, RPD gives us a rough estimate, but it is not a real dBm figure, so what we are reading is relative occupancy, not signal strength. In a professional setting, you'd definitely want a real spectrum analyser, but for surveying your property, this does the job, and it will tell you that channel X is a warzone and channel Y is empty, or that your Zigbee is using the same channel as your busiest Wi-Fi.

***

## Wiring the unit

The radio needs to be wired up to the ESP32 over SPI. I'm using the default VSPI pins and the two control lines:

| nRF24L01 pin | ESP32 pin |
| ------------ | --------- |
| VCC | 5V / VIN (into the adapter) |
| GND | GND |
| CE | GPIO4 |
| CSN | GPIO5 |
| SCK | GPIO18 |
| MOSI | GPIO23 |
| MISO | GPIO19 |
| IRQ | not connected |


***

## The code

The full code is available at the end of this post if you want to skip the explanation. The libraries you'll need are RF24 by TMRh20 and WebSockets by Markus Sattler. WiFi, WebServer and DNSServer uses ESP32 core.

### Libraries and globals

```
#include <SPI.h>
#include <RF24.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <DNSServer.h>

const char* AP_SSID = "Spectrum Analyser";

RF24 radio(4, 5);
WebServer        server(80);
WebSocketsServer webSocket(81);
DNSServer        dnsServer;
const byte DNS_PORT = 53;

const uint8_t  NUM_CHANNELS  = 126;
const uint8_t  SAMPLES       = 32;
const uint16_t RPD_SETTLE_US = 130;
uint8_t hits[NUM_CHANNELS];
```

Four servers and a radio. `RF24 radio(4, 5)` sets CE on GPIO4 and CSN on GPIO5. The web server runs on port 80 to serve the page, the WebSocket on port 81 to stream data, and the DNS server on port 53 powers the captive portal we'll get to later. `SAMPLES` is how many times we read the detector on each channel: higher gives a better, slower sweep, lower gives a faster, jumpier one. `hits` holds the per-channel scores.

### Configuring the radio to detect energy

```
void setupRadio() {
  radio.begin();
  radio.setAutoAck(false);
  radio.setDataRate(RF24_2MBPS);
  radio.disableCRC();
  radio.setAddressWidth(2);
  radio.setPALevel(RF24_PA_MIN);
}
```

We don't want the radio to acknowledge any packets (`setAutoAck(false)`), we don't want it validating checksums (`disableCRC`), as we're not actually trying to receive valid data at all, so the address handling is stripped down. `setPALevel(RF24_PA_MIN)` only affects transmitting, and we're only ever listening, so there's no reason to use it.

### Sweeping

```
void sweep() {
  for (uint8_t ch = 0; ch < NUM_CHANNELS; ch++) {
    radio.setChannel(ch);
    uint8_t count = 0;
    for (uint8_t s = 0; s < SAMPLES; s++) {
      radio.startListening();
      delayMicroseconds(RPD_SETTLE_US);
      radio.stopListening();
      if (radio.testRPD()) count++;
    }
    hits[ch] = count;
    dnsServer.processNextRequest();
    server.handleClient();
    webSocket.loop();
  }
}
```

We listen to each of the 126 channels then take 32 samples. After each sample, we wait about 130 microseconds for the detector to settle, then read `testRPD()`. This call returns the Received Power Detector bit: true if there was something above roughly -64 dBm in that window, false if not. We count how many of the 32 came back true, and that count is the channel's score.

The reason for the `startListening` / `stopListening` on every single sample is that the RPD uses the radio while it is in receive mode, so to get a fresh reading we need to drop out of receive and back in.

### Streaming the result

```
void sendSweep() {
  String json = "{\"max\":";
  json += SAMPLES;
  json += ",\"data\":[";
  for (uint8_t ch = 0; ch < NUM_CHANNELS; ch++) {
    json += hits[ch];
    if (ch < NUM_CHANNELS - 1) json += ",";
  }
  json += "]}";
  webSocket.broadcastTXT(json);
}
```

We package the 126 scores into a small JSON object that also carries the sample count, so we can visualise it later in terms of percentage and broadcast it to any connected browser.

### Self-contained access point and captive portal

Recently I've been setting up captive portals for projects like this...because I keep forgetting the assigned IP. This way you don't need to navigate to the IP of the ESP32 after establishing connection, it just pops up like a regular captive portal. The ESP runs a DNS server that answers every lookup with its own address, and serves the page for the connectivity-check URLs each operating system pings to decide whether it has internet.

```
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID);
IPAddress ip = WiFi.softAPIP();

dnsServer.start(DNS_PORT, "*", ip);

server.on("/", handleRoot);
server.on("/generate_204", handleCaptive);
server.on("/hotspot-detect.html", handleCaptive);
server.on("/ncsi.txt", handleCaptive);
server.onNotFound(handleRoot);
server.begin();
webSocket.begin();
```

No password makes it an open network, so joining is one tap.

```
void handleCaptive() {
  server.sendHeader("Location", "http://192.168.4.1/", true);
  server.send(302, "text/plain", "");
}
```

### The main loop

```
void loop() {
  dnsServer.processNextRequest();
  server.handleClient();
  webSocket.loop();
  sweep();
  sendSweep();
}
```

Service the three servers, run a full sweep, broadcast it, repeat.

### The part that answers the Zigbee question

Everything above gets us a live occupancy profile, and the 2.4 GHz Wi-Fi channel is 20 MHz wide. The three non-overlapping ones everybody should be using are 1, 6 and 11, centred at 2412, 2437 and 2462 MHz, which use the nRF24 channels 12, 37 and 62. Being 20 MHz wide, each one spans approx. ten nRF channels either side of its centre.

A Zigbee channel is much narrower and the numbering runs 11 to 26. Channel *k* sits at 2405 + 5(*k* − 11) MHz, which simplifies to nRF24 channel 5*k* − 50. So Zigbee 15 is nRF channel 25, Zigbee 20 is channel 50, Zigbee 25 is channel 75 and Zigbee 26 is channel 80. The reason everyone suggests using Zigbee channel 15, 20, 25 or 26 is because those channels fall in the gaps between Wi-Fi 1, 6 and 11.

To score a band, we just average the measured occupancy across the nRF channels it covers:

```
function bandAvg(center, half) {
  let s = 0, n = 0;
  for (let ch = center - half; ch <= center + half; ch++) {
    if (ch >= 0 && ch < NUM) { s += acc[ch]; n++; }
  }
  return (n && accCount) ? (s / n / accCount) : 0;
}
```

`acc` is a running total of occupancy per channel accumulated over the whole survey, so `bandAvg` returns the long-run average airtime in a band rather than a noisy instant. Then picking a recommendation is just sorting:

```
const wifi = [[1, 12], [6, 37], [11, 62]].map(p => ({ ch: p[0], occ: bandAvg(p[1], 10) }));
const bestWifi = [...wifi].sort((a, b) => a.occ - b.occ)[0];

const zb = [15, 20, 25, 26].map(k => ({ k, occ: bandAvg(5 * k - 50, 1) }));
const bestZigbee = [...zb].sort((a, b) => a.occ - b.occ)[0];
```

We measure the three Wi-Fi bands and the four Zigbee channels, sort each by occupancy, and the clearest one is the one you should use.

### Drawing the waterfall

Each incoming sweep scrolls the existing image down by one pixel and paints the new row across the top, colouring each channel with an inferno-style palette where black is dead air and white-hot is saturated.

```
const img = wfx.getImageData(0, 0, NUM, WF_H - 1);
wfx.putImageData(img, 0, 1);
for (let x = 0; x < NUM; x++) {
  const c = inferno(norm[x]);
  wfx.fillStyle = `rgb(${c[0]},${c[1]},${c[2]})`;
  wfx.fillRect(x, 0, 1, 1);
}
```

## Using it

Flash it, then join the Spectrum Analyser network, and the page loads itself. Set the Zigbee field to whatever channel your mesh currently uses, then leave it running for a few minutes. A longer survey will average it out better. When it's had time to settle, you get a line like "put your APs on channel 1, the clearest at 14% airtime; move Zigbee from 20 to channel 25."

I do want to highlight that changing your coordinator's channel is not exactly a smooth experience when you've got a large mesh. Some mains-powered routers may jump to the new channel, but in my case, most of my Zigbee devices needed re-pairing afterwards.

***

## The full code

```
#include <SPI.h>
#include <RF24.h>
#include <WiFi.h>
#include <WebServer.h>
#include <WebSocketsServer.h>
#include <DNSServer.h>

const char* AP_SSID = "Spectrum Analyser";

RF24 radio(4, 5);
WebServer        server(80);
WebSocketsServer webSocket(81);
DNSServer        dnsServer;
const byte DNS_PORT = 53;

const uint8_t  NUM_CHANNELS  = 126;
const uint8_t  SAMPLES       = 32;
const uint16_t RPD_SETTLE_US = 130;
uint8_t hits[NUM_CHANNELS];

const char INDEX_HTML[] PROGMEM = R"rawliteral(
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spectrum Analyser</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600;700&display=swap');
  :root{--bg:#07090a;--panel:#0d1113;--line:#1c2429;--ink:#c9d6d3;--dim:#5d6e6b;
    --amber:#ffb031;--amber-dim:#7a5410;--green:#3ddc84;--red:#ff4d4d;--wifi:#22d3ee;--zb:#f25fc0;}
  *{box-sizing:border-box;} html,body{margin:0;height:100%;}
  body{background:radial-gradient(120% 90% at 50% -10%,#0f1517 0%,var(--bg) 60%);color:var(--ink);
    font-family:'IBM Plex Mono',ui-monospace,'Cascadia Code',Menlo,Consolas,monospace;font-size:13px;
    padding:14px;-webkit-font-smoothing:antialiased;}
  body::after{content:"";position:fixed;inset:0;pointer-events:none;z-index:50;
    background:repeating-linear-gradient(0deg,rgba(255,255,255,.018) 0px,rgba(255,255,255,.018) 1px,transparent 1px,transparent 3px);
    mix-blend-mode:overlay;opacity:.6;}
  .frame{max-width:1080px;margin:0 auto;border:1px solid var(--line);
    background:linear-gradient(180deg,var(--panel),#0a0d0e);box-shadow:0 0 0 1px #000,0 30px 80px -40px #000;}
  header{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;padding:13px 16px;border-bottom:1px solid var(--line);}
  h1{font-size:14px;font-weight:700;letter-spacing:.3em;text-transform:uppercase;margin:0;color:var(--amber);
    text-shadow:0 0 18px rgba(255,176,49,.35);}
  h1 small{color:var(--dim);font-weight:400;letter-spacing:.16em;}
  .spacer{flex:1;}
  .status{display:flex;align-items:center;gap:7px;letter-spacing:.12em;}
  .dot{width:8px;height:8px;border-radius:50%;background:var(--red);box-shadow:0 0 10px var(--red);transition:.3s;}
  .dot.live{background:var(--green);box-shadow:0 0 12px var(--green);}
  .dot.demo{background:var(--amber);box-shadow:0 0 12px var(--amber);}
  .controls{display:flex;gap:12px;align-items:center;flex-wrap:nowrap;padding:10px 16px;border-bottom:1px solid var(--line);color:var(--dim);font-size:12px;}
  .controls label{letter-spacing:.1em;text-transform:uppercase;font-size:11px;}
  input[type=number]{width:60px;background:#05080a;border:1px solid var(--line);color:var(--ink);font-family:inherit;font-size:12px;padding:6px 9px;border-radius:2px;}
  input:focus{outline:none;border-color:var(--amber-dim);}
  button{background:#05080a;border:1px solid var(--amber-dim);color:var(--amber);font-family:inherit;font-size:12px;
    letter-spacing:.12em;text-transform:uppercase;padding:6px 14px;border-radius:2px;cursor:pointer;transition:.15s;}
  button:hover{background:var(--amber);color:#000;}
  button.ghost{border-color:var(--line);color:var(--dim);} button.ghost:hover{background:var(--line);color:var(--ink);}
  input[type=range]{accent-color:var(--amber);width:90px;}
  .scope{position:relative;padding:0 16px 6px;}
  .readout{display:flex;gap:24px;padding:11px 0 10px;color:var(--dim);font-size:12px;letter-spacing:.08em;flex-wrap:wrap;}
  .readout b{color:var(--ink);font-weight:600;} .readout .k{color:var(--amber);}
  .stack{position:relative;width:100%;}
  canvas{display:block;width:100%;image-rendering:pixelated;}
  #profile{height:110px;background:#05080a;border:1px solid var(--line);border-bottom:none;}
  #waterfall{height:360px;background:#000;border:1px solid var(--line);}
  .bands{position:absolute;inset:0;pointer-events:none;}
  .band{position:absolute;top:0;bottom:0;border-left:1px dashed;border-right:1px dashed;}
  .band.wifi{border-color:rgba(34,211,238,.42);background:rgba(34,211,238,.07);}
  .band.zb{border-color:rgba(242,95,192,.6);background:rgba(242,95,192,.14);}
  .band span{position:absolute;top:3px;left:50%;transform:translateX(-50%);font-size:10px;letter-spacing:.08em;
    white-space:nowrap;padding:1px 4px;border-radius:2px;background:rgba(0,0,0,.55);}
  .band.wifi span{color:var(--wifi);} .band.zb span{color:var(--zb);}
  .axis{position:relative;height:34px;margin-top:4px;}
  .tick{position:absolute;top:0;transform:translateX(-50%);text-align:center;color:var(--dim);font-size:10px;}
  .tick i{display:block;width:1px;height:6px;background:var(--line);margin:0 auto 3px;}
  .tick b{color:var(--ink);font-weight:500;display:block;}
  .legend{display:flex;align-items:center;gap:10px;padding:4px 16px 14px;color:var(--dim);font-size:11px;letter-spacing:.1em;}
  .ramp{height:10px;width:200px;border:1px solid var(--line);
    background:linear-gradient(90deg,#000004,#280b54,#65156e,#9f2a63,#d44842,#f57d15,#fac127,#fcffa4);}
  .analysis{padding:0 16px 16px;}
  .ana-head{display:flex;align-items:center;gap:14px;padding:12px 0 10px;border-top:1px solid var(--line);
    color:var(--amber);letter-spacing:.2em;font-size:12px;text-transform:uppercase;}
  .ana-head .ana-meta{color:var(--dim);letter-spacing:.08em;text-transform:none;font-size:11px;}
  .ana-head .ana-meta b{color:var(--ink);} .ana-head button{margin-left:auto;}
  .ana-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px;}
  @media(max-width:640px){.ana-grid{grid-template-columns:1fr;}}
  .ana-col h3{margin:0 0 8px;font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--dim);font-weight:600;}
  .ar{display:flex;align-items:center;gap:8px;margin:5px 0;font-size:12px;}
  .al{width:48px;color:var(--ink);} .av{width:40px;text-align:right;color:var(--ink);}
  .ab{flex:1;height:9px;background:#05080a;border:1px solid var(--line);position:relative;overflow:hidden;}
  .abf{position:absolute;left:0;top:0;bottom:0;background:linear-gradient(90deg,var(--green),var(--amber),var(--red));}
  .ar i{font-style:normal;font-size:9px;letter-spacing:.08em;padding:1px 5px;border-radius:2px;margin-left:2px;}
  .ar i.best{background:rgba(61,220,132,.16);color:var(--green);}
  .ar i.busy{background:rgba(255,77,77,.16);color:var(--red);}
  .ar i.cur{background:rgba(255,176,49,.16);color:var(--amber);}
  .verdict{margin-top:14px;padding:12px 14px;border:1px solid var(--amber-dim);border-radius:3px;
    background:rgba(255,176,49,.05);color:var(--ink);font-size:12px;line-height:1.65;}
  .verdict b{color:var(--amber);} .verdict .warn{color:var(--dim);}
  footer{padding:10px 16px 14px;color:var(--dim);font-size:11px;letter-spacing:.06em;line-height:1.6;border-top:1px solid var(--line);}
  footer code{color:var(--amber);}
</style>
<div class="frame">
  <header>
    <h1>Spectrum Analyser</h1>
    <div class="spacer"></div>
    <div class="status"><span class="dot demo" id="dot"></span></div>
  </header>
  <div class="controls">
    <button class="ghost" id="freezeBtn">Freeze</button>
    <div class="spacer" style="flex:1"></div>
    <label>zigbee&nbsp;ch</label><input type="number" id="zbCh" value="20" min="11" max="26">
    <label>contrast</label><input type="range" id="gamma" min="0.4" max="2.5" step="0.1" value="1.0">
  </div>
  <div class="scope">
    <div class="readout">
      <span>SWEEP&nbsp;RATE <b id="rate">&mdash;</b> <span class="k">/s</span></span>
      <span>PEAK&nbsp;CH <b id="peakCh">&mdash;</b></span>
      <span>PEAK&nbsp;FREQ <b id="peakFreq">&mdash;</b> <span class="k">MHz</span></span>
      <span>OCCUPANCY <b id="occ">&mdash;</b></span>
    </div>
    <div class="stack" id="stack">
      <canvas id="profile" width="126" height="110"></canvas>
      <canvas id="waterfall" width="126" height="360"></canvas>
      <div class="bands" id="bands"></div>
    </div>
    <div class="axis" id="axis"></div>
  </div>
  <div class="legend"><span>QUIET</span><div class="ramp"></div><span>SATURATED</span></div>
  <div class="analysis">
    <div class="ana-head"><span>Channel Advisor</span>
      <span class="ana-meta">survey <b id="surveyTime">0s</b> &middot; <b id="sweepCount">0</b> sweeps</span>
      <button class="ghost" id="resetBtn">Reset survey</button></div>
    <div class="ana-grid">
      <div class="ana-col"><h3>WiFi 2.4 GHz</h3><div id="wifiRows"></div></div>
      <div class="ana-col"><h3>Zigbee</h3><div id="zbRows"></div></div>
    </div>
    <div class="verdict" id="verdict">Collecting data&hellip; let the survey run a few minutes for a reliable suggestion.</div>
  </div>
  <footer>Channel <code>n</code> = <code>2400 + n MHz</code> &middot; cyan = WiFi 20 MHz bands (ch 1/6/11), pink = your Zigbee channel.</footer>
</div>
<script>
const NUM=126;
const wf=document.getElementById('waterfall'),pf=document.getElementById('profile');
const wfx=wf.getContext('2d'),pfx=pf.getContext('2d');const WF_H=wf.height;
const STOPS=[[0,[0,0,4]],[.13,[31,12,72]],[.25,[85,15,109]],[.38,[136,34,106]],
  [.5,[186,54,85]],[.63,[227,89,51]],[.75,[249,140,10]],[.87,[249,201,50]],[1,[252,255,164]]];
function inferno(t){t=Math.max(0,Math.min(1,t));for(let i=1;i<STOPS.length;i++){if(t<=STOPS[i][0]){
  const a=STOPS[i-1],b=STOPS[i],f=(t-a[0])/(b[0]-a[0]);
  return[Math.round(a[1][0]+(b[1][0]-a[1][0])*f),Math.round(a[1][1]+(b[1][1]-a[1][1])*f),Math.round(a[1][2]+(b[1][2]-a[1][2])*f)];}}
  return STOPS[STOPS.length-1][1];}
let frozen=false,gamma=1.0,sweepTimes=[],peakHold=new Array(NUM).fill(0);
let acc=new Array(NUM).fill(0),accCount=0,surveyStart=Date.now(),lastAna=0;
function pushRow(data,max){if(frozen)return;
  accumulate(data,max);
  const norm=data.map(v=>Math.pow(Math.min(1,v/max),1/gamma));
  const img=wfx.getImageData(0,0,NUM,WF_H-1);wfx.putImageData(img,0,1);
  for(let x=0;x<NUM;x++){const c=inferno(norm[x]);wfx.fillStyle=`rgb(${c[0]},${c[1]},${c[2]})`;wfx.fillRect(x,0,1,1);}
  drawProfile(norm);updateReadout(data,max);
  const now=performance.now();sweepTimes.push(now);sweepTimes=sweepTimes.filter(t=>now-t<1000);
  document.getElementById('rate').textContent=sweepTimes.length;
  if(now-lastAna>1000){lastAna=now;renderAnalysis();}}
function drawProfile(norm){const W=NUM,H=pf.height;pfx.clearRect(0,0,W,H);
  for(let x=0;x<NUM;x++){peakHold[x]=Math.max(peakHold[x]*0.97,norm[x]);const h=norm[x]*H;
    const c=inferno(norm[x]);pfx.fillStyle=`rgb(${c[0]},${c[1]},${c[2]})`;pfx.fillRect(x,H-h,1,h);}
  pfx.strokeStyle='rgba(255,176,49,.55)';pfx.beginPath();
  for(let x=0;x<NUM;x++){const y=H-peakHold[x]*H;x===0?pfx.moveTo(x,y):pfx.lineTo(x,y);}pfx.stroke();}
function updateReadout(data,max){let pk=0,pi=0,sum=0;
  for(let i=0;i<NUM;i++){sum+=data[i];if(data[i]>pk){pk=data[i];pi=i;}}
  document.getElementById('peakCh').textContent=pi;
  document.getElementById('peakFreq').textContent=2400+pi;
  document.getElementById('occ').textContent=Math.round(100*sum/(NUM*max))+'%';}
function chToPct(ch){return(ch/(NUM-1))*100;}
function drawAxis(){const a=document.getElementById('axis');a.innerHTML='';
  for(let ch=0;ch<=125;ch+=10){const t=document.createElement('div');t.className='tick';
    t.style.left=chToPct(ch)+'%';t.innerHTML=`<i></i><b>${ch}</b>${2400+ch}`;a.appendChild(t);}}
function drawBands(){const bands=document.getElementById('bands');bands.innerHTML='';
  [[1,12],[6,37],[11,62]].forEach(p=>{const b=document.createElement('div');b.className='band wifi';
    b.style.left=chToPct(p[1]-10)+'%';b.style.width=(20/(NUM-1))*100+'%';
    b.innerHTML=`<span>WiFi ${p[0]}</span>`;bands.appendChild(b);});
  const k=parseInt(document.getElementById('zbCh').value)||20,zc=5*k-50;
  const b=document.createElement('div');b.className='band zb';
  b.style.left=chToPct(zc-1)+'%';b.style.width=(2/(NUM-1))*100+'%';
  b.innerHTML=`<span>ZB ${k}</span>`;bands.appendChild(b);}
let ws=null,demoOn=true;
function setStatus(s,t){document.getElementById('dot').className='dot '+s;}
function connect(){const url=`ws://${location.hostname}:81`;
  try{ws&&ws.close();}catch(e){}setStatus('demo','CONNECTING');
  try{ws=new WebSocket(url);}catch(e){setStatus('demo','DEMO');return;}
  ws.onopen=()=>{demoOn=false;setStatus('live','LIVE');};
  ws.onclose=()=>{setStatus('demo','DEMO');demoOn=true;};
  ws.onerror=()=>{setStatus('demo','DEMO');demoOn=true;};
  ws.onmessage=ev=>{try{const m=JSON.parse(ev.data);if(m&&m.data)pushRow(m.data,m.max||32);}catch(e){}};}
let phase=0;
function demoSweep(){phase+=0.15;const max=32;
  const out=new Array(NUM).fill(0).map((_,ch)=>{let v=Math.random()*3;
    [[12,0.9],[37,1.0],[62,0.7]].forEach((p,i)=>{const d=ch-p[0];
      v+=p[1]*28*Math.exp(-(d*d)/90)*(0.6+0.4*Math.abs(Math.sin(phase+i)));});
    const hop=(Math.floor(phase*7)*13)%NUM;if(Math.abs(ch-hop)<2)v+=22*Math.random();
    return Math.min(max,Math.round(v));});return{data:out,max};}
setInterval(()=>{if(demoOn){const s=demoSweep();pushRow(s.data,s.max);}},350);
function accumulate(data,max){for(let i=0;i<NUM;i++)acc[i]+=data[i]/max;accCount++;}
function bandAvg(center,half){let s=0,n=0;for(let ch=center-half;ch<=center+half;ch++){if(ch>=0&&ch<NUM){s+=acc[ch];n++;}}return(n&&accCount)?(s/n/accCount):0;}
function pct(x){return Math.round(x*100);}
function anaRow(label,occ,tag){return `<div class="ar"><span class="al">${label}</span><span class="ab"><span class="abf" style="width:${Math.max(2,pct(occ))}%"></span></span><span class="av">${pct(occ)}%</span>${tag||''}</div>`;}
function renderAnalysis(){
  if(!accCount)return;
  const secs=Math.floor((Date.now()-surveyStart)/1000);
  document.getElementById('surveyTime').textContent=secs<60?secs+'s':Math.floor(secs/60)+'m '+(secs%60)+'s';
  document.getElementById('sweepCount').textContent=accCount;
  const wifi=[[1,12],[6,37],[11,62]].map(p=>({ch:p[0],occ:bandAvg(p[1],10)}));
  const ws=[...wifi].sort((a,b)=>a.occ-b.occ),bestW=ws[0],worstW=ws[2];
  document.getElementById('wifiRows').innerHTML=wifi.map(w=>{
    let t='';if(w.ch===bestW.ch)t='<i class="best">CLEAREST</i>';else if(w.ch===worstW.ch)t='<i class="busy">BUSIEST</i>';
    return anaRow('ch '+w.ch,w.occ,t);}).join('');
  const cur=parseInt(document.getElementById('zbCh').value)||20;
  const set=[15,20,25,26];if(!set.includes(cur))set.push(cur);
  const zb=set.map(k=>({k,occ:bandAvg(5*k-50,1)}));
  const bestZ=[...zb].sort((a,b)=>a.occ-b.occ)[0];
  document.getElementById('zbRows').innerHTML=zb.sort((a,b)=>a.k-b.k).map(z=>{
    let t='';if(z.k===bestZ.k)t+='<i class="best">CLEAREST</i>';if(z.k===cur)t+='<i class="cur">CURRENT</i>';
    return anaRow('ch '+z.k,z.occ,t);}).join('');
  const curOcc=bandAvg(5*cur-50,1);
  let v=`<b>WiFi:</b> put your APs on <b>channel ${bestW.ch}</b>, the clearest at ${pct(bestW.occ)}% airtime (channel ${worstW.ch} is busiest at ${pct(worstW.occ)}%). `;
  if(bestZ.k===cur||curOcc<=bestZ.occ+0.03){v+=`<b>Zigbee:</b> channel ${cur} is already a clear spot, so leave it.`;}
  else{v+=`<b>Zigbee:</b> move from channel ${cur} (${pct(curOcc)}%) to <b>channel ${bestZ.k}</b> (${pct(bestZ.occ)}%).`;}
  if(secs<45)v+=' <span class="warn">Not enough data yet to show reliable results, please keep scanning.</span>';
  document.getElementById('verdict').innerHTML=v;
}
document.getElementById('resetBtn').onclick=()=>{acc=new Array(NUM).fill(0);accCount=0;surveyStart=Date.now();renderAnalysis();};
document.getElementById('freezeBtn').onclick=e=>{frozen=!frozen;
  e.target.textContent=frozen?'Resume':'Freeze';e.target.classList.toggle('ghost',!frozen);};
document.getElementById('zbCh').oninput=()=>{drawBands();renderAnalysis();};
document.getElementById('gamma').oninput=e=>{gamma=parseFloat(e.target.value);};
drawAxis();drawBands();window.addEventListener('resize',()=>{drawAxis();drawBands();});
connect();
</script>
)rawliteral";

void onWsEvent(uint8_t num, WStype_t type, uint8_t* payload, size_t len) {
  if (type == WStype_CONNECTED) Serial.printf("WS client %u connected\n", num);
}

void handleRoot() { server.send_P(200, "text/html", INDEX_HTML); }

void handleCaptive() {
  server.sendHeader("Location", "http://192.168.4.1/", true);
  server.send(302, "text/plain", "");
}

void setupRadio() {
  radio.begin();
  radio.setAutoAck(false);
  radio.setDataRate(RF24_2MBPS);
  radio.disableCRC();
  radio.setAddressWidth(2);
  radio.setPALevel(RF24_PA_MIN);
}

void setup() {
  Serial.begin(115200);
  delay(500);

  if (!radio.begin()) { Serial.println("radio.begin() failed - check wiring/power."); while (1) {} }
  setupRadio();

  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_SSID);
  IPAddress ip = WiFi.softAPIP();
  Serial.println("Access point up.");
  Serial.printf("  Join WiFi:  %s  (open network)\n", AP_SSID);
  Serial.printf("  Portal should pop automatically; else browse http://%s\n", ip.toString().c_str());

  dnsServer.start(DNS_PORT, "*", ip);

  server.on("/", handleRoot);
  server.on("/generate_204", handleCaptive);
  server.on("/gen_204", handleCaptive);
  server.on("/hotspot-detect.html", handleCaptive);
  server.on("/library/test/success.html", handleCaptive);
  server.on("/ncsi.txt", handleCaptive);
  server.on("/connecttest.txt", handleCaptive);
  server.on("/fwlink", handleCaptive);
  server.onNotFound(handleRoot);
  server.begin();
  webSocket.begin();
  webSocket.onEvent(onWsEvent);
}

void sweep() {
  for (uint8_t ch = 0; ch < NUM_CHANNELS; ch++) {
    radio.setChannel(ch);
    uint8_t count = 0;
    for (uint8_t s = 0; s < SAMPLES; s++) {
      radio.startListening();
      delayMicroseconds(RPD_SETTLE_US);
      radio.stopListening();
      if (radio.testRPD()) count++;
    }
    hits[ch] = count;
    dnsServer.processNextRequest();
    server.handleClient();
    webSocket.loop();
  }
}

void sendSweep() {
  String json = "{\"max\":";
  json += SAMPLES;
  json += ",\"data\":[";
  for (uint8_t ch = 0; ch < NUM_CHANNELS; ch++) {
    json += hits[ch];
    if (ch < NUM_CHANNELS - 1) json += ",";
  }
  json += "]}";
  webSocket.broadcastTXT(json);
}

void loop() {
  dnsServer.processNextRequest();
  server.handleClient();
  webSocket.loop();
  sweep();
  sendSweep();
}
```

So now I have an over-engineered one-time pocket spectrum survey tool that runs off a single board, costs about the price of a coffee in parts, and answers the Zigbee channel question with an approximate measurement.
