📋 專案概述

在家工作的第三天,視頻會議卡在对方最后一句「所以這個案子……」上,畫面定格成遺照。晚上十點網路恢復,隔天中午又斷。打電話給 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:

🔬 為什麼選 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-C6Wi-Fi 6/BLE/Zigbee~NT$120⭐⭐⭐⭐ 同定位、稍貴,本機相容
ESP32-C2Wi-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-N41180N4 已夠(事件日誌才幾 KB)
有線網卡W5500 SPI 模組(带变压器)1120100M;必須帶網路变压器那版
螢幕0.96" SSD1306 OLED I2C1950x3C;掛牆看狀態
燈條WS2812B 8 燈條165綠=正常/黃=延遲/紅閃=斷線
蜂鳴器主動式 5mm+S8050+10kΩ110斷線嗶三聲、恢复嗶一聲
按鈕6×6 微動13短按=翻日誌,長按=輸出 CSV
供電USB-C 線+5V 1A 電源(或數據機 USB 埠)160從數據機 USB 取電=數據機死它就死,反而正確
網路線0.3m 短帽 Cat5e135數據機 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
ESP32-C61 斷網守護者 · 線路示意 ESP32-C61 DevKitC-1 N4 GPIO6 SCKGPIO7 CSGPIO22 MOSIGPIO23 MISOGPIO24 RSTGPIO15 INTGPIO4/5 I2CGPIO16 蜂鳴GPIO10 燈條 W5500 100M 有線・SPI LAN ══► H660 LAN4 SCKSISORSTINT SSD1306 狀態・事件時間軸 SDA/SCL BUZ S8050 G16+10k DIN+330Ω BTN GPIO0→GND
⚠️ 供電眉角:本機刻意從數據機 USB 埠取電——數據機整個掛掉(跳電、變壓器燒)時守護者一同陣亡,恢復供電=數據機回魂,「停電式斷網」與「ISP 斷網」自動分開記帳。想區分兩者就改用行動電源供電,程式裡把「探測失敗但 NTP 仍同步中」的邏輯再拆細即可。

🖨️ 3D 列印設計

零件材質數量列印設定備註
機殼本體PETG10.2mm/2 壁/20%正面 OLED 窗+燈條光帶,側面 USB-C/RJ45 穿孔
數據機背夾PETG20.2mm/3 壁/豎印夾 H660 散熱格柵,機殼雙面膠上夾——掛在斷線兇手旁邊鎮場
按鈕帽PLA10.2mm/3 壁免工具拆殼,換機再說
桌立架PETG10.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(); }

🔧 組裝步驟

  1. W5500 模組確認帶变压器(板上有兩顆小變壓器或標注 HR911105A 一類),裸 W5500 晶片板还要自己绕变压器,不是這個專案的玩具。
  2. SPI 五線+INT 依表焊接。W5500 的 SPI 模式由 SCS 上電狀態選擇——模組通常已預設 SPI,上電前不要把 CS 拉到地。
  3. OLED、燈條、蜂鳴器、按鈕照表接;燈條 DIN 串 330Ω,蜂鳴器基極 10kΩ,兩個電阻直接焊在 DevKit 背面走線。
  4. 機殼先試疊:電路板四柱鎖緊、RJ45 穿孔對正、USB-C 母座不歪,再鎖 OLED 面板窗。
  5. 背夾夾上 H660 格柵(左右各一),機殼雙面膠貼夾子外側;網路線 0.3m 短帽走線沿機殼邊緣,不壓到數據機散熱孔。
  6. 燒機:USB-C 接電腦、選 ESP32C61 Dev Module 上傳;Serial 看 DHCP 拿到 192.168.1.x=有線鏈路通。
  7. 試機:正常態綠燈+OLED 顯示「正常 <100ms」;拔數據機电源线 10 秒再插回——紅燈閃、嗶三聲、TG 收推播、插回後綠燈、恢复推播附中斷時長。这一套跑完,你就知道 HiNet 下次敢不敢說「正常」。
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到書桌 → 開啟 HTML 組裝說明書

🧪 驗證

測試動作合格線
斷線偵測延遲拔數據機電源≤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 元的目擊者?

← 上一篇:ESP32-S3 桌上型顯微鏡 📚 系列索引 下一篇:ESP32 全系列 × 3D 列印系列索引 →