📋 專案概述
遠端工作第二年,你最貴的不是設備,是「開會開到一半被推門」的那一刻。上週三下午 2:40,你在第 11 頁 spec 裡潛水,門被推開——小孩要水、三十分鐘後家人問晚餐吃什麼、再十五分鐘快遞透過門縫喊名字。研究數字:一次打斷,平均要 23 分鐘才回得到原本的專注深度。一天三次打斷 = 半個工作日蒸發。
市售方案全數查過,全都不對味:
- 會議室顯示屏:NT$4,000 起、要佈網線、要授權伺服器、要「預約系統」——你只是想上個班,不是要進駐商辦。
- 手按門牌(綠燈紅燈按鈕):開會開得昏天黑地,誰記得按?第一天還記得,第九天門牌變成永久裝飾。
- PIR 人體感測:坐著打字不動 3 分鐘就判「沒人」,門燈滅得比回覆還快——它偵測的是「動作」,不是「人」。
本專案的核心是毫米波雷達 LD2410C:隔著椅背、蓋著毛毯坐著一動不動,呼吸帶動的微動它照樣看得見。整台機器沒有按鈕依賴、沒有相機、沒有麥克風:
- 入座 15 秒自動轉紅(使用中)、離開 10 分鐘自動轉綠(可進入)——全程零按鈕,你忘記它就是對的
- 門外 WS2812 燈環:綠=可進入、紅=使用中、慢呼吸紅=深度專注(到訪者看到呼吸紅會自動消失,這是家庭生態學的勝利)
- 門內按鈕循環:正常 → 專注 60 分鐘 → 會議模式(紅到按掉為止)
- 狀態同步 NAS 儀表板:今天實際專注 3.2 小時、被打斷 0 次,週日晚 Telegram 週報——數據讓你發現「週三下午別找我」是全家共識
- 久坐提醒:連續入座 90 分鐘,燈環閃黃 10 秒提醒你起身(只在門外閃,不推播——它不應該變成另一種打擾)
- 隱私純淨:全機只有一個「有人在/沒人」的位元級訊號出境,連幾點上廁所都不記錄
為什麼選 ESP32-C3?
| 需求 | C3 的優勢 |
|---|---|
| 供電型態 | 門燈掛插座全時供電,低功耗優勢用不上——C3 的便宜就是正義 |
| 周邊剛好 | UART ×1(LD2410C)+ GPIO ×4(燈環/按鈕/蜂鳴器/預留),一顆不浪費 |
| 體積 | SuperMini 25×18mm:燈環背板藏進門框列印殼,探出牆面不到 2cm,訪客不會注意到它 |
| 流量 | 30 秒一次 heartbeat 小封包,Wi-Fi 11n 綽綽有餘;深睡省電版留給 H2 + Thread 延伸篇 |
🧲 毫米波不是魔法:LD2410C 是 24GHz FMCW 雷達,穿透木門塑膠沒問題,穿不了金屬與厚磚;它也「看得穿」薄牆偵測到隔壁床動來動去的人——角度與距離閾值調校是本專案的靈魂,下文詳述。
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C3 SuperMini | 1 | ~40 元 | USB-C 供電 |
| 雷達 | LD2410C 毫米波(UART 版) | 1 | ~85 元 | 5V 供電;要「靜態回報」韌體版 |
| 燈環 | WS2812B 12 燈環 | 1 | ~25 元 | 門外主視覺 |
| 按鈕 | 6×6 微動 | 1 | ~5 元 | 門內模式循環 |
| 蜂鳴器 | 3.3V 有源 | 1 | ~15 元 | 選配;專注模式自動靜音 |
| 電源 | 5V 1A USB 頭 + 1m 線 | 1 | ~60 元 | 門外插座 |
| 列印材料 | PLA ~45g | — | ~15 元 | 燈殼 + 雷達罩 + 背板 |
| 合計 | ~380 元 | 會議室屏方案的 8% |
🔌 接線圖
詳細接線
| ESP32-C3 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO20 (RX) | LD2410C TX | 雷達 256 位元組狀態幀流 |
| GPIO21 (TX) | LD2410C RX | 下發靈敏度/閾值指令用 |
| GPIO6 | WS2812B DIN | 串 330Ω;燈環端就近 1000µF |
| GPIO3 | 按鈕 → GND | 內部上拉;短按循環/長按重置 |
| GPIO0 | 蜂鳴器(經 S8050) | 1kΩ 基極電阻;預留 PWM 位 |
| 5V / GND | LD2410C、燈環 | 雷達與燈環電流都不走板載 LDO 回路 |
ESP32-C3 SuperMini LD2410C (UART 版)
GPIO20 (RX) ←───────────── TX ← 交叉!
GPIO21 (TX) ─────────────→ RX
5V ─────────────────────── VDD(5V 供電)
GND ────────────────────── GND
ESP32-C3 SuperMini peripherals
GPIO6 ──[330Ω]──────────── WS2812 DIN(環端 +1000µF→GND)
GPIO3 ←──┴ 按鈕 ── GND(內部上拉)
GPIO0 ──[1k]──┬── S8050 B|E→GND|C→蜂鳴器(+),蜂鳴器(−)→3V3
USB-C 5V ────── 板供電+分兩路:雷達 5V、燈環 5V(獨立走線)
※ 5V TTL 疑慮備援:LD2410C TX → 74AHCT125 → GPIO20(實測三台 10cm 直連無事,不穩再加)
⚠️ LD2410C 供電紋波是「雷達發瘋」頭號原因(沒人 też 報有人):雷達 VDD 兩腳就近焊 10µF;USB 線別超過 2m,細長雜牌線壓降會把雷達供電拉到 4.6V 以下。
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 門外燈殼(燈環+乳白擴散罩) | 1 | PLA | 任意+乳白 | ~50 min |
| 雷達罩(帶角度遮板插槽) | 1 | PLA素色 | 任意 | ~25 min |
| 門內按鈕帽 | 1 | PLA | 任意 | ~8 min |
| 門框夾背板(免鑽孔) | 1 | PLA/PETG | 白/黑 | ~20 min |
列印設定
- 材質:PLA;雷達罩严禁金屬漆/碳纖 PLA——24GHz 會被直接擋死,雷達變磚
- 燈殼壁厚:1.2mm(3 週)透光均勻;擴散罩乳白 2 層頂+實心側壁(防漏光)
- 填充:燈殼/雷達罩 10%(透光件厚實區靠壁數不靠填充)、背板 35% 線
- 層厚:0.2mm;擴散罩用 0.12mm 減層紋(亮燈後紋路透出來像掃描線,難看)
設計要點
- 雷達窗+遮板插槽:罩正面 22×13mm 開窗貼 PETG 片防塵;側預插槽insert角度遮板(鋁箔夾 PLA 芯),窗簾誤判時把偵測錘收窄 30°
- 門框夾背板:卡門縫 2.8mm 公差設計,免鑽孔免膠;燈殼與雷達罩都卡在同一背板上,調角度不用重貼
- 燈環導光柱:燈環光軸被擴散罩前 8mm 空腔打散,整圈均勻發光不見燈珠——相親級質感
- 散熱與防水:雷達 IC 微溫(<1W)無感;殼內走線槽讓 USB 線從背板下沿出,正面零開孔
💻 程式碼(韌體)
📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
* ESP32-C3 書房在座狀態燈 | mmWave Room Occupancy Light
* ------------------------------------------------------------
* 功能:LD2410C 呼吸級在座偵測、門外燈環狀態機、門內模式按鈕、
* NAS heartbeat 與專注統計、久坐閃黃、深夜自動調暗
* 硬體:ESP32-C3 SuperMini + LD2410C(UART) + WS2812 環×12
* + 微動按鈕 + 有源蜂鳴器(S8050)
* 供電:USB-C 5V 1A 全時(門外插座)
* 相依:Arduino core for ESP32 ≥3.0、FastLED ≥3.6
* 板型:ESP32C3 Dev Module / USB CDC on boot: Enabled
* ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <FastLED.h>
/* ------------------------------------------------------------
* 1. 使用者設定區
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* NAS_URL = "http://192.168.8.120:5000/room_state";
const char* ROOM_ID = "study";
#define PIN_RADAR_RX 20 // C3 RX ← 雷達 TX(交叉)
#define PIN_RADAR_TX 21
#define PIN_LED 6
#define PIN_BUTTON 3
#define PIN_BUZZER 0
#define NUM_LEDS 12
CRGB strips[NUM_LEDS];
/* 在座判定的兩個時間常數(本專案的靈魂,見概述) */
const uint32_t SETTLE_MS = 15UL * 1000; // 微動持續 15s 才算入座:防走過
const uint32_t LEAVE_MS = 10UL * 60000; // 歸零 10 分鐘才算離座:防上廁所
const uint32_t SITREMIND = 90UL * 60000; // 連續入座 90 分鐘閃黃提醒
/* 雷達三組距離閾值(cm):只認雷達正向 0~2.4m,
* 窗外行人(>6m 穿牆)與走廊(斜向)全被閾值擋掉 */
const uint8_t TH_REDETECT = 240; // 有體動偵測距離
const uint8_t TH_MOTION = 240; // 微動(呼吸級)偵測距離
const uint8_t TH_STILL = 180; // 靜態(呼吸)距離上限:坐位 1.2~1.8m
enum Mode { NORMAL, FOCUS, MEETING };
struct State { bool occupied; Mode mode; uint32_t sinceMs; };
/* ------------------------------------------------------------
* 2. 全域狀態
* ------------------------------------------------------------ */
bool occupied = false;
Mode mode = NORMAL;
uint32_t sinceMs = 0, stillSince = 0, moveSince = 0, sitStart = 0;
uint32_t lastBreath = 0, lastBeat = 0, lastFrameLog = 0;
uint8_t rptMotion = 0, rptStill = 0; // LD2410C 回報的能量值
bool netOk = false; uint8_t netFail = 0;
/* ------------------------------------------------------------
* parseRadar —— LD2410C UART 幀剝洋蔥
* LD2410C 每 200ms 吐 5.5KB:報頭 F4F3F2F1 + 報文 + F8F7F6F5。
* 我們只要「報文內 02 01 長度 FF」那一節點:
* idx+0..2 內容類型 02 01 FF, idx+3 長度,
* 之後 8 位元組:本體距離L,H 微動距離L,H 微動能量 靜止能量 …
* 參數:無(讀 Serial1 內部緩衝)
* 回傳:true = 本輪解析到有效能量對
* 為什麼手parse不發 library:幀格式五年沒改過,
* 40 行自己寫 < 依賴一個三年沒更新的第三方 lib
* ------------------------------------------------------------ */
static uint8_t rxBuf[512];
static size_t rxBufLen = 0;
bool parseRadar() {
while (Serial1.available()) {
// 環狀剝:找 F4F3F2F1 開頭,湊滿 5.5K 就丟棄掃下一幀
rxBuf[rxBufLen < sizeof(rxBuf) ? rxBufLen++ : (rxBufLen = 0)] = Serial1.read();
if (rxBufLen > 3 && rxBuf[rxBufLen-4] == 0xF8 && rxBuf[rxBufLen-3] == 0xF7
&& rxBuf[rxBufLen-2] == 0xF6 && rxBuf[rxBufLen-1] == 0xF5) {
// 在緩衝裡掃描 02 01 FF 能量節點
for (size_t i = 0; i + 14 < rxBufLen; i++) {
if (rxBuf[i] == 0x02 && rxBuf[i+1] == 0x01 && rxBuf[i+2] == 0xFF
&& rxBuf[i+3] == 0x08) {
rptStill = rxBuf[i+12]; // 靜止能量(呼吸訊號在這裡)
rptMotion = rxBuf[i+11]; // 微動能量(手勢/起身)
rxBufLen = 0;
return true;
}
}
rxBufLen = 0;
}
if (rxBufLen >= sizeof(rxBuf)) rxBufLen = 0; // 防呆:半幀重來
}
return false;
}
/* ------------------------------------------------------------
* updateOccupancy —— 在座狀態機(遲滞雙計時器)
* 入座:微動或靜止能量 > 閾值「連續」15s(SETTLE_MS)
* 離座:能量歸零「連續」10min(LEAVE_MS)
* 中途任何微動重置離座計時器——蓋毛毯滑手機也算在座,
* 這是特色不是 bug:我們要攔的是「打斷」不是「如廁」
* ------------------------------------------------------------ */
void updateOccupancy() {
uint32_t now = millis();
bool signal = (rptMotion > 10) || (rptStill > 4); // 能量死區見問題排除
if (signal) moveSince = now;
if (!occupied && signal && now - moveSince >= SETTLE_MS * 0 ) {
// 入座需要「連續」:signal 剛把 moveSince 刷新,用回溯計時
static uint32_t streakStart = 0; static bool prevSig = false;
if (!prevSig) streakStart = now;
prevSig = true;
if (now - streakStart >= SETTLE_MS) { occupied = true; sinceMs = now; sitStart = now; onFlip(); }
} else if (!signal) { /* 入座 streak 由 prevSig 管 */ }
static bool prevSig2 = false;
if (!signal) prevSig2 = false;
if (signal) prevSig2 = true;
if (occupied && now - moveSince >= LEAVE_MS) {
occupied = false; sinceMs = now; onFlip(); // 離座立即翻綠上報
}
}
/* ------------------------------------------------------------
* onFlip —— 狀態翻轉即報 NAS(不等 heartbeat)
* 為什麼:專注時長統計的邊界精度靠翻轉時刻,
* 30s heartbeat 誤差會把 25 分鐘的番茄鐘統計成 24.5
* ------------------------------------------------------------ */
void onFlip() { reportState(true); }
/* ------------------------------------------------------------
* reportState —— NAS heartbeat / 翻轉即報
* 參數:force = true 忽略 30s 間隔
* 網路掛了燈照樣準:燈效純本地狀態機,這裡只盡力而為
* ------------------------------------------------------------ */
void reportState(bool force) {
uint32_t now = millis();
if (!force && now - lastBeat < 30000) return;
lastBeat = now;
if (WiFi.status() != WL_CONNECTED) { netOk = false; return; }
HTTPClient http;
http.begin(String(NAS_URL) + "?room=" + ROOM_ID
+ "&occupied=" + (occupied ? 1 : 0)
+ "&mode=" + (int)mode + "&since=" + (millis() - sinceMs) / 1000);
http.setTimeout(2500);
int code = http.GET();
http.end();
netOk = (code == 200);
netFail = netOk ? 0 : netFail + 1;
if (netFail == 3) WiFi.reconnect(); // 離線 3 次重連,路由重啟自救
}
/* ------------------------------------------------------------
* readButton —— 短按循環模式 / 長按 3s 強制重置在座
* 回傳:true = 有事件
* 長按是「誤判自救閘」:家人坐著打盹被誤判離座時,
* 按三秒立刻救回紅燈,不用等雷達慢慢確認
* ------------------------------------------------------------ */
bool readButton() {
static uint32_t downAt = 0; static bool last = HIGH, longFired;
bool lvl = digitalRead(PIN_BUTTON);
uint32_t now = millis();
if (last == HIGH && lvl == LOW) { downAt = now; longFired = false; }
if (lvl == LOW && !longFired && now - downAt >= 3000) {
longFired = true; // 長按:重置在座計時
occupied = true; sinceMs = now; sitStart = now; onFlip();
tone(PIN_BUZZER, 1200, 60); // 按到長按要有回饋
return true;
}
if (last == LOW && lvl == HIGH && !longFired && now - downAt < 600) {
mode = (Mode)(((int)mode + 1) % 3); // 正常→專注→會議→正常
sinceMs = now; onFlip();
if (mode != FOCUS) tone(PIN_BUZZER, 880, 50); // 專注進場不嗶:靜音本身是功能
return true;
}
last = lvl;
return false;
}
/* ------------------------------------------------------------
* paintLed —— 燈效引擎:狀態機輸出(顏色, 模式)
* 綠=可進入 / 紅=使用中 / 慢呼吸紅=深度專注 / 黃閃=久坐
* 深夜 22:00~07:00 亮度 15%(SNTP 校時,斷網走本地鐘)
* 為什麼 sin² 呼吸:線性明滅在眼角有「閃爝感」,
* sin² 曲線接近人眼對亮度的對數響應,像睡眠呼吸
* ------------------------------------------------------------ */
void paintLed() {
uint32_t now = millis();
float b = 1.0f;
struct tm t; time_t clk = time(nullptr); localtime_r(&clk, &t);
if (t.tm_hour >= 22 || t.tm_hour < 7) b = 0.15f;
CRGB base; bool breath = false; float speed = 1.0f;
if (!occupied) { base = CRGB(0, 160, 60); breath = true; speed = 0.4f; } // 綠:慢呼吸=歡迎
else if (mode == NORMAL){ base = CRGB(200, 30, 30); } // 紅:恆亮=使用中
else if (mode == FOCUS) { base = CRGB(220, 20, 20); breath = true; speed = 0.33f; } // 呼吸紅:3s 一呼=深度專注
else { base = CRGB(220, 60, 0); } // 會議:橙紅恆亮
// 久坐閃黃:優先級蓋過狀態色,10 秒後自動收(只在門外閃、不嗶不推播)
if (occupied && now - sitStart > SITREMIND && now - sitStart < SITREMIND + 10000) {
base = CRGB(255, 170, 0); breath = false;
if (((now / 400) % 2) != 0) { FastLED.clear(true); return; } // 400ms 明滅
}
fill_solid(strips, NUM_LEDS, base);
float g = breath ? 0.25f + 0.75f * sq(sinf(now * 0.0021f * speed)) // sin²:柔性呼吸曲線
: 0.75f;
for (int i = 0; i < NUM_LEDS; i++) fadesub8(strips[i], (uint8_t)(g * 255 * b));
FastLED.show();
}
/* ------------------------------------------------------------
* setup —— 雷達上電序列:等穩定再校時校鐘
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
Serial1.begin(256000, SERIAL_8N1, PIN_RADAR_RX, PIN_RADAR_TX); // LD2410C: 256000
FastLED.addLeds<WS2812, PIN_LED, GRB>(strips, NUM_LEDS).setCorrection(TypicalLEDStrip);
pinMode(PIN_BUTTON, INPUT_PULLUP);
pinMode(PIN_BUZZER, OUTPUT);
WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
for (int i = 0; i < 40 && WiFi.status() != WL_CONNECTED; i++) delay(250);
configTime(8 * 3600, 0, "192.168.8.120", "pool.ntp.org"); // NAS 先當校時源
delay(1200); // 雷達上電後約 1s 才吐穩定幀,之前是亂碼
tone(PIN_BUZZER, 660, 120); // 嗶=就緒(開門見山的自證)
}
/* ------------------------------------------------------------
* loop —— 主循環:解析→判定→燈效→heartbeat
* ------------------------------------------------------------ */
void loop() {
parseRadar();
updateOccupancy();
readButton();
paintLed();
reportState(false);
delay(5); // 5ms:餵 watchdog、又不漏 200ms 幀(256kbaud 一幀 21ms)
}
⚠️ LD2410C 出廠「靜態回報」開關各批次不一:呼吸級偵測全靠這檔——用藍牙模組連官方 HC-08 App 確認「無人上報」與「靜態微動」都是開啟,這是本專案最重要的 5 分鐘。
🏗️ 組裝步驟
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
- 列印零件:4 個零件;雷達罩確認是素色 PLA(碳纖=雷達瞎)
- 雷達 App 調校:HC-08 開靜態回報、感度本體 60/微動 50/靜止 30 起步,距離閾值 240/240/180
- 焊接與接線:UART 交叉;雷達 VDD 就近 10µF;燈環端 1000µF
- 燒錄與能量觀測:序列埠看 rptMotion/rptStill——坐著打字 ~40/8,蓋毯滑手機 ~12/5,空房 0/0
- 門框夾背板定位:雷達面與椅子同高(±30cm)、正對坐姿位置、離椅背 1.2~1.8m
- NAS 儀表板:收 heartbeat 畫專注曲線;週日 21:00 Telegram 週報
- 七天驗證:每天對照 NAS 曲線與真實作息,誤判點記進備忘錄調閾值
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 沒人也亮紅 | 掃到窗簾飄動/貓窩/隔壁床 | App 調低靈敏度、收窄距離閾值;裝角度遮板;貓窩方向加遮板 |
| 有人在卻變綠 | 「靜態回報」沒開/靜止閾值過高 | HC-08 App 開啟靜態回報;TH_STILL 能量死區從 4 降到 2 |
| 兩台互相干擾 | 相鄰房間對裝同頻撞車 | 錯開安裝高度 >50cm、面相反方向;LD2410 有跳頻但大面積遮擋仍會撞 |
| 燈環首燈亂閃 | 5V 線長壓降+缺儲能電容 | 燈環端加 1000µF;5V 獨立走線回電源頭,不與雷達共用細線 |
| 深夜燈太亮刺眼 | 預設亮度全天不變 | 本韌體已內建 22:00~07:00 亮度 15%(SNTP,斷網走本地鐘) |
| NAS 週報斷更 | router 重啟後 C3 沒重連 | 本韌體離線 3 次自動 reconnect;仍在离線就查 NAS 端 5000 埠防火牆 |
🎯 進階延伸
- 多房間儀表板:4 間房各一台,NAS 一屏看全家「誰在忙」——紅燈多的房間自動降訪
- 門鈴聯動:訪客按門鈴,只有綠燈房間的蜂鳴器會響——紅燈房間的人從手機看到「有人找你」自選擇
- 日曆雙閘:讀 NAS CalDAV,行事曆會議進行中自動轉會議模式(雷達+日曆雙確認,开会中途倒水也不破燈)
- ESP32-H2 + Thread 版:改走 Thread/Matter 接 Home Assistant 原生生態,電池供電全年免充——另開一篇的主題
📦 檔案清單
occupancy-light/
├── src/
│ └── occupancy-light.ino # ESP32 韌體(本頁完整程式)
├── stl/
│ ├── lamp-shell.stl # 門外燈殼(燈環+擴散罩)
│ ├── radar-cover.stl # 雷達罩(含遮板插槽)
│ ├── button-cap.stl # 門內按鈕帽
│ └── doorframe-clip.stl # 門框夾背板(免鑽孔)
├── f3d/
│ └── occupancy-design.f3d # Fusion 360 原始檔
├── nas/
│ ├── room_state.py # NAS 端接收端點+儀表板(Flask)
│ └── weekly_report.py # 週日 Telegram 專注週報
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件