Community project

ESP32-C3 Pi-hole Ad Blocker

ESP32
Photo of ESP32-C3 Pi-hole Ad Blocker
Generated with AI

Dezcom Project

Published August 31, 2026

Build a Pi-hole-style DNS ad blocker powered by the ESP32-C3 microcontroller. This project intercepts DNS queries on the local network and blocks requests to known ad-serving domains, while displaying real-time statistics on a small OLED screen. A push button allows quick Wi-Fi reconfiguration without reflashing firmware.

The guide includes a complete wiring diagram, parts list, and step-by-step assembly instructions. Readers will learn how to set up the ESP32-C3 with Wi-Fi connectivity, integrate the SSD1306 display for status monitoring, and deploy the DNS blocking firmware. Once assembled and powered via USB, the device acts as a transparent DNS filter for any device on the network that points to it.

Wiring diagram

Wiring diagram for ESP32-C3 Pi-hole Ad Blocker

Gather all the parts

QtyComponent
1

Type-C & Micro 2-in-1 USB Cable

USB-C data cable

1m flat USB cable with a Type-C and Micro USB connector on one end, supporting data transfer. Flat design reduces tangling.

1

SSD1306 OLED

0.96 in, 128×64, I²C, SSD1306

0.96 inch 128x64 OLED display with I2C interface

1

Push Button

Momentary, normally open

Momentary push button switch

Assemble it in 6 steps

1. Place the 0.96-inch screen beside the board

Put the QIUWU 0.96-inch 128×64 OLED where its four labeled pins can reach the ESP32-C3 SuperMini with four short jumper wires. Read the labels printed beside the OLED pins; the physical order can differ between modules.

  • This screen is taller than the earlier 0.91-inch version, so its full 128 by 64 pixel area can show a larger, cleaner boot logo.
  • Make sure VCC and GND are not swapped — swapped power can damage the screen.

2. Connect power to the screen

Connect OLED VCC → ESP32-C3 3V3 (power). Connect OLED GND → ESP32-C3 GND (ground). These two wires give the display safe 3.3-volt power from the board.

  • Use red for VCC and black for GND so the two power wires are easy to check.
  • Do not connect the OLED VCC wire to the board’s 5V or VIN pin; this project uses the board’s 3V3 pin.

3. Connect the two screen signal wires

Connect OLED SDA → GPIO0 (data). Connect OLED SCL → GPIO1 (clock). These two wires carry the logo and status information to the screen.

  • Keep these two wires short and firmly seated. The ESP32-C3 can assign its I²C signal function to these chosen GPIO pins.
  • Do not move the signal wires to USB pins GPIO18 or GPIO19; those pins are used by the board’s USB connection.

4. Add the Wi-Fi reset button

Put the small push button across the center gap of a breadboard so its two sides are separate. Connect one button leg → GPIO3 (signal), and connect a leg on the opposite side → ESP32-C3 GND (ground). The button has no positive or negative side.

  • If your button has four legs, the two legs on each same side are already joined together; use one leg from each opposite side.
  • No separate resistor is needed because the board handles this button connection internally.
  • Do not connect this button to 3V3; it is meant to connect GPIO3 to GND only while pressed.

5. Power the board by USB

Plug the ESP32-C3 SuperMini into a reliable USB power source using its USB connector. The cable powers the board and lets Schematik load the firmware.

  • Use a known-good USB data cable when you are ready to deploy.
  • Do not feed 5 volts into the board’s 3V3 pin; use its USB connector for power.

6. Use Wi-Fi setup and test the DNS blocker

After deployment, if the board has no saved Wi-Fi it creates a network named C3-Adblock-Setup. Join that network with a phone or computer; its sign-in page should open automatically, or open any ordinary website to reach the Wi-Fi page. Enter your home Wi-Fi name and password. When the OLED shows an IP address, test one device by setting its DNS server to that address before changing your whole router.

  • To change to a different home Wi-Fi later, hold the button down for five seconds until the OLED says Wi-Fi erased. Release it and wait for C3-Adblock-Setup to appear again.
  • Test one phone or computer before changing DNS settings for your full home network.
  • A Wi-Fi or DNS setting mistake can interrupt internet access, so do not change every device at once.

Review all connections

1. Connections between "usb_power_cable" and "ESP32"

Functionusb_power_cableESP32
powerUSB-C endESP32-C3 SuperMini USB connectorEXT

2. Connections between "oled_091" and "ESP32"

Functionoled_091ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 0
i2cSCLGPIO 1

3. Connections between "wifi_reset_button" and "ESP32"

Functionwifi_reset_buttonESP32
digitalSIGNALGPIO 3
groundGNDGND

Deploy the firmware

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <WebServer.h>
#include <WiFiManager.h>
// Browser builds have no multicast DNS network; keep the real ESP32 mDNS
// implementation out of that compatibility build without changing device code.
#if defined(ESP32) && !defined(__EMSCRIPTEN__)
#include <ESPmDNS.h>
#endif
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

#include "app_config.h"
#include "blocklist.h"
#include "blocklist_store.h"
#include "dezcom_boot_logo.h"

#define OLED_SDA_PIN 0
#define OLED_SCL_PIN 1
#define RESET_BUTTON_PIN 3

struct CacheEntry {
  bool used = false;
  String domain;
  uint16_t queryType = 0;
  uint8_t reply[512];
  uint16_t replyLength = 0;
  uint32_t savedAt = 0;
};

struct ClientEntry {
  bool used = false;
  IPAddress ip;
  uint32_t lastSeenAt = 0;
};

struct Counters {
  uint32_t total = 0;
  uint32_t blocked = 0;
  uint32_t forwarded = 0;
  uint32_t cacheHits = 0;
  uint32_t upstreamFailures = 0;
};


// Forward declarations

// Forward declarations

// Forward declarations
void updateBlocklistFromDashboard();

bool sameIp(const IPAddress &left, const IPAddress &right);

String normalizeDomain(String domain);
String htmlEscape(String text);
bool domainMatches(const String &domain, const String &rule);
bool isBlocked(const String &domain);
void showText(const String &first, const String &second, const String &third);
bool bootLogoVisible();
void showBootLogo();
void rememberClient(const IPAddress &ip);
size_t clientCount();
void updateDisplay();
String readQuestionDomain(const uint8_t *packet, int length, int &questionEnd, uint16_t &queryType);
void replyToClient(const uint8_t *reply, int length, IPAddress ip, uint16_t port);
void sendBlockedReply(const uint8_t *query, int queryLength, int questionEnd, IPAddress clientIp, uint16_t clientPort);
void sendLocalHostReply(const uint8_t *query, int queryLength, int questionEnd, IPAddress clientIp, uint16_t clientPort);
bool serveCachedReply(const String &domain, uint16_t queryType, const uint8_t *query, IPAddress clientIp, uint16_t clientPort);
void cacheReply(const String &domain, uint16_t queryType, const uint8_t *reply, int length);
void handleDnsRequest();
String dezcomHeaderSvg();
void showDashboard();
void addBlockRule();
void removeBlockRule();
void clearWifiAndRestart();
void checkResetButton();
void startNetworkServices();
void startMdns();

WiFiUDP dnsSocket;
WebServer webServer(80);
WiFiManager wifiManager;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);

CacheEntry dnsCache[MAX_CACHE_ENTRIES];
ClientEntry recentClients[MAX_DISPLAYED_CLIENTS];
Counters counters;
uint32_t lastDnsActivityAt = 0;
bool remoteUpdateInProgress = false;
String lastBlockedDomain;

// This is a custom WiFiManager form element, not a head-only style hook.
// WiFiManager prints it immediately above the Wi-Fi fields in the captive portal.
const char PORTAL_BRAND_HTML[] =
  "<div id='dezcomPortalBrand' aria-label='Dezcom Beyond Connection'>"
  "<svg viewBox='0 0 732 267' xmlns='http://www.w3.org/2000/svg' role='img' aria-label='Dezcom Beyond Connection'>"
  "<text x='68' y='157' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='140' fill='#e5322d' textLength='326' lengthAdjust='spacingAndGlyphs'>Dezc</text>"
  "<g fill='none' stroke='#fff' stroke-width='7'><path d='M473.6 141A12.5 12.5 0 1 0 454.4 141'/><path d='M491.2 155.8A35.5 35.5 0 1 0 436.8 155.8'/><path d='M504.6 167.1A53 53 0 1 0 423.4 167.1'/></g>"
  "<text x='536' y='157' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='140' fill='#e5322d' textLength='109' lengthAdjust='spacingAndGlyphs'>m</text>"
  "<g stroke='#fff' stroke-width='4' fill='none'><line x1='464' y1='141' x2='389' y2='183'/><line x1='389' y1='183' x2='464' y2='214'/></g>"
  "<circle cx='464' cy='139' r='9' fill='#e5322d'/><circle cx='389' cy='183' r='9' fill='#e5322d'/><circle cx='464' cy='214' r='9' fill='#e5322d'/>"
  "<text x='225' y='189' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='20' fill='#fff' textLength='131' lengthAdjust='spacing'>BEYOND</text>"
  "<text x='445' y='189' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='20' fill='#fff' textLength='200' lengthAdjust='spacing'>CONNECTION</text></svg></div>";

uint32_t bootStartedAt = 0;
uint32_t resetPressedAt = 0;
bool resetHandled = false;
bool servicesRunning = false;
bool mdnsRunning = false;
uint32_t lastDisplayRefreshAt = 0;
int lastDisplayPage = -1;
size_t lastDisplayClientCount = SIZE_MAX;
uint32_t lastDisplayTotal = UINT32_MAX;
uint32_t lastDisplayBlocked = UINT32_MAX;
uint32_t lastDisplayForwarded = UINT32_MAX;
uint32_t lastDisplayCacheHits = UINT32_MAX;
uint32_t lastDisplayTimeouts = UINT32_MAX;
bool showStatisticsPage = false;

String normalizeDomain(String domain) {
  domain.toLowerCase();
  domain.trim();
  while (domain.endsWith(".")) domain = domain.substring(0, domain.length() - 1);
  return domain;
}

bool sameIp(const IPAddress &left, const IPAddress &right) {
  // String form is supported by both the ESP32 and the browser simulator.
  return left.toString() == right.toString();
}

String htmlEscape(String text) {
  text.replace("&", "&amp;");
  text.replace("<", "&lt;");
  text.replace(">", "&gt;");
  text.replace("\"", "&quot;");
  return text;
}

bool domainMatches(const String &domain, const String &rule) {
  return domain == rule || domain.endsWith("." + rule);
}

bool isBlocked(const String &domain) {
  for (size_t i = 0; i < BUILTIN_BLOCK_COUNT; ++i) {
    if (domainMatches(domain, BUILTIN_BLOCKLIST[i])) return true;
  }
  for (size_t i = 0; i < storedCustomDomainCount(); ++i) {
    if (domainMatches(domain, storedCustomDomainAt(i))) return true;
  }
  return remoteDomainBlocked(domain);
}

void showText(const String &first, const String &second, const String &third) {
  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(1);
  oled.setCursor(0, 8); oled.print(first);
  oled.setCursor(0, 27); oled.print(second);
  oled.setCursor(0, 46); oled.print(third);
  oled.display();
}

bool bootLogoVisible() {
  return millis() - bootStartedAt < BOOT_LOGO_MS;
}

void showBootLogo() {
  // The uploaded DEZCOM artwork has a white background.  On this one-bit
  // OLED, every non-white logo pixel becomes white and the background stays
  // black, preserving both the red wordmark and the dark connection symbol.
  oled.clearDisplay();
  for (int y = 0; y < DEZCOM_BOOT_LOGO_HEIGHT; ++y) {
    for (int x = 0; x < DEZCOM_BOOT_LOGO_WIDTH; ++x) {
      const uint16_t pixel = pgm_read_word(&DEZCOM_BOOT_LOGO_DATA[y * DEZCOM_BOOT_LOGO_WIDTH + x]);
      const uint8_t red = ((pixel >> 11) & 0x1F) << 3;
      const uint8_t green = ((pixel >> 5) & 0x3F) << 2;
      const uint8_t blue = (pixel & 0x1F) << 3;
      const bool isNearWhiteBackground = red > 232 && green > 232 && blue > 232;
      if (!isNearWhiteBackground) oled.drawPixel(x, y, SSD1306_WHITE);
    }
  }
  oled.display();
}

void rememberClient(const IPAddress &ip) {
  for (size_t i = 0; i < MAX_DISPLAYED_CLIENTS; ++i) {
    if (recentClients[i].used && sameIp(recentClients[i].ip, ip)) {
      recentClients[i].lastSeenAt = millis();
      // This client has moved to the top of the visible recent-device list.
      lastDisplayRefreshAt = 0;
      return;
    }
  }

  size_t replacement = 0;
  uint32_t oldestTime = UINT32_MAX;
  for (size_t i = 0; i < MAX_DISPLAYED_CLIENTS; ++i) {
    if (!recentClients[i].used) { replacement = i; break; }
    if (recentClients[i].lastSeenAt < oldestTime) {
      oldestTime = recentClients[i].lastSeenAt;
      replacement = i;
    }
  }
  recentClients[replacement].used = true;
  recentClients[replacement].ip = ip;
  recentClients[replacement].lastSeenAt = millis();
  lastDisplayRefreshAt = 0;
}

size_t clientCount() {
  size_t count = 0;
  for (const ClientEntry &client : recentClients) if (client.used) ++count;
  return count;
}

void updateDisplay() {
  if (bootLogoVisible()) return;

  // An SSD1306 display() call transfers the whole 1 KB screen buffer. Redraw
  // only when the selected page's visible values change.
  const size_t clients = clientCount();
  const int page = showStatisticsPage ? 1 : 0;
  const bool pageChanged = page != lastDisplayPage;
  const bool devicesChanged = clients != lastDisplayClientCount;
  const bool statisticsChanged = counters.total != lastDisplayTotal ||
    counters.blocked != lastDisplayBlocked || counters.forwarded != lastDisplayForwarded ||
    counters.cacheHits != lastDisplayCacheHits || counters.upstreamFailures != lastDisplayTimeouts;
  if (lastDisplayRefreshAt && !pageChanged &&
      (showStatisticsPage ? !statisticsChanged : !devicesChanged)) return;

  oled.clearDisplay();
  oled.setTextColor(SSD1306_WHITE);
  oled.setTextSize(1);

  if (showStatisticsPage) {
    const String title = "DNS ACTIVITY";
    oled.setCursor((128 - title.length() * 6) / 2, 1);
    oled.print(title);
    oled.drawFastHLine(8, 11, 112, SSD1306_WHITE);
    const String blocks = "Blocked: " + String(counters.blocked);
    const String forwarded = "Forwarded: " + String(counters.forwarded);
    const String accepted = "Accepted: " + String(counters.total - counters.blocked);
    const String cached = "Cache hits: " + String(counters.cacheHits);
    const String timeouts = "Timeouts: " + String(counters.upstreamFailures);
    oled.setCursor((128 - blocks.length() * 6) / 2, 15); oled.print(blocks);
    oled.setCursor((128 - forwarded.length() * 6) / 2, 25); oled.print(forwarded);
    oled.setCursor((128 - accepted.length() * 6) / 2, 35); oled.print(accepted);
    oled.setCursor((128 - cached.length() * 6) / 2, 45); oled.print(cached);
    oled.setCursor((128 - timeouts.length() * 6) / 2, 55); oled.print(timeouts);
  } else {
    // One centered column: the C3's own address is first, then five recent DNS clients.
    const String boardAddress = WiFi.status() == WL_CONNECTED
      ? WiFi.localIP().toString() : "Connecting to Wi-Fi";
    const int boardX = (128 - boardAddress.length() * 6) / 2;
    oled.setCursor(boardX, 1); oled.print(boardAddress);
    oled.setCursor(boardX + 1, 1); oled.print(boardAddress);
    oled.drawFastHLine(8, 11, 112, SSD1306_WHITE);

    size_t shown = 0;
    bool selected[MAX_DISPLAYED_CLIENTS] = {};
    while (shown < 5) {
      int newestIndex = -1;
      uint32_t newestTime = 0;
      for (size_t i = 0; i < MAX_DISPLAYED_CLIENTS; ++i) {
        if (recentClients[i].used && !selected[i] &&
            (newestIndex < 0 || recentClients[i].lastSeenAt > newestTime)) {
          newestIndex = int(i);
          newestTime = recentClients[i].lastSeenAt;
        }
      }
      if (newestIndex < 0) break;
      selected[newestIndex] = true;
      const String address = recentClients[newestIndex].ip.toString();
      oled.setCursor((128 - address.length() * 6) / 2, 15 + shown * 10);
      oled.print(address);
      ++shown;
    }
    if (!shown) {
      const String waiting = "Waiting for DNS use";
      oled.setCursor((128 - waiting.length() * 6) / 2, 28);
      oled.print(waiting);
    }
  }

  oled.display();
  lastDisplayRefreshAt = millis();
  lastDisplayClientCount = clients;
  lastDisplayTotal = counters.total;
  lastDisplayBlocked = counters.blocked;
  lastDisplayForwarded = counters.forwarded;
  lastDisplayCacheHits = counters.cacheHits;
  lastDisplayTimeouts = counters.upstreamFailures;
  lastDisplayPage = page;
}

String readQuestionDomain(const uint8_t *packet, int length,
                          int &questionEnd, uint16_t &queryType) {
  questionEnd = 0;
  queryType = 0;
  if (length < 17) return "";

  int position = 12;
  String domain;
  while (position < length) {
    const uint8_t labelLength = packet[position++];
    if (labelLength == 0) {
      if (position + 4 > length) return "";
      queryType = (uint16_t(packet[position]) << 8) | packet[position + 1];
      questionEnd = position + 4;
      return normalizeDomain(domain);
    }
    if ((labelLength & 0xC0) != 0 || position + labelLength > length) return "";
    if (domain.length()) domain += '.';
    for (uint8_t i = 0; i < labelLength; ++i) domain += char(tolower(packet[position++]));
  }
  return "";
}

void replyToClient(const uint8_t *reply, int length, IPAddress ip, uint16_t port) {
  dnsSocket.beginPacket(ip, port);
  dnsSocket.write(reply, length);
  dnsSocket.endPacket();
}

void sendBlockedReply(const uint8_t *query, int queryLength, int questionEnd,
                      IPAddress clientIp, uint16_t clientPort) {
  if (questionEnd < 17 || questionEnd > queryLength) return;
  uint8_t reply[512];
  memcpy(reply, query, questionEnd);
  reply[2] = 0x85; reply[3] = 0x80;  // standard recursive response
  reply[6] = 0; reply[7] = 1;        // one answer
  reply[8] = reply[9] = reply[10] = reply[11] = 0;
  const uint8_t answer[] = {0xC0, 0x0C, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 0, 0, 0, 0};
  memcpy(reply + questionEnd, answer, sizeof(answer));
  replyToClient(reply, questionEnd + sizeof(answer), clientIp, clientPort);
}

void sendLocalHostReply(const uint8_t *query, int queryLength, int questionEnd,
                        IPAddress clientIp, uint16_t clientPort) {
  if (questionEnd < 17 || questionEnd > queryLength) return;
  const String localIpText = WiFi.localIP().toString();
  uint8_t octets[4] = {0, 0, 0, 0};
  int start = 0;
  for (int i = 0; i < 4; ++i) {
    const int dot = localIpText.indexOf('.', start);
    const String part = dot < 0 ? localIpText.substring(start) : localIpText.substring(start, dot);
    octets[i] = uint8_t(part.toInt());
    start = dot < 0 ? localIpText.length() : dot + 1;
  }
  uint8_t reply[512];
  memcpy(reply, query, questionEnd);
  reply[2] = 0x85; reply[3] = 0x80;
  reply[6] = 0; reply[7] = 1;
  reply[8] = reply[9] = reply[10] = reply[11] = 0;
  const uint8_t answerPrefix[] = {0xC0, 0x0C, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4};
  memcpy(reply + questionEnd, answerPrefix, sizeof(answerPrefix));
  memcpy(reply + questionEnd + sizeof(answerPrefix), octets, sizeof(octets));
  replyToClient(reply, questionEnd + sizeof(answerPrefix) + sizeof(octets), clientIp, clientPort);
}

bool serveCachedReply(const String &domain, uint16_t queryType,
                      const uint8_t *query, IPAddress clientIp, uint16_t clientPort) {
  for (CacheEntry &entry : dnsCache) {
    if (!entry.used || entry.domain != domain || entry.queryType != queryType ||
        millis() - entry.savedAt > CACHE_TTL_MS) continue;
    uint8_t reply[512];
    memcpy(reply, entry.reply, entry.replyLength);
    reply[0] = query[0]; reply[1] = query[1];
    replyToClient(reply, entry.replyLength, clientIp, clientPort);
    ++counters.cacheHits;
    return true;
  }
  return false;
}

void cacheReply(const String &domain, uint16_t queryType,
                const uint8_t *reply, int length) {
  if (length < 2 || length > 512) return;
  CacheEntry *slot = &dnsCache[0];
  for (CacheEntry &entry : dnsCache) {
    if (!entry.used) { slot = &entry; break; }
    if (entry.savedAt < slot->savedAt) slot = &entry;
  }
  slot->used = true;
  slot->domain = domain;
  slot->queryType = queryType;
  slot->replyLength = length;
  slot->savedAt = millis();
  memcpy(slot->reply, reply, length);
}

void handleDnsRequest() {
  const int packetSize = dnsSocket.parsePacket();
  if (!packetSize) return;
  if (packetSize > 512) {
    while (dnsSocket.available()) dnsSocket.read();
    return;
  }

  uint8_t query[512];
  const int queryLength = dnsSocket.read(query, packetSize);
  const IPAddress clientIp = dnsSocket.remoteIP();
  const uint16_t clientPort = dnsSocket.remotePort();
  rememberClient(clientIp);
  lastDnsActivityAt = millis();
  ++counters.total;

  int questionEnd;
  uint16_t queryType;
  const String domain = readQuestionDomain(query, queryLength, questionEnd, queryType);
  if (!domain.length()) return;
  // Standard mDNS uses one host label before .local.  esp-pihole.local is
  // advertised through multicast DNS; keep the earlier dotted spelling as a
  // DNS-server alias for clients that use this C3 as their DNS server.
  if (queryType == 1 && (domain == "esp-pihole.local" || domain == "esp.pihole.local")) {
    sendLocalHostReply(query, queryLength, questionEnd, clientIp, clientPort);
    ++counters.forwarded;
    return;
  }
  if (isBlocked(domain)) {
    ++counters.blocked;
    lastBlockedDomain = domain;
    sendBlockedReply(query, queryLength, questionEnd, clientIp, clientPort);
    return;
  }
  if (serveCachedReply(domain, queryType, query, clientIp, clientPort)) return;

  dnsSocket.beginPacket(UPSTREAM_DNS, DNS_PORT);
  dnsSocket.write(query, queryLength);
  dnsSocket.endPacket();
  const uint32_t startedAt = millis();
  while (millis() - startedAt < UPSTREAM_TIMEOUT_MS) {
    const int replySize = dnsSocket.parsePacket();
    if (replySize <= 0) { delay(1); continue; }
    uint8_t reply[512];
    const int replyLength = dnsSocket.read(reply, min(replySize, 512));
    if (sameIp(dnsSocket.remoteIP(), UPSTREAM_DNS) && replyLength >= 2 &&
        reply[0] == query[0] && reply[1] == query[1]) {
      replyToClient(reply, replyLength, clientIp, clientPort);
      cacheReply(domain, queryType, reply, replyLength);
      ++counters.forwarded;
      return;
    }
  }
  ++counters.upstreamFailures;
}

String dezcomHeaderSvg() {
  return String(PORTAL_BRAND_HTML);
}

void showDashboard() {
  String rules;
  for (size_t i = 0; i < storedCustomDomainCount(); ++i) {
    const String escapedRule = htmlEscape(storedCustomDomainAt(i));
    rules += "<li>";
    rules += escapedRule;
    rules += " <form action='/unblock' method='post' style='display:inline'><input type='hidden' name='index' value='";
    rules += String(i);
    rules += "'><button type='submit' aria-label='Remove ";
    rules += escapedRule;
    rules += "'>Remove</button></form></li>";
  }
  if (!rules.length()) rules = "<li>No local additions yet.</li>";
  const uint32_t accepted = counters.total - counters.blocked;

  const String page = "<!doctype html><html><head><meta name='viewport' content='width=device-width,initial-scale=1'>"
    "<title>Dezcom DNS</title><style>body{font-family:Arial,Helvetica,sans-serif;max-width:44rem;margin:0 auto;padding:0 1rem 2rem;background:#000;color:#f7f7f7}.dezcom-header{padding:1.2rem 0 .5rem;text-align:center}.dezcom-header svg{width:min(360px,94vw);height:auto}.card{padding:1rem;margin:1rem 0;background:#121212;border:1px solid #333;border-radius:.6rem}.n{font-size:2rem;color:#e5322d;margin:.1rem 0}input,button{font-size:1rem;padding:.55rem;border-radius:.35rem}input{background:#050505;color:#fff;border:1px solid #777}button{background:#e5322d;color:#fff;border:0;margin-left:.35rem}b{color:#fff}li{margin:.35rem 0}</style></head><body>"
    + dezcomHeaderSvg() + "<div class='card'><b>DNS SINKHOLE RUNNING</b><br>Dashboard: <b>http://esp-pihole.local</b><br>Board IP: <b>" + WiFi.localIP().toString() + "</b><br>Upstream DNS: 1.1.1.1</div>"
    "<div class='card'><p class='n'>" + String(counters.blocked) + "</p>blocked &middot; " + String(counters.forwarded) + " forwarded &middot; " + String(accepted) + " accepted &middot; " + String(counters.cacheHits) + " cache hits<br>Total: " + String(counters.total) + " &middot; upstream timeouts: " + String(counters.upstreamFailures) + "</div>"
    "<div class='card'>Last blocked: <b>" + htmlEscape(lastBlockedDomain.length() ? lastBlockedDomain : "none yet") + "</b></div>"
    "<div class='card'><b>Automatic public list</b><br>Source: StevenBlack unified HOSTS list<br>Saved remote rules: <b>" + String(remoteDomainCount()) + "</b><br>Status: <b>" + htmlEscape(remoteStatus()) + "</b><br><form action='/update-blocklist' method='post' style='margin-top:.7rem'><button type='submit'>Update now</button></form><small>The C3 also checks weekly after DNS has been quiet for five seconds. The list is capped at 600 rules so this small C3 stays responsive.</small></div>"
    "<div class='card'><form action='/block' method='post'><b>Block an extra domain</b><br><input name='domain' placeholder='example.com' required><button>Add</button></form><ul>" + rules + "</ul></div>"
    "<div class='card'>Tap the GPIO3 button to change the OLED page. Hold it for five seconds to erase saved Wi-Fi and reopen <b>C3-Adblock-Setup</b>.</div></body></html>";
  webServer.send(200, "text/html", page);
}

void addBlockRule() {
  const String domain = normalizeDomain(webServer.arg("domain"));
  const bool valid = domain.length() && domain.indexOf(' ') < 0 && domain.indexOf('.') > 0;
  if (valid && !isBlocked(domain)) addStoredCustomDomain(domain);
  webServer.sendHeader("Location", "/");
  webServer.send(303);
}

void removeBlockRule() {
  const int index = webServer.arg("index").toInt();
  if (index >= 0) removeStoredCustomDomain(size_t(index));
  webServer.sendHeader("Location", "/");
  webServer.send(303);
}

void updateBlocklistFromDashboard() {
  String result;
  remoteUpdateInProgress = true;
  const bool updated = updateRemoteBlocklist(result);
  remoteUpdateInProgress = false;
  webServer.send(200, "text/html", String("<!doctype html><meta name='viewport' content='width=device-width,initial-scale=1'><body style='font-family:Arial;background:#000;color:#fff;padding:1rem'><h2>") + (updated ? "Blocklist updated" : "Blocklist update did not finish") + "</h2><p>" + htmlEscape(result) + "</p><p><a style='color:#e5322d' href='/'>Return to dashboard</a></p></body>");
}

void clearWifiAndRestart() {
  showText("Wi-Fi erased", "Setup network starts", "Restarting...");
  delay(700);
  wifiManager.resetSettings();
  WiFi.disconnect(true);
  delay(300);
  ESP.restart();
}

void checkResetButton() {
  if (digitalRead(RESET_BUTTON_PIN) == LOW) {
    if (!resetPressedAt) resetPressedAt = millis();
    if (!resetHandled && millis() - resetPressedAt >= RESET_HOLD_MS) {
      resetHandled = true;
      clearWifiAndRestart();
    }
  } else {
    // Releasing before the five-second erase time is an ordinary page-change press.
    if (resetPressedAt && !resetHandled) {
      showStatisticsPage = !showStatisticsPage;
      lastDisplayRefreshAt = 0;
    }
    resetPressedAt = 0;
    resetHandled = false;
  }
}

void startMdns() {
#if defined(ESP32) && !defined(__EMSCRIPTEN__)
  if (mdnsRunning || WiFi.status() != WL_CONNECTED) return;
  // ESPmDNS adds the final .local suffix. A hostname is one label, so this
  // advertises the Windows-compatible address esp-pihole.local.
  if (MDNS.begin("esp-pihole")) {
    MDNS.addService("http", "tcp", 80);
    mdnsRunning = true;
  }
#else
  // The browser simulator has no multicast-DNS transport.
  mdnsRunning = true;
#endif
}

void startNetworkServices() {
  if (servicesRunning || WiFi.status() != WL_CONNECTED) return;
  startMdns();
  dnsSocket.begin(DNS_PORT);
  webServer.on("/", HTTP_GET, showDashboard);
  webServer.on("/block", HTTP_POST, addBlockRule);
  webServer.on("/unblock", HTTP_POST, removeBlockRule);
  webServer.on("/update-blocklist", HTTP_POST, updateBlocklistFromDashboard);
  webServer.on("/health", HTTP_GET, []() { webServer.send(200, "text/plain", "ok\n"); });
  webServer.begin();
  servicesRunning = true;
}

void setup() {
  Serial.begin(115200);
  pinMode(RESET_BUTTON_PIN, INPUT_PULLUP);
  Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
  oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS);
  loadStoredBlocklists();
  bootStartedAt = millis();
  showBootLogo();

#if defined(ESP32)
  // The real ESP32 WiFiManager keeps the setup portal active while the logo is shown.
  wifiManager.setConfigPortalBlocking(false);
#endif
  wifiManager.setConfigPortalTimeout(180);
  // Keep WiFiManager's DNS catch-all and HTTP redirects enabled: phones and
  // Windows should open the setup page after joining C3-Adblock-Setup, and
  // manually entered web addresses should also return to that same portal.
  // The browser simulator does not implement this WiFiManager API, but the
  // deployed ESP32-C3 always executes it.
#if defined(ESP32) && !defined(__EMSCRIPTEN__)
  wifiManager.setCaptivePortalEnable(true);
#endif
#if defined(ESP32)
  // WiFiManager parameters are only rendered in its optional configuration
  // form. Insert the brand into the document body instead, so it appears at
  // the top of every captive-portal route, including the Wi-Fi network page.
  wifiManager.setCustomHeadElement("<style>html,body,.wrap{background:#000!important;color:#fff!important}#dezcomPortalBrand{display:block!important;width:100%!important;max-width:360px!important;margin:0 auto 18px!important;padding:12px 8px 4px!important;box-sizing:border-box!important}#dezcomPortalBrand svg{display:block!important;width:100%!important;height:auto!important}input{background:#111!important;color:#fff!important;border-color:#777!important}button{background:#e5322d!important;color:#fff!important}</style><script>document.addEventListener('DOMContentLoaded',function(){if(document.getElementById('dezcomPortalBrand'))return;var b=document.createElement('div');b.id='dezcomPortalBrand';b.setAttribute('aria-label','Dezcom Beyond Connection');b.innerHTML=\"<svg viewBox='0 0 732 267' xmlns='http://www.w3.org/2000/svg' role='img' aria-label='Dezcom Beyond Connection'><text x='68' y='157' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='140' fill='#e5322d' textLength='326' lengthAdjust='spacingAndGlyphs'>Dezc</text><g fill='none' stroke='#fff' stroke-width='7'><path d='M473.6 141A12.5 12.5 0 1 0 454.4 141'/><path d='M491.2 155.8A35.5 35.5 0 1 0 436.8 155.8'/><path d='M504.6 167.1A53 53 0 1 0 423.4 167.1'/></g><text x='536' y='157' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='140' fill='#e5322d' textLength='109' lengthAdjust='spacingAndGlyphs'>m</text><g stroke='#fff' stroke-width='4' fill='none'><line x1='464' y1='141' x2='389' y2='183'/><line x1='389' y1='183' x2='464' y2='214'/></g><circle cx='464' cy='139' r='9' fill='#e5322d'/><circle cx='389' cy='183' r='9' fill='#e5322d'/><circle cx='464' cy='214' r='9' fill='#e5322d'/><text x='225' y='189' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='20' fill='#fff' textLength='131' lengthAdjust='spacing'>BEYOND</text><text x='445' y='189' font-family='Arial,Helvetica,sans-serif' font-weight='700' font-size='20' fill='#fff' textLength='200' lengthAdjust='spacing'>CONNECTION</text></svg>\";document.body.insertBefore(b,document.body.firstChild);});</script>");
#endif
  wifiManager.setAPCallback([](WiFiManager *) {
    if (!bootLogoVisible()) showText("Wi-Fi setup", "Join C3-Adblock-Setup", "then enter Wi-Fi");
  });
  wifiManager.autoConnect(CONFIG_PORTAL_SSID);
}

void loop() {
  checkResetButton();
#if defined(ESP32)
  // WiFiManager's non-blocking portal work is required on the deployed ESP32.
  wifiManager.process();
#endif
  startNetworkServices();
  startMdns();
  if (servicesRunning) {
    webServer.handleClient();
    handleDnsRequest();
  }
  // A scheduled HTTPS download only starts while DNS has been quiet, avoiding
  // an update interrupting an active client lookup.
  if (!remoteUpdateInProgress && WiFi.status() == WL_CONNECTED &&
      millis() - remoteLastUpdateUptime() >= BLOCKLIST_UPDATE_INTERVAL_MS &&
      millis() - lastDnsActivityAt >= BLOCKLIST_UPDATE_IDLE_MS) {
    String ignored;
    remoteUpdateInProgress = true;
    updateRemoteBlocklist(ignored);
    remoteUpdateInProgress = false;
  }
  updateDisplay();
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik