📋 專案概述

遠端工作第二年,你最貴的不是設備,是「開會開到一半被推門」的那一刻。上週三下午 2:40,你在第 11 頁 spec 裡潛水,門被推開——小孩要水、三十分鐘後家人問晚餐吃什麼、再十五分鐘快遞透過門縫喊名字。研究數字:一次打斷,平均要 23 分鐘才回得到原本的專注深度。一天三次打斷 = 半個工作日蒸發。

市售方案全數查過,全都不對味:

本專案的核心是毫米波雷達 LD2410C:隔著椅背、蓋著毛毯坐著一動不動,呼吸帶動的微動它照樣看得見。整台機器沒有按鈕依賴、沒有相機、沒有麥克風:

為什麼選 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 SuperMini1~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 SuperMini・USB-C GPIO20(RX) GPIO3 GPIO0 GPIO21(TX) GPIO6 5V LD2410C 雷達 TX・RX・VDD(5V)・GND 青線:TX→GPIO20(RX)、RX←GPIO21(TX) 交叉! 紅線:VDD←5V(雷達要 5V,勿接 3V3) 燈環 綠線:GPIO6 →[330Ω]→ DIN+1000µF 門內按鈕→GND 蜂鳴器←S8050 GPIO0 →[1k]→ B 極
▲ UART 一定要交叉(雷達 TX→板 RX);雷達吃 5V 但 TX 輸出是 3.3V TTL——直連 C3 安全,反過來的話見下方準位轉換說明。

詳細接線

ESP32-C3 腳位連接說明
GPIO20 (RX)LD2410C TX雷達 256 位元組狀態幀流
GPIO21 (TX)LD2410C RX下發靈敏度/閾值指令用
GPIO6WS2812B DIN串 330Ω;燈環端就近 1000µF
GPIO3按鈕 → GND內部上拉;短按循環/長按重置
GPIO0蜂鳴器(經 S8050)1kΩ 基極電阻;預留 PWM 位
5V / GNDLD2410C、燈環雷達與燈環電流都不走板載 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 列印零件

零件清單

零件數量材質建議顏色列印時間
門外燈殼(燈環+乳白擴散罩)1PLA任意+乳白~50 min
雷達罩(帶角度遮板插槽)1PLA素色任意~25 min
門內按鈕帽1PLA任意~8 min
門框夾背板(免鑽孔)1PLA/PETG白/黑~20 min

列印設定

設計要點

  1. 雷達窗+遮板插槽:罩正面 22×13mm 開窗貼 PETG 片防塵;側預插槽insert角度遮板(鋁箔夾 PLA 芯),窗簾誤判時把偵測錘收窄 30°
  2. 門框夾背板:卡門縫 2.8mm 公差設計,免鑽孔免膠;燈殼與雷達罩都卡在同一背板上,調角度不用重貼
  3. 燈環導光柱:燈環光軸被擴散罩前 8mm 空腔打散,整圈均勻發光不見燈珠——相親級質感
  4. 散熱與防水:雷達 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 組裝說明書
  1. 列印零件:4 個零件;雷達罩確認是素色 PLA(碳纖=雷達瞎)
  2. 雷達 App 調校:HC-08 開靜態回報、感度本體 60/微動 50/靜止 30 起步,距離閾值 240/240/180
  3. 焊接與接線:UART 交叉;雷達 VDD 就近 10µF;燈環端 1000µF
  4. 燒錄與能量觀測:序列埠看 rptMotion/rptStill——坐著打字 ~40/8,蓋毯滑手機 ~12/5,空房 0/0
  5. 門框夾背板定位:雷達面與椅子同高(±30cm)、正對坐姿位置、離椅背 1.2~1.8m
  6. NAS 儀表板:收 heartbeat 畫專注曲線;週日 21:00 Telegram 週報
  7. 七天驗證:每天對照 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 埠防火牆

🎯 進階延伸

📦 檔案清單

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                    # 說明文件
← 上一篇:ESP32-S3 本地端 AI 語音助理 📚 系列索引 下一篇:ESP32-S3 靜音鼓練習墊 →