Building a Suspicious Little Rogue AP Detector Bot
ESP32 bot that spots rogue Wi-Fi
I recently posted about building an evil twin credential harvester, 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.

Parts List & Wiring
- ESP-WROOM-32
- 3D printed case
- Double 0.71” round LCD display
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 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 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
- 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.
- 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.
- 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.