15  Mobile Labs: Coverage Planning

In 60 Seconds

A link budget calculation determines whether an RF link is viable before any hardware is installed by summing transmit power, antenna gains, path losses, and environmental losses, then comparing the result to the receiver’s sensitivity plus a fade margin. Python-based coverage planners extend this to two dimensions, generating RSSI heatmaps that reveal dead zones and guide access point placement for reliable IoT deployments.

15.1 Learning Objectives

By the end of this chapter, you will be able to:

  • Derive electromagnetic wave properties: Compute wavelength, photon energy, and free-space path loss across ISM-band frequencies using Python
  • Evaluate spectrum utilization: Construct channel-analysis tools that quantify interference and justify optimal channel selection
  • Construct a complete link budget: Aggregate transmit power, antenna gains, path losses, and fade margins to determine link viability for IoT deployments
  • Design RF coverage layouts: Generate RSSI heatmaps that pinpoint dead zones and guide access-point placement decisions
  • Implement a spectrum monitoring system: Program an ESP32-based scanner with SD logging and a web dashboard for continuous RF surveillance

This lab walks you through planning wireless coverage for a real space – calculating how many access points or base stations you need, where to place them, and how to handle obstacles like walls and floors. Think of it as creating a blueprint for ensuring every corner of your space has a reliable wireless signal.

“Before installing a single sensor, we need a coverage plan!” said Max the Microcontroller, spreading out a floor plan of a warehouse. “A link budget tells us whether our radio signal will actually reach from point A to point B. It is like calculating whether you can throw a ball far enough to reach your friend.”

Sammy the Sensor studied the floor plan. “How do I know if my signal is strong enough?” Max wrote a simple equation on the board. “Start with your transmit power – say 10 dBm. Add your antenna gain. Then subtract the path loss, which depends on distance and frequency. Subtract extra loss for walls and floors. If the result is above your receiver’s sensitivity – usually around minus 100 dBm for IoT – you have a working link!”

“The really cool part is making heatmaps with Python,” said Lila the LED excitedly. “You calculate the signal strength at every point on the floor plan and color-code it. Red means strong signal, blue means weak. The blue spots are dead zones where you need another access point.”

Bella the Battery offered practical wisdom. “Always add a fade margin – usually 10 to 20 dB extra. Real-world signals fluctuate because of people moving, doors opening, and machines running. If your link budget works perfectly on paper with zero margin, it will fail in the real world half the time.”

15.2 Prerequisites

Before diving into this chapter, you should be familiar with:

  • Mobile Wireless: Fundamentals: Understanding path loss, wavelength, and frequency relationships
  • Mobile Labs: Wi-Fi Spectrum Analysis: Practical Wi-Fi scanning and RSSI measurement
  • Python programming: Familiarity with numpy and matplotlib for data analysis and visualization
  • Basic RF concepts: Link budget, receiver sensitivity, and transmit power
  • Coverage Planning Tool: Software tool (e.g., Ekahau, iBwave) that uses propagation models and site maps to predict Wi-Fi or cellular coverage
  • Heat Map: Visual representation of signal strength across a floor plan; hot colors indicate strong signal, cool colors indicate weak
  • Access Point Placement: Strategic positioning of APs to maximize coverage while minimizing interference and channel reuse conflicts
  • Channel Reuse Pattern: Non-overlapping channel assignment to adjacent APs (channels 1, 6, 11 for 2.4 GHz Wi-Fi)
  • Roaming Threshold: RSSI level at which a client device switches from one AP to another; typically -70 to -75 dBm
  • Coverage Hole: Area with insufficient signal strength for reliable connectivity; requires additional AP or repositioning
  • Co-Channel Interference: Interference between APs using the same channel; controlled by AP power levels and placement
  • Predictive Site Survey: Pre-deployment coverage simulation using floor plan and propagation models

Objective: Measure Wi-Fi signal strength (RSSI) and estimate distance using the log-distance path loss model.

Paste this code into the Wokwi editor. The sketch connects to Wi-Fi, periodically measures RSSI, estimates distance using the log-distance path loss model, and calculates free-space path loss at 2.4 GHz.

#include <WiFi.h>
#include <math.h>

const char* ssid = "Wokwi-GUEST";
const char* password = "";

// Path loss model parameters
const float RSSI_AT_1M = -40.0;    // RSSI at 1 meter (calibration value)
const float PATH_LOSS_EXP = 2.7;   // Path loss exponent (2=free space, 2.7=indoor)

// Free-space path loss calculator
float fspl_dB(float distance_m, float freq_Hz) {
  if (distance_m <= 0) return 0;
  float wavelength = 299792458.0 / freq_Hz;
  return 20.0 * log10(4.0 * PI * distance_m / wavelength);
}

// Estimate distance from RSSI
float rssiToDistance(float rssi, float rssiAt1m, float n) {
  return pow(10.0, (rssiAt1m - rssi) / (10.0 * n));
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  Serial.println("=== RF Coverage Planning Tool ===\n");

  WiFi.begin(ssid, password);
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println(" Connected!\n");

  // Current signal measurement
  int rssi = WiFi.RSSI();
  float estDistance = rssiToDistance(rssi, RSSI_AT_1M, PATH_LOSS_EXP);

  Serial.println("--- Current Signal Measurement ---");
  Serial.printf("  SSID: %s\n", WiFi.SSID().c_str());
  Serial.printf("  RSSI: %d dBm\n", rssi);
  Serial.printf("  Estimated distance: %.1f meters\n", estDistance);
  Serial.printf("  Model: RSSI_1m=%.0f, n=%.1f\n\n", RSSI_AT_1M, PATH_LOSS_EXP);

  // Link budget table for common IoT frequencies
  Serial.println("--- Free-Space Path Loss Table ---\n");
  Serial.println("  Dist(m) | 433 MHz  | 868 MHz  | 2.4 GHz  | 5.8 GHz");
  Serial.println("  --------|----------|----------|----------|--------");
  float distances[] = {1, 5, 10, 50, 100, 500, 1000};
  float freqs[] = {433e6, 868e6, 2.4e9, 5.8e9};
  for (int d = 0; d < 7; d++) {
    Serial.printf("  %7.0f |", distances[d]);
    for (int f = 0; f < 4; f++) {
      Serial.printf(" %6.1f dB |", fspl_dB(distances[d], freqs[f]));
    }
    Serial.println();
  }

  // Link budget analysis
  Serial.println("\n--- Link Budget Analysis ---\n");
  float txPower = 14.0;      // dBm (ESP32 typical)
  float txAntennaGain = 2.0; // dBi (PCB antenna)
  float rxAntennaGain = 2.0; // dBi
  float rxSensitivity = -90.0; // dBm (ESP32 typical)
  float fadeMargin = 10.0;    // dB

  float linkBudget = txPower + txAntennaGain + rxAntennaGain
                     - rxSensitivity - fadeMargin;
  float maxRange = pow(10.0, (linkBudget - 20*log10(4*PI*2.4e9/299792458.0)) / 20.0);

  Serial.printf("  TX Power:          %+.1f dBm\n", txPower);
  Serial.printf("  TX Antenna Gain:   %+.1f dBi\n", txAntennaGain);
  Serial.printf("  RX Antenna Gain:   %+.1f dBi\n", rxAntennaGain);
  Serial.printf("  RX Sensitivity:    %.1f dBm\n", rxSensitivity);
  Serial.printf("  Fade Margin:       %.1f dB\n", fadeMargin);
  Serial.printf("  Total Link Budget: %.1f dB\n", linkBudget);
  Serial.printf("  Max Range (FSPL):  %.0f meters\n\n", maxRange);

  // Signal quality reference
  Serial.println("--- Wi-Fi Signal Quality Reference ---");
  Serial.println("  RSSI (dBm) | Quality    | Typical Use");
  Serial.println("  -----------|------------|------------------");
  Serial.println("  > -30      | Excellent  | Next to AP");
  Serial.println("  -30 to -50 | Excellent  | Same room");
  Serial.println("  -50 to -60 | Good       | 1-2 rooms away");
  Serial.println("  -60 to -70 | Fair       | Usable for IoT");
  Serial.println("  -70 to -80 | Weak       | Basic connectivity");
  Serial.println("  -80 to -90 | Very Weak  | Unreliable");
  Serial.println("  < -90      | No signal  | Out of range");
}

void loop() {
  delay(5000);
  int rssi = WiFi.RSSI();
  float dist = rssiToDistance(rssi, RSSI_AT_1M, PATH_LOSS_EXP);
  Serial.printf("[Monitor] RSSI: %d dBm | Est. distance: %.1f m | Quality: %s\n",
    rssi, dist,
    rssi > -50 ? "Excellent" :
    rssi > -60 ? "Good" :
    rssi > -70 ? "Fair" :
    rssi > -80 ? "Weak" : "Very Weak");
}

What to Observe:

  1. RSSI measurement shows current signal strength and estimated distance using the log-distance model
  2. The FSPL table compares path loss across IoT frequencies (433 MHz, 868 MHz, 2.4 GHz, 5.8 GHz)
  3. Link budget calculation determines maximum theoretical range with fade margin
  4. Periodic monitoring shows RSSI fluctuation and signal quality classification
How It Works: Link Budget Analysis

A link budget is an accounting system for RF power from transmitter to receiver. Start with transmit power (e.g., +14 dBm), add antenna gains (+2 dBi TX, +8 dBi RX), subtract path losses (free-space + environmental), and compare the received power to receiver sensitivity (e.g., -137 dBm). The difference is your link margin. A 20 dB margin means the link tolerates 20 dB of fading (rain, moving obstacles, multipath) before failing. Coverage planning extends this to 2D: calculate the link budget at every grid point on a floor plan, color-code by RSSI (red=strong, blue=weak), and the blue zones tell you where to add access points or adjust antenna placement.

15.4 Python Implementation 1: Wave Property Calculator

This implementation provides comprehensive calculations for electromagnetic wave properties, including frequency/wavelength relationships, energy calculations, and path loss analysis. It computes properties for common IoT bands (433 MHz, 868 MHz, 2.4 GHz, 5 GHz) and includes Fresnel zone radius calculations.

import math

# Physical constants
SPEED_OF_LIGHT = 299792458  # m/s
PLANCK_CONSTANT = 6.62607015e-34  # J·s


def frequency_to_wavelength(frequency_hz: float) -> float:
    """Convert frequency to wavelength."""
    return SPEED_OF_LIGHT / frequency_hz


def wavelength_to_frequency(wavelength_m: float) -> float:
    """Convert wavelength to frequency."""
    return SPEED_OF_LIGHT / wavelength_m


def photon_energy(frequency_hz: float) -> float:
    """Calculate photon energy in Joules."""
    return PLANCK_CONSTANT * frequency_hz


def free_space_path_loss_db(distance_m: float, frequency_hz: float) -> float:
    """
    Calculate free-space path loss in dB.
    FSPL = 20*log10(4*pi*d/lambda)
    """
    wavelength = frequency_to_wavelength(frequency_hz)
    if distance_m <= 0:
        return 0
    return 20 * math.log10(4 * math.pi * distance_m / wavelength)


def max_range_m(
    tx_power_dbm: float,
    rx_sensitivity_dbm: float,
    frequency_hz: float,
    fade_margin_db: float = 10.0,
    extra_losses_db: float = 0.0
) -> float:
    """Calculate maximum theoretical range given link budget."""
    available_loss = tx_power_dbm - rx_sensitivity_dbm - fade_margin_db - extra_losses_db
    wavelength = frequency_to_wavelength(frequency_hz)
    # Rearrange FSPL: d = lambda * 10^(FSPL/20) / (4*pi)
    distance = wavelength * (10 ** (available_loss / 20)) / (4 * math.pi)
    return distance


def fresnel_zone_radius(distance_m: float, frequency_hz: float, zone: int = 1) -> float:
    """Calculate Fresnel zone radius at midpoint."""
    wavelength = frequency_to_wavelength(frequency_hz)
    # r_n = sqrt(n * lambda * d1 * d2 / (d1 + d2))
    # At midpoint: d1 = d2 = d/2
    d_half = distance_m / 2
    return math.sqrt(zone * wavelength * d_half * d_half / distance_m)


def main():
    print("=== Electromagnetic Wave Properties ===\n")

    frequencies = [
        ("433 MHz ISM", 433e6),
        ("868 MHz (Europe)", 868e6),
        ("2.4 GHz ISM", 2.4e9),
    ]

    for name, freq in frequencies:
        wavelength = frequency_to_wavelength(freq)
        energy = photon_energy(freq)
        print(f"{name}:")
        print(f"Frequency: {freq/1e6:.2f} MHz ({freq:.2e} Hz)")
        print(f"Wavelength: {wavelength:.4f} m ({wavelength*100:.2f} cm)")
        print(f"Energy: {energy:.2e} Joules\n")

    print("=== Path Loss Comparison at 100m ===\n")
    distance = 100
    test_freqs = [
        ("868 MHz (Europe)", 868e6),
        ("2.4 GHz ISM", 2.4e9),
        ("5 GHz", 5e9),
    ]

    for name, freq in test_freqs:
        pl = free_space_path_loss_db(distance, freq)
        print(f"{name}: {pl:.2f} dB")

    # Compare 868 MHz vs 2.4 GHz advantage
    pl_868 = free_space_path_loss_db(distance, 868e6)
    pl_2400 = free_space_path_loss_db(distance, 2.4e9)
    print(f"\nPath loss advantage of 868 MHz over 2.4 GHz: {pl_2400 - pl_868:.2f} dB")

    print("\n=== Range Calculation ===\n")
    for name, freq in [("868 MHz (Europe)", 868e6), ("2.4 GHz ISM", 2.4e9)]:
        range_m = max_range_m(
            tx_power_dbm=14,
            rx_sensitivity_dbm=-137,
            frequency_hz=freq,
            fade_margin_db=10,
            extra_losses_db=0
        )
        print(f"{name}: {range_m/1000:.2f} km range")

    print("\n=== Fresnel Zone Analysis ===\n")
    link_distance = 1000  # 1 km
    freq = 2.4e9
    fz_radius = fresnel_zone_radius(link_distance, freq)
    print(f"First Fresnel zone radius at midpoint of 1 km link (2.4 GHz): {fz_radius:.2f} m")
    print(f"Required clearance (60% of first Fresnel zone): {fz_radius * 0.6:.2f} m")


if __name__ == "__main__":
    main()

Expected Output:

=== Electromagnetic Wave Properties ===

433 MHz ISM:
Frequency: 433.00 MHz (4.33e+08 Hz)
Wavelength: 0.6924 m (69.24 cm)
Energy: 2.87e-25 Joules

868 MHz (Europe):
Frequency: 868.00 MHz (8.68e+08 Hz)
Wavelength: 0.3454 m (34.54 cm)
Energy: 5.75e-25 Joules

2.4 GHz ISM:
Frequency: 2400.00 MHz (2.40e+09 Hz)
Wavelength: 0.1249 m (12.49 cm)
Energy: 1.59e-24 Joules

=== Path Loss Comparison at 100m ===

868 MHz (Europe): 71.22 dB
2.4 GHz ISM: 80.05 dB
5 GHz: 86.43 dB

Path loss advantage of 868 MHz over 2.4 GHz: 8.83 dB

=== Range Calculation ===

868 MHz (Europe): 308.38 km range
2.4 GHz ISM: 111.53 km range

=== Fresnel Zone Analysis ===

First Fresnel zone radius at midpoint of 1 km link (2.4 GHz): 5.59 m
Required clearance (60% of first Fresnel zone): 3.35 m

15.6 Lab: ESP32 Real-Time Spectrum Monitor

This advanced lab creates a continuous spectrum monitoring system with data logging and web interface.

15.6.1 Hardware Required

  • ESP32 development board
  • MicroSD card module
  • SD card (formatted FAT32)
  • Breadboard and jumper wires
  • Optional: OLED display (128x64, I2C)

15.6.2 Wiring Diagram

ESP32          SD Card Module
-----          --------------
GPIO 5    -->  CS
GPIO 18   -->  SCK
GPIO 19   -->  MISO
GPIO 23   -->  MOSI
3.3V      -->  VCC
GND       -->  GND

Optional OLED:
GPIO 21   -->  SDA
GPIO 22   -->  SCL

15.6.3 Complete Spectrum Monitor Code

The full spectrum monitor (~320 lines) implements Wi-Fi scanning, SD card logging, and a web dashboard. Key architectural components:

  • Wi-Fi Scanner: Periodic scans storing SSID, RSSI, channel, and encryption for up to 50 networks
  • SD Card Logger: CSV output with timestamps for offline analysis
  • Web Dashboard: Real-time HTML interface served from ESP32 in AP mode
  • Statistics Engine: Per-channel network counts, strongest signals, interference detection
#include <WiFi.h>
#include <WebServer.h>
#include <SD.h>
#include <SPI.h>
#include <time.h>

// SD Card pins
#define SD_CS 5

// Web server
WebServer server(80);

// Scan configuration
#define SCAN_INTERVAL_MS 30000  // 30 seconds
#define MAX_NETWORKS 50

struct NetworkScan {
    char ssid[33];
    int channel;
    int rssi;
    char encryption[20];
    unsigned long timestamp;
};

NetworkScan scanHistory[MAX_NETWORKS];
int scanCount = 0;
unsigned long lastScanTime = 0;

// Statistics
int channelCounts[14] = {0};
float channelAvgRSSI[14] = {0};
int totalScans = 0;

void setup() {
    Serial.begin(115200);
    delay(1000);

    Serial.println("\n\n================================");
    Serial.println("ESP32 Spectrum Monitor");
    Serial.println("================================\n");

    // Initialize SD card
    if (!SD.begin(SD_CS)) {
        Serial.println("SD Card initialization failed!");
    } else {
        Serial.println("SD Card initialized successfully");

        // Create header in log file if new
        if (!SD.exists("/spectrum_log.csv")) {
            File logFile = SD.open("/spectrum_log.csv", FILE_WRITE);
            if (logFile) {
                logFile.println("Timestamp,SSID,Channel,RSSI,Encryption");
                logFile.close();
                Serial.println("Created new log file");
            }
        }
    }

    // Set Wi-Fi mode
    WiFi.mode(WIFI_STA);
    WiFi.disconnect();

    // Start access point for web interface
    WiFi.softAP("ESP32-SpectrumMonitor", "spectrum123");
    IPAddress IP = WiFi.softAPIP();
    Serial.print("AP IP address: ");
    Serial.println(IP);

    // Setup web server routes
    server.on("/", handleRoot);
    server.on("/scan", handleScan);
    server.on("/data", handleData);
    server.on("/download", handleDownload);
    server.begin();

    Serial.println("Web server started");
    Serial.println("Connect to 'ESP32-SpectrumMonitor' and navigate to " + IP.toString());
    Serial.println("\nStarting continuous monitoring...\n");
}

void loop() {
    server.handleClient();

    // Periodic scan
    if (millis() - lastScanTime >= SCAN_INTERVAL_MS) {
        performScan();
        lastScanTime = millis();
    }
}

void performScan() {
    Serial.println("Scanning networks...");

    int n = WiFi.scanNetworks();
    totalScans++;

    // Reset channel statistics
    for (int i = 0; i < 14; i++) {
        channelCounts[i] = 0;
        channelAvgRSSI[i] = 0;
    }

    Serial.printf("Found %d networks\n\n", n);

    if (n > 0) {
        // Clear previous scan
        scanCount = 0;

        // Open log file for appending
        File logFile = SD.open("/spectrum_log.csv", FILE_APPEND);

        for (int i = 0; i < n && i < MAX_NETWORKS; i++) {
            // Store in memory
            strncpy(scanHistory[scanCount].ssid, WiFi.SSID(i).c_str(), 32);
            scanHistory[scanCount].channel = WiFi.channel(i);
            scanHistory[scanCount].rssi = WiFi.RSSI(i);
            strncpy(scanHistory[scanCount].encryption,
                   getEncryptionType(WiFi.encryptionType(i)), 19);
            scanHistory[scanCount].timestamp = millis();

            // Update channel statistics
            int ch = WiFi.channel(i);
            if (ch >= 1 && ch <= 13) {
                channelCounts[ch]++;
                channelAvgRSSI[ch] += WiFi.RSSI(i);
            }

            // Log to SD card
            if (logFile) {
                logFile.printf("%lu,%s,%d,%d,%s\n",
                              millis(),
                              WiFi.SSID(i).c_str(),
                              WiFi.channel(i),
                              WiFi.RSSI(i),
                              getEncryptionType(WiFi.encryptionType(i)));
            }

            scanCount++;
        }

        if (logFile) {
            logFile.close();
        }

        // Calculate average RSSI per channel
        for (int ch = 1; ch <= 13; ch++) {
            if (channelCounts[ch] > 0) {
                channelAvgRSSI[ch] /= channelCounts[ch];
            }
        }

        // Print summary
        printChannelSummary();
    }
}

void printChannelSummary() {
    Serial.println("\nChannel Summary:");
    Serial.println("Ch | Networks | Avg RSSI | Bar Chart");
    Serial.println("---|----------|----------|------------------------------");

    for (int ch = 1; ch <= 13; ch++) {
        Serial.printf("%2d | %8d | %8.1f | ",
                     ch, channelCounts[ch], channelAvgRSSI[ch]);

        for (int i = 0; i < channelCounts[ch]; i++) {
            Serial.print("█");
        }
        Serial.println();
    }

    // Find best channel
    int bestChannel = 1;
    int minCount = 999;
    for (int ch : {1, 6, 11}) {
        if (channelCounts[ch] < minCount) {
            minCount = channelCounts[ch];
            bestChannel = ch;
        }
    }

    Serial.printf("\nRecommended channel: %d (%d networks)\n",
                 bestChannel, minCount);
}

void handleRoot() {
    String html = R"(
<!DOCTYPE html>
<html>
<head>
    <title>ESP32 Spectrum Monitor</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <style>
        body { font-family: Arial; margin: 20px; background: #f0f0f0; }
        .container { max-width: 1000px; margin: 0 auto; background: white; padding: 20px; border-radius: 10px; }
        h1 { color: #333; }
        button { background: #4CAF50; color: white; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; margin: 5px; }
        button:hover { background: #45a049; }
        table { width: 100%; border-collapse: collapse; margin-top: 20px; }
        th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
        th { background: #4CAF50; color: white; }
        .channel-viz { margin-top: 20px; }
        .bar { background: #2196F3; color: white; padding: 5px; margin: 2px 0; }
    </style>
    <script>
        function refreshData() {
            fetch('/data')
                .then(response => response.json())
                .then(data => updateDisplay(data));
        }

        function updateDisplay(data) {
            document.getElementById('scanCount').innerText = data.totalScans;
            document.getElementById('networkCount').innerText = data.currentNetworks;

            let table = '<table><tr><th>SSID</th><th>Channel</th><th>RSSI</th><th>Security</th></tr>';
            data.networks.forEach(net => {
                table += '<tr><td>' + net.ssid + '</td><td>' + net.channel + '</td><td>' + net.rssi + ' dBm</td><td>' + net.encryption + '</td></tr>';
            });
            table += '</table>';
            document.getElementById('networkTable').innerHTML = table;

            let viz = '';
            for (let ch = 1; ch <= 13; ch++) {
                let count = data.channelCounts[ch] || 0;
                let width = (count * 30) + 'px';
                viz += '<div class="bar" style="width: ' + width + '">Ch ' + ch + ': ' + count + ' networks</div>';
            }
            document.getElementById('channelViz').innerHTML = viz;
        }

        setInterval(refreshData, 5000);
        window.onload = refreshData;
    </script>
</head>
<body>
    <div class="container">
        <h1>ESP32 Spectrum Monitor</h1>
        <p>Total Scans: <strong id="scanCount">0</strong> |
           Current Networks: <strong id="networkCount">0</strong></p>

        <button onclick="fetch('/scan').then(() => setTimeout(refreshData, 2000))">Scan Now</button>
        <button onclick="refreshData()">Refresh</button>
        <button onclick="window.location='/download'">Download Log</button>

        <div class="channel-viz">
            <h2>Channel Distribution</h2>
            <div id="channelViz"></div>
        </div>

        <div id="networkTable"></div>
    </div>
</body>
</html>
    )";

    server.send(200, "text/html", html);
}

void handleScan() {
    performScan();
    server.send(200, "text/plain", "Scan initiated");
}

void handleData() {
    String json = "{";
    json += "\"totalScans\":" + String(totalScans) + ",";
    json += "\"currentNetworks\":" + String(scanCount) + ",";
    json += "\"networks\":[";

    for (int i = 0; i < scanCount; i++) {
        if (i > 0) json += ",";
        json += "{";
        json += "\"ssid\":\"" + String(scanHistory[i].ssid) + "\",";
        json += "\"channel\":" + String(scanHistory[i].channel) + ",";
        json += "\"rssi\":" + String(scanHistory[i].rssi) + ",";
        json += "\"encryption\":\"" + String(scanHistory[i].encryption) + "\"";
        json += "}";
    }
    json += "],";

    json += "\"channelCounts\":{";
    for (int ch = 1; ch <= 13; ch++) {
        if (ch > 1) json += ",";
        json += "\"" + String(ch) + "\":" + String(channelCounts[ch]);
    }
    json += "}}";

    server.send(200, "application/json", json);
}

void handleDownload() {
    File logFile = SD.open("/spectrum_log.csv");
    if (!logFile) {
        server.send(404, "text/plain", "Log file not found");
        return;
    }

    server.sendHeader("Content-Disposition", "attachment; filename=spectrum_log.csv");
    server.streamFile(logFile, "text/csv");
    logFile.close();
}

const char* getEncryptionType(wifi_auth_mode_t encryptionType) {
    switch (encryptionType) {
        case WIFI_AUTH_OPEN: return "Open";
        case WIFI_AUTH_WEP: return "WEP";
        case WIFI_AUTH_WPA_PSK: return "WPA-PSK";
        case WIFI_AUTH_WPA2_PSK: return "WPA2-PSK";
        case WIFI_AUTH_WPA_WPA2_PSK: return "WPA/WPA2";
        case WIFI_AUTH_WPA2_ENTERPRISE: return "WPA2-Enterprise";
#ifdef WIFI_AUTH_WPA3_PSK
        case WIFI_AUTH_WPA3_PSK: return "WPA3-PSK";
#endif
#ifdef WIFI_AUTH_WPA2_WPA3_PSK
        case WIFI_AUTH_WPA2_WPA3_PSK: return "WPA2/WPA3";
#endif
        default: return "Unknown";
    }
}

15.6.4 Lab Tasks

  1. Deploy the monitor in your environment and let it run for 1 hour
  2. Access the web interface at 192.168.4.1 from your phone or laptop
  3. Download the CSV log and analyze temporal patterns in Excel/Python
  4. Identify the busiest times for network activity
  5. Test channel switching: Change your Wi-Fi router to the recommended channel and measure performance improvement

15.7 Lab: Python RF Coverage Planner

This lab creates a tool to plan IoT network deployments with coverage analysis.

15.7.1 Installation

python3 -m pip install numpy matplotlib

15.7.2 Complete Coverage Planner Code

Save the following as rf_coverage_planner.py and run python3 rf_coverage_planner.py. The script calculates path loss using the log-distance model, generates a coverage heatmap with matplotlib, and identifies dead zones needing additional APs.

import math

import matplotlib.pyplot as plt
import numpy as np


def free_space_path_loss_db(distance_m: np.ndarray, frequency_hz: float) -> np.ndarray:
    distance_m = np.maximum(distance_m, 1e-3)
    wavelength_m = 3e8 / frequency_hz
    return 20 * np.log10(4 * math.pi * distance_m / wavelength_m)


def received_power_dbm(
    tx_power_dbm: float,
    distance_m: np.ndarray,
    frequency_hz: float,
    path_loss_exponent: float = 3.0,
    reference_distance_m: float = 1.0,
    extra_losses_db: float = 0.0,
) -> np.ndarray:
    pl_ref = free_space_path_loss_db(reference_distance_m, frequency_hz)
    pl = pl_ref + 10 * path_loss_exponent * np.log10(np.maximum(distance_m, reference_distance_m) / reference_distance_m)
    return tx_power_dbm - pl - extra_losses_db


def main() -> None:
    frequency_hz = 2.4e9
    width_m, height_m = 50, 30
    sensitivity_dbm = -85

    access_points = [
        {"name": "AP1", "x_m": 10, "y_m": 15, "tx_dbm": 20},
        {"name": "AP2", "x_m": 40, "y_m": 15, "tx_dbm": 20},
    ]

    grid_resolution_m = 0.5
    x = np.arange(0, width_m + grid_resolution_m, grid_resolution_m)
    y = np.arange(0, height_m + grid_resolution_m, grid_resolution_m)
    xx, yy = np.meshgrid(x, y)

    rssi_stack = []
    for ap in access_points:
        distance = np.sqrt((xx - ap["x_m"]) ** 2 + (yy - ap["y_m"]) ** 2)
        rssi = received_power_dbm(ap["tx_dbm"], distance, frequency_hz, path_loss_exponent=3.0, extra_losses_db=8.0)
        rssi_stack.append(rssi)

    best_rssi = np.max(np.stack(rssi_stack, axis=0), axis=0)

    coverage_mask = best_rssi >= sensitivity_dbm
    coverage_percent = 100 * float(np.mean(coverage_mask))
    mean_rssi = float(np.mean(best_rssi))
    min_rssi = float(np.min(best_rssi))
    dead_points = int(np.sum(~coverage_mask))

    def pct(mask: np.ndarray) -> float:
        return 100 * float(np.mean(mask))

    excellent = best_rssi >= -50
    good = (best_rssi < -50) & (best_rssi >= -65)
    fair = (best_rssi < -65) & (best_rssi >= -75)
    poor = (best_rssi < -75) & (best_rssi >= -85)
    dead = best_rssi < -85

    print("=" * 60)
    print("RF NETWORK COVERAGE PLANNER")
    print("=" * 60)
    print(f"\nArea: {width_m}m x {height_m}m")
    print(f"Access Points: {len(access_points)}")
    for ap in access_points:
        print(f"  {ap['name']}: ({ap['x_m']}m, {ap['y_m']}m), {ap['tx_dbm']} dBm")

    print("\nCalculating coverage map...")

    print("\n" + "=" * 60)
    print("COVERAGE ANALYSIS")
    print("=" * 60)
    print(f"Sensitivity threshold: {sensitivity_dbm:.0f} dBm")
    print(f"Overall coverage: {coverage_percent:.1f}%")
    print(f"Mean RSSI: {mean_rssi:.1f} dBm")
    print(f"Minimum RSSI: {min_rssi:.1f} dBm")
    print("\nSignal Quality Distribution:")
    print(f"  Excellent (≥-50 dBm): {pct(excellent):.1f}%")
    print(f"  Good (-50 to -65 dBm): {pct(good):.1f}%")
    print(f"  Fair (-65 to -75 dBm): {pct(fair):.1f}%")
    print(f"  Poor (-75 to -85 dBm): {pct(poor):.1f}%")
    print(f"  Dead Zone (<-85 dBm): {pct(dead):.1f}%")
    print(f"\nDead zone locations: {dead_points} points")

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5), constrained_layout=True)

    im = ax1.imshow(best_rssi, origin="lower", extent=[0, width_m, 0, height_m], cmap="viridis")
    ax1.set_title("Best RSSI (dBm)")
    ax1.set_xlabel("x (m)")
    ax1.set_ylabel("y (m)")
    for ap in access_points:
        ax1.scatter(ap["x_m"], ap["y_m"], c="red", s=60, marker="x")
        ax1.text(ap["x_m"] + 0.5, ap["y_m"] + 0.5, ap["name"], color="white", fontsize=9)
    fig.colorbar(im, ax=ax1, shrink=0.85)

    categories = np.zeros_like(best_rssi, dtype=int)
    categories[good] = 1
    categories[fair] = 2
    categories[poor] = 3
    categories[dead] = 4
    cmap = plt.cm.get_cmap("Set1", 5)
    ax2.imshow(categories, origin="lower", extent=[0, width_m, 0, height_m], cmap=cmap, vmin=0, vmax=4)
    ax2.set_title("Coverage Zones")
    ax2.set_xlabel("x (m)")
    ax2.set_ylabel("y (m)")

    fig.suptitle("RF Coverage Planner (Log-Distance Model)", fontsize=12)
    out_path = "coverage_map.png"
    print("\nGenerating visualization...")
    fig.savefig(out_path, dpi=150)
    print(f"\nVisualization saved as '{out_path}'")

    print("\nRecommendations:")
    if coverage_percent < 95:
        print("  - Coverage is below 95%. Consider adding more access points.")
    else:
        print("  - Coverage meets or exceeds 95%. Validate with a site survey.")

    if dead_points > 0:
        print("  - Focus on dead zone areas identified in the visualization.")


if __name__ == "__main__":
    main()

Link budget analysis determines whether an RF link is viable. For a Wi-Fi sensor at 50 m with 2 walls (5 dB each), we calculate received power at 2.4 GHz:

\[ \text{FSPL}(\text{dB}) = 20\log_{10}(d_{\text{km}}) + 20\log_{10}(f_{\text{MHz}}) + 32.45 \]

\[ \text{FSPL} = 20\log_{10}(0.05) + 20\log_{10}(2400) + 32.45 = -26.02 + 67.6 + 32.45 = 74.0 \text{ dB} \]

Worked example: TX power: +20 dBm, antenna gains: +2 dBi (TX) + +2 dBi (RX) = +4 dBi total. Path loss: 74.0 dB (free space) + 10 dB (walls) = 84 dB. Received power: \(20 + 4 - 84 = -60\) dBm. With RX sensitivity of -85 dBm, link margin is \(-60 - (-85) = +25\) dB (PASS – 25 dB margin is comfortable for indoor deployment with fade margin to spare).

15.7.3 Expected Output

============================================================
RF NETWORK COVERAGE PLANNER
============================================================

Area: 50m x 30m
Access Points: 2
  AP1: (10m, 15m), 20 dBm
  AP2: (40m, 15m), 20 dBm

Calculating coverage map...

============================================================
COVERAGE ANALYSIS
============================================================
Sensitivity threshold: -85 dBm
Overall coverage: 94.3%
Mean RSSI: -64.2 dBm
Minimum RSSI: -97.5 dBm

Signal Quality Distribution:
  Excellent (≥-50 dBm): 15.2%
  Good (-50 to -65 dBm): 42.8%
  Fair (-65 to -75 dBm): 28.1%
  Poor (-75 to -85 dBm): 8.2%
  Dead Zone (<-85 dBm): 5.7%

Dead zone locations: 342 points

Generating visualization...

Visualization saved as 'coverage_map.png'

Recommendations:
  - Coverage is below 95%. Consider adding more access points.
  - Focus on dead zone areas identified in the visualization.

The visualization shows:

  1. Left plot: Continuous RSSI heatmap with AP locations marked
  2. Right plot: Discrete coverage quality zones (color-coded from dead zone to excellent)

15.8 Knowledge Check: Coverage Planning

15.10 Concept Relationships

Concept Relationship to Other Concepts Practical Impact
Link Budget Sum of gains minus losses from TX to RX Determines max range before signal falls below sensitivity
Free-Space Path Loss Baseline loss increasing with distance and frequency 20 dB per decade of distance, 20 dB per decade of frequency
Fade Margin Safety buffer above receiver sensitivity 10-20 dB typical; accounts for multipath fading
RSSI Heatmap 2D visualization of signal strength across space Blue zones identify dead spots needing additional APs
Path Loss Exponent Environment-dependent propagation factor (n=2 to 6) n=3.5 suburban, n=4.5 dense urban; higher = faster attenuation
Fresnel Zone Ellipsoidal clearance region around line-of-sight path 60% clearance prevents 6-10 dB loss from obstruction

15.11 See Also

Common Pitfalls

Coverage planning often shows adequate RSSI (-65 dBm) everywhere but ignores co-channel interference. If two APs on the same channel both provide -65 dBm, the SINR is 0 dB — far below the minimum for reliable communication. Plan channel assignments alongside signal strength.

Radio signals propagate vertically between floors. A ground-floor AP can interfere with second-floor APs on the same channel. Use 3D propagation models for multi-story buildings to correctly account for vertical co-channel interference.

Coverage planning ensures signal reaches every point. Capacity planning ensures enough APs exist to handle simultaneous device connections. A warehouse with 200 IoT sensors all connected to one AP may have excellent signal but severe throughput degradation. Plan for both coverage and device density.

Office layouts change frequently. Moving metal shelving units, adding partition walls, or rearranging equipment can create new coverage holes or interference patterns not present in the original plan. Conduct post-deployment validation surveys after major facility changes.

15.12 Summary

This chapter provided comprehensive tools for RF coverage planning and analysis:

  • Wave property calculations enable you to quickly compare frequencies for wavelength, path loss, and range
  • Link budget analysis determines if a deployment is viable before you install hardware
  • Spectrum monitoring captures temporal patterns to identify time-varying interference
  • Coverage planning visualizes RSSI heatmaps and dead zones to guide AP placement
  • Python tools provide reusable code for deployment planning and validation

15.13 What’s Next

With coverage planning tools mastered, continue your wireless IoT journey:

Next Step Chapter Why It Helps
Cellular Modem Mobile Labs: Cellular Modem Integration Practice AT command bring-up for cellular IoT links
Wi-Fi Scanning Mobile Labs: Wi-Fi Spectrum Analysis Complement coverage planning with real-time ESP32 channel scans
Technology Review Mobile Wireless: Comprehensive Review Compare Wi-Fi, cellular, and LPWAN coverage trade-offs side by side
Wi-Fi Deep Dive Wi-Fi Fundamentals Understand 802.11 standards before optimizing AP placement
Simulations Simulations Hub Run interactive RF propagation simulators without hardware