📋 專案概述
週五晚上十點,我把一個 14 小時的 PETG 件放上台面,噴頭試性刮過第一層,完美,關燈,睡覺。週六早上七點十五我走進工作室,空氣是干净的——這代表不是燒了——但台面旁邊垂掛著一整坨熔化又冷卻的「義大利麵」,直直垂到地面,模型本體歪在角落,從第二小時起就在印一坨抽象雕塑。失敗發生在凌晨 2:40 左右,之後它安靜地把剩下的料全吐成了麵:四個小時的電、三分之二的料、一整台機器的時間,還有一個被賠掉的週六早上。而我原本只是「想說隔天早上收」。
這不是我的失誤,是物理:首層脫落、溢線成麵、重心越高越容易倒——它們都發生在你不在的時候,因為你在的時候機器通常很給面子。市售方案我評估過一圈:
- OctoPrint +舊手機+AI 訂閱:把樹莓派和舊手機湊齊要花一個週末,AI 失敗偵測掛在雲端訂閱制上——我辛苦架伺服器就是為了不繳月費,而且「偵測到失敗」後還是要人遠端點暫停,網路一斷就白搭。
- 連失敗偵測都內建的智慧列印機:整台換機成本就不談了,它们的偵測對「慢性的歪」一樣遲鈍,而且封閉韌體不給你任何輸出埠。
- 「印失敗是必經的旅程」:對,但我 2026 年的旅程預算只夠學到這裡。
⚠️ 誠實定位:這台哨兵用「區塊亮度差分」偵測的是劇烈失敗——落麵、倒塔、整片脫落、噴頭拖出一條龍。它偵測不到首層微捲邊、層紋異音這類細微失敗(那是音頻 AI 的領域)。它的價值在「把損失從剩餘 12 小時壓到 40 秒」,不是取代你檢查第一層。
這台 ESP32-S3 列印失敗哨兵做的事:
- 8×8 區塊亮度差分,完全離線判斷:畫面切 64 格算灰階平均差,底部兩列=落麵區、中間=倒塔區、頂部=拖絲區,各區獨立閾值——沒有雲端、沒有訂閱、沒有影片流出網路
- 第 40 秒按下暫停,不是停止:光耦合器並接印表機的料架感測器埠,觸發=觸發韌體自己的「料絲用盡暫停」。停在原地、溫度維持、排除失敗後按 Resume 從中斷層續印——這是社群驗證多年的正統做法,不碰供電、不碰主機
- 報案附證據:Telegram 推播附觸發瞬間的 JPEG 快照+觸發區塊熱力摘要,你在公司就能判斷是倒塔还是脫落,要不要回家救
- 完工也通知:60 分鐘畫面零變化=「疑似印完」,再也不用早上七點假摸魚真巡台
- 斷網自律:暫停輸出與警報燈完全離線運作,Wi-Fi 斷了照樣救印;每 6 小時自動重抓基準,日光慢漂移不累積誤報
帳目:AI 鏡頭機台 $4,000 起+訂閱、雲端訂閱服務年費 $1,200 上下、本機 約 560 元一次買斷。以每月少賠一卷 500 元的料計,兩週回本;更別說那些「失敗的剛好是最後兩小時」的昂貴夜印。
為什麼選 ESP32-S3?
| 需求 | S3 的優勢(以及其他型號為什麼不選) |
|---|---|
| 官方相機路徑 | esp32-camera 的並口 DVP 支援清單只有初代 ESP32 與 S3——C6/C5/H2 無 DVP,C3 的單線幀率崩潰到每秒一幀。鏡頭專案就是 S3(或二手初代)的內戰區 |
| PSRAM 讓 Wi-Fi 和鏡頭和平共處 | N16R8 的八線 PSRAM 讓 frame buffer 直接 pin 在 PSRAM(fb_in_psram=true),主記憶體留給 Wi-Fi TLS 與 JPEG 編碼。初代 ESP32 挂上 OV2640 再開 HTTPS 推播,抓幀就 OOM 是社區日常 |
| GPIO 夠塞 | 相機 16 根訊號線+光耦輸出+燈+蜂鳴器+按鈕——S3 的可用腳才夠分,還能避開 USB(19/20)與 strapping(0/3/45/46)雷區 |
| 算力餘裕 | 240MHz 雙核跑 QVGA 取樣+64 格差分+TLS 推播,CPU 佔用個位數百分比。未來想換「真·AI 偵測」(TinyML 分類器)板子不用換 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3-DevKitC-1 N16R8 | 1 | ~260 元 | 必選 N16R8(16MB Flash+八線 PSRAM);N8R2 的 QD 線 PSRAM 也能跑但頻寬對半砍 |
| 相機 | OV2640 DVP 24pin 模組 | 1 | ~120 元 | 2MP、支援 grayscale/JPEG 硬壓;買附 FPC 轉接板的,別焊 0.5mm FPC |
| 暫停輸出 | EL357N 光耦+LED 電阻 | 1 組 | ~15 元 | 對印表機側完全电气隔离——這是本機唯一接上別人機器的線,隔離不是可選項 |
| 狀態燈 | WS2812B 燈珠 | 1 | ~8 元 | 藍=監視中、紅=失敗、綠=完工、紫=離線哨兵 |
| 蜂鳴器 | 有源蜂鳴器 5V | 1 | ~12 元 | 工作室門關著,觸發要聽得到 |
| 相機臂 | 磁吸球頭+夾座(列印) | 1 | ~120 元 | 球頭=對位三分鐘的事;列印件詳見 3D 列印節 |
| 電源 | 5V 2A USB-C 火牛 | 1 | ~45 元 | 相机瞬間電流可比平均高一倍,省電火牛是誤刷之源 |
| 線材雜項 | FPC 延長、杜邦、鐵氟龍管、螺絲 | 1 組 | ~80 元 | 相機排線盡量短(<15cm)且離步进機馬線遠走 |
| 合計 | ~560 元 | 無訂閱、無雲端,影片不出你的機房網段 |
🔌 接線圖
ASCII 對照表(S3-DevKitC 左排 / 右排)
┌─ ESP32-S3-DevKitC-1 N16R8 ─────────┐
│ 3V3 ●──→ OV2640 VDD(另走線,別與馬達共地迴路)
│ GND ●──→ 共地母排(單點)
│ GPIO4 ●──→ OV2640 SIOD(I2C 控制)
│ GPIO5 ●──→ OV2640 SIOC
│ GPIO6 ●──→ OV2640 VSYNC
│ GPIO7 ●──→ OV2640 HREF
│ GPIO8 ●──→ OV2640 PCLK
│ GPIO15 ●──→ OV2640 XCLK(20MHz,經 PLL 分頻)
│ GPIO11/9/10/12/14/13/16/17 ●──→ D0…D7(數據束)
│ GPIO1 ●──→ EL357N 光耦初級(經 1k)→ 並接印表機料架感測埠
│ GPIO2 ●──→ WS2812 燈珠 DIN(330Ω)
│ GPIO21 ●──→ S8050 基極(10k)→ 蜂鳴器(5V 側)
│ GPIO48 ●──→ 重基準按鈕(對 GND,內部上拉)
│ 5V ●──→ 火牛 5V(相機+板+蜂鳴器)
└────────────────────────────────────┘
⚠ 印表機側只准接「感測器訊號兩根」,供電一律自己來。
感測埠的 3.3V/5V 輸出腳不要借用——兩台電源地迴路
是燒光耦和燒主機板的經典路線。
詳細接線
| ESP32-S3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO4/5(SIOD/SIOC) | OV2640 SCCB | 相機自己的控制匯流排,不與任何東西共用;模組自帶上拉 |
| GPIO6/7/8 | VSYNC/HREF/PCLK | DVP 時序三兄弟;線長盡量一致,PCLK 別跨整塊麵包板 |
| GPIO15 | XCLK | 20MHz;XCLK_USE_PLL80 讓它不佔用 ledc 資源 |
| GPIO9–17 八支 | D0–D7 | 資料束。順序照韌體 #define,接錯就是滿屏雪花——用顏色排線可救生命 |
| GPIO1 | EL357N 光耦初級側 | 輸出 LOW=光耦導通=模擬「料絲用盡」訊號 600ms;對印表機而言這就是一顆感測器 |
| GPIO2 | WS2812 燈珠 | 330Ω 必加;狀態色见組裝步驟 |
| GPIO21 | 蜂鳴器(經 S8050) | 高電位叫;待機拉低 |
| GPIO48 | 重基準按鈕 | 長按 2 秒=「我重新放件了,重看基準」;內部上拉,按下對 GND |
📊 偵測邏輯(區塊差分狀態機)
🧊 3D 列印設計
| 零件 | 材質 | 列印設定 | 時間 | 備註 |
|---|---|---|---|---|
| 夾座(夾機台豎桿 Ø20) | PETG | 0.2mm/4 周界/40% | ~40 分 | 近擠出機環境選 PETG;夾口預留 0.2mm 過盈,鎖 M4 螺絲收緊 |
| 球頭臂(母公對) | PETG | 0.16mm/實心 4 層 | ~70 分 | Ø16 球+M5 鎖緊螺桿;列印球面請用弧面向上方向,層紋會咬住潤滑脂 |
| 鏡頭盒+遮光罩 | PLA 或 PETG | 0.2mm/15% | ~80 分 | 遮光罩擋擠出機燈直吹鏡頭——燈也是「亮度差分」的天敵 |
| 光耦接線座/火牛勾 | PLA | 0.24mm/20% | ~30 分 | 把光耦和線固定成「掛在印表機旁」而非「垂在皮帶上」 |
💻 Arduino 程式碼
開發板選 ESP32S3 Dev Module,PSRAM: OPI PSRAM(N16R8 必選,選錯抓不到幀)、Flash Size: 16MB、USB CDC On Boot: Enabled。相依:內建 esp_camera(core ≥ 3.0)、Adafruit NeoPixel、ArduinoJson。相機腳位以下方 #define 為準;用 XIAO S3 Sense/S3-CAM 等整合板請改 CAMERA_MODEL_* 對應腳位。
📐 註解規範:區塊以框註分段;函式附用途/參數/回傳框註;行內註解解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
🧠 架構決策:判斷迴路(抓幀→差分→按暫停)完全離線,Wi-Fi/Telegram 只在「事件發生後」出現——断網哨兵照樣按暫停,只是不報案。
🧠 架構決策:判斷迴路(抓幀→差分→按暫停)完全離線,Wi-Fi/Telegram 只在「事件發生後」出現——断網哨兵照樣按暫停,只是不報案。
/* ================================================================
* ESP32-S3 列印失敗哨兵 firmware v1.0 (2026-10-25)
* ----------------------------------------------------------------
* 功能 :OV2640 8×8 區塊亮度差分,離線偵測落麵/倒塔/拖絲,
* 光耦模擬料絲用盡→印表機暫停(非停止),
* Telegram 報案附 JPEG 證據,60 分鐘靜止=完工通知
* 硬體 :ESP32-S3-DevKitC-1 N16R8 + OV2640(DVP)
* SIOD=4 SIOC=5 VS=6 HR=7 PK=8 XCLK=15
* D0..D7=11,9,10,12,14,13,16,17
* 光耦=1 WS2812=2 蜂鳴器=21 按鈕=48
* 供電 :5V 2A USB-C(相機另走線,勿與馬達共地迴路)
* 相依 :esp_camera(core內建), Adafruit_NeoPixel, ArduinoJson
* 看板 :「ESP32S3 Dev Module」/PSRAM=OPI PSRAM/Flash 16MB
* ================================================================ */
#include <esp_camera.h>
#include <Adafruit_NeoPixel.h>
#include <Preferences.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <esp_task_wdt.h>
/* --- 1. 相機腳位(自接 OV2640 標準版;整合板請改本區) ---------- */
#define CAM_PIN_SIOD 4
#define CAM_PIN_SIOC 5
#define CAM_PIN_VSYNC 6
#define CAM_PIN_HREF 7
#define CAM_PIN_PCLK 8
#define CAM_PIN_XCLK 15
#define CAM_PIN_D0 11
#define CAM_PIN_D1 9
#define CAM_PIN_D2 10
#define CAM_PIN_D3 12
#define CAM_PIN_D4 14
#define CAM_PIN_D5 13
#define CAM_PIN_D6 16
#define CAM_PIN_D7 17
#define PIN_OPTO 1 // 光耦初級:LOW=導通=模擬「料絲用盡」
#define PIN_LED 2 // WS2812 狀態燈
#define PIN_BUZZ 21 // 蜂鳴器(高電位叫)
#define PIN_REBASE 48 // 重基準按鈕(內部上拉,按下=GND)
/* --- 2. 可調原則 ------------------------------------------------- */
const uint8_t GRID = 8; // 8×8=64 格:QVGA 下每格 20×15px
const uint16_t FRAME_MS = 5000; // 5 秒一幀:QVGA+差分的甜蜜點,CPU <8%
const uint8_t HIT_ARM = 12; // 連續命中 12 次(≈40 秒)才觸發
const float HIT_DECAY = 2.0f; // 平靜時計數 −2:要慢進快出,誤報閘門
const uint8_t Z_BOTTOM = 6, Z_TOP = 2; // 落麵區=第6–7列 拖絲區=第0–1列
const float TH_BOTTOM = 26, TH_MID = 34, TH_TOP = 30; // 灰階差門檻
const float TH_GLOBAL = 6; // 「全域平靜」門檻:完工偵測用
const uint32_t CALM_MS = 60UL*60UL*1000UL; // 60 分鐘靜止=疑似完工
const uint32_t REBASE_MS = 6UL*3600UL*1000UL; // 6 小時自動重基準
const char* WIFI_SSID = "你的WiFi";
const char* WIFI_PASS = "你的密碼";
const char* TG_TOKEN = "你的Bot Token"; // 留空=不推播,純本地
const char* TG_CHAT = "你的 ChatID";
/* --- 3. 全域狀態 -------------------------------------------------- */
enum St { DISARMED, BASELINE, ARMED, FAILED, DONE };
St st = DISARMED;
float baseGrid[64]; // 基準影格的 64 格灰階平均
float hits[64]; // 每格命中計數(+1 / −2)
uint32_t lastFrame = 0, calmSince = 0, lastRebase = 0;
bool wifiUp = false;
Preferences nvs;
Adafruit_NeoPixel led(1, PIN_LED, NEO_GRB + NEO_KHZ800);
TaskHandle_t hReport;
/* 用途:抓一幀並回傳 64 格灰階平均(呼叫端負責放還 fb)
* 參數:fb—已抓到的灰階影格;out—64 格輸出
* 為什麼是灰階平均而不是邊緣偵測:S3 上高斯+Sobel 每幀 80ms+,
* 而落麵/倒塔都是「大块亮度重分配」事件——平均差又便宜又穩 */
void gridOf(const camera_fb_t* fb, float* out) {
const uint16_t bw = fb->width / GRID, bh = fb->height / GRID;
for (uint8_t r = 0; r < GRID; r++) for (uint8_t c = 0; c < GRID; c++) {
uint32_t s = 0; const uint8_t* p = fb->buf;
for (uint16_t y = r*bh; y < (uint16_t)(r+1)*bh; y += 2) // 半取樣再省一半
for (uint16_t x = c*bw; x < (uint16_t)(c+1)*bw; x += 2)
s += p[y * fb->width + x];
out[r*GRID + c] = (float)s / ((bh/2)*(bw/2) + 1);
}
}
/* 用途:對一幀更新命中計數,回傳「觸發了哪個區」
* 回傳:0 無 / 1 落麵區 / 2 倒塔區 / 3 拖絲區
* 為什麼分區:落麵從底部堆起、倒塔是中間大面積位移、拖絲在
* 噴頭層附近——三個區用不同閾值,一個數字管全部必然又漏又誤 */
uint8_t zoneHit(const float* g) {
uint8_t zone = 0;
for (uint8_t r = 0; r < GRID; r++) for (uint8_t c = 0; c < GRID; c++) {
uint8_t i = r*GRID + c;
float d = fabsf(g[i] - baseGrid[i]);
float th = (r >= Z_BOTTOM) ? TH_BOTTOM : (r < Z_TOP) ? TH_TOP : TH_MID;
if (d > th) { hits[i] += 1; if (d > th * 1.6f) hits[i] += 1; }
else hits[i] = max(0.0f, hits[i] - HIT_DECAY);
if (hits[i] >= HIT_ARM) zone = (r >= Z_BOTTOM) ? 1 : (r < Z_TOP) ? 3 : 2;
}
return zone;
}
float globalDiff(const float* g) {
float s = 0;
for (uint8_t i = 0; i < 64; i++) s += fabsf(g[i] - baseGrid[i]);
return s / 64.0f;
}
/* --- 4. 輸出:暫停脈衝、燈、蜂鳴器(全程離線) --------------------- */
void pausePrinter() {
// EL357N 初級導通 600ms:韌體端這是「感測器斷料」→ pause
digitalWrite(PIN_OPTO, LOW); delay(600); digitalWrite(PIN_OPTO, HIGH);
// 之後維持 HIGH(=有料絲):印表機停在暫停中,人回來可 Resume
}
void ledState(uint8_t m) { // 0滅 1藍監視 2紫離線 3紅失敗 4綠完工
uint32_t c = m==1 ? led.Color(0,60,255) : m==2 ? led.Color(140,0,255)
: m==3 ? led.Color(255,0,0) : m==4 ? led.Color(0,255,90) : 0;
led.clear(); led.setPixelColor(0, c); led.show();
}
void buzz(uint8_t n) { for (uint8_t i=0;i<n;i++){ digitalWrite(PIN_BUZZ,HIGH);
delay(120); digitalWrite(PIN_BUZZ,LOW); delay(140);} }
/* --- 5. Telegram 報案(事件後才連網;文字+JPEG 證據) -------------- */
/* 用途:切 JPEG 格式抓一張證據照再切回灰階
* 回傳:true=成功,buf/len 帶出影格副本(呼叫端 free)
* 為什麼臨時切格式:常駐 JPEG 解不了差分、常駐灰階拍不了照;
* 切換要 200ms,但報案一分鐘後才送出,慢得起 */
bool snapEvidence(uint8_t** buf, size_t* len) {
sensor_t* s = esp_camera_sensor_get();
s->set_pixformat(s, PIXFORMAT_JPEG);
delay(250); // 等管線穩定
camera_fb_t* fb = esp_camera_fb_get();
bool ok = fb && fb->len < 128*1024;
if (ok) { *buf = (uint8_t*)malloc(fb->len); memcpy(*buf, fb->buf, fb->len);
*len = fb->len; }
if (fb) esp_camera_fb_return(fb);
s->set_pixformat(s, PIXFORMAT_GRAYSCALE); // 切回差分模式
delay(250);
return ok && *buf;
}
void reportTask(void* p) { // 參數=事件描述字串(heap)
String* msg = (String*)p;
if (strlen(TG_TOKEN) == 0) { delete msg; vTaskDelete(NULL); }
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
for (int i = 0; i < 24 && WiFi.status() != WL_CONNECTED; i++) vTaskDelay(500);
if (WiFi.status() == WL_CONNECTED) {
uint8_t* jpg = nullptr; size_t jlen = 0;
bool hasJpg = snapEvidence(&jpg, &jlen);
WiFiClientSecure c; c.setInsecure();
for (int t = 0; t < 3 && !c.connect("api.telegram.org", 443, 5000); t++)
vTaskDelay(2000); // backoff:網路慢時別轟炸
if (c.connected()) { // 1) 文字
JsonDocument d; d["chat_id"] = TG_CHAT; d["text"] = *msg;
char body[512]; size_t n = serializeJson(d, body);
c.print(String("POST /bot") + TG_TOKEN + "/sendMessage HTTP/1.1\r\n"
"Host: api.telegram.org\r\nContent-Type: application/json\r\n"
"Content-Length: " + String(n) + "\r\n\r\n" + body);
c.readStringUntil('{'); c.readStringUntil('}');
}
if (hasJpg && c.connect("api.telegram.org", 443, 5000)) { // 2) 證據照
String bd = "----hermes3dp";
String head = "--" + bd + "\r\nContent-Disposition: form-data; name=\"chat_id\"\r\n\r\n"
+ TG_CHAT + "\r\n--" + bd +
"\r\nContent-Disposition: form-data; name=\"photo\"; filename=\"fail.jpg\"\r\n"
"Content-Type: image/jpeg\r\n\r\n";
String tail = "\r\n--" + bd + "--\r\n";
c.print(String("POST /bot") + TG_TOKEN + "/sendPhoto HTTP/1.1\r\n"
"Host: api.telegram.org\r\nContent-Type: multipart/form-data; boundary=" + bd +
"\r\nContent-Length: " + String(head.length()+jlen+tail.length()) +
"\r\n\r\n" + head);
c.write(jpg, jlen); c.print(tail);
free(jpg);
}
c.stop();
}
delete msg; vTaskDelete(NULL); // Wi-Fi 保持開:完工通知也要用
}
void report(String m) { xTaskCreate(reportTask, "report", 12288, new String(m), 2, &hReport); }
/* --- 6. 基準擷取與武裝 --------------------------------------------- */
void takeBaseline() {
float acc[64] = {0}, g[64];
for (uint8_t k = 0; k < 5; k++) { // 5 幀平均:吃掉燈閃與震動
camera_fb_t* fb = esp_camera_fb_get();
if (fb) { gridOf(fb, g); esp_camera_fb_return(fb); }
for (uint8_t i = 0; i < 64; i++) acc[i] += g[i];
delay(300);
}
for (uint8_t i = 0; i < 64; i++) baseGrid[i] = acc[i] / 5.0f;
memset(hits, 0, sizeof hits);
calmSince = lastRebase = millis();
st = ARMED; ledState(wifiUp ? 1 : 2);
Serial.println(F("[base] 基準已更新,哨兵武裝"));
}
/* --- 7. setup/loop -------------------------------------------------- */
void setup() {
Serial.begin(115200);
pinMode(PIN_OPTO, OUTPUT); digitalWrite(PIN_OPTO, HIGH); // 高=「有料」
pinMode(PIN_BUZZ, OUTPUT); pinMode(PIN_REBASE, INPUT_PULLUP);
led.begin(); ledState(0);
camera_config_t cfg = {};
cfg.pin_sccb_sda = CAM_PIN_SIOD; cfg.pin_sccb_scl = CAM_PIN_SIOC;
cfg.pin_vsync = CAM_PIN_VSYNC; cfg.pin_href = CAM_PIN_HREF;
cfg.pin_pclk = CAM_PIN_PCLK; cfg.pin_xclk = CAM_PIN_XCLK;
cfg.pin_d0 = CAM_PIN_D0; cfg.pin_d1 = CAM_PIN_D1; cfg.pin_d2 = CAM_PIN_D2;
cfg.pin_d3 = CAM_PIN_D3; cfg.pin_d4 = CAM_PIN_D4; cfg.pin_d5 = CAM_PIN_D5;
cfg.pin_d6 = CAM_PIN_D6; cfg.pin_d7 = CAM_PIN_D7;
cfg.pin_pwdn = -1; cfg.pin_reset = -1; cfg.pin_xclk = CAM_PIN_XCLK;
cfg.xclk_freq_hz = 20000000;
cfg.pixel_format = PIXFORMAT_GRAYSCALE;
cfg.frame_size = FRAMESIZE_QVGA; // 160×120:差分夠用,PSRAM 壓力小
cfg.fb_count = 2; // 雙緩衝:抓幀不丟幀
cfg.fb_location = CAMERA_FB_IN_PSRAM;
if (esp_camera_init(&cfg) != ESP_OK) { // 相機掛了=本機失去意義:叫給你看
Serial.println(F("[cam] init FAIL—檢查排線順序與 OPI PSRAM 設定"));
buzz(9); while (true) delay(500);
}
sensor_t* s = esp_camera_sensor_get();
s->set_hmirror(s, 1); s->set_vflip(s, 1); // 吊裝時畫面正过来
esp_task_wdt_init(15, true); esp_task_wdt_add(NULL);
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASS);
buzz(2); // 兩聲=相機 OK,等你武裝
}
void loop() {
esp_task_wdt_reset();
bool btn = digitalRead(PIN_REBASE) == LOW;
static uint8_t btnHeld = 0;
if (btn && ++btnHeld == 40) { st = BASELINE; } // 長按 2 秒重基準
if (!btn) btnHeld = 0;
if (st == DISARMED) { ledState(WiFi.status()==WL_CONNECTED ? 0 : 2); return; }
if (WiFi.status() != WL_CONNECTED) { if (st==ARMED) ledState(2);
if (millis() % 30000 < 100) WiFi.reconnect(); } // 30s 一試
else wifiUp = true;
if (st == BASELINE) { takeBaseline(); return; }
if (millis() - lastFrame < FRAME_MS) return;
lastFrame = millis();
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) { Serial.println(F("[cam] 抓幀失敗—重試")); return; }
float g[64]; gridOf(fb, g); esp_camera_fb_return(fb);
uint8_t z = zoneHit(g);
if (z) { // 失敗確認
st = FAILED; ledState(3); buzz(6); pausePrinter();
const char* zn = z==1 ? "落麵(床前堆料)" : z==2 ? "倒塔/大面積位移" : "噴頭拖絲";
report(String("🍝 列印失敗哨兵:") + zn + "|已下暫停指令|開機 "
+ String(millis()/60000) + " 分");
memset(hits, 0, sizeof hits);
return;
}
if (globalDiff(g) > TH_GLOBAL) calmSince = millis(); // 有任何變化=活著
else if (st==ARMED && millis()-calmSince > CALM_MS) { // 印完了?
st = DONE; ledState(4);
report("✅ 哨兵:檯面 60 分鐘零變化,疑似完工(去收貨吧)");
}
else if (st==ARMED && millis()-lastRebase > REBASE_MS) {
st = BASELINE; // 日光慢漂移救星
Serial.println(F("[rebase] 6 小時自動重基準"));
}
}
⚠️ 光耦接印表機前必讀:先確認你的韌體啟用了料絲感測器且行為是
PAUSE(Marlin:ADVANCED_PAUSE_FEATURE+FILAMENT_RUNOUT_SCRIPT: M600 或改 pause;Klipper:filament_switch_sensor + pause_on_runout: True)。接錯腳=列印中「假斷料」觸發暫停的是正在列印的當下——先在待機狀態試觸發,確認機器只是進 PAUSE 狀態才掛線。🔧 組裝步驟
- 燒機與相機驗證:先用
CameraWebServer範例驗證相機能出圖(改腳位為本專案版),能看到畫面=硬體鏈通了,再刷哨兵韌體。 - 相機上臂:鏡頭鎖進鏡頭盒(遮光罩朝擠出機燈方向),球頭插入夾座,夾座鎖在機台豎桿或龍門頂桿。对位原則:床面四角+四周 2cm 餘裕全部入框,噴頭在框內上 1/3。
- 鎖焦與鎖曝光:Web 介面把
AEC(自動曝光)關掉、手動曝光固定(自動曝光在「大面積黑床→白麵」時會整屏拉亮,差分全亂),手動對焦到床面前後都清楚(縮光圈:把鏡頭轉到hyperfocal附近),Serial 輸出基準格值確認穩定後記下參數。 - 光耦接印表機:待機狀態下接料架感測埠兩腳(Sense+GND),韌體測試指令觸發一次——印表機進入 PAUSE=正確。用絕緣氈把光耦包好掛在機側。
- 走線:相機排線沿機架後側鐵龍龍固定,離 X 皮帶與馬達線 5cm 以上;火牛掛牆,線不跨移動軸。
- 武裝演練:放個小件開印 → 長按按鈕武裝 → 手捏一根料絲塞到噴頭前讓它堆 60 秒 → 暫停+Telegram 附照。成功=正式上崗。
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
🧪 驗證——把它當保全系統一樣驗收
| 測試 | 動作 | 合格線 |
|---|---|---|
| 落麵演練 | 列印中手拉料絲在噴頭旁堆積 60 秒 | 40–90 秒內觸發:印表機 PAUSE+Telegram 附照+紅燈蜂鳴 |
| 倒塔演練 | 列印中把模型推倒 | 同上(觸發區塊落在中間四列) |
| 人影演練 | 正常走過機台前 3 次、每次 10 秒 | 零觸發(12 連發+−2 衰減在工作) |
| 燈閃演練 | 開關檯燈 3 次 | 零觸發;若觸發→遮光罩裝正、曝光鎖死 |
| 斷網演練 | 關 Wi-Fi AP 後重複落麵演練 | 暫停照樣動作、燈變紫、Telegram 稍後補發或留本地記錄 |
| 完工偵測 | 印完後整機靜置 60 分鐘 | 收到「疑似完工」通知、燈變綠 |
| 續印演練 | 觸發暫停→清掉麵→按 Resume | 從中斷層續印,接痕可接受(≪整件報廢) |
🆘 問題排除
| 症狀 | 最可能的原因 | 處方 |
|---|---|---|
| 相機 init 失敗/滿屏雪花 | D0–D7 順序接錯(最常見)、XCLK 腳不是 15、PSRAM 選錯(非 OPI) | 逐根核對韌體 #define;開發板 PSRAM 一定要選 OPI PSRAM |
| 開機就 OOM/重啟循環 | fb 在內部 RAM、Wi-Fi TLS 搶不到記憶體 | fb_location = CAMERA_FB_IN_PSRAM;看板 PSRAM 沒開等於 PSRAM 不存在 |
| 半夜誤報一長串 | 自動曝光遇到大面積亮度變化、檯燈定時器、窗外車燈掃過 | AEC 關鎖曝光;遮光罩;把 TH_MID 從 34 上調並拉長 HIT_ARM 到 18 |
| 從不觸發 | 镜头髒/對焦糊(差分被抹平)、床面入框太少、閾值過高 | 重新鎖焦;重擺對位讓四角入框;用 Serial 印出的實際 diff 值校閾值(落在門檻 40–60% 為正常列印) |
| 觸發了但印表機沒停 | 韌體沒啟用 runout→pause、光耦極性反了、接到供電腳 | 確認 M600/pause_on_runout;光耦 LOW=導通;換對稱測試腳位 |
| 完工通知從不來 | 環境有週期變化(風扇轉影、燈頻閃)讓 globalDiff 恆 > ε | 調高 TH_GLOBAL;鏡頭別對到會動的風扇葉;鎖曝光後頻閃通常消失 |
📁 專案檔案結構
print-failure-sentry/
├── firmware/
│ └── print_sentry.ino # 上方完整韌體
├── stl/
│ ├── arm-clamp.stl # 夾座(Ø20 豎桿)
│ ├── ball-arm-male.stl # 球頭臂(公)
│ ├── ball-arm-female.stl # 球頭臂(母)
│ ├── cam-box.stl # 鏡頭盒+遮光罩
│ └── opto-mount.stl # 光耦接線座
├── f3d/
│ └── print-sentry.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件