📋 專案概述

我侄女去年升國中,媽媽規定每晚九點開始寫作業。三個月後她在家族群組抱怨最多的不是寫不完,而是「叫了八次進去幫她調亮度、關燈——她那個位置的光不是太暗就是刺眼」。更扯的是她原本那盪紅外線感應桌燈:寫作業寫到一動不動,燈直接熄滅,十一歲的小孩坐在黑暗裡揮手的樣子,被爸爸調侃像在演恐怖片。

市售感應燈的致命傷就在這裡:紅外線(PIR)偵測的是「移動的熱源」,偵測不到靜止的人。写作业、看書、打電腦——恰恰都是「人在、不動」的情境。查了一圈對策:米家感應桌燈組合要 1,300 元起、亮度只有三段、照度不會跟著環境調;飛利浦慧智桌燈 3,000 元起、人體感應要再加購。而「人在不在(靜止也算)+光夠不夠」這兩個需求,其實只要毫米波雷達 + 照度迴圈兩顆 100 元以內的感測器就解了。

這個專案做一盞400 元出頭的智能護眼桌燈:

裝好之後最常被誇的一句話是:「這燈好像知道我在。」——毫米波雷達就是這句話背後的零件。

為什麼選 ESP32-C3?

需求C3 的優勢
GPIO 數量剛好UART 2 支 + I2C 2 支 + 編碼器 3 支 + PWM 1 支 = 8 支,C3 的 GPIO0~10 加 TX/RX 線線有落著
成本優先SuperMini 約 45 元,比 C6 再省一點;本專案用不到 Wi-Fi 6,錢花在雷達上
單核夠快160 MHz RISC-V 跑「20ms 軟過渡 + 50ms 照度輪詢 + 雷達幀解析」,負載 <2%
BLE 未來性C3 內建 BLE 5.0,日後要手機 App 調 lux 映射、離座時間,韌體不用換板

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C3 SuperMini1~45 元必須有引出 GPIO20/21(UART)
人在感應LD2410 毫米波雷達(纜線天線版)1~95 元5V 供電、UART 3.3V 邏輯;纜線天線好藏窗件
照度BH1750FVI 模組1~35 元ADDR 接 GND(位址 0x23)
調光開關AO3400 MOS 開關模組1~10 元板載 10kΩ 下拉,閘極不用另加
光源12V COB LED 燈條 1m + 鋁擠條 20cm1~150 元COB 線光源無光斑、演色 Ra>90
電源12V 2A 電源適配器1~60 元燈條 + 降壓共用
降壓MP1584 mini 降壓模組(12V→5V)1~15 元輸出先調到 5.0V 再接板
旋鈕KY-040 旋轉編碼器(附按開關)1~15 元按鍵 = 自動/手動切換
線材五金杜邦線 + 18AWG 電源線 + M3 銅柱螺母1 批~15 元12V 側一律用 18AWG,不用杜邦線
合計~440 元同功能商品 1,300 元起跳

🔌 接線圖

ESP32-C3 SuperMini USB-C(燒錄用) GPIO1 PWM GPIO3 ENC-A GPIO4 ENC-B GPIO5 ENC-SW GPIO6 SDA GPIO7 SCL GPIO20 TX→雷達 GPIO21 RX←雷達 BH1750 照度(感光窗朝上) SDA SCL LD2410 毫米波(窗件內) RX TX KY-040 旋鈕 AO3400 MOS 模組 SIG ← GPIO1 · OUT → 燈條 12V(+) → 燈條(+);燈條(−) → MOS OUT;MOS GND ← 降壓 GND(單點共地)
▲ 雷達走 UART(黃)、BH1750 走 I2C(綠)、編碼器 3 支(藍)、PWM 調光(紅)。12V 大電流路徑(虛線文字)走燈座後段,與訊號線分槽。

詳細接線

ESP32-C3 腳位連接說明
GPIO1MOS 模組 SIGPWM 調光;模組板載 10kΩ 下拉,開機瞬間燈不會全亮
GPIO3 / 4 / 5編碼器 A / B / SW啟用內部上拉;A/B 雙邊中斷計數
GPIO6 / 7BH1750 SDA / SCL模組自帶 4.7kΩ 上拉;ADDR 腳接 GND
GPIO20 / 21LD2410 RX / TX交叉互連(板 TX→模組 RX);雷達 5V 供電、邏輯 3.3V 直連
5V 腳MP1584 輸出(先調好 5.0V)SuperMini 的 5V 腳直接供電
GND12V−、降壓−、各模組 GND全部在燈座內單點共地,避免地迴路雜訊
ESP32-C3 LD2410 (雷達) BH1750 (照度) ┌──────────┐ ┌────────────┐ ┌──────────┐ │ GPIO20 ●───────● RX VCC ●── 5V │ │ SDA ●── GPIO6 │ GPIO21 ●───────● TX GND ●── GND│ │ SCL ●── GPIO7 │ │ └────────────┘ │ ADDR ●── GND │ GPIO6 ●──────────────────────────● VCC ●── 3V3 │ GPIO7 ●──────────────────────────● GND ●── GND │ GPIO3/4/5●── 編碼器 A/B/SW └──────────┘ │ GPIO1 ●── MOS.SIG ─ 燈條: 12V(+)─▶ strip(+) │ 5V ●── MP1584 OUT(5V) strip(−)─▶ MOS.OUT │ GND ●── 單點共地匯流排 MOS.GND ─▶ 共地 └──────────┘
⚠️ MP1584 輸出務必先用電表調準 5.0V 再接板——出廠預設可能是 12V 直出,一插下去 C3 直接报销。12V 燈條線用 18AWG 以上,杜邦線撐不起 1A。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
燈座主體(配重艙 + 電子艙)1PLA深灰~3.5 hr
感測器窗件(LD2410 + BH1750)1PLA深灰~40 min
燈臂夾座(夾鋁擠條)1PLA深灰~50 min
旋鈕帽1TPU 95A黑~15 min

列印設定

設計要點

  1. 毫米波窗件壁厚 ≤1.2mm:PLA 超過 3mm 對 24GHz 衰減明顯,雷達直接變近視;纜線天線把雷達板推到貼窗位置,窗後不堆電子零件
  2. BH1750 感光窗朝上 45°:量的是「環境光 + 桌面反射光」混合照度,比直視天花板更接近眼睛感受
  3. 後段配重艙 240g:塞 4 顆 M4 螺母,燈臂前伸 35cm 按鍵不翻倒——桌燈翻倒砸筆電的損失比螺絲貴 200 倍
  4. 鋁擠條 200mm 是散熱也是結構:COB 燈條 1m 約 8W,鋁擠背貼(撕離型膜 + 導熱膏)燈條壽命差 3 倍
  5. 訊號槽 / 電源槽分離:燈座內走線分兩槽,12V PWM 大電流線與 I2C/UART 訊號線不同槽,抗調光雜訊

💻 程式碼

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
 *  ESP32-C3 智能護眼桌燈  |  Presence-Aware Desk Lamp
 *  ------------------------------------------------------------
 *  功能:LD2410 毫米波人在感應(靜止不滅燈)+ BH1750 照度迴圈
 *        自動調光 + 旋鈕手動超控 + 5kHz PWM 軟過渡無頻閃
 *  硬體:ESP32-C3 SuperMini + LD2410(UART) + BH1750(I2C)
 *        + KY-040 編碼器 + AO3400 MOS 調光 12V COB 燈條
 *  供電:12V 2A → 燈條;MP1584 降 5V → 板 5V 腳
 *  相依:Arduino core for ESP32 ≥ 3.0(僅用 Wire,無外部庫)
 *  板型:Board: "ESP32C3 Dev Module"
 *  ============================================================ */

#include <Wire.h>

/* ------------------------------------------------------------
 * 1. 腳位與行爲參數
 * ------------------------------------------------------------ */
#define PWM_PIN   1     // MOS 模組 SIG(板載下拉,開機燈不亮)
#define ENC_A     3
#define ENC_B     4
#define ENC_SW    5
#define LD_RX     21    // C3 RX ← LD2410 TX
#define LD_TX     20    // C3 TX → LD2410 RX

const uint8_t  BH_ADDR = 0x23;          // ADDR 接 GND
const uint16_t PWM_HZ = 5000;           // 5kHz:低於 20kHz 但遠高於攝影機閃頻閾值,
                                        // 手機拍桌面影片不會出現滾動條紋
const uint8_t  PWM_RES = 10;            // 0~1023
const uint32_t RAMP_STEP_MS = 20;       // 軟過渡節奏
const uint16_t RAMP_FULL_MS = 800;      // 0→100% 用 0.8 秒:夜間開燈不嚇眼
const uint16_t RAMP_STEP = (1023 * RAMP_STEP_MS) / RAMP_FULL_MS;

/* 照度 → 亮度映射(互補照明:環境越暗補越多) */
const uint16_t LUX_MIN = 20;            // ≤20 lux:補到 100%
const uint16_t LUX_MAX = 800;           // ≥800 lux:環境夠亮,留 25% 氛圍光
const uint8_t  LEVEL_MIN = 25;
const uint8_t  LEVEL_MAX = 100;

/* 人在邏輯 */
const uint32_t LEAVE_OFF_MS = 10UL * 60UL * 1000UL; // 離座 10 分鐘關燈
const uint32_t MANUAL_TIMEOUT_MS = 10UL * 1000UL;   // 手動 10 秒無操作回自動

/* ------------------------------------------------------------
 * 2. 全域狀態
 * ------------------------------------------------------------ */
float    currentPwm = 0;      // 目前實際輸出(浮點,軟過渡用)
float    targetPwm  = 0;      // 目標輸出
bool     manualMode = false;
uint32_t manualUntil = 0;     // 手動模式逾時點
bool     present = false;
uint32_t lastSeenMs = 0;      // 最後一次「有人在」時間
uint32_t lux = 0;             // 最近照度(中值濾波後)
volatile int32_t encPos = 0;  // 編碼器累計位置(中斷修改,volatile)
uint32_t encLastIntMs = 0;

/* LD2410 幀解析狀態機 */
uint8_t  ldBuf[32];
uint8_t  ldIdx = 0;

/* ------------------------------------------------------------
 * gammaPwm —— 線性亮度% → gamma 2.2 補償後的 PWM 值
 *   參數:pct 0~100(人眼感知線性)
 *   回傳:0~1023
 *   為什麼:LED 亮度與 PWM 佔空比近似線性,但人眼感知是
 *   次方律——不補 gamma 的話,低亮度區「一轉就爆亮」
 * ------------------------------------------------------------ */
uint16_t gammaPwm(float pct) {
  if (pct <= 0) return 0;
  if (pct >= 100) return 1023;
  return (uint16_t)(powf(pct / 100.0f, 2.2f) * 1023.0f + 0.5f);
}

/* ------------------------------------------------------------
 * readLux —— 讀 BH1750(1 次解析度模式,含逾時保護)
 *   回傳:照度 lux;讀取失敗回傳 0xFFFFFFFF(呼叫端保留舊值)
 *   為什麼手刻 I2C:省掉整個庫,且重試邏輯自己可控
 * ------------------------------------------------------------ */
uint32_t readLux() {
  Wire.beginTransmission(BH_ADDR);
  Wire.write(0x21);                    // 0.5 lux 解析度、單次測量
  if (Wire.endTransmission() != 0) return 0xFFFFFFFF;
  delay(120);                          // 單次測量需 ~120ms,急會讀到 0
  Wire.requestFrom((uint8_t)BH_ADDR, (uint8_t)2);
  if (Wire.available() < 2) return 0xFFFFFFFF;
  uint16_t raw = (Wire.read() << 8) | Wire.read();
  return raw / 1.2f;                   // 資料手冊換算係數
}

/* ------------------------------------------------------------
 * luxToLevel —— 照度 → 目標亮度%(對數映射)
 *   參數:lux 環境照度
 *   回傳:LEVEL_MIN~LEVEL_MAX
 *   為什麼用對數:照度本身是倍數感(50→100 與 400→800 的
 *   「亮一倍」感受相同),線性映射會讓白天區間全擠在轉頭
 * ------------------------------------------------------------ */
uint8_t luxToLevel(uint32_t luxVal) {
  if (luxVal <= LUX_MIN) return LEVEL_MAX;
  if (luxVal >= LUX_MAX) return LEVEL_MIN;
  float t = logf((float)luxVal - LUX_MIN + 1) /
            logf((float)(LUX_MAX - LUX_MIN));
  t = constrain(t, 0.0f, 1.0f);
  return (uint8_t)(LEVEL_MAX - t * (LEVEL_MAX - LEVEL_MIN));
}

/* ------------------------------------------------------------
 * ldParseByte —— LD2410 UART 幀逐字節狀態機
 *   幀格式:55 00 11 55 00 | 動目標 動距離 靜目標 靜距離 …
 *           | 和檢驗 | FE ED AB CD
 *   回傳:true = 本幀「有人在」(動目標或靜目標 > 0)
 *   為什麼要驗和:雷達供電雜訊會造成跳字,驗和擋掉誤觸
 * ------------------------------------------------------------ */
bool ldParseByte(uint8_t b) {
  static uint8_t stage = 0;            // 0=找幀頭 1=收資料 2=驗收
  static uint8_t len = 0, need = 0;
  if (stage == 0) {                    // 找幀頭 55 00 11 55 00
    ldBuf[ldIdx++] = b;
    if (ldIdx == 5) {
      if (ldBuf[0] == 0x55 && ldBuf[1] == 0x00 && ldBuf[2] == 0x11 &&
          ldBuf[3] == 0x55 && ldBuf[4] == 0x00) {
        len = 0; need = ldBuf[2];      // 資料段長度 0x11
        stage = 1;
      }
      ldIdx = 0;
    }
    return false;
  }
  if (stage == 1) {                    // 收 data + checksum + 幀尾
    ldBuf[ldIdx++] = b;
    if (++len == need + 5) {           // data(need) + cks(1) + tail(4)
      uint8_t cks = 0;
      for (uint8_t i = 0; i < need + 3; i++) cks += ldBuf[i];  // 由長度欄起算
      stage = 0; ldIdx = 0;
      bool tailOk = (ldBuf[need + 3] == 0xFE && ldBuf[need + 4] == 0xED &&
                     ldBuf[need + 5] == 0xAB && ldBuf[need + 6] == 0xCD);
      if (cks != ldBuf[need + 2] || !tailOk) return false;      // 壞幀直接丟
      uint8_t moving = ldBuf[0];       // data[0] 移動目標狀態
      uint8_t still  = ldBuf[3];       // data[3] 靜止目標狀態
      return (moving > 0) || (still > 0);
    }
    return false;
  }
  return false;
}

/* ------------------------------------------------------------
 * 編碼器中斷 —— A 相雙邊計數 + 1ms 消抖
 *   為什麼不用庫:KY-040 無段點火,1ms 消抖 + 讀 B 相判向
 *   已經夠穩,省一個依賴
 * ------------------------------------------------------------ */
void IRAM_ATTR onEnc() {
  uint32_t now = millis();
  if (now - encLastIntMs < 1) return;  // 消抖:1ms 內的抖動脈衝直接丟
  encLastIntMs = now;
  if (digitalRead(ENC_B) == digitalRead(ENC_A)) encPos--;
  else                                          encPos++;
}

/* ------------------------------------------------------------
 * applyTarget —— 依模式計算本輪目標亮度
 *   優先序:無人關燈 > 手動 > 照度自動
 * ------------------------------------------------------------ */
void applyTarget() {
  uint32_t now = millis();
  if (!present && (now - lastSeenMs) > LEAVE_OFF_MS) {
    targetPwm = 0;                     // 離座逾時:關燈(手動模式也關,省電)
    return;
  }
  if (manualMode) {
    if (now > manualUntil) {           // 手動逾時:以當前亮度無縫回自動
      manualMode = false;
    } else {
      targetPwm = gammaPwm(map(encPos, 0, 100, 0, 100) * 1.0f);
      return;
    }
  }
  targetPwm = gammaPwm(luxToLevel(lux));
}

/* ------------------------------------------------------------
 * setup —— 開機:PWM → I2C → 雷達 → 編碼器
 * ------------------------------------------------------------ */
void setup() {
  Serial.begin(115200);

  ledcAttach(PWM_PIN, PWM_HZ, PWM_RES);   // core 3.x API
  ledcWrite(PWM_PIN, 0);

  Wire.begin(6, 7);                       // SDA=GPIO6, SCL=GPIO7
  Serial1.begin(25600, SERIAL_8N1, LD_RX, LD_TX);  // LD2410 出廠 25600

  pinMode(ENC_A, INPUT_PULLUP);
  pinMode(ENC_B, INPUT_PULLUP);
  pinMode(ENC_SW, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(ENC_A), onEnc, RISING);

  lux = readLux();
  if (lux == 0xFFFFFFFF) lux = 300;      // 感測器還沒就緒:給個安全中間值
  lastSeenMs = millis();                 // 開機預設有人在,避免「裝完就黑」
  Serial.println("desk lamp ready");
}

/* ------------------------------------------------------------
 * loop —— 20ms 節奏:雷達幀 → 照度(每 50ms) → 目標 → 軟過渡
 * ------------------------------------------------------------ */
void loop() {
  static uint32_t tickMs = 0;
  static uint8_t  luxDiv = 0;
  static uint32_t luxWin[3]; static uint8_t luxN = 0;

  /* --- 雷達:非阻塞逐字節餵入 --- */
  while (Serial1.available()) {
    if (ldParseByte((uint8_t)Serial1.read())) {
      present = true;
      lastSeenMs = millis();
    }
  }

  /* --- 照度:每 50ms 取一次,3 點視窗取中值 --- */
  if (++luxDiv >= 3) {                   // 20ms × 3 ≈ 60ms 一讀
    luxDiv = 0;
    uint32_t v = readLux();
    if (v != 0xFFFFFFFF) {               // 失敗保留舊值,燈不會亂跳
      luxWin[luxN++ % 3] = v;
      if (luxN >= 3) {
        uint32_t a = luxWin[0], b = luxWin[1], c = luxWin[2];
        lux = a + b + c - min(a, min(b, c)) - max(a, max(b, c)); // 中值
      }
    }
  }

  /* --- 手動/自動切換(按鍵) --- */
  static uint32_t swLastMs = 0;
  if (digitalRead(ENC_SW) == LOW && millis() - swLastMs > 400) {
    swLastMs = millis();
    manualMode = !manualMode;
    manualUntil = millis() + MANUAL_TIMEOUT_MS;
    encPos = map((int)(targetPwm == 0 ? 0 : 100 * (targetPwm / 1023.0f)), 0, 100, 0, 100);
    // 切手動時把「當前亮度」同步進旋鈕位置,避免一轉就跳變
  }

  applyTarget();

  /* --- 軟過渡:每 20ms 向目標逼近一步 --- */
  if (currentPwm < targetPwm) currentPwm = min((float)targetPwm, currentPwm + RAMP_STEP);
  else if (currentPwm > targetPwm) currentPwm = max((float)targetPwm, currentPwm - RAMP_STEP);
  ledcWrite(PWM_PIN, (uint32_t)currentPwm);

  delay(RAMP_STEP_MS);
  (void)tickMs;
}

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 列印零件:窗件感測窗 100% 填滿 + 0.12mm 層厚;燈座壓入 4 顆熱熔銅螺母
  2. 燈條貼鋁擠條:撕離型膜、沿鋁擠中央貼直,兩端各留 3cm 尾線打錫
  3. 感測窗件組裝:LD2410 板貼窗(纜線天線拉直貼窗緣)、BH1750 卡進 45° 感光窗
  4. 先接 12V 側:電源→燈條→MOS→共地,用電源器直接碰觸測燈條亮不亮,再鎖進燈座
  5. 再接 3.3V 側:MP1584 先調 5.0V 才接板;全部照接線表完成後才燒錄
  6. 校正:雷達靈敏度(序列指令)、lux 上下限依書桌實測調整、離座关灯時間試坐
  7. 收尾:塞配重螺母、鎖燈臂、套 TPU 旋鈕帽,完整跑「離座 10 分 → 回座亮燈」循環

🔧 問題排除

問題原因解決方法
開機瞬間燈全亮再閃爍MOS 閘極浮接,PWM 腳未初始化前雜訊用板載下拉的 AO3400 模組;或自行加 10kΩ 閘極→GND
人坐著燈還是關了窗件太厚 / 靜止靈敏度不足 / 裝設角度錯窗厚 ≤1.2mm;雷達對準人體軀幹高度(非桌面);必要時縮短窗件與雷達距離
沒人在燈常亮電扇、窗簾、硬碟震動被當「微動」序列指令調降靜目標靈敏度、縮小距離上限;燈具避開電扇氣流路徑
BH1750 恆讀 121 luxADDR 腳浮接,位址飄到 0x5CADDR 明確接 GND(本專案位址 0x23)
旋鈕轉一下跳好幾格消抖視窗太短 / 線太長本程式已 1ms 消抖;編碼器線控制在 10cm 內
插 12V 後板子重開機降壓器開關雜訊耦合到 5VMP1584 輸入並 220µF 電解;12V 線與訊號線分槽走
雷達偶爾整幀亂碼5V 供電紋波雷達電源加 100µF;確認不是從板子 3V3 拉電(它撐不住雷達峰值)

🎯 進階延伸

📦 檔案清單

smart-desk-lamp/
├── src/
│   └── smart-desk-lamp.ino      # ESP32 程式碼(本頁完整程式)
├── stl/
│   ├── lamp-base.stl            # 燈座主體(配重艙+電子艙)
│   ├── sensor-bezel.stl         # 感測器窗件
│   ├── arm-clip.stl             # 燈臂夾座
│   └── knob.stl                 # TPU 旋鈕帽
├── f3d/
│   └── smart-desk-lamp-design.f3d
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件
← 上一篇:ESP32-C6 番茄鐘專注方塊 📚 系列索引 下一篇:ESP32 發酵恆溫箱 →