# Building a Suspicious Little Rogue AP Detector Bot

> ESP32 bot that spots rogue Wi-Fi

By Zsolt Bizderi · Published 2025-10-09
Canonical: https://ambientnode.uk/building-a-suspicious-little-rogue-ap-detector-bot

I recently posted about building an [evil twin credential harvester](/credential-harvesting-with-evil-twins/), so I thought it would be a good exercise to switch to the blue team's side this time (with the actual code published) and build a device that detects rogue access points.

Since I'm actually using this build in my office, I thought it would be fun to add a bit of personality by turning it into a tiny desktop bot with animated eyes. It's half security tool, half desk buddy.



![](/media/4b18cb8d-571d-4bb3-88ec-d36a01608660/vid.gif)



---

## Parts List & Wiring

* ESP-WROOM-32
* 3D printed case
* Double 0.71” round LCD display



<a href="/media/82946914-30cc-4058-b9b6-fac587f84038/case-1.stl" download>case-1.stl (55.3 KB)</a>



| Module Pin | Colour | ESP32 GPIO |
| --- | --- | --- |
| VCC (3V3) | Red | **3V3** pin |
| GND | Black | **GND** pin |
| SDA (MOSI) | Blue | **GPIO 23** |
| CLK (SCK) | Green | **GPIO 18** |
| DC | White | **GPIO 21** |
| CS1 | Orange | **GPIO 5** |
| RST1 | Brown | **GPIO 17** |
| CS2 | Yellow | **GPIO 16** |
| RST2 | Purple | **GPIO 4** |

## Note on Display

This build uses a knockoff [round LCD display](https://www.aliexpress.com/item/1005007461502607.html) rather than the official Waveshare one. That means the official Waveshare eye library and examples won't work here. Instead, I used the [Arduino\_GFX library](https://github.com/moononournation/Arduino_GFX) to drive the displays and render the custom eye animations.

---

## Modes of Operation

The bot has three main animation states:

* Normal mode – eyes blink and wander around naturally.
* Sleeping mode – the eyes imitate a calm wave pattern.
* Scanning mode – the eyes squint and dart suspiciously while actively scanning for access points.

To make it feel a little more alive, the bot randomly switches between normal and sleeping modes when idle.

---

## How It Works

1. **Baseline Scan**
   * On startup, the ESP32 performs a scan of all available Wi-Fi networks.
   * It records the BSSIDs (unique MAC addresses) of your allowed SSIDs (in my case: `TestSSID`).
   * This creates a fingerprint of legitimate access points for comparison.
2. **Rogue Detection**
   * Every 5 minutes, the device scans again.
   * If it finds an SSID that matches your approved list but is being broadcast by a new, unseen BSSID, it flags it as a potential evil twin AP.
   * When this happens, both eyes turn red for 5 minutes as a visual alert.
3. **Eye Animations**
   * All animations run on a dedicated FreeRTOS task, so scanning never causes the display to freeze.
   * The bot blinks randomly and squints when scanning.
   * This keeps it visually interesting even when no threats are detected.

---

## Code

The full Arduino sketch:

```
#include <Arduino_GFX_Library.h>
#include <WiFi.h>
#include <map>
#include <vector>
#include <algorithm>
#include <math.h>

// ---------- Pins ----------
#define PIN_MOSI 23
#define PIN_SCLK 18
#define PIN_DC   21
#define PIN_CS1  5
#define PIN_RST1 17
#define PIN_CS2  16
#define PIN_RST2 4

// ---------- Displays ----------
Arduino_DataBus *bus1 = new Arduino_ESP32SPI(PIN_DC, PIN_CS1, PIN_SCLK, PIN_MOSI, GFX_NOT_DEFINED, VSPI, true);
Arduino_DataBus *bus2 = new Arduino_ESP32SPI(PIN_DC, PIN_CS2, PIN_SCLK, PIN_MOSI, GFX_NOT_DEFINED, VSPI, true);
Arduino_GFX *eye1 = new Arduino_GC9D01(bus1, PIN_RST1, 0, false);
Arduino_GFX *eye2 = new Arduino_GC9D01(bus2, PIN_RST2, 0, false);
Arduino_Canvas *cv1 = new Arduino_Canvas(160, 160, eye1, 0, 0, false);
Arduino_Canvas *cv2 = new Arduino_Canvas(160, 160, eye2, 0, 0, false);

// ---------- Eye parameters ----------
static const int CX = 80, CY = 80;
static const int SCLERA_R = 70, PUPIL_R = 20, PUPIL_RANGE = 5, PUPIL_BLOCK = 6;

// ---------- Modes ----------
enum EyeMode { MODE_NEUTRAL, MODE_SCAN, MODE_SLEEP };
volatile EyeMode eyeMode = MODE_NEUTRAL;

// ---------- Blink ----------
unsigned long tBlink = 0;
bool closing = false, queuedDouble = false;
float blink = 0.0f;

// ---------- Motion ----------
unsigned long tMove = 0, tScan = 0;
int px = 0, py = 0;
float squint = 0.0f;

// ---------- Rogue AP scanning ----------
const char* ALLOWED_SSIDS[] = { "TestSSID" };
const size_t NUM_ALLOWED = sizeof(ALLOWED_SSIDS) / sizeof(ALLOWED_SSIDS[0]);

// Map SSID -> list of known BSSIDs (UPPERCASE "AA:BB:...").
std::map<String, std::vector<String>> knownBSSIDs;

unsigned long lastScan = 0;
const unsigned long SCAN_INTERVAL = 5UL * 60UL * 1000UL; // every 5 minutes
unsigned long alertUntil = 0; // eyes red until this millis()
bool baselineComplete = false;

// ---------- Idle switching ----------
unsigned long lastIdleSwitch = 0;
const unsigned long IDLE_SWITCH_INTERVAL = 10000;

// ---------- Helpers ----------
static void rotate90(int x, int y, int *xr, int *yr) { *xr = y; *yr = 159 - x; }
static void rotate270(int x, int y, int *xr, int *yr) { *xr = 159 - y; *yr = x; }

static void drawPixelPupil(Arduino_Canvas *c, int cx, int cy, bool rotateCW) {
  for (int y = -PUPIL_R; y <= PUPIL_R; y += PUPIL_BLOCK) {
    for (int x = -PUPIL_R; x <= PUPIL_R; x += PUPIL_BLOCK) {
      if (x * x + y * y <= PUPIL_R * PUPIL_R) {
        int xr, yr;
        if (rotateCW) rotate90(cx + x, cy + y, &xr, &yr);
        else rotate270(cx + x, cy + y, &xr, &yr);
        c->fillRect(xr, yr, PUPIL_BLOCK, PUPIL_BLOCK, BLACK);
      }
    }
  }
}

static void renderEye(Arduino_Canvas *c, int px, int py, float blinkFrac, float squintBase, bool rotateCW) {
  float lid = (squintBase > blinkFrac) ? squintBase : blinkFrac;
  c->fillScreen(BLACK);
  if (lid >= 1.0f) { c->flush(); return; }
  int shrink = (int)(SCLERA_R * lid);
  int visibleHeight = max(0, SCLERA_R - shrink);
  int xr, yr;
  if (rotateCW) {
    rotate90(CX, CY, &xr, &yr);
    c->fillEllipse(xr, yr, visibleHeight, SCLERA_R, WHITE);
    if (lid < 0.95f) drawPixelPupil(c, CX + px, CY + py, true);
  } else {
    rotate270(CX, CY, &xr, &yr);
    c->fillEllipse(xr, yr, visibleHeight, SCLERA_R, WHITE);
    if (lid < 0.95f) drawPixelPupil(c, CX + px, CY + py, false);
  }
  c->flush();
}

static void renderEyeSleep(Arduino_Canvas *c, bool rotateCW) {
  c->fillScreen(BLACK);
  unsigned long now = millis();
  float breath = 0.6f + 0.4f * sin(now / 1600.0f);
  for (int x = CX - 70; x <= CX + 70; x += 2) {
    float phase = (x + now / 32.0f) * 0.15f;
    int y = CY + (int)(sin(phase) * 6 * breath);
    int xr, yr;
    if (rotateCW) rotate90(x, y, &xr, &yr);
    else rotate270(x, y, &xr, &yr);
    c->drawPixel(xr, yr, WHITE);
  }
  c->flush();
}

static void renderAlert() {
  cv1->fillScreen(RED); cv1->flush();
  cv2->fillScreen(RED); cv2->flush();
}

// ---------- Small utils ----------
static bool isAllowedSSID(const String &ssid) {
  for (size_t i = 0; i < NUM_ALLOWED; ++i) {
    if (ssid == ALLOWED_SSIDS[i]) return true;
  }
  return false;
}

static String toUpperStr(String s) {
  s.toUpperCase();
  return s;
}

static bool vecContains(const std::vector<String> &v, const String &val) {
  return std::find(v.begin(), v.end(), val) != v.end();
}

// ---------- Wi-Fi scanning (rogue detector) ----------
static void baselineScan() {
  eyeMode = MODE_SCAN;
  tScan = millis();

  // Blocking scan, but animation task keeps running on other core
  int n = WiFi.scanNetworks(/*async=*/false, /*show_hidden=*/true);
  if (n <= 0) { baselineComplete = true; return; }

  for (int i = 0; i < n; ++i) {
    String ssid = WiFi.SSID(i);
    if (!isAllowedSSID(ssid)) continue;

    String bssid = toUpperStr(WiFi.BSSIDstr(i));
    auto &list = knownBSSIDs[ssid];
    if (!vecContains(list, bssid)) list.push_back(bssid);
  }
  baselineComplete = true;
  eyeMode = MODE_NEUTRAL;
}

static int checkForRogueAPs() {
  // Show scan animation while scanning
  EyeMode prev = eyeMode;
  eyeMode = MODE_SCAN;
  tScan = millis();

  int rogues = 0;
  int n = WiFi.scanNetworks(/*async=*/false, /*show_hidden=*/true);
  if (n > 0) {
    for (int i = 0; i < n; ++i) {
      String ssid = WiFi.SSID(i);
      if (!isAllowedSSID(ssid)) continue; // only care about your SSIDs

      String bssid = toUpperStr(WiFi.BSSIDstr(i));
      auto it = knownBSSIDs.find(ssid);

      if (it == knownBSSIDs.end()) {
        // If the SSID wasn't seen during baseline (e.g., powered off then on later),
        // treat first sighting as legit and add it.
        knownBSSIDs[ssid] = { bssid };
      } else {
        if (!vecContains(it->second, bssid)) {
          // A new BSSID is broadcasting an approved SSID -> likely rogue AP (evil twin)
          rogues++;
          it->second.push_back(bssid); // remember it so we don't alert repeatedly this session
        }
      }
    }
  }

  // Return to whatever mode we were in before
  eyeMode = prev;
  return rogues;
}

// ---------- Animation Task ----------
TaskHandle_t animTaskHandle;

void animTask(void *param) {
  for (;;) {
    unsigned long now = millis();

    // If we’re in alert window, override visuals to red, but keep timing going
    if (now < alertUntil) {
      renderAlert();
      vTaskDelay(33 / portTICK_PERIOD_MS);
      continue;
    }

    // Blink logic (unchanged)
    if (eyeMode != MODE_SLEEP) {
      if (blink == 0.0f && !closing && now - tBlink > (unsigned long)random(3000, 7000)) {
        closing = true; tBlink = now; queuedDouble = (random(5) == 0);
      }
      if (closing) {
        blink += 0.4f;
        if (blink >= 1.0f) { blink = 1.0f; closing = false; tBlink = now; }
      } else if (blink > 0.0f) {
        if (!(blink == 1.0f && now - tBlink < 40)) {
          blink -= 0.2f;
          if (blink < 0.0f) { blink = 0.0f; if (queuedDouble) { closing = true; queuedDouble = false; } }
        }
      }
    } else blink = 0.0f;

    // Render modes (unchanged)
    if (eyeMode == MODE_NEUTRAL) {
      squint = 0.0f;
      if (now - tMove > 800) {
        px = random(-PUPIL_RANGE, PUPIL_RANGE + 1);
        py = random(-PUPIL_RANGE, PUPIL_RANGE + 1);
        tMove = now;
      }
      renderEye(cv1, px, py, blink, squint, true);
      renderEye(cv2, px, py, blink, squint, false);
    } else if (eyeMode == MODE_SCAN) {
      squint = 0.85f;
      float phase = (now - tScan) / 2500.0f * 2.0f * PI;
      px = (int)(sin(phase) * (PUPIL_RANGE + 12)); py = 0;
      renderEye(cv1, px, py, blink, squint, true);
      renderEye(cv2, px, py, blink, squint, false);
    } else { // MODE_SLEEP
      renderEyeSleep(cv1, true); renderEyeSleep(cv2, false);
    }

    vTaskDelay(33 / portTICK_PERIOD_MS); // ~30 FPS
  }
}

// ---------- Setup ----------
void setup() {
  eye1->begin(); eye2->begin();
  cv1->begin();  cv2->begin();
  randomSeed(analogRead(0));

  // Start animation task FIRST so visuals never freeze
  xTaskCreatePinnedToCore(
    animTask, "AnimTask", 16384, NULL, 2, &animTaskHandle, 0
  );

  // Boot sleep animation (same visuals, timed via the anim task)
  eyeMode = MODE_SLEEP;
  vTaskDelay(3000 / portTICK_PERIOD_MS);

  // Wi-Fi in STA mode; not connecting to any network is fine for scanning
  WiFi.mode(WIFI_STA);
  WiFi.disconnect(); // ensure not connected; scanning works regardless

  // Baseline known BSSIDs for your approved SSIDs
  baselineScan();
  lastScan = millis();

  // Idle mode after boot
  eyeMode = MODE_NEUTRAL;
  lastIdleSwitch = millis();

  // Immediate rogue check at boot as requested
  int rogues = checkForRogueAPs();
  if (rogues > 0) {
    alertUntil = millis() + (5UL * 60UL * 1000UL); // 5 minutes
  }
}

// ---------- Loop ----------
void loop() {
  unsigned long now = millis();

  // Periodic rogue check
  if (now - lastScan >= SCAN_INTERVAL) {
    int rogues = checkForRogueAPs();
    if (rogues > 0) {
      alertUntil = millis() + (5UL * 60UL * 1000UL); // 5 minutes
    }
    lastScan = now;
  }

  // Idle mode switching (unchanged)
  if (eyeMode != MODE_SCAN && now - lastIdleSwitch > IDLE_SWITCH_INTERVAL) {
    eyeMode = (random(2) == 0) ? MODE_NEUTRAL : MODE_SLEEP;
    lastIdleSwitch = now;
  }

  vTaskDelay(50 / portTICK_PERIOD_MS); // be nice to the scheduler
}
```

---

## Final Thoughts

This project was a fun little crossover between red team concepts and blue team defenses. I may update this post when the next steps are implemented:

* Sending rogue AP alerts over MQTT to integrate with Home Assistant or a SIEM.
* Expanding detection logic for things like sudden signal strength changes or SSID spoof floods.

For now, I'm happy to have a little guardian watching over the network, even if it sometimes looks like it’s judging me.
