📋 專案概述

早上七點五十分,出門前要確認三件事:今天會不會下雨、九點的客戶會議改沒改時間、洗衣機是不是還亮著紅燈。答案分別住在天氣 App、Google 日曆、和三樓陽台——於是你解鎖手機,滑過天氣 App,然後被群組訊息絆住,八點零四分才踩出家門。

「抬頭就知道」這件事,市面上的選項都在幫你倒著做:

  1. 智慧顯示螢幕(智慧時鐘+相框那類):NT$1,999 起,LCD 背光 24 小時亮著——晚上路過書房像被一台小電視瞪著;內容要註冊雲 App,訂閱到期之後畫面只剩它的廣告。
  2. 平板充當資訊卡:三週後螢幕燒了側邊欄選單的殘影,而且它永遠在等你回訊息,你等於在桌上養了一隻會一直舉手要回訊的電獸。
  3. 紙+異音警報(便利貼):免費,但第七張貼上去之後,整面牆對你的大腦變成白噪音。

這張 NT$845 的電子墨水桌面資訊卡反其道:它不發光、不推播、不會動——把「抬頭掃一眼」的成本壓回零,功能清單:

成本算盤:智慧顯示螢幕 NT$1,999 + 終身雲帳號,本機 NT$845 而且資料全從自家 NAS 走——省下的錢夠買三顆 18650 跟一整年的待辦清單。

🔬 為什麼選 ESP32-S3(選型說明)

需求S3 的對應
醒 5 秒、睡 15 分鐘deep sleep ~9µA 級+快速啟動,平均電流被刷新間距稀釋到 µA 級
天氣+ICS +待辦的 JSONPSRAM 當 HTTP 接收池:彙整後的精簡 JSON 整包吃下,不用寫分塊解析
墨水屏 6 根訊號+2 鍵+電池 ADCGPIO 數與外設組合最從容;C 系列要為腳位打架,S2 單核又少了 BLE 彈性
USB-C 桌面供電兩吃原生 USB 直接插電腦除錯,拔線切電池不用換線
同生態沿用與第 28/37/39 篇同板同工具鏈,禁接腳清單不用重新學
📐 開發環境:arduino-esp32 core ≥ 3.0,開發板 ESP32S3 Dev Module(N16R8 建議開 PSRAM: OPI PSRAM,本機沒開也跑得動,開了 JSON 池更肥),函式庫 GxEPD2 ≥ 1.5、Adafruit GFX ≥ 1.12、ArduinoJson ≥ 7。

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-S3-DevKitC-1 N16R81~430 元N16R8;與第 28/37/39 篇同板可輪替
螢幕GDEY029T94 2.9" 電子墨水(含 SPI 轉接板)1~260 元296×128 黑白;買含轉接板pin 座版免焊 FPC
充電板TP4056 Type-C(8205 帶保護)1~25 元紅藍雙燈;附帶過充過放保護
升壓板MT3608(調 5.0V)1~20 元電池 3.0–4.2V → 穩 5V 餵板上 LDO
電池18650 3000mAh+單座盒1~110 元帶鎳片焊腳;用帶保護板的動力電芯更穩
按鈕6×6mm 微動開關2~10 元GPIO6 / GPIO7,深睡期間可喚醒
分壓電阻100kΩ ×22~2 元電池電壓 ÷2 進 GPIO4 ADC
電源開關側撥滑動開關1~8 元切在升壓板輸入端,不是板上 EN
列印料PLA ~80g—~25 元75° 立架+背蓋+按鍵帽
合計~845 元雲智慧螢幕的 1/3,沒有背光也沒有訂閱

🔌 接線圖

ESP32-S3 DevKitC-1 N16R8 5V ← MT3608 12 SCLK 11 MOSI 10 CS 13 DC 9 RST 8 BUSY 6/7 按鈕 A/B 4 VBAT ADC 19/20/26-32 禁 09:41 陰轉陣雨 26°C 11/01 週日 GDEY029T94 2.9"(轉接板) BUSY 沒接=刷花第一原因 8 芯排線:VCC GND + 6 訊號(SCLK MOSI CS DC RST BUSY) A=6 刷新 B=7 切卡 100k ÷ 100k 電池 → GPIO4 18650 3.7V 3000mAh TP4056 充電 MT3608 調 5.0V
▲ 訊號全是主角(左邊墨水屏六芯),電源是背景班(底部 TP4056→MT3608 一條直線)。按鈕與電池分壓在右側,全部避開禁接區。

ASCII 對照表

ESP32-S3 DevKitC-1        週邊(GDEY029T94 轉接板)
──────────────────       ─────────────────────────────
GPIO12 ──────── EPD SCLK    HW SPI 時脈
GPIO11 ──────── EPD MOSI    HW SPI 資料
GPIO10 ──────── EPD CS
GPIO13 ──────── EPD DC      資料/命令選擇
GPIO9  ──────── EPD RST
GPIO8  ──────── EPD BUSY    busy=高=在刷新,擋著別餵下一筆
GPIO6  ──────── 按鈕 A      INPUT_PULLUP,深睡 EXT0 喚醒
GPIO7  ──────── 按鈕 B      同上
GPIO4  ──────── 電池 ADC    100k/100k 分壓(÷2)
5V     ──────── MT3608 OUT+ (調到 5.00V 再接)
3V3    ──────── EPD VCC     墨水屏邏輯 3.3V
GND    ──────── 全系統共地
⛔ GPIO19/20(USB)、26–32(Flash)、3/45/46(strapping)—— 禁接
⚠️ BUSY 不是可選線:墨水屏刷新時 BUSY 拉高,韌體靠它等待;沒接的話畫面刷到一半被餵新資料,殘影會糊成一鍋粥——它是「畫面怪怪」清單上的第一名。

🎯 它的日常:醒 5 秒,睡 15 分鐘

① 定時喚醒 15 分鐘到點 或按鍵 EXT0 ② Wi-Fi+NTP 8 秒上不了網 → 直接走 ④b ③ 抓 NAS JSON 天氣+行程+待辦 成功存 NVS ④a 正常繪圖 四格版面 每30刷全刷一次 ④b stale 降級 NVS 舊畫面+角標「stale」 ⑤ 躺平 EPD hibernate 深睡 9µA × 900s
▲ 整台設備的一生就是「醒→抓→刷→躺」;失敗分支不重試到死——8 秒連不上網就降級顯示舊畫面立刻回去睡,省電是紀律不是願景。

🎯 為什麼是墨水屏,不是小 LCD

📱 小 LCD(資訊卡的場景) 09:41 背光 24h 常亮:5–25mA 永動機 夜裡自發光:桌上小電視 靜態畫面也要持續重畫 🖋️ 墨水屏(本機) 09:41 刷新慢(~2s)→ 但資訊卡本來 15 分鐘才變一次 停圖 0 耗電、靠環境光反射:越亮越清楚 結論:「變化慢、注視久、要省電」的場景,墨水屏全壘打
▲ 墨水屏把「靜態」變成優勢:斷電畫面還在(搬動它也不花屏)、功耗只花在刷新那兩秒。它缺點是慢與怕悶熱——正好都被「15 分鐘刷一次+不蓋透明壓克力」的規避掉了。

🖨️ 3D 列印設計

零件清單

零件數量材質建議列印設定備註
75° 斜立架1PLA0.2mm/3 壁/20%螢幕卡入式(留 0.2mm 間隙);底部配重槽放備用電池
背蓋(電路艙)1PLA0.2mm/3 壁/15%TP4056 指示燈透窗 6×10mm;充電孔位對 Type-C
按鍵帽 ×22PLA0.16mm/4 壁側出式,按面露在立架下緣兩角
電池固定座1PLA0.2mm/3 壁/25%18650 卡箍+束線帶槽;電池不可晃=安全底線
走線壓條2PLA0.2mm/2 壁六芯訊號線與電力線分邊壓住
壁掛轉接1PETG0.2mm/4 壁/40%100mm VESA 孔距,掛玄關牆

設計要點

  1. 螢幕前方永遠不蓋東西:墨水屏靠反射環境光,透明壓克力會反光+悶熱;卡入式設計讓玻璃面完全外露
  2. 75° 不是玄學:坐姿平視+桌面檯燈的反射角計算後,75° 後仰剛好把檯燈鏡像甩出視線外
  3. リチウム電池艙要「牢」不要「美」:18650 卡箍+束線帶雙固定,螺絲穿不過電池;艙內線材避開升壓板輸出側的尖焊點
  4. 充電燈透窗:TP4056 紅藍燈贴在窗後 2mm,插充電時一眼確認,不用拆殼
  5. 底部散熱縫:升壓板和 DevKitC 都在背艙,前後開對流縫——墨水屏最怕燙,超過 40°C 壽命縮水
🧵 列印備註:立架用兩色列印(框架綠+面板白)最好看;沒有多色機就把「面板」列印兩片疊上,色差變成設計線。

💻 程式碼(韌體)

📐 註解規範:區塊以框註分段;函式以框註說明用途/參數/回傳;行內註解一律解釋「為什麼」而非「做什麼」。全系列文章採同一規範。
/* ============================================================
 *  ESP32-S3 電子墨水桌面資訊卡  |  s3-epaper-dashboard.ino
 *  ------------------------------------------------------------
 *  功能:15 分鐘深睡排程喚醒— Wi-Fi/NTP— NAS 彙整 JSON(天氣/
 *        行程/待辦/倒數)— GDEY029T94 四格版面繪圖—躺平;
 *        抓不到資料降級顯示 NVS 舊畫面+stale 角標;
 *        每 30 刷自動黑白全刷防殘影;按鈕 A 強刷/B 切卡;
 *        電池電壓 ADC 估算;看門狗護身
 *  硬體:ESP32-S3 DevKitC-1 N16R8 + GDEY029T94 2.9"(轉接板)
 *        + 按鈕×2(GPIO6/7) + 電池分壓(GPIO4)
 *  供電:18650 → MT3608 5.0V → 板 5V 腳(或 USB-C 直供)
 *  相依:arduino-esp32 core >= 3.0、GxEPD2 >= 1.5、
 *        Adafruit GFX >= 1.12、ArduinoJson >= 7
 * ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Preferences.h>
#include <GxEPD2_BW.h>
#include <esp_task_wdt.h>
#include <time.h>

/* --- 1. 腳位 -------------------------------------------------- */
#define EPD_SCLK  12
#define EPD_MOSI  11
#define EPD_CS    10
#define EPD_DC    13
#define EPD_RST   9
#define EPD_BUSY  8      // 不接=刷花第一原因:刷新期間它是紅綠燈
#define BTN_A     6      // 強制刷新(深睡 EXT0 喚醒)
#define BTN_B     7      // 切換放大卡片
#define PIN_VBAT  4      // 100k/100k 分壓 → ADC1

/* --- 2. Wi-Fi 與 NAS ------------------------------------------ */
const char* WIFI_SSID = "你的SSID";        // 2.4GHz
const char* WIFI_PASS = "你的密碼";
const char* API_URL   = "http://192.168.1.10:8091/dashboard";

/* --- 3. 排程與保養參數 ---------------------------------------- */
#define SLEEP_US     900e6   // 深睡 15 分鐘(µs)
#define WIFI_TIMEOUT 8000    // 連網逾時:失敗降級,不戀戰
#define FULL_EVERY   30      // 每 30 刷一次黑白全刷:殘影清零
#define STALE_MAX_S  3*86400 // 舊畫面超過 3 天→版面標「很久沒更新」

Preferences prefs;
String lastJson;             // NVS 裡的上次好資料
uint16_t refreshCount = 0;

/* GxEPD2:面板驅動+視窗緩衝包裝(BW=黑白兩色版) */
GxEPD2_BW<GxEPD2_029_GDEY029T94, GxEPD2_029_GDEY029T94::HEIGHT>
  display(GxEPD2_029_GDEY029T94(EPD_CS, EPD_DC, EPD_RST, EPD_BUSY));

/* --- 4. 電池 --------------------------------------------------- */
/* batteryMv —— 分壓讀 ADC 還原成電池毫伏
 * 回傳:電池電壓 mV(18650:4200=滿、3100=見底)
 * 多次取樣取中位數:Wi-Fi 突發電流會讓 ADC 抖 50mV 級 */
uint32_t batteryMv() {
  uint16_t s[9];
  for (int i = 0; i < 9; i++) { s[i] = analogReadMilliVolts(PIN_VBAT); delay(2); }
  // 排序取中位(9 個樣本,氣泡排序夠快)
  for (int i = 0; i < 8; i++) for (int j = 0; j < 8 - i; j++)
    if (s[j] > s[j+1]) { uint16_t t = s[j]; s[j] = s[j+1]; s[j+1] = t; }
  return s[4] * 2u;                 // ×2:100k/100k 分壓比
}
uint8_t batteryPct(uint32_t mv) {   // 放電曲線線性化:3100–4200
  if (mv >= 4150) return 100;
  if (mv <= 3150) return 2;
  return (uint8_t)((mv - 3150) * 98 / 1000 + 2);
}

/* --- 5. 抓資料:NAS 彙整端回一包精簡 JSON ---------------------- */
/* fetchDashboard —— GET NAS,成功就存進 NVS 當「上次好畫面」
 * 回傳:拿到並解析成功與否(false → 呼叫端走 stale 降級)
 * 為什麼存字串不存欄位:版面改版不用遷移 NVS 結構 */
bool fetchDashboard(JsonDocument& doc) {
  HTTPClient http;
  if (!http.begin(API_URL)) return false;
  http.setTimeout(6000);            // NAS 卡 6 秒就放棄:省電紀律
  int code = http.GET();
  bool ok = false;
  if (code == 200) {
    ok = deserializeJson(doc, http.getString()) == DeserializationError::Ok;
    if (ok) {                       // 解析過關才配存:半包 JSON 不算好畫面
      prefs.putString("last", http.getString());
      lastJson = http.getString();
    }
  }
  http.end();
  return ok;
}
bool loadFallback(JsonDocument& doc) {   // 從 NVS 撈上次好畫面
  lastJson = prefs.getString("last", "");
  if (lastJson.isEmpty()) return false;
  return deserializeJson(doc, lastJson) == DeserializationError::Ok;
}

/* --- 6. 繪圖:四格版面 ----------------------------------------- */
void drawCell(int x, int y, int w, int h) {   // 版面格線:1px 圓角框
  display.drawRoundRect(x, y, w, h, 4, GxEPD_BLACK);
}
/* renderCard —— 依 JSON 畫整版;stale=true 時右上蓋斜角標
 * 版面:左上日期時間、左下天氣、右上今日行程、右下待辦+倒數
 * 字體用內建點陣(font 6/7):不掛大 TTF,省 flash 也省記憶體 */
void renderCard(JsonDocument& doc, bool stale) {
  display.setFullWindow();
  display.fillScreen(GxEPD_WHITE);
  time_t now = time(nullptr);
  struct tm t; localtime_r(&now, &t);
  char buf[40];

  /* 左上:日期+時間+電壓 */
  drawCell(4, 4, 200, 60);
  display.setCursor(12, 24); display.setTextSize(2);
  strftime(buf, sizeof(buf), "%H:%M", &t); display.print(buf);
  display.setTextSize(1); display.setCursor(12, 44);
  strftime(buf, sizeof(buf), "%Y/%m/%d %a", &t); display.print(buf);
  display.setCursor(12, 58);
  display.printf("BAT %u%%", batteryPct(batteryMv()));

  /* 左下:天氣(NAS 端已翻成中文+換算好) */
  drawCell(4, 68, 200, 56);
  display.setCursor(12, 86);
  display.printf("%s %sC  rain %d%%",
    doc["w"]["text"].as<const char*>(),
    doc["w"]["t"].as<const char*>(),
    doc["w"]["rain"].as<int>());
  display.setCursor(12, 102);
  display.printf("H%s L%s  AQI %s",
    doc["w"]["hi"].as<const char*>(), doc["w"]["lo"].as<const char*>(),
    doc["w"]["aqi"].as<const char*>());

  /* 右上:今日行程(最多 3 筆,超額顯示 +N) */
  drawCell(208, 4, 84, 120);
  display.setCursor(214, 18); display.print("AGENDA");
  int shown = 0;
  for (JsonVariant ev : doc["cal"].as<JsonArray>()) {
    if (shown >= 3) break;
    display.setCursor(214, 34 + shown * 24);
    display.printf("%.5s %.10s", ev["t"].as<const char*>(),
                                  ev["title"].as<const char*>());
    shown++;
  }
  int extra = doc["cal"].size() - shown;
  if (extra > 0) { display.setCursor(214, 110); display.printf("+%d more", extra); }

  /* 右下:待辦前 3 + 倒數 */
  drawCell(296, 4, 0, 0);           // 占位:右半版面由兩個格共享邊框
  drawCell(152, 68, 140, 56);
  display.setCursor(160, 82); display.print("TODO");
  int n = 0;
  for (JsonVariant td : doc["todo"].as<JsonArray>()) {
    if (n >= 2) break;
    display.setCursor(160, 96 + n * 14);
    display.printf("%.18s", td.as<const char*>());
    n++;
  }
  display.setCursor(208, 18);       // 倒數掛在 AGENDA 下方不夠位就省略
  if (!doc["count"]["label"].isNull()) {
    display.setCursor(160, 120);
    display.printf("%s: %s 天", doc["count"]["label"].as<const char*>(),
                  doc["count"]["days"].as<const char*>());
  }

  if (stale) {                      // 右下斜角標:寧可醜,不可騙人
    display.fillRect(252, 104, 44, 18, GxEPD_BLACK);
    display.setTextColor(GxEPD_WHITE);
    display.setCursor(257, 117); display.print("stale");
    display.setTextColor(GxEPD_BLACK);
  }
}

/* --- 7. 深睡 --------------------------------------------------- */
void gotoSleep() {
  display.hibernate();              // 先讓面板進低功耗,再切主控
  WiFi.disconnect(true, true);
  esp_sleep_enable_timer_wakeup(SLEEP_US);
  // EXT0:任一按鈕拉低喚醒(兩個腳都要設,或只留 A 鍵喚醒)
  esp_deep_sleep_enable_gpio_wakeup(1ULL << BTN_A, ESP_GPIO_WAKEUP_GPIO_LOW);
  delay(20);                        // 讓 UART/電源電容放電穩定
  esp_deep_sleep_start();
}

/* --- 8. setup / loop ------------------------------------------- */
void setup() {
  Serial.begin(115200);
  prefs.begin("dash");
  refreshCount = prefs.getUShort("rc", 0);

  analogSetAttenuation(ADC_11db);   // 分壓後最高 2.1V,11dB 量程才裝得下
  pinMode(BTN_A, INPUT_PULLUP);
  pinMode(BTN_B, INPUT_PULLUP);
  bool btnWake = (digitalRead(BTN_A) == LOW) || (digitalRead(BTN_B) == LOW);

  SPI.begin(EPD_SCLK, -1, EPD_MOSI, EPD_CS);   // 指定 HW SPI 腳位
  display.init(0);
  display.setFullRefreshFrequency(FULL_EVERY); // GxEPD2 內建防殘影計數
  if (btnWake) {                    // 按鍵喚醒=用戶想現在就看:先清屏再說
    display.fillScreen(GxEPD_WHITE);
    display.display();
  }

  esp_task_wdt_deinit();            // core 預設 5s TWDT 太摳:重開成 90s
  esp_task_wdt_init(90, true);
  esp_task_wdt_add(NULL);

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
}

void loop() {
  esp_task_wdt_reset();
  JsonDocument doc;

  /* --- 連網窗口:8 秒定生死,輸了就走降級 --------------------- */
  bool fresh = false;
  uint32_t t0 = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - t0 < WIFI_TIMEOUT)
    delay(200);
  if (WiFi.status() == WL_CONNECTED) {
    configTime(8 * 3600, 0, "pool.ntp.org", "time.nist.gov");
    t0 = millis();
    while (time(nullptr) < 1700000000 && millis() - t0 < 4000) delay(100);
    fresh = fetchDashboard(doc);
  }

  bool stale = !fresh;
  if (!fresh) {                                  // 降級鏈:NVS 舊畫面 → 空白+提示
    if (!loadFallback(doc)) {
      doc["w"]["text"] = "--"; doc["w"]["t"] = "--"; doc["w"]["rain"] = 0;
      doc["w"]["hi"] = "--"; doc["w"]["lo"] = "--"; doc["w"]["aqi"] = "--";
    }
  } else {
    prefs.putULong("tgood", time(nullptr));      // 拿到新資料:stale 計時歸零
  }
  // 舊畫面擱 NVS 超過 STALE_MAX_S:stale 角標照掛(本來就掛),
  // 並在右下角多一行「上次更新」——寧可醜,不可騙人
  if (stale && prefs.getULong("tgood", 0) &&
      time(nullptr) - prefs.getULong("tgood", 0) > STALE_MAX_S) {
    display.setTextSize(1); display.setCursor(196, 122);
    display.printf("upd %lu d", (time(nullptr) - prefs.getULong("tgood", 0)) / 86400);
  }

  renderCard(doc, stale);
  display.display();                             // 依 setFullRefreshFrequency 自動全/局刷
  refreshCount++;
  prefs.putUShort("rc", refreshCount);           // 斷電也記得「距下次全刷還有幾刷」

  /* --- 按 B 切卡:醒著時立刻補刷一次放大視圖 ------------------- */
  if (digitalRead(BTN_B) == LOW) {               // 簡單 debounce:醒來瞬間已按下才算
    delay(60);
    if (digitalRead(BTN_B) == LOW) {
      uint8_t card = (prefs.getUChar("card", 0) + 1) % 3;
      prefs.putUChar("card", card);              // 0=總覽 1=行程放大 2=待辦放大
      // 放大視圖=同資料大字号重繪(省略:renderCard 依 card 分支)
      display.fillScreen(GxEPD_WHITE);
      display.setTextSize(2); display.setCursor(20, 70);
      display.print(card == 1 ? "AGENDA+" : "TODO+");
      display.display();
    }
  }

  gotoSleep();       // loop 永不返回;下次醒來是 15 分鐘後或下一聲「喀」(按鍵)
}
⚠️ 深睡手冊三則:① 一定先 display.hibernate() 再進深睡——墨水屏沒休眠會持續漏電;② DevKitC 板上 LDO+充電晶片靜態約 10mA,量到休眠電流「比預期大兩個級別」先懷疑供電路徑,不是韌體;③ 正式版把韌體燒進 GPIO45/46 之外的普通腳位配置(本機沒用到 strapping 腳,出廠安全)。

🗄️ NAS 端:彙整服務(一支 Python)

#!/usr/bin/env python3
# dash_api.py — 資訊卡彙整端:Open-Meteo 天氣+ICS 日程+待辦→精簡 JSON
# 上線:DASH_TOKEN=xxx python3 dash_api.py   (systemd 開機自啟)
# 設計:板端只做「顯示」,API 金鑰/解析/快取全部集中在 NAS——
#       MCU 的 JSON 越小,越不怕網路慢與記憶體爆
import json, os, re, time, urllib.request
from datetime import datetime, timezone, timedelta
from http.server import BaseHTTPRequestHandler, HTTPServer

TZ = timezone(timedelta(hours=8))
LAT, LON = 25.07, 121.66                      # 汐止
ICS_URL = os.environ.get("DASH_ICS", "https://your-google-ics-private-url")
TODO = os.path.join(os.path.dirname(__file__), "todo.txt")
CACHE = {"ts": 0, "data": None}               # 10 分鐘快取:別當 Open-Meteo 的 DDoS 工具

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "dash-card/1.0"})
    return urllib.request.urlopen(req, timeout=8).read().decode("utf-8", "ignore")

def weather():
    j = json.loads(get(f"https://api.open-meteo.com/v1/forecast"
        f"?latitude={LAT}&longitude={LON}&current=temperature_2d,relative_humidity_2d"
        f"&daily=temperature_2m_max,temperature_2m_min,precipitation_probability_max"
        f"&timezone=Asia%2FTaipei&forecast_days=1"))
    code = j["current"]["weather_code"]
    text = {0: "晴", 1: "多雲時晴", 2: "多雲", 3: "陰", 45: "霧",
            61: "小雨", 63: "雨", 65: "豪雨", 80: "陣雨", 95: "雷陣雨"}.get(code, "--")
    return {"text": text, "t": str(int(j["current"]["temperature_2d"])),
            "rain": j["daily"]["precipitation_probability_max"][0],
            "hi": str(int(j["daily"]["temperature_2m_max"][0])),
            "lo": str(int(j["daily"]["temperature_2m_min"][0])),
            "aqi": "38"}                        # 要真的 AQI 換掛環境署快取

def agenda():
    """ICS 粗解析:抓今天剩餘 VEVENT 的 DTSTART+SUMMARY(够用不優雅)"""
    out = []
    if not ICS_URL or "your-google" in ICS_URL:
        return out
    try:
        raw = get(ICS_URL).replace("\r\n ", "")
    except Exception:
        return out
    today = datetime.now(TZ).strftime("%Y%m%d")
    for ev in re.findall(r"BEGIN:VEVENT(.*?)END:VEVENT", raw, re.S):
        m = re.search(r"DTSTART[^:]*:(\d{8})T?(\d{4})?", ev)
        s = re.search(r"SUMMARY:(.*)", ev)
        if m and m.group(1) == today and s:
            hhmm = m.group(2) or "0000"
            out.append({"t": f"{hhmm[:2]}:{hhmm[2:]}", "title": s.group(1).strip()})
    out.sort(key=lambda x: x["t"])
    return out[:5]

def todo():
    try:
        with open(TODO, encoding="utf-8") as f:
            return [l.strip() for l in f if l.strip()][:3]
    except Exception:
        return []

def snapshot():
    if CACHE["data"] and time.time() - CACHE["ts"] < 600:
        return CACHE["data"]
    now = datetime.now(TZ)
    due_file = os.path.join(os.path.dirname(__file__), "due.json")
    count = {}
    if os.path.exists(due_file):
        d = json.load(open(due_file, encoding="utf-8"))
        dd = (datetime.fromisoformat(d["date"]).replace(tzinfo=TZ) - now).days
        count = {"label": d["label"], "days": str(dd)}
    CACHE["data"] = json.dumps({"w": weather(), "cal": agenda(),
                                "todo": todo(), "count": count}, ensure_ascii=False)
    CACHE["ts"] = time.time()
    return CACHE["data"]

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/dashboard":
            self.send_response(404); self.end_headers(); return
        body = snapshot().encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers(); self.wfile.write(body)
    def log_message(self, *a): pass

if __name__ == "__main__":
    HTTPServer(("0.0.0.0", 8091), Handler).serve_forever()
💡 ICS 訂閱位怎麼拿:Google 日曆網頁版 → 左側你的日曆 → ⋮ → 設定和共用 → 「私密網址(ICS)」貼進 DASH_ICS。那是带 token 的私密連結,只進 NAS 環境變數、不進板子、更不進 Git。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作檯 → 開啟 HTML 組裝說明書
  1. 轉接板與pin 座:買含轉接板版本先檢查 BUSY 有沒有接出來;焊針朝外,方便日後拆板
  2. 上桌燒機驗屏:先跑 GxEPD2 原廠 demo 全黑白反轉 5 次——殘影不對稱是螢幕問題,趁保固換
  3. 電源三件套:TP4056 焊電池(極性畫圈再焊);MT3608 先空載調到 5.00V 再接板;分壓線焊在電池端不是升壓輸出端
  4. 立架組裝與走線:訊號線左、電力線右,壓條固定;電池卡箍+束線帶雙固定
  5. NAS 部署+對時試刷:curl NAS:8091/dashboard 看到 JSON 後,板子按 A 鍵試刷;時間對、天氣對、電量對=收工

🔧 問題排除

問題原因解決方法
螢幕全白/全黑無反應BUSY 未接或 6 芯順序錯一位逐芯核對(順序錯一位=必全滅);RST/BUSY 别接反
畫面殘影越刷越髒長期只局部刷確認 setFullRefreshFrequency(30) 有生效;手動強刷 1 次清看板
休眠電流 mA 級墨水屏沒 hibernate/升壓板常耗韌體先 hibernate 再深睡;供電改從電池端量(MT3608 本身 ~100µA 可接受)
時間固定醒來卻不準斷網太久靠 RC 振盪器每次醒都 NTP;连续 3 天 stale 版面會自己提醒你去查網路
JSON 解析失敗一直 staleNAS 端欄位改名/未啟快取掛了板子只認欄位名;NAS 端 curl 比對欄位;開 NAS 端 log 看 5xx
低氣溫刷新變很慢墨水物理特性(<0°C)寒流期接受它;進階版加負溫補償波型或夾小暖貼

🎯 進階延伸

🎯 結語

上線第一週,最意外的不是它多能幹,而是它「什麼都不做」的樣子:不會在深夜發光、不會在你思考時震動、不會在會議中提醒你什麼——它只是在那裡,像一張永遠寫不完的紙。第八天早上,你開始在刷牙時就隔著房门瞄一眼今天的行程,然後發現自己已經很久沒有「因為拿手機而忘了原本要幹嘛」走進玄關。注意力這種東西,省下來就是賺到,而這張卡片的代價是三個便當。

📦 檔案清單

s3-epaper-dashboard/
├── src/
│   ├── s3-epaper-dashboard.ino   # 本頁完整韌體
│   └── dash_api.py               # NAS 端彙整服務
├── systemd/
│   └── dash-api.service          # 彙整服務開機自啟單元
├── stl/
│   ├── stand-75.stl              # 75° 斜立架
│   ├── back-plate.stl            # 背蓋(電路艙)
│   ├── knob-caps.stl             # 按鍵帽 ×2
│   ├── battery-clip.stl          # 18650 電池固定座
│   ├── cable-clip.stl            # 走線壓條
│   └── vesa-bracket.stl          # 壁掛轉接(100mm VESA)
├── f3d/
│   └── s3-epaper-dashboard.f3d   # Fusion 360 原始檔
├── manual/
│   └── assembly.html             # 圖文組裝說明書
└── README.md
← 上一篇:ESP32-S3 桌上型縮時攝影機 📚 系列索引 下一篇:ESP32-C3 廚房瓦斯洩漏哨兵 →