📋 專案概述
我侄女去年升國中,媽媽規定每晚九點開始寫作業。三個月後她在家族群組抱怨最多的不是寫不完,而是「叫了八次進去幫她調亮度、關燈——她那個位置的光不是太暗就是刺眼」。更扯的是她原本那盪紅外線感應桌燈:寫作業寫到一動不動,燈直接熄滅,十一歲的小孩坐在黑暗裡揮手的樣子,被爸爸調侃像在演恐怖片。
市售感應燈的致命傷就在這裡:紅外線(PIR)偵測的是「移動的熱源」,偵測不到靜止的人。写作业、看書、打電腦——恰恰都是「人在、不動」的情境。查了一圈對策:米家感應桌燈組合要 1,300 元起、亮度只有三段、照度不會跟著環境調;飛利浦慧智桌燈 3,000 元起、人體感應要再加購。而「人在不在(靜止也算)+光夠不夠」這兩個需求,其實只要毫米波雷達 + 照度迴圈兩顆 100 元以內的感測器就解了。
這個專案做一盞400 元出頭的智能護眼桌燈:
- LD2410 毫米波「人在感應」:微動都抓得到,坐著寫字一小時燈不滅;人離座 10 分鐘才關燈
- BH1750 照度迴圈自動調光:環境越暗燈補得越亮(互補照明邏輯),白天不浪費瓦數
- 旋鈕手動超控:轉一下切手動微調,10 秒無操作無縫回到自動
- 5 kHz PWM 無頻閃 + gamma 2.2 曲線:低亮度不閃眼;0.8 秒漸亮漸滅,夜間不嚇眼
- 全 3D 列印燈座與感測窗件,COB 燈條鋁擠條散熱,演色性 >90
裝好之後最常被誇的一句話是:「這燈好像知道我在。」——毫米波雷達就是這句話背後的零件。
為什麼選 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 SuperMini | 1 | ~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 + 鋁擠條 20cm | 1 | ~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 腳位 | 連接 | 說明 |
|---|---|---|
| GPIO1 | MOS 模組 SIG | PWM 調光;模組板載 10kΩ 下拉,開機瞬間燈不會全亮 |
| GPIO3 / 4 / 5 | 編碼器 A / B / SW | 啟用內部上拉;A/B 雙邊中斷計數 |
| GPIO6 / 7 | BH1750 SDA / SCL | 模組自帶 4.7kΩ 上拉;ADDR 腳接 GND |
| GPIO20 / 21 | LD2410 RX / TX | 交叉互連(板 TX→模組 RX);雷達 5V 供電、邏輯 3.3V 直連 |
| 5V 腳 | MP1584 輸出(先調好 5.0V) | SuperMini 的 5V 腳直接供電 |
| GND | 12V−、降壓−、各模組 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 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 燈座主體(配重艙 + 電子艙) | 1 | PLA | 深灰 | ~3.5 hr |
| 感測器窗件(LD2410 + BH1750) | 1 | PLA | 深灰 | ~40 min |
| 燈臂夾座(夾鋁擠條) | 1 | PLA | 深灰 | ~50 min |
| 旋鈕帽 | 1 | TPU 95A | 黑 | ~15 min |
列印設定
- 材質:PLA(桌面室內使用);窗件改 0.12mm 層厚求表面平整
- 填滿:燈座 30%(結構件要挺);其餘 15% 網格
- 窗件:感測窗壁薄 1.2mm、實心 100% 填滿——薄而實心比厚而中空對毫米波更透明
- 特殊:燈座面朝下列印,電子艙頂蓋開孔留到最后頂層,熱熔銅螺母 205°C 壓入
設計要點
- 毫米波窗件壁厚 ≤1.2mm:PLA 超過 3mm 對 24GHz 衰減明顯,雷達直接變近視;纜線天線把雷達板推到貼窗位置,窗後不堆電子零件
- BH1750 感光窗朝上 45°:量的是「環境光 + 桌面反射光」混合照度,比直視天花板更接近眼睛感受
- 後段配重艙 240g:塞 4 顆 M4 螺母,燈臂前伸 35cm 按鍵不翻倒——桌燈翻倒砸筆電的損失比螺絲貴 200 倍
- 鋁擠條 200mm 是散熱也是結構:COB 燈條 1m 約 8W,鋁擠背貼(撕離型膜 + 導熱膏)燈條壽命差 3 倍
- 訊號槽 / 電源槽分離:燈座內走線分兩槽,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 組裝說明書
- 列印零件:窗件感測窗 100% 填滿 + 0.12mm 層厚;燈座壓入 4 顆熱熔銅螺母
- 燈條貼鋁擠條:撕離型膜、沿鋁擠中央貼直,兩端各留 3cm 尾線打錫
- 感測窗件組裝:LD2410 板貼窗(纜線天線拉直貼窗緣)、BH1750 卡進 45° 感光窗
- 先接 12V 側:電源→燈條→MOS→共地,用電源器直接碰觸測燈條亮不亮,再鎖進燈座
- 再接 3.3V 側:MP1584 先調 5.0V 才接板;全部照接線表完成後才燒錄
- 校正:雷達靈敏度(序列指令)、lux 上下限依書桌實測調整、離座关灯時間試坐
- 收尾:塞配重螺母、鎖燈臂、套 TPU 旋鈕帽,完整跑「離座 10 分 → 回座亮燈」循環
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 開機瞬間燈全亮再閃爍 | MOS 閘極浮接,PWM 腳未初始化前雜訊 | 用板載下拉的 AO3400 模組;或自行加 10kΩ 閘極→GND |
| 人坐著燈還是關了 | 窗件太厚 / 靜止靈敏度不足 / 裝設角度錯 | 窗厚 ≤1.2mm;雷達對準人體軀幹高度(非桌面);必要時縮短窗件與雷達距離 |
| 沒人在燈常亮 | 電扇、窗簾、硬碟震動被當「微動」 | 序列指令調降靜目標靈敏度、縮小距離上限;燈具避開電扇氣流路徑 |
| BH1750 恆讀 121 lux | ADDR 腳浮接,位址飄到 0x5C | ADDR 明確接 GND(本專案位址 0x23) |
| 旋鈕轉一下跳好幾格 | 消抖視窗太短 / 線太長 | 本程式已 1ms 消抖;編碼器線控制在 10cm 內 |
| 插 12V 後板子重開機 | 降壓器開關雜訊耦合到 5V | MP1584 輸入並 220µF 電解;12V 線與訊號線分槽走 |
| 雷達偶爾整幀亂碼 | 5V 供電紋波 | 雷達電源加 100µF;確認不是從板子 3V3 拉電(它撐不住雷達峰值) |
🎯 進階延伸
- Wi-Fi + Home Assistant:C3 連 HA,把「在座/離座」「當前亮度」暴露成感測器,跟窗簾、房燈做場景聯動
- BLE 手機調參:lux 映射上下限、離座時間做成 GATT 特徵值,免拆殼免接序列埠
- 雙通道色溫:再加一路 PWM + 暖白燈條,晚 9 點後自動從 5000K 滑到 3000K,配合褪黑激素曲線
- 用眼時數日報:「在座且亮燈」時長每天 POST 到自家 API,家長端看趨勢不侵犯隱私
📦 檔案清單
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 # 說明文件