📋 專案概述
早上七點五十分,出門前要確認三件事:今天會不會下雨、九點的客戶會議改沒改時間、洗衣機是不是還亮著紅燈。答案分別住在天氣 App、Google 日曆、和三樓陽台——於是你解鎖手機,滑過天氣 App,然後被群組訊息絆住,八點零四分才踩出家門。
「抬頭就知道」這件事,市面上的選項都在幫你倒著做:
- 智慧顯示螢幕(智慧時鐘+相框那類):NT$1,999 起,LCD 背光 24 小時亮著——晚上路過書房像被一台小電視瞪著;內容要註冊雲 App,訂閱到期之後畫面只剩它的廣告。
- 平板充當資訊卡:三週後螢幕燒了側邊欄選單的殘影,而且它永遠在等你回訊息,你等於在桌上養了一隻會一直舉手要回訊的電獸。
- 紙+異音警報(便利貼):免費,但第七張貼上去之後,整面牆對你的大腦變成白噪音。
這張 NT$845 的電子墨水桌面資訊卡反其道:它不發光、不推播、不會動——把「抬頭掃一眼」的成本壓回零,功能清單:
- 四格版面:日期時間/天氣+降雨機率+室內溫濕、Google 行程(ICS 訂閱直讀)、NAS 待辦清單、重要日倒數
- 15 分鐘一醒:deep sleep ~9µA,醒 5 秒、抓資料、刷圖、躺回去——18650 一顆跑 2~3 週
- 斷網不慌:NAS 抓不到就沿用上次的好畫面,右上角蓋一個
stale角標——資訊卡可以舊,不可以白 - 防殘影自主保養:每 30 刷自動來一次黑白全刷,三個月後玻璃上不會留上個月的行程表
- 雙鍵盲操作:左鍵=強制刷新(喚醒它),右鍵=切換放大視圖(今天行程/待辦清單誰佔 C 位)
成本算盤:智慧顯示螢幕 NT$1,999 + 終身雲帳號,本機 NT$845 而且資料全從自家 NAS 走——省下的錢夠買三顆 18650 跟一整年的待辦清單。
🔬 為什麼選 ESP32-S3(選型說明)
| 需求 | S3 的對應 |
|---|---|
| 醒 5 秒、睡 15 分鐘 | deep sleep ~9µA 級+快速啟動,平均電流被刷新間距稀釋到 µA 級 |
| 天氣+ICS +待辦的 JSON | PSRAM 當 HTTP 接收池:彙整後的精簡 JSON 整包吃下,不用寫分塊解析 |
| 墨水屏 6 根訊號+2 鍵+電池 ADC | GPIO 數與外設組合最從容;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 N16R8 | 1 | ~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Ω ×2 | 2 | ~2 元 | 電池電壓 ÷2 進 GPIO4 ADC |
| 電源開關 | 側撥滑動開關 | 1 | ~8 元 | 切在升壓板輸入端,不是板上 EN |
| 列印料 | PLA ~80g | — | ~25 元 | 75° 立架+背蓋+按鍵帽 |
| 合計 | ~845 元 | 雲智慧螢幕的 1/3,沒有背光也沒有訂閱 |
🔌 接線圖
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 分鐘
🎯 為什麼是墨水屏,不是小 LCD
🖨️ 3D 列印設計
零件清單
| 零件 | 數量 | 材質建議 | 列印設定 | 備註 |
|---|---|---|---|---|
| 75° 斜立架 | 1 | PLA | 0.2mm/3 壁/20% | 螢幕卡入式(留 0.2mm 間隙);底部配重槽放備用電池 |
| 背蓋(電路艙) | 1 | PLA | 0.2mm/3 壁/15% | TP4056 指示燈透窗 6×10mm;充電孔位對 Type-C |
| 按鍵帽 ×2 | 2 | PLA | 0.16mm/4 壁 | 側出式,按面露在立架下緣兩角 |
| 電池固定座 | 1 | PLA | 0.2mm/3 壁/25% | 18650 卡箍+束線帶槽;電池不可晃=安全底線 |
| 走線壓條 | 2 | PLA | 0.2mm/2 壁 | 六芯訊號線與電力線分邊壓住 |
| 壁掛轉接 | 1 | PETG | 0.2mm/4 壁/40% | 100mm VESA 孔距,掛玄關牆 |
設計要點
- 螢幕前方永遠不蓋東西:墨水屏靠反射環境光,透明壓克力會反光+悶熱;卡入式設計讓玻璃面完全外露
- 75° 不是玄學:坐姿平視+桌面檯燈的反射角計算後,75° 後仰剛好把檯燈鏡像甩出視線外
- リチウム電池艙要「牢」不要「美」:18650 卡箍+束線帶雙固定,螺絲穿不過電池;艙內線材避開升壓板輸出側的尖焊點
- 充電燈透窗:TP4056 紅藍燈贴在窗後 2mm,插充電時一眼確認,不用拆殼
- 底部散熱縫:升壓板和 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}¤t=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 組裝說明書
- 轉接板與pin 座:買含轉接板版本先檢查 BUSY 有沒有接出來;焊針朝外,方便日後拆板
- 上桌燒機驗屏:先跑 GxEPD2 原廠 demo 全黑白反轉 5 次——殘影不對稱是螢幕問題,趁保固換
- 電源三件套:TP4056 焊電池(極性畫圈再焊);MT3608 先空載調到 5.00V 再接板;分壓線焊在電池端不是升壓輸出端
- 立架組裝與走線:訊號線左、電力線右,壓條固定;電池卡箍+束線帶雙固定
- 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 解析失敗一直 stale | NAS 端欄位改名/未啟快取掛了 | 板子只認欄位名;NAS 端 curl 比對欄位;開 NAS 端 log 看 5xx |
| 低氣溫刷新變很慢 | 墨水物理特性(<0°C) | 寒流期接受它;進階版加負溫補償波型或夾小暖貼 |
🎯 進階延伸
- 分鐘局部刷新版:USB 供電模式下改用 GxEPD2 partial window 只刷時鐘格,每分鐘 0.3s 無閃爍走字——電模式下的優雅彩蛋
- 通知鏡像:NAS 加一行「Telegram 訊息→寫 notify.txt→板子下次醒顯示 30 分鐘」——不推播的推播,一天只亮一次臉
- 三色墨水(黑白紅):換 GDEY0213F51 紅白黑 2.1",紅色的用途:到期待辦、雨勢 ≥50%、倒數 ≤3 天——資訊層級一眼分
- 太阳能版:靠窗桌面改 6V 太阳能板+TP4056 直充,15 分鐘刷新的耗電量對它只是毛毛雨
- NFC 切換設定檔:後殼貼 NTAG213(~10 元),手機貼一下切「工作模式/假日模式」版面組合
🎯 結語
上線第一週,最意外的不是它多能幹,而是它「什麼都不做」的樣子:不會在深夜發光、不會在你思考時震動、不會在會議中提醒你什麼——它只是在那裡,像一張永遠寫不完的紙。第八天早上,你開始在刷牙時就隔著房门瞄一眼今天的行程,然後發現自己已經很久沒有「因為拿手機而忘了原本要幹嘛」走進玄關。注意力這種東西,省下來就是賺到,而這張卡片的代價是三個便當。
📦 檔案清單
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