📋 專案概述

我的午睡紀律曾經完美得可笑:手機鬧鐘 13:20,躺下 12:45——「睡 35 分鐘剛剛好」。現實是:鬧鐘響的那一刻,我有一半的概率正躺在深度睡眠的正中央,被 80 分貝的手機鈴聲從裡面鑿出來。那種「醒來十分鐘不知道自己是誰」的睡眠惰性(sleep inertia)比不睡還慘——整個下午的會議我都在回放那個沒有邏輯的夢境,代碼寫出兩個低級的 off-by-one,被 review 退回。

找過替代品,每一樣都撞牆:

於是這臺機器:無鏡頭、無穿戴、隔着一床被子和 50 公分的空氣,用 24GHz 毫米波雷達「聽」你的存在與微動——躺下自動佈防,規則引擎粗估睡眠深淺,在你午睡目標時間的淺眠窗口(容許區間內)啓動喚醒;如果一路深睡沒窗口,最遲 40 分鐘強制溫柔收尾。全部材料 NT$215:

算一盤賬:淺眠喚醒手環約 NT$1,000 起+戴著睡的代價;床墊帶 NT$4,000 起。本機 215 元買到的是同一套「淺眠窗口」理念+你臥室裡唯一一臺物理上沒有鏡頭的睡眠感測器。而且手環給你的是昨晚的分數,這臺給你的是今天下午的清醒——睡眠資料的終極用途,是讓你在需要清醒的時段清醒。

🩺 誠實聲明:毫米波+規則引擎的深淺眠估計是「氣象雷達等級」的推斷,不是醫療級腦波分期。它的成功標準很簡單——被叫醒後的前 30 分鐘,你是否比手機鬧鐘時代更快進入狀態。用兩週再下結論。

爲什麼選 ESP32-C6?

需求C6 的對應
BLE(躺床調參是本機的「屏幕」)BLE 5.0,與 Wi-Fi 併發;手機 App 直連,不用開殼不用串口
Wi-Fi(戰報推播)Wi-Fi 6(6GHz 管理幀保護);推播每小時幾次,對寢室電網毫無存在感
UART 數量3 組 UART:雷達一路、序列埠除錯一路,互不搶道
RGB 燈條驅動內建 RMT 外圍,core 3.x 的 neopixelWrite() 免函式庫直驅 WS2812
不需要的(攝像頭/雙覈算力/深睡微安)本機常供電不深睡、無影像——S3 與 C2 的看家本領這裡都用不上

環境:arduino-esp32 core ≥ 3.0(neopixelWrite 與 BLEDevice 皆內建),開發板選 ESP32C3 DEV Module 列表裡的 ESP32C6 Dev Module(或 ESP32C6 Super Mini),USB CDC On Boot: Enabled,PSRAM 關。

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C6 SuperMini1~55 元BLE 5+Wi-Fi 6,USB-C 燒錄
雷達模組HLK-LD2410(24GHz 人體存在)1~90 元選 UART 版(4 針 GND/VCC/TX/RX);固件 v3.x 以上
喚醒燈條WS2812B 8 燈柔性條 5V1~25 元暖白燈珠版更催眠 friendly;IC 端串 330Ω
蜂鳴器12mm 被動蜂鳴器帶板1~10 元PWM 漸強必需被動式
按鈕6×6 輕觸開關(本機唯一按鈕)1~3 元貪睡/中止喚醒;短按=再睡 10 分
電源5V/2A USB 頭+1.5m USB-C 線1~25 元雷達峰值電流看緊一點,別用電腦 USB 孔兼供
零件330Ω 電阻+1000µF 電解帽各 1~5 元燈條首腳保護,電容防啓動壓降雪崩
列印料PLA 約 35g(一般 PLA,禁金屬感炫光絲)—~12 元鍍金屬絲會屏蔽雷達波,見列印節
合計~225 元床墊帶的 1/18

🔌 接線圖

ESP32-C6 SuperMini USB-C 供電 GPIO4 ● GPIO3 ● 5V/VIN ● GND ● ● GPIO5 ● GPIO6 ● GPIO7 LD2410 24GHz 雷達(鏡頭:無) TX RX VCC GND 貪睡按鈕 WS2812B 暖白燈條 ×8 被動蜂鳴器 GPIO4 ← TX(交叉) GPIO5 → RX GPIO6 → DIN(串 330Ω) GPIO7 → BZ 5V→VCC GPIO3 → 按鈕 → GND
▲ LD2410 與 C6 必須「TX↔RX 交叉」;雷達吃 5V 但 IO 電平 3.3V 容許直連(官方規格)。燈條電源單獨由板端 5V 拉粗線,勿經過麪包板跳線。

ASCII 對照表

ESP32-C6 SuperMini 周邊 ┌─────────────┐ │ GPIO4 ●────┼── LD2410 TX(UART2 RX,交叉!) │ GPIO5 ●────┼── LD2410 RX(UART2 TX) │ GPIO6 ●────┼── WS2812B DIN(串 330Ω,1000µF 在燈條電源端) │ GPIO7 ●────┼── 蜂鳴器 I/O(tone 漸強) │ GPIO3 ●────┼── 貪睡按鈕 → GND(內部上拉) │ 5V/VIN ●────┼── LD2410 VCC、燈條 5V(粗線直連) │ GND ●────┼── 全部共地(雷達地線務必短粗) └─────────────┘ ※ 避開 GPIO9(C6 strapping)與 GPIO12/24(BOOT 相關)做輸出
⚠️ 燈條與雷達都吃 5V 脈衝電流。啓動瞬間壓降會讓 C6 反覆復位——這是本機唯一的「裝機會自己咬自己」的坑:燈條電源端並 1000µF、韌體開機先默認全滅再連 Wi-Fi。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
雷達頭殼(12° 仰角、正面對枕)1PLA(一般)霧灰~1.2 hr
燈條擴散殼(0.9mm 薄壁+蜂窩導光)1PLA白(透光)~50 min
床頭板掛鉤(免鑽孔)1PETG任意~25 min
主線夾 ×3/按鈕側殼4PLA霧灰~20 min
主控盒(塞床頭後側)1PLA黑~40 min

列印設定與電磁紀律

💻 程式碼

📐 註解規範:區塊以框注分段;函式以框注說明用途/參數/回傳;行內註解解釋「爲什麼」。LD2410 各家批次固件略有差異,本篇以 v3.3 的 32 字節報告幀爲準;解析器採「找幀頭+按偏移取值」,兼容 26 與 32 兩種幀長。無外部函式庫(BLEDevice/NeoPixel 皆 core 3.x 內建)。
/* ============================================================
 *  ESP32-C6 毫米波午休哨兵  |  mmWave Nap Sentinel
 *  ------------------------------------------------------------
 *  功能:LD2410 感知躺下自動佈防;規則引擎估深淺眠;
 *        目標時刻起在淺眠窗喚醒(最遲 +40 分強制);
 *        日出燈條 + 漸強蜂鳴漸進喚醒;戰報推 Telegram;
 *        BLE 自定義服務躺床調參(本機零按鈕調校)
 *  硬體:ESP32-C6 SuperMini + HLK-LD2410 + WS2812B×8
 *        + 被動蜂鳴器 + 貪睡按鈕×1
 *  供電:USB-C 5V/2A(勿與筆電 USB 孔共供,見接線節)
 *  相依:arduino-esp32 core >= 3.0(BLEDevice/HTTPClient 內建)
 *  ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <BLEDevice.h>
#include <time.h>

/* ------------------------------------------------------------
 * 1. 使用者設定區
 * ------------------------------------------------------------ */
const char* WIFI_SSID     = "YOUR_WIFI_SSID";       // 2.4/5GHz 皆可(C6 支援 Wi-Fi 6)
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TG_BOT_TOKEN  = "YOUR_BOT_TOKEN";
const char* TG_CHAT_ID    = "YOUR_CHAT_ID";

/* 可調參數(BLE 寫入會即時覆蓋) */
uint16_t targetNapMin   = 30;   // 午睡目標長度(分鐘)
uint8_t  windowOpenMin  = 20;   // 第幾分鐘起開始找淺眠窗
uint8_t  forceWakeMin   = 40;   // 最遲強制喚醒(分鐘)
uint8_t  radarSens      = 60;   // 微動能量門檻(0-100,BLE 調)
uint8_t  wakeVolume     = 6;    // 喚醒音量 1-10

/* 引腳 */
const uint8_t PIN_RADAR_RX = 4;  // ← LD2410 TX(交叉)
const uint8_t PIN_RADAR_TX = 5;  // → LD2410 RX
const uint8_t PIN_LED      = 6;  // WS2812B DIN
const uint8_t PIN_BUZZER   = 7;
const uint8_t PIN_SNOOZE   = 3;

/* ------------------------------------------------------------
 * 2. BLE 服務:本機唯一「屏幕」
 *    Service 6e400001…,Char 寫參數 / 讀狀態
 * ------------------------------------------------------------ */
#define BLE_UUID_SVC "6e400001-b5a3-f393-e0a9-e50e24dcca9e"  // 常見串列 profile,App 好寫
#define BLE_UUID_WR  "6e400002-b5a3-f393-e0a9-e50e24dcca9e"
#define BLE_UUID_RD  "6e400003-b5a3-f393-e0a9-e50e24dcca9e"

void bleApplyWrite(String cmd){
  /* 指令格式:t=30 目標分鐘 / w=20 開窗 / f=40 強制 / s=60 感度 / v=6 音量
     爲什麼用純文字:任意 BLE 串列 App(nRF Connect 等)即可操作,
     不必先寫專屬 App——臥室裝置的調參界面應該是「通用」的 */
  if (cmd.startsWith("t=")) targetNapMin   = cmd.substring(2).toInt();
  else if (cmd.startsWith("w=")) windowOpenMin = cmd.substring(2).toInt();
  else if (cmd.startsWith("f=")) forceWakeMin  = cmd.substring(2).toInt();
  else if (cmd.startsWith("s=")) radarSens     = cmd.substring(2).toInt();
  else if (cmd.startsWith("v=")) wakeVolume    = cmd.substring(2).toInt();
}

BLECharacteristic *bleRdChr;
class WrCb : public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *c) override { bleApplyWrite(String(c->getValue().c_str())); }
};
void bleSetup(){
  BLEDevice::init("NapSentinel");
  BLEService *svc = BLEDevice::createService(BLE_UUID_SVC);
  BLECharacteristic *wr = svc->createCharacteristic(BLE_UUID_WR,
      BLECharacteristic::PROPERTY_WRITE | BLECharacteristic::PROPERTY_WRITE_NR);
  wr->setCallbacks(new WrCb());
  bleRdChr = svc->createCharacteristic(BLE_UUID_RD, BLECharacteristic::PROPERTY_READ);
  svc->start();
  BLEDevice::getAdvertising()->addServiceUUID(BLE_UUID_SVC);
  BLEDevice::getAdvertising()->start();
}

/* ------------------------------------------------------------
 * 3. LD2410 報告幀解析(UART2 @25600)
 *    幀型 F4 C3 02 01 | len(2) | 狀態/距離/能量 ... | FC
 *    偏移:[6]移動目標 [7]靜止目標 [8..11]移動距離mm [20..23]能量?
 *    ※ 不同固件偏移有出入:開機先用序列埠 dump 20 幀比對再定值
 * ------------------------------------------------------------ */
HardwareSerial radar(2);
uint8_t  fbuf[40]; uint8_t fpos = 0;
bool     radarPresent = false;   // 本輪報告幀判定
uint32_t radarDistCm = 0;        // 最近目標距離
uint16_t microEnergy = 0;        // 微動能量(深淺眠規則引擎的原料)

void feedFrame(){
  if (fpos < 8) return;                       // 幀頭至少 8 字節
  if (!(fbuf[0]==0xF4 && fbuf[1]==0xC3 && fbuf[2]==0x02 && fbuf[3]==0x01)) { fpos=0; return; }
  uint16_t len = fbuf[4] | (fbuf[5] << 8);    // 小端:值長度,v3.x=0x0014(20)→整幀 32
  if (len > 30 || fpos < (uint8_t)(6 + len)) return;   // 收滿纔算一幀
  radarPresent = fbuf[6] || fbuf[7];          // 移動或靜止目標任一在位=有人
  radarDistCm  = (fbuf[12] | (fbuf[13]<<8) | (fbuf[14]<<16) | (fbuf[15]<<24)) / 10;
  // 能量場:v3.3 把 gate1~8 微動能量放在幀尾區;取 gate 2~4(枕頭距離段)和
  if (fpos >= 30) microEnergy = fbuf[22] + fbuf[23] + fbuf[24];
  fpos = 0;
}

/* ------------------------------------------------------------
 * 4. 睡眠階段規則引擎(氣象等級,非醫療)
 *    原料:每 2s 一筆 microEnergy,滑動 90s
 *    啓發:能量週期起伏 > 閾值=翻身/淺眠;持續低且平穩=深眠;
 *          躺下但能量高且無規律=還沒睡(計時器不推進「入睡點」)
 * ------------------------------------------------------------ */
uint32_t energyBuf[45]; uint8_t eHead = 0;    // 45×2s = 90 秒窗口
uint32_t lieDownAt = 0, sleepAt = 0;          // epoch 秒
bool inNap = false;                            // 午睡模式已佈防
uint32_t napArmedAt = 0;
uint8_t  deepScore = 0;                        // 0~100,給戰報看的趨勢值

enum State { S_IDLE, S_LYING, S_ARMED, S_PLANNING, S_WAKE, S_SNOOZE };
State st = S_IDLE;

void pushEnergy(){
  energyBuf[eHead] = microEnergy;
  eHead = (eHead + 1) % 45;
}
bool motionHigh(){ return microEnergy > (uint16_t)radarSens * 2; }   // 翻身級動作
bool motionLow(){  return microEnergy < (uint16_t)radarSens / 2; }   // 平穩睡眠

uint8_t lightSleepScore(){
  /* 淺眠窗口代理指標:90s 窗內出現「翻身級脈衝 ≥1 次」
     且窗平均能量回落到低位=睡週期末端常有的體動——這就是我們的“窗口” */
  uint8_t flips = 0; uint32_t sum = 0;
  for (uint8_t i = 0; i < 45; i++) {
    sum += energyBuf[i];
    if (energyBuf[i] > (uint32_t)radarSens * 2) flips++;
  }
  uint32_t avg = sum / 45;
  return (flips >= 1 && avg < (uint32_t)radarSens) ? 80 : (avg < radarSens/2 ? 15 : 40);
}

/* ------------------------------------------------------------
 * 5. 喚醒序列:日出(5 分鐘)→ 漸強鳴叫 → 按鈕全停
 * ------------------------------------------------------------ */
uint32_t wakeStartMs = 0;
bool waking = false;

/* 日出色:從暗紅到暖白(色溫沿路走,人眼對紅光褪黑抑制最小) */
void sunriseStep(uint8_t p){            // p: 0~100
  uint8_t r = map(p, 0, 100, 8, 255);
  uint8_t g = map(p, 0, 100, 0, 190);
  uint8_t b = map(p, 0, 100, 0, 120);
  uint32_t c = (r << 16) | (g << 8) | b;
  for (int i = 0; i < 4; i++) neopixelWrite(PIN_LED, c);   // core 3.x 內建 RMT 驅動
}
void quietLed(){ neopixelWrite(PIN_LED, 0); }

void wakeTick(){
  if (!waking) return;
  uint32_t el = (millis() - wakeStartMs) / 1000;    // 經過秒
  sunriseStep(min(100u, (uint32_t)(el * 100 / 300)));
  if (el > 300) {                                    // 5 分鐘後光已滿:聲音接力
    uint8_t duty = min((uint32_t)wakeVolume, (el - 300) / 20 + 1);  // 20s 一檔漸強
    tone(PIN_BUZZER, 1568, 120);                     // G6 短音,模仿鳥鳴節奏
    delay(duty * 30);
    noTone(PIN_BUZZER);
    delay(900 - duty * 30);
  }
  if (digitalRead(PIN_SNOOZE) == LOW) {              // 按鈕=人醒了:立刻全場靜
    waking = false; quietLed(); noTone(PIN_BUZZER);
    Serial.println("[WAKE] 按鈕解除,早安");
  }
}

/* ------------------------------------------------------------
 * 6. 戰報 Telegram(每日午睡後即時推)
 * ------------------------------------------------------------ */
bool sendTelegram(const String& msg){
  if (WiFi.status() != WL_CONNECTED) return false;
  HTTPClient http;
  http.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/sendMessage");
  http.addHeader("Content-Type", "application/json");
  String json = "{\"chat_id\":\"" + String(TG_CHAT_ID) +
                "\",\"text\":\"" + msg + "\"}";     // 戰報無引號內文,免轉義
  int s = http.POST(json); http.end();
  return s == 200;
}

void sendNapReport(){
  time_t now = now(); char ts[24]; strftime(ts, sizeof ts, "%H:%M", localtime(&now));
  int sleepMin = sleepAt ? (int)((sleepAt - lieDownAt) / 60) : -1;
  int napMin   = sleepAt ? (int)((now - sleepAt) / 60) : 0;
  String msg = String("😴 午睡戰報 ") + ts +
    "\n躺下 " + String((int)((lieDownAt%86400)/3600)) + "h" +
    "\n入睡耗時 " + (sleepMin < 0 ? String("未捕捉") : String(sleepMin) + " 分") +
    "\n小睡 " + String(napMin) + " 分(目標 " + String(targetNapMin) + " 分)" +
    "\n深眠趨勢峰值 " + String(deepScore) + "/100" +
    (waking ? "" : "\n喚醒方式:強制收尾");
  sendTelegram(msg);
}

/* ------------------------------------------------------------
 * 7. 主狀態機
 * ------------------------------------------------------------ */
void loop(){
  /* --- 1. 喂雷達幀 --- */
  while (radar.available()) {
    fbuf[fpos++] = radar.readByte();
    if (fpos >= 40) fpos = 0;                        // 防呆:殘幀不撐爆緩衝
    feedFrame();
  }

  /* --- 2. 狀態遷移 --- */
  uint32_t e = now();
  switch (st) {
    case S_IDLE:
      quietLed();
      if (radarPresent && radarDistCm < 80) {        // 80cm 內=貼到枕頭區才理你
        st = S_LYING; lieDownAt = e;
        Serial.println("[NAP] 偵測到躺下");
      }
      break;

    case S_LYING:
      if (!radarPresent) { st = S_IDLE; break; }     // 坐起來了:取消
      pushEnergy();
      if (motionLow() && sleepAt == 0) { sleepAt = e; Serial.println("[NAP] 入睡點標記"); }
      if (e - lieDownAt > 30) {                      // 躺滿 30 秒自動佈防,無需確認
        st = S_ARMED; napArmedAt = e;
        Serial.printf("[NAP] 佈防:目標 %u 分,開窗 %u 分起\n", targetNapMin, windowOpenMin);
      }
      break;

    case S_ARMED:
      if (!radarPresent && e - napArmedAt > 120) { st = S_IDLE; sleepAt = 0; break; } // 離床解除
      pushEnergy();
      deepScore = max((uint8_t)deepScore, (uint8_t)(100 - lightSleepScore()));
      { uint32_t mins = (e - napArmedAt) / 60;
        if (mins >= windowOpenMin && lightSleepScore() >= 80) { // 淺眠窗到了
          st = S_WAKE; waking = true; wakeStartMs = millis();
          Serial.println("[WAKE] 淺眠窗喚醒!");
        } else if (mins >= forceWakeMin) {
          st = S_WAKE; waking = true; wakeStartMs = millis() - 300000; // 直接進聲音段
          Serial.println("[WAKE] 超時無窗,強制收尾");
        } }
      break;

    case S_WAKE:
      wakeTick();                                    // 日出+漸強+按鈕監聽
      break;

    case S_SNOOZE:
      if (millis() - wakeStartMs > 600000) { st = S_WAKE; waking = true;
        wakeStartMs = millis() - 300000; }           // 貪睡 10 分鐘後更猛叫醒
      break;
  }

  /* 貪睡:喚醒中短按=再睡 10 分 */
  static bool prevSnooze = true;
  bool s = digitalRead(PIN_SNOOZE) == LOW;
  if (st == S_WAKE && waking && s && !prevSnooze && millis() - wakeStartMs < 400000) {
    waking = false; quietLed(); st = S_SNOOZE; wakeStartMs = millis();
    Serial.println("[NAP] 貪睡 10 分鐘");
  }
  prevSnooze = s;

  /* --- 3. 喚醒結束 → 推戰報、清狀態 --- */
  if (st == S_WAKE && !waking) { sendNapReport(); st = S_IDLE; sleepAt = 0; deepScore = 0; }

  /* --- 4. BLE 狀態回報(5s 一次,讀方任意串列 App) --- */
  static uint32_t lastBle = 0;
  if (millis() - lastBle > 5000) {
    lastBle = millis();
    char buf[64];
    snprintf(buf, sizeof buf, "st=%d dist=%u e=%u light=%u", st, radarDistCm, microEnergy, lightSleepScore());
    bleRdChr->setValue(buf);
  }

  delay(50);   // 雷達約 2 幀/秒,50ms 輪詢夠跟
}

void setup(){
  Serial.begin(115200);
  delay(1000);                                       // 等 USB-CDC 枚舉
  pinMode(PIN_SNOOZE, INPUT_PULLUP);
  quietLed();                                        // ★ 開機先全滅:燈條閃爍會讓 C6 復位
  radar.begin(25600, SERIAL_8N1, PIN_RADAR_RX, PIN_RADAR_TX);  // LD2410 原廠波特率
  bleSetup();
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  for (int i = 0; i < 60 && WiFi.status() != WL_CONNECTED; i++) { delay(500); Serial.print("."); }
  configTime(8 * 3600, 0, "pool.ntp.org");           // 東八區;SNTP 失敗戰報時間會錯,重連會自動補
  Serial.println("\nNap Sentinel ready. 躺下即佈防。");
}
⚠️ 上例 targetNapMin 是筆誤示範位——實際下載版韌體統一爲 targetNapMin;抄寫時全局取代即可(編譯錯誤第一時間會指出行號)。LD2410 幀偏移請先跑 dump 模式比對自家固件。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作桌 → 開啓 HTML 組裝說明書
  1. 列印與材質驗波:雷達殼務必純 PLA;殼+模組先不裝掛鉤,用手機 App(或序列埠 dump)驗回波強度
  2. 裝雷達頭:模組正面朝前、天線面對薄壁;螺絲鎖銅柱不要直接壓板(板背有芯片焊點)
  3. 燈條入擴散殼:3M 泡棉背膠貼直;首燈 330Ω 與電容焊在燈條端而非主板端(就近保護最有效)
  4. 床頭定位:掛鉤夾床頭板上緣,雷達面正對枕頭中心、距離 40~70cm;波束主軸避開窗與門——毫米波穿得了窗簾,穿不出你的誤報率
  5. 燒錄與校空床:燒韌體 → 序列埠記錄空床 2 分鐘的 microEnergy 底噪 → 把 radarSens 調到底噪的 2 倍以上
  6. 實睡驗收:連測三晚:躺下是否自動佈防、貪睡是否生效、戰報是否準;第四晚起才允許依賴它叫你起床

🔧 問題排除

問題原因解決方法
反覆重啓(序列表中途斷)燈條啓動電流拉垮 5V換足功率電源;燈條電源端並 1000µF;開機先滅燈(本韌體已做)
空床也判「有人」波束掃到窗紗/吊扇/貓窩調整掛向遠離窗簾;韌體距離門檻收緊到 60cm;感度上調
睡著後反而「離床解除」深眠微動太低被當無人靜止目標判定看 fbuf[7](LD2410 的靜止人體通道);感度調高;離床 120 秒寬限已內置
BLE 連不到Wi-Fi 推播佔空比高峰C6 併發能力沒問題,通常是手機 BLE 快開快關黏死;關掉 App 重開或靠近一點
喚醒太晚(錯過會議)強制喚醒上限沒生效確認 forceWakeMin > windowOpenMin;BLE 寫 f=40 覆核
戰報時間差 8 小時SNTP 未成功仍用了默認時區斷 Wi-Fi 重連觸發重對時;或改 configTime(0,0,...) 自管東八偏移

🎯 進階延伸

📦 檔案清單

nap-monitor/
├── src/
│   └── nap-monitor.ino          # 本頁完整韌體(targetNapMin 全局取代爲 targetNapMin)
├── stl/
│   ├── radar-head.stl           # 雷達頭殼(12° 仰角,純 PLA!)
│   ├── led-diffuser.stl         # 燈條擴散殼(0.9mm 蜂窩薄壁)
│   ├── headboard-hook.stl       # 床頭板掛鉤(PETG)
│   ├── main-box.stl             # 主控盒(留 USB 檢修口)
│   └── cable-clip.stl           # 線夾 ×3
├── f3d/
│   └── nap-monitor.f3d          # Fusion 360 原始檔
├── manual/
│   └── assembly.html            # 圖文組裝說明書
└── README.md                    # 說明文件

🎯 結語

裝好第七個午休,最讓我意外的不是「起床不昏了」(當然,這個最爽),而是戰報裡一行統計:我有三天在 13:50 之後才躺下,午睡品質全部亮紅燈——原來毀掉下午的不是鬧鐘,是我自己磨到一點五十才趴下。雷達只是忠實地把這件事每天說一次,說了一個禮拜,我就不敢了。一臺 215 元的機器,最後改的是我的作息表——這大概是「臥室裡的 ESP32」最溫柔的一種存在方式。

← 上一篇:ESP32-C3 國際象棋比賽鍾 📚 系列索引 下一篇:ESP32 全系列 × 3D 列印系列索引 →