Community project
ESP32-C3 Pi-hole Ad Blocker
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

Gather all the parts
| Qty | Component |
|---|---|
| 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 | 0.96 in, 128×64, I²C, SSD1306 0.96 inch 128x64 OLED display with I2C interface |
| 1 | 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"
| Function | usb_power_cable | ESP32 |
|---|---|---|
| power | USB-C end → ESP32-C3 SuperMini USB connector | EXT |
2. Connections between "oled_091" and "ESP32"
| Function | oled_091 | ESP32 |
|---|---|---|
| power | VCC | 3V3 |
| ground | GND | GND |
| i2c | SDA | GPIO 0 |
| i2c | SCL | GPIO 1 |
3. Connections between "wifi_reset_button" and "ESP32"
| Function | wifi_reset_button | ESP32 |
|---|---|---|
| digital | SIGNAL | GPIO 3 |
| ground | GND | GND |
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("&", "&");
text.replace("<", "<");
text.replace(">", ">");
text.replace("\"", """);
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 · " + String(counters.forwarded) + " forwarded · " + String(accepted) + " accepted · " + String(counters.cacheHits) + " cache hits<br>Total: " + String(counters.total) + " · 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.




