ESP32 全系列 × 3D 列印實作系列
🛡️ ESP32-C61 斷網守護者 — 完整實作指南
📅 2026-10-27 · 🏷️ 數位科技 · 📂 ESP32 全系列 × 3D 列印系列 · 29/52 · ⏱️ 實作約 1 小時(不含列印)
📋 專案概述
在家工作的第三天,視頻會議卡在对方最后一句「所以這個案子……」上,畫面定格成遺照。晚上十點網路恢復,隔天中午又斷。打電話給 HiNet,客服的標準三連問是:「重啟過了嗎?」「有圖示嗎?」「現在好呢?」——然後電話那頭傳來鍵盤聲:「這邊看線路是正常的喔」。「正常」兩個字沒有一張時間戳支撐,客服端就沒有任何立案根據。更糟的是 H660 這種機型,韌體更新後連橋接模式都要打客服開通,你想自證清白都得排隊。
市售方案呢?企業級 AP 監控盒(NT$8,000 起,Syslog、SNMP、報告模組應有盡有,客戶是 IDC);ISP 自家 App 只顯示「線路正常」——它當然正常,不正常它也不會承認;路由器內建日誌呢?路由器斷網時自己不會知道,它的日誌是「我連上線了」的记录簿,不是「我掉線了多久」的驗傷單。
這個專案做一個獨立於你全部網路設備之外的第三者:一颗 90 元的 ESP32-C61 加一張有線網卡,直接插進數據機 LAN 埠,每 5 秒沿路徑逐段叩門——數據機活著嗎?DNS 解得開嗎?對 1.1.1.1 的 TCP 握手要幾毫秒?斷線當下:蜂鳴器叫、燈條轉紅、Telegram 推播(含時間戳與故障段判定),恢復時記錄總時長。積滿一週的 CSV 貼進客服對話框,「10/24 22:31–22:39 中斷、本週第三次」就是工單。全部材料 NT$450:
- 有線探測、獨立供電:走 W5500 有線插數據機,不跟你抢 Wi-Fi——被監控者與監控者不同台,這是監控第一鐵律
- 三段定位:WAN 斷线/DNS 掛點/路由延遲劣化,三種燈色三種推播文案,叫修前就知道該罵誰
- 9 秒粒度時間軸:OLED 跑馬燈顯示最近事件,斷線瞬間自動存檔,不會「我記得好像是晚上」
- Telegram 推播:斷線 30 秒內收到推播;恢复再一則,附中斷時長
- CSV 舉證輸出:長按按鈕序列埠輸出全部事件,直接貼 Excel/GPT 畫甘特圖
- 5V/0.15W 靜音運行:掛數據機旁邊一年電費 2 元,一年省下一張誤判叫修費
🔬 為什麼選 ESP32-C61(選型說明)
守護者的需求清單:Wi-Fi(推播備援)、夠用就好、越便宜越好(它要做成家家户户裝得起的「網路黑盒子」)、低功耗 7×24。C61 是 Espressif 2024 年底推出的入門 Wi-Fi 4+BLE 5+802.15.4 三無線晶片,目標就是搶佔 IoT 入門價位:
| 型號 | 無線 | 模組價 | 適合度 |
| ESP32-C61(本篇) | Wi-Fi 4/BLE 5/802.15.4 | ~NT$89 | ⭐⭐⭐⭐⭐ 全家族最便宜、功能够用 |
| ESP32-C6 | Wi-Fi 6/BLE/Zigbee | ~NT$120 | ⭐⭐⭐⭐ 同定位、稍貴,本機相容 |
| ESP32-C2 | Wi-Fi 4 | ~NT$65 | ⭐⭐⭐ 更便宜但 RAM 更瘦、生態更新慢 |
| 經典 ESP32(雙核) | Wi-Fi 4/BT | ~NT$110 | ⭐⭐⭐ 能用;待機功耗高一截 |
選 C61 的另一個理由是測試哲學:探測走 W5500 有線、Wi-Fi 只當推播備援,所以 Wi-Fi 4 完全夠——把省下的 30 元預算拿去買一張可靠的 W5500,比給監控器上 Wi-Fi 6 有意義得多。Arduino 支援:arduino-esp32 core ≥ 3.1(C61 板選單裡叫「ESP32C3 Dev Module」時代已過去,現在直接有 ESP32C5/C61 選項;C61 於 3.1.x 加入)。
⚠️ 接線前必讀:C61 的 strapping 敏感腳是 GPIO2/8/9/12,原生 USB 是 GPIO13/14,UART0 是 GPIO11/12,板載 RGB 燈在 GPIO27。本專案全部避開,SPI/控制腳選 GPIO6/7/8 之外最安全的 15/16/22/23/24 與 6/7/10 群(詳見下表,以你的 DevKitC 絲印為準)。
🛒 硬體清單
| 零件 | 規格 | 數量 | 單價 | 備註 |
| 主控 | ESP32-C61-DevKitC-1-N4 | 1 | 180 | N4 已夠(事件日誌才幾 KB) |
| 有線網卡 | W5500 SPI 模組(带变压器) | 1 | 120 | 100M;必須帶網路变压器那版 |
| 螢幕 | 0.96" SSD1306 OLED I2C | 1 | 95 | 0x3C;掛牆看狀態 |
| 燈條 | WS2812B 8 燈條 | 1 | 65 | 綠=正常/黃=延遲/紅閃=斷線 |
| 蜂鳴器 | 主動式 5mm+S8050+10kΩ | 1 | 10 | 斷線嗶三聲、恢复嗶一聲 |
| 按鈕 | 6×6 微動 | 1 | 3 | 短按=翻日誌,長按=輸出 CSV |
| 供電 | USB-C 線+5V 1A 電源(或數據機 USB 埠) | 1 | 60 | 從數據機 USB 取電=數據機死它就死,反而正確 |
| 網路線 | 0.3m 短帽 Cat5e | 1 | 35 | 數據機 LAN 4 → 守護者 |
| 總計 | ≈NT$570(含列印約 650;企業監控盒的 1/13) |
🔌 接線圖(ESP32-C61 腳位)
ESP32-C61 DevKitC-1 W5500(SPI 模式)
────────────────── ────────────────────────
GPIO6 SCK ───────────── SCK
GPIO7 CS ───────────── SCS(兼拉高選擇 SPI 模式)
GPIO22 MOSI ───────────── SI
GPIO23 MISO ───────────── SO
GPIO24 RST ───────────── RST(內部上拉,脈衝重啟)
GPIO15 INT ───────────── INT(中斷:有封包才醒)
GPIO4 SDA ──┐
GPIO5 SCL ──┼────────── OLED SSD1306(0x3C)
GPIO16 ──10kΩ─ S8050 ─── 蜂鳴器
GPIO10 ──330Ω────────── WS2812 燈條 DIN
GPIO0 ──────────────── 按鈕 → GND
5V/GND ──── W5500 與燈條吃 5V;OLED/蜂鳴器吃 3V3
⚠️ 供電眉角:本機刻意從數據機 USB 埠取電——數據機整個掛掉(跳電、變壓器燒)時守護者一同陣亡,恢復供電=數據機回魂,「停電式斷網」與「ISP 斷網」自動分開記帳。想區分兩者就改用行動電源供電,程式裡把「探測失敗但 NTP 仍同步中」的邏輯再拆細即可。
🖨️ 3D 列印設計
| 零件 | 材質 | 數量 | 列印設定 | 備註 |
| 機殼本體 | PETG | 1 | 0.2mm/2 壁/20% | 正面 OLED 窗+燈條光帶,側面 USB-C/RJ45 穿孔 |
| 數據機背夾 | PETG | 2 | 0.2mm/3 壁/豎印 | 夾 H660 散熱格柵,機殼雙面膠上夾——掛在斷線兇手旁邊鎮場 |
| 按鈕帽 | PLA | 1 | 0.2mm/3 壁 | 免工具拆殼,換機再說 |
| 桌立架 | PETG | 1 | 0.24mm/3 壁 | 15° 仰角,燈條朝上余光可感知 |
🧵 列印備註:RJ45 孔周邊留 0.2mm 間隙(插拔順暢即可);燈條光帶用透明 PETG「牆 1 層」列印設定最漂亮;背夾卡勾厚度 1.6mm+箭頭形,格柵夾得緊又拆得下;全機列印時間約 2 小時。
💻 Arduino 程式碼
Board Manager 選 ESP32C61 Dev Module(core ≥3.1)。庫依賴:Ethernet2(W5500 SPI 驅動)、Adafruit SSD1306、Adafruit NeoPixel。三段探測各自獨立計分,連續 2 輪同態才翻牌(防單次抖動誤報):
/* ============================================================
* Net-Guardian.ino — ESP32-C61 斷網守護者
* ------------------------------------------------------------
* 功能 : 每 5 秒三段探測(WAN HTTP/DNS 解析/TCP 握手延遲),
* 斷線定位(WAN 斷・DNS 死・延遲劣化),蜂鳴+燈條+
* OLED 時間軸+Telegram 推播,事件環形緩衝 CSV 輸出
* 硬體 : ESP32-C61-DevKitC-1・W5500(SPI)・SSD1306・WS2812×8・
* 蜂鳴器(S8050)・微動×1・5V 供電(數據機 USB)
* 相依 : arduino-esp32 >= 3.1、Ethernet2、Adafruit SSD1306/NeoPixel
* 備註 : 探測走有線(被監控者不在迴路裡);Wi-Fi 僅供 TG 推播
* ============================================================ */
#include <SPI.h>
#include <Ethernet2.h>
#include <WiFi.h>
#include <Wire.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_NeoPixel.h>
#include <WiFiClientSecure.h>
#include <time.h>
/* --- 1. 腳位與參數 ------------------------------------------- */
#define PIN_SPI_SCK 6
#define PIN_SPI_CS 7
#define PIN_SPI_MOSI 22
#define PIN_SPI_MISO 23
#define PIN_W5500_RST 24
#define PIN_W5500_INT 15
#define PIN_BTN 0
#define PIN_BUZZ 16
#define PIN_BAR 10
#define BAR_N 8
#define PROBE_MS 5000 // 探測週期
#define FAIL_STREAK 2 // 連續 N 輪同態才翻牌(防誤報)
#define LOG_CAP 400 // 事件環形緩衝(5 秒輪替 ≈ 半小时全記錄)
#define DEGRADE_MS 350 // TCP 握手逾此值=延遲劣化
const char* WIFI_SSID = "YourHomeWiFi";
const char* WIFI_PASS = "your_password";
const char* TG_TOKEN = ""; // 空=不推播
const char* TG_CHAT = "";
byte mac[] = { 0xDE, 0xAD, 0xBE, 0xEF, 0x61, 0x01 };
/* --- 2. 狀態機 ------------------------------------------------ */
enum NetState { S_UP, S_WAN_DOWN, S_DNS_FAIL, S_DEGRADED, S_LAN_DOWN };
const char* STATE_NAME[] = {"正常", "WAN中斷", "DNS異常", "延遲劣化", "路由不可達"};
NetState state = S_UP, cand = S_UP;
uint8_t candCnt = 0;
bool lastProbeOk = true;
uint32_t downSince = 0; // 本次中斷起點(millis)
uint32_t tcpMs = 0; // 最近 TCP 握手毫秒
uint32_t evT[LOG_CAP]; uint8_t evS[LOG_CAP]; uint16_t evN = 0, evW = 0;
void commit(NetState next); // 前置宣告
String fmtNow();
void logEv(NetState s) { evT[evW] = millis(); evS[evW] = s;
evW = (evW + 1) % LOG_CAP; if (evN < LOG_CAP) evN++; }
EthernetClient eth;
Adafruit_SSD1306 oled(128, 64, &Wire, -1);
Adafruit_NeoPixel bar(BAR_N, PIN_BAR, NEO_GRB + NEO_KHZ800);
/* --- 3. 三段探測:各回傳 true/false+tcpMs -------------------- */
/* 順序有意義:先叩數據機出去的路(WAN),再查 DNS,再量延遲。
* WAN 掛→不必再問 DNS;路由層掛(連數據機 DHCP 都拿不到)→LAN_DOWN */
bool probeTcp() { // TCP 443 握手到 Cloudflare
uint32_t t0 = millis();
if (!eth.connect(IPAddress(1, 1, 1, 1), 443)) return false;
tcpMs = millis() - t0;
eth.stop();
return true;
}
bool probeWan() { // 向公網要自己的 IP:出得去=WAN 活
if (!eth.connect("ipv4.icanhazip.com", 80)) return false;
eth.println("GET / HTTP/1.1");
eth.println("Host: ipv4.icanhazip.com");
eth.println("Connection: close");
uint32_t t0 = millis();
while (eth.available() == 0 && millis() - t0 < 4000) delay(10);
bool ok = eth.available() > 0;
eth.stop();
return ok;
}
bool probeDns() {
IPAddress ip;
return WiFi.hostByName("hinet.net", ip); // Wi-Fi 未聯機時走 W5500 的 DNS 棧亦可
}
/* --- 4. 人機界面:燈條/蜂鳴/OLED ---------------------------- */
void paintBar(NetState s) {
uint32_t g = bar.Color(0, 160, 60), y = bar.Color(220, 160, 0), r = bar.Color(200, 20, 20);
uint32_t c = (s == S_UP) ? g : (s == S_DEGRADED) ? y : r;
bool blink = (s == S_UP || s == S_DEGRADED) || ((millis() / 350) % 2);
for (int i = 0; i < BAR_N; i++) bar.setPixel(i, blink ? c : 0);
bar.show();
}
void chirp(int n, uint16_t hz) { // 斷線 3 長嗶、恢復 1 短嗶
for (int i = 0; i < n; i++) { tone(PIN_BUZZ, hz, 180); delay(260); }
noTone(PIN_BUZZ);
}
void drawOled() {
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
oled.printf("NET: %s %lums\n", STATE_NAME[state], tcpMs);
// 最近 4 條事件時間軸
for (int i = 0; i < 4 && i < evN; i++) {
uint16_t k = (evW + LOG_CAP - 1 - i) % LOG_CAP;
uint32_t t = evT[k] / 1000;
oled.printf("%02lu:%02lu %s\n", (t / 60) % 60, t % 60, STATE_NAME[evS[k]]);
}
oled.display();
}
/* --- 5. Telegram 推播(Wi-Fi 備援路徑:WAN 掛了照樣推得出) --- */
String urlEncode(const String& in) { // UTF-8 安全:中文訊息必須編碼
String out; char b[4];
for (size_t i = 0; i < in.length(); i++)
(isalnum(in[i]) || in[i] == '-' || in[i] == '.') ? out += in[i]
: (sprintf(b, "%%%02X", (uint8_t)in[i]), out += b);
return out;
}
void tgPush(const String& msg) {
if (strlen(TG_TOKEN) < 10) return;
if (WiFi.status() != WL_CONNECTED) return; // 推播路徑自己沒通就拉倒
WiFiClientSecure sec; sec.setInsecure();
if (!sec.connect("api.telegram.org", 443)) return;
String body = String("chat_id=") + TG_CHAT + "&text=" + urlEncode(msg);
sec.printf("POST /bot%s/sendMessage HTTP/1.1\r\nHost: api.telegram.org\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Content-Length: %d\r\n\r\n%s", TG_TOKEN, body.length(), body.c_str());
delay(400); sec.stop(); // 狀態行不回讀,fire-and-forget
}
/* --- 6. 狀態翻牌(遲滯:連續 FAIL_STREAK 輪同態才生效) ------ */
void feed(NetState obs) {
if (obs == cand) { if (++candCnt >= FAIL_STREAK && cand != state) commit(cand); }
else { cand = obs; candCnt = 1; }
}
void commit(NetState next) {
NetState prev = state; state = next;
logEv(next);
if (next != S_UP && prev == S_UP) { // 新中斷
downSince = millis();
chirp(3, 700);
tgPush(String("⛔ 網路中斷 ") + STATE_NAME[next] + " @ " + fmtNow());
} else if (next == S_UP && prev != S_UP) { // 恢復
uint32_t sec = (millis() - downSince) / 1000;
chirp(1, 1400);
tgPush(String("✅ 網路恢復,中斷 ") + String(sec / 60) + "分" + String(sec % 60) + "秒");
}
}
/* --- 7. 時間戳:Wi-Fi SNTP 同步過才可信,否則用 millis 相對時間 */
String fmtNow() {
struct tm t; time_t now = time(nullptr);
if (now > 1700000000) { localtime_r(&now, &t);
char buf[24]; strftime(buf, sizeof(buf), "%m-%d %H:%M", &t); return buf; }
return String(millis() / 60000) + "min";
}
/* --- 8. CSV 輸出:長按按鈕 0.9 秒 → 序列埠 ------------------- */
void dumpCsv() {
Serial.println("rel_min,state");
for (uint16_t i = 0; i < evN; i++) {
uint16_t k = (evW + LOG_CAP - evN + i) % LOG_CAP;
Serial.printf("%.2f,%s\n", evT[k] / 60000.0, STATE_NAME[evS[k]]);
}
}
void setup() {
Serial.begin(115200);
pinMode(PIN_BTN, INPUT_PULLUP);
Wire.begin(4, 5);
oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
bar.begin(); bar.setBrightness(60);
// W5500:RST 脈衝→DHCP(數據機側通常是 192.168.1.1/24)
SPI.begin(PIN_SPI_SCK, PIN_SPI_MISO, PIN_SPI_MOSI, PIN_SPI_CS);
Ethernet.init(PIN_SPI_CS);
digitalWrite(PIN_W5500_RST, LOW); delay(50); digitalWrite(PIN_W5500_RST, HIGH);
delay(300); // W5500 上電穩定
while (Ethernet.begin(mac) == 0) { Serial.print("."); delay(1000); } // 0=DHCP 失敗
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS); // 只為推播與校時
configTime(8 * 3600, 0, "pool.ntp.org");
logEv(S_UP);
}
void loop() {
static uint32_t lastProbe = 0, lastBtn = 0, holdStart = 0;
uint32_t now = millis();
/* --- 9. 按鈕:短按翻日誌、長按輸出 CSV ------------------- */
if (!digitalRead(PIN_BTN) && !holdStart) holdStart = now;
if (digitalRead(PIN_BTN)) {
if (holdStart) { (now - holdStart > 900) ? dumpCsv() : drawOled(); holdStart = 0; }
}
if (now - lastProbe < PROBE_MS) { paintBar(state); delay(50); return; }
lastProbe = now;
/* --- 10. 探測主流程(順序即故障樹) --------------------- */
NetState obs;
if (!probeTcp()) {
// 連 1.1.1.1 都握不了手:分 WAN 斷(DHCP 仍活)或路由層死
obs = probeWan() ? S_DEGRADED : S_WAN_DOWN;
if (!probeDns() && !probeWan()) obs = S_DNS_FAIL;
} else {
obs = (tcpMs > DEGRADE_MS) ? S_DEGRADED : S_UP;
}
feed(obs);
drawOled();
}
🔧 組裝步驟
- W5500 模組確認帶变压器(板上有兩顆小變壓器或標注 HR911105A 一類),裸 W5500 晶片板还要自己绕变压器,不是這個專案的玩具。
- SPI 五線+INT 依表焊接。W5500 的 SPI 模式由 SCS 上電狀態選擇——模組通常已預設 SPI,上電前不要把 CS 拉到地。
- OLED、燈條、蜂鳴器、按鈕照表接;燈條 DIN 串 330Ω,蜂鳴器基極 10kΩ,兩個電阻直接焊在 DevKit 背面走線。
- 機殼先試疊:電路板四柱鎖緊、RJ45 穿孔對正、USB-C 母座不歪,再鎖 OLED 面板窗。
- 背夾夾上 H660 格柵(左右各一),機殼雙面膠貼夾子外側;網路線 0.3m 短帽走線沿機殼邊緣,不壓到數據機散熱孔。
- 燒機:USB-C 接電腦、選 ESP32C61 Dev Module 上傳;Serial 看 DHCP 拿到 192.168.1.x=有線鏈路通。
- 試機:正常態綠燈+OLED 顯示「正常 <100ms」;拔數據機电源线 10 秒再插回——紅燈閃、嗶三聲、TG 收推播、插回後綠燈、恢复推播附中斷時長。这一套跑完,你就知道 HiNet 下次敢不敢說「正常」。
🧪 驗證
| 測試 | 動作 | 合格線 |
| 斷線偵測延遲 | 拔數據機電源 | ≤12 秒紅燈+嗶(FAIL_STREAK×PROBE_MS 上限) |
| 恢復記錄 | 10 秒後插回 | 恢復推播的中斷時長與實際誤差 ≤10 秒 |
| 誤報率 | 連續掛 24 小時比對路由器日誌 | 零誤報(單次 2 秒内抖動不記事件) |
| 推播路徑獨立性 | 拔 WAN 側訊號線(光纖),Wi-Fi 留活 | 推播仍送達(走 Wi-Fi 備援路徑) |
| 延遲分級 | 手機開熱點搶带宽制造壅塞 | TCP ms 超 350 轉黃燈、日誌記「延遲劣化」 |
| CSV 完整性 | 長按按鈕,序列埠輸出 | 事件按時間排序、無缺帳 |
🆘 問題排除
| 症狀 | 最可能的原因 | 處方 |
| DHCP 拿不到 IP(Serial 印點點) | 網路線壞/W5500 沒切到 SPI 模式/RST 脈衝太短 | 換線測試;模組 CS 腳確認沒有外接下拉;RST 脈衝改 50ms;有些模組要補 10kΩ 上拉 |
| Wi-Fi 聯機成功但推播不出現 | TG 域名被路由器 DNS 污染/Wi-Fi 頻段與探測搶 radio(C61 是單空間流) | 推播改走 Wi-Fi 的 2.4G 獨立 SSID;或在路由器把 TG 域名加白;先用瀏覽器驗證 Token/Chat_id 正確 |
| 時間戳都是「xxxmin」相對時間 | SNTP 從未成功(NTP 埠被擋) | 校時走 Wi-Fi 路徑多試幾次;或接受相對時間——CSV 匯入 Excel 用「首次事件=0 分鐘」仍然能畫甘特圖 |
| 燈條紅燈閃但網路明明正常 | icanhazip 被牆擋/被擋;TCP 通但 HTTP 出口 80 不通 | 把 probeWan 端點換成 http://ip.dnsexit.com 或家附近自建服務;三探測端點最好不同家 |
| 半夜誤報「DNS 異常」 | ISP 維護視窗真的斷,或 DNS 伺服器抽風;FAIL_STREAK 設 1 | 把 FAIL_STREAK 調到 2–3;推播加靜默時段(深夜只記檔不推播) |
| OLED 偶爾花屏 | I2C 線長+開關電源噪聲;4.7kΩ 上拉缺失 | I2C 線減短、加 4.7kΩ SDA/SCL 上拉到 3V3;供電端加 100µF |
| 整机反覆重啟 | 數據機 USB 埠供電不足(插了行動盤之類的共用埠) | 改獨立 5V 電源;燈條亮度調降(8 燈全亮綠約 160mA) |
📁 專案檔案結構
net-guardian/
├── firmware/
│ └── net_guardian.ino # 上方完整韌體
├── stl/
│ ├── guardian-case.stl # 機殼本體
│ ├── modem-clip.stl # 數據機背夾 ×2
│ ├── button-cap.stl # 按鈕帽
│ └── desk-stand.stl # 桌立架(15°)
├── f3d/
│ └── net-guardian.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件
🎯 結語
這三台做完,我家的網路從「感覺怪怪的」變成了一份有時間戳的体检報告:C5 偵測筆量出死角、S3 顯微鏡查線路焊點、C61 守護者把 HiNet 的間歇性失憶全部記帳。上週我把 CSV 貼進客服對話框,三小時後後台回訪:「偵測到您處線路品質異常,已派員」。工程師跟 ISP 談判,靠的不是氣音,是日誌。這個系列從陽台風速塔走到斷網守護者,四篇剛好湊齊「家裡網路三部曲」——測得準、看得清、賴得掉。ESP32 家族便宜到可以一個問題買一顆晶片,剩下的只有一件事:你家的問題,值得幾顆 90 元的目擊者?