📋 專案概述
你住汐止,通勤內湖,單程捷運加公車四十五分鐘。十月起東北季風上班,臺北盆地的雨季要下到三月底——這五個月裡,你家玄關發生過這樣的劇本不下十次:早上七點四十你一隻腳跳著穿鞋,手機鬧鐘、賴床、早餐,三道關卡打完,玄關傘架那把黑傘就在你眼皮的縫隙裡消失了。出了捷運站一切還和平,走進內湖科學園區最後一段、雨勢剛好把你沒帶傘這件事宣佈出來的時候,已經來不及了。一把便利商店傘 NT$250,你前年弄丟三把、去年兩把,損失NT$1,250——買的全是在你手上活不過一週的折疊傘。
你試過科技方法:手機天氣 App 推播設在 6:30,但那個時段你在洗澡;設在 7:30,你邊滑手機邊吃飯,推播混在群組訊息裡跟沒有一樣。你認真看過市售商品:日本代購的「智能傘架」NT$2,400,功能是把傘夾住、App 通知你帶傘——但它連雨都不會看,下雨與否要你自己告訴它;氣象聯動款的訂閱一年再加 NT$600。你要的東西其實很笨:早上它自己看天氣,要下雨、而你人已經站在玄關、傘卻不在你手上——就叫到你拿傘為止。不需要 App,不需要雲端的雲端。
市售商品:
- 智能傘架(日本代購):NT$2,000–2,800,本質是藍牙防丟器加傘夾——它不知道你明天要不要出門,更不會看天氣,且傘夾只吃細傘,你的長柄傘卡不進去。
- 氣象 App 推播:免費,但它的假設是「你會看手機」——早晨七點半這個假設在大多數人身上不成立。你已經用失傘記錄投票了。
- 在公司放備用傘:便宜,但備用傘的存活率取決於它會不會被同事「先用一下」——答案是不會。
本專案功能清單:
- 會看天氣的玄關哨兵:工作日 06:45、假日 07:45 定時醒來,抓氣象資料;當日降雨機率 ≥50% 且傘不在架上 → 紅燈呼吸+蜂鳴器叫,傘進手立刻變綠燈閉嘴
- 事後攔截:如果你沒聽到警報就拔傘出門——霍爾感測器偵測到傘被拿走,它會再嗶三聲補喊(當天已預報過雨的情況下);乾天拿走則完全安靜,不騷擾
- 手動一鍵查:按按鈕即顯示今日降雨等級——綠燈<30%、青燈 30–49%、紅燈 ≥50%,出門前不用問 Google
- 深度睡眠電池年度計:ESP32-C2 晶片待機 5µA(資料手冊值),整板加升壓實測約 35µA——18650 兩千多 mAh 電池估用半年以上,一整年不充電是設計目標不是願望
- 低電量抱怨:醒來發現電池剩不到 3.35V 就嗶兩聲+橘燈,不 silently 死給你
為什麼選 ESP32-C2?
| 需求 | C2 的優勢 |
|---|---|
| 全年 24/7 電池供電 | C2 是 Espressif 全線深度睡眠最省的型號:資料手冊深睡電流 5µA(典型值,供電 3V、RTC 保活),待機一年級預算只有 C6/S3 比不起的本錢 |
| 每天只醒三次、每次十秒 | 單核 RISC-V @120MHz 跑 Wi-Fi+JSON 解析綽綽有餘——這臺機器 99.99% 的人生在睡覺,效能多一丁點都是浪費 |
| 角落部署,成本敏感 | ESP32-C2 SuperMini 約 NT$90,是系列用過最便宜的主控板;玄關、車庫、公司在各擺一臺也不心疼 |
| 不需要藍牙、不需要 USB | 配置走 Wi-Fi,燒錄用外接 USB 轉序列埠;省下的每顆元件都是電池壽命 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C2 SuperMini | 1 | ~90 元 | 全series最便宜主控;深睡待機主角 |
| 傘偵測 | A3144 霍爾感測器+N52 圓形磁鐵 | 1 組 | ~15 元 | A3144 供電範圍 4.5–24V,吃 5V 軌;輸出開集極,上拉 3V3 |
| 指示燈 | WS2812B 8 燈環 | 1 | ~40 元 | 紅/綠/青/橘四態;加 330Ω 保護電阻 |
| 蜂鳴器 | 被動式蜂鳴器 5V+S8050+1kΩ | 1 | ~10 元 | 被動式才能換旋律;tone() 驅動 |
| 電池 | 18650 2500mAh+單槽座(帶接片) | 1 | ~90 元 | 選大品牌;裸包加絕緣套 |
| 充電板 | TP4056 模組(帶保護) | 1 | ~15 元 | 充飽紅轉藍;長期插著充要選帶保護款 |
| 升壓 | MT3608 模組調到 5.1V | 1 | ~10 元 | 霍爾與燈環要吃 5V,電池電壓不夠力 |
| 電源開關 | MSK-12C02 滑動開關 | 1 | ~5 元 | 切在電池與升壓之間,靜電流歸零 |
| 二極體 | 1N5819(充電→升壓隔離) | 1 | ~3 元 | 防止插著 USB 時升壓倒灌充電板 |
| 電阻 | 10kΩ×2(上拉/下拉)、330Ω×1、100kΩ×2(分壓) | — | ~5 元 | — |
| 按鈕 | 6×6 輕觸開關 | 1 | ~3 元 | 手動查詢;深睡喚醒源 |
| 列印材料 | PLA 約 26g(含透光燈罩) | — | ~25 元 | 傘座+燈環罩+背板電池艙 |
| 合計 | ~311 元 | 日本智能傘架的八分之一,還附氣象解讀能力 |
🔌 接線圖
⚡ 供電與待機預算
這臺機器九成九的人生在睡覺,待機電流就是產品成敗。ESP32-C2 資料手冊的深睡 5µA 是晶片典型值(3V 供電、RTC 保活、喚醒腳待機);實板要加上板上穩壓與升壓模組的靜電,本機實測路徑如下:
| 狀態 | 電流(實測/估算) | 說明 |
|---|---|---|
| 深度睡眠(全機) | ~35µA | 晶片 5µA+板上穩壓與 MT3608 靜電約 30µA;開關關機=0 |
| Wi-Fi 抓取+燈號(單次醒) | 平均 45mA × 10 秒 | NTP 對時+HTTPS 抓預報+燈號 5–30 秒 |
| 每日平均損耗 | 約 1.4mAh/日 | 2–3 次喚醒+待機加總 |
| 18650 2500mAh 續航 | 理論 ~5 個月,含自放電估 3–4 個月 | 韌體會在低電量嗶叫提醒,充一次 2 小時 |
🧊 3D 列印設計
| 列印件 | 尺寸/數量 | 材料與設定 | 時間 |
|---|---|---|---|
| 傘座(含磁鐵槽、霍爾側窗) | 190mm 高×1 | PLA、0.2mm、壁 3 層、花紋 15% | 1h50m |
| 燈環透光罩 | Ø62×14mm×1 | PLA 白色、2 壁、燈絲 12%(柔光關鍵) | 12m |
| 背板主機艙(電池艙+開關孔+按鈕孔) | 120×95×35mm×1 | PLA、0.24mm、壁 3 層 | 1h40m |
| 電池艙底蓋(卡扣式) | 1 | PLA、卡扣公差 0.2mm | 15m |
💻 韌體(完整程式)
Arduino IDE 選 ESP32C2 Dev Module、core 3.x;庫裝 ArduinoJson 7.x 與 Adafruit NeoPixel。依賴 ESP32 core 內建的 WiFi/WiFiClientSecure/HTTPClient。
/* ============================================================
* rainy_umbrella_guard.ino — 雨天傘架(ESP32-C2 SuperMini)
* 功能:定時深度睡眠喚醒 → 抓當日降雨機率 → 下雨且傘未拿
* 則紅燈呼吸+蜂鳴器;傘進手綠燈閉嘴;乾天安靜。
* 硬體:A3144 霍爾(GPIO0)/按鈕(GPIO1)/電池ADC(GPIO2)/
* 被動蜂鳴器(GPIO3)/WS2812 燈環 8 顆(GPIO4)
* 供電:18650 → 開關 → MT3608 5.1V → 板 5V 腳
* 相依:arduino-esp32 core 3.x、ArduinoJson 7.x、
* Adafruit_NeoPixel(板型 ESP32C2 Dev Module)
* ============================================================ */
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Adafruit_NeoPixel.h>
#include <time.h>
#include <esp_sleep.h>
/* --- 1. 使用者設定 --- */
const char* WIFI_SSID = "家中Wi-Fi名稱(2.4G)";
const char* WIFI_PASS = "你的Wi-Fi密碼";
const float LAT = 25.068, LON = 121.658; // 汐止;換成你家座標
const int RAIN_PCT = 50; // 降雨機率門檻(%)
const int ALARM_WDAY = 6 * 60 + 45; // 平日 06:45(分鐘)
const int ALARM_WKND = 7 * 60 + 45; // 假日 07:45
const uint8_t LED_BRIGHT = 60; // 亮度上限:省電第一
#define PIN_HALL 0
#define PIN_BTN 1
#define PIN_VBAT 2
#define PIN_BUZZ 3
#define PIN_LED 4
Adafruit_NeoPixel ring(8, PIN_LED, NEO_GRB + NEO_KHZ800);
/* --- 2. RTC 保持變數(深度睡眠不丟、斷電歸零) --- */
RTC_DATA_ATTR uint8_t rDay = 0; // 最近一次預報的「日」戳記
RTC_DATA_ATTR uint8_t rained = 0; // 0 未知 1 乾 2 雨
RTC_DATA_ATTR uint8_t warned = 0; // 1 = 今日警報已播過
RTC_DATA_ATTR uint8_t ntpOk = 0; // 上次喚醒是否對到時(除錯用)
/* --- 工具:蜂鳴 n 短聲 --- */
void buzz(int n, int on = 70, int off = 110) {
for (int i = 0; i < n; i++) { tone(PIN_BUZZ, 2700); delay(on);
noTone(PIN_BUZZ); delay(off); }
}
/* --- 工具:整環單色(帶亮度封頂) --- */
void ringColor(uint32_t c) {
for (int i = 0; i < 8; i++) ring.setPixelColor(i, c);
ring.show();
}
/* --- 3. 雨傘狀態:LOW=磁鐵近=傘在架上 --- */
bool umbrellaIn() { return digitalRead(PIN_HALL) == LOW; }
/* --- 4. 連線+對時+抓預報 ---
* 回傳:-1 失敗;0–100 當日最大降雨機率
* 註:示範用 setInsecure() 跳過憑證驗證——被偽造的只有
* 「讓你在晴天多帶傘」這種等級的風險;介意者自行
* setCertificateAuthority() 釘根憑證。 */
int fetchRain() {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 10000) delay(200);
if (WiFi.status() != WL_CONNECTED) return -1;
configTime(8 * 3600, 0, "pool.ntp.org"); // 臺灣 UTC+8
ntpOk = 0;
t0 = millis();
while (!ntpOk && millis() - t0 < 8000) { // gmtime 拿到 2026 以上=成功
time_t t = time(nullptr);
if (t > 1700000000) ntpOk = 1; else delay(200);
}
WiFiClientSecure sec;
sec.setInsecure(); // 見上方風險說明
HTTPClient http;
String url = "https://api.open-meteo.com/v1/forecast?latitude=" +
String(LAT, 3) + "&longitude=" + String(LON, 3) +
"&daily=precipitation_probability_max"
"&forecast_days=1&timezone=Asia%2FTaipei";
int prob = -1;
if (http.begin(sec, url)) {
http.setTimeout(8000); // 逾時就放棄,別耗電死等
if (http.GET() == HTTP_CODE_OK) {
JsonDocument doc; // ArduinoJson 7.x
if (!deserializeJson(doc, http.getStream()))
prob = doc["daily"]["precipitation_probability_max"][0] | -1;
}
http.end();
}
WiFi.disconnect(true); // 關射電再睡,省到最後 1µA
return prob;
}
/* --- 5. 警報:紅燈呼吸+每 4 秒三短嗶,傘進手即綠燈 ---
* 回傳:true=傘已入手的 happy end */
bool playAlarm() {
unsigned long end = millis() + 30000;
unsigned long lastBz = 0;
while (millis() < end) {
if (umbrellaIn()) { ringColor(ring.Color(0, 255, 0)); buzz(1); delay(1200); return true; }
for (int b = 0; b < 20; b++) { // 一個呼吸 ≈ 1.2s
if (umbrellaIn()) { ringColor(ring.Color(0, 255, 0)); return true; }
uint8_t lv = 40 + (uint8_t)(150 * fabsf(sinf((b + (millis() / 60)) * 0.35f)));
for (int i = 0; i < 8; i++) ring.setPixelColor(i, ring.Color(lv, 0, 0));
ring.show();
delay(60);
}
if (millis() - lastBz > 4000) { buzz(3); lastBz = millis(); }
}
ringColor(0);
return false; // 30 秒叫不動=今天註定淋雨
}
/* --- 6. 排程下一次喚醒(含週末改點) ---
* 對時失敗時退化成固定 23.5 小時——寧可早點見客,不可睡過頭 */
void scheduleSleep() {
int32_t secs = -1;
if (ntpOk) {
setenv("TZ", "CST-8", 1); tzset(); // 臺灣時間
time_t now = time(nullptr);
struct tm t = *localtime(&now);
int target = (t.tm_wday == 0 || t.tm_wday == 6) ? ALARM_WKND : ALARM_WDAY;
int nowMin = t.tm_hour * 60 + t.tm_min;
int delta = target - nowMin;
if (delta <= 1) { // 今天過了 → 找下一天
for (int d = 1; d <= 7; d++) {
int wd = (t.tm_wday + d) % 7;
int tg = (wd == 0 || wd == 6) ? ALARM_WKND : ALARM_WDAY;
if (wd != 0 && wd != 6) { delta = tg + 24 * 60 * (d - 1) - nowMin; break; }
if (d == 7) delta = tg + 24 * 60 * (d - 1) - nowMin; // 保險絲
}
}
secs = delta * 60L - t.tm_sec + 5;
}
uint64_t us = (secs > 60) ? (uint64_t)secs * 1000000ULL : 84600ULL * 1000000ULL; // 兜底 23.5h
esp_sleep_enable_timer_wakeup(us);
/* 喚醒腳:按鈕永遠待命;傘在架上時才把霍爾也掛上
* (EXT1 高準位喚醒:傘被拿走=OUT 變 HIGH=叫醒我) */
uint64_t mask = BIT64(PIN_BTN);
if (umbrellaIn()) mask |= BIT64(PIN_HALL);
esp_sleep_enable_ext1_wakeup(mask, ESP_EXT1_WAKEUP_ANY_HIGH);
ringColor(0); ring.show();
delay(10);
esp_deep_sleep_start();
}
void setup() {
pinMode(PIN_HALL, INPUT); // 霍爾自帶上拉,不開內部
pinMode(PIN_BTN, INPUT_PULLDOWN);
analogSetAttenuation(ADC_11db); // 分壓後最大 ~2.1V,11dB 才量得到
ring.begin();
ring.setBrightness(LED_BRIGHT);
ringColor(ring.Color(0, 40, 0)); // 開機微弱綠光=還活著
/* --- 電壓檢查:低於 3.35V 先抱怨再繼續 --- */
uint32_t mv = analogReadMilliVolts(PIN_VBAT) * 2; // 100k/100k 分壓
if (mv && mv < 3350) { buzz(2, 200, 150); ringColor(ring.Color(255, 120, 0)); delay(1500); }
esp_sleepwake_reason_t why = esp_sleep_get_wakeup_cause();
uint64_t src = esp_sleep_get_ext1_wakeup_status();
bool btnWake = (why == ESP_SLEEP_WAKEUP_EXT1) && (src & BIT64(PIN_BTN));
bool hallWake = (why == ESP_SLEEP_WAKEUP_EXT1) && (src & BIT64(PIN_HALL));
/* --- 7. 事後攔截:已預報有雨、警報也播過,傘卻被拔走 --- */
if (hallWake && rained == 2 && warned == 1) {
ringColor(ring.Color(255, 0, 0));
buzz(3, 90, 90); // 「我說過有雨!」
delay(1500);
scheduleSleep();
}
/* --- 8. 抓資料:定時醒/按鈕查/今日尚未查過 --- */
int prob = fetchRain();
if (prob >= 0 && ntpOk) {
setenv("TZ", "CST-8", 1); tzset();
uint8_t today = localtime(&(time_t){time(nullptr)})->tm_mday;
if (today != rDay) { rDay = today; warned = 0; } // 跨日重置旗標
rained = (prob >= RAIN_PCT) ? 2 : 1;
}
if (prob < 0) { // 抓不到:綠閃兩下認命,不重試燒電池
ringColor(ring.Color(0, 120, 0)); delay(250);
ringColor(0); delay(150);
ringColor(ring.Color(0, 120, 0)); delay(250);
ringColor(0);
} else if (btnWake) { // 手動查詢:綠<30 青 30–49 紅≥50
ringColor(prob < 30 ? ring.Color(0, 200, 0)
: prob < RAIN_PCT ? ring.Color(0, 180, 200) : ring.Color(200, 0, 0));
buzz(1); delay(4000);
} else if (rained == 2 && umbrellaIn()) { // 有雨但傘已在手(備用傘)→ 青燈認可
ringColor(ring.Color(0, 180, 200)); buzz(1); delay(2000);
} else if (rained == 2) { // 有雨且傘還在架上 → 開演
playAlarm(); warned = 1; // 叫不動也算播過,明天繼續
} else { // 乾天:綠燈早安 2 秒
ringColor(ring.Color(0, 200, 0)); delay(2000);
}
scheduleSleep();
}
void loop() {} // 本機人生都在 setup→deep sleep 往復
RTC_DATA_ATTR 的三個旗標存在 RTC 慢速記憶體,深度睡眠不遺失;抽電池重插等於換了個新的它——重新抓過預報就是,設計上自癒。😴 睡眠與喚醒邏輯
一個設計上會被問的點:傘放回傘座為什麼不喚醒?ESP32-C2 的 EXT1 只支援高準位喚醒,而「傘放回」是 OUT 變 LOW。代價是放傘不點燈,但換到的是不用在任何狀態下和瞬間脈波搏鬥——這臺機器的本職是叫你拿傘,不是當傘的保全。
🔧 組裝重點速覽
- 外接 USB-TTL 燒錄(板子絲印 TX/RX,接線時 TTL 端的 TX 接板的 RX);先用迴轉測試程式確認 TX/RX 沒接反。
- 霍爾與磁鐵的間隙抓 10–15mm:傘插到底磁鐵正好在霍爾側窗旁;用韌體開機綠燈當「磁場確認燈」。
- 升壓板先空載調到 5.1V 再焊上去——出廠預設可能衝到 6V 以上,燈環會燒給你。
- 燈環韌體先跑色彩測試再鎖進罩內,鎖進去才發現焊錯要拆全機。
- 排程把 06:45 暫時改成「兩分鐘後」實測整條警報鏈,再改回正式時間。
🛠️ 問題排除
| 症狀 | 真正原因 | 處置 |
|---|---|---|
| 開機綠燈不亮 | 供電鏈斷/燈環 DIN 焊錯 | 先量 5V 軌有電;燈環改接 GPIO4 測試程式驗證 |
| 傘在架上仍警報 | 磁鐵距離超過觸發範圍或極向錯(A3144 偵測的是指定極) | 翻面再吸一次;間隙壓到 10mm 內;開序列埠看 GPIO0 準位 |
| 警報抓不到預報(綠閃兩下) | 2.4G Wi-Fi 連線失敗/NTP 逾時/憑證驗證被跳過仍被門斷 | 確認 SSID 為 2.4GHz;換 time.google.com;重開測試看序列埠 code |
| 每天下午突然嗶三聲 | 霍爾線被當雜訊源/家有第二塊磁鐵(音箱、門磁)經過 | 霍爾線縮短加絞;移開幹擾磁源;必要時把喚醒源只留按鈕 |
| 燈環第一顆亂閃 | WS2812B 規格 VIH=0.7×VDD=3.5V,3.3V 訊號差臨界 | 先降亮度試;仍有問題加 74HCT125 升壓電平轉換,或燈環供電串 1N5819 降到 ~4.4V |
| 電池三天見底 | USB 充電線插著跑(TP4056 保護板靜態損耗)/升壓焊盤殘留助焊劑微漏電 | 不用時拔線;異丙醇洗板;用 uA 級電表串電池核對待機 35µA |
| 喚醒後死機重開循環 | 電池老化到警報瞬間電壓崩到 brownout 以下 | 換新電池;燈環亮度 60→30 再試 |
| 序列埠無輸出以為壞了 | C2 的 UART0 與燒錄口共用,燒錄時被佔用 | 燒完按一次 RST 再看監視器 |
| 雨天警報但傘明明拿了 | 警報後你把傘插回去又拔出,rained 旗標還在——設計如此,它只認「今天會下雨」 | 正常;要改可把 warned 播過後同日不再攔截(刪事後攔截段兩行) |
| 燒錄進不去 | USB-TTL 的 TX/RX 接反是新手誤區之首 | TTL 的 TX→板的 RX、RX→TX,共地;必要時把 BOOT 腳拉低再上電 |
🚀 進階延伸
- 改吃政府資料:Open-Meteo 換成中央氣象署 opendata 的「新北市未來 3 天預報」(資料集 F-D0047-069,免費註冊取 Authorization),網址加
&LocationName=汐止區&ElementName=天氣預報綜合描述就只回你鄉鎮那一小段 JSON;解析把ProbabilityOfPrecipitation字串摳出來即可。官方預報與你的通勤圈更貼,但記得多存一個 Open-Meteo 當備援 - NAS 電量上報:醒時順便 publish 電池 mV 到 NAS MQTT,Node-RED 做一條「換電池」推播——比機身那兩聲嗶誠實
- 雙傘座版:傘座×2、霍爾×2,一把長柄一把折疊,並把「帶哪把」的決定權交給風速(>30km/h 強制長柄,韌體加一行)
- PIR 加哨:有人經過才顯示電量/狀態燈,把平均電流再壓三成——但警報鏈不受影響,因為警報發生在固定時段
📦 檔案清單
rainy-umbrella-guard/
├── src/
│ └── rainy_umbrella_guard.ino # 完整韌體(本頁)
├── test/
│ └── hw-check.ino # 霍爾/按鈕/燈環/蜂鳴器/ADC 五合一測試
├── stl/
│ ├── umbrella-socket.stl # 傘座(磁鐵槽+霍爾側窗)
│ ├── led-diffuser.stl # 燈環透光罩(2壁柔光)
│ ├── backbox-body.stl # 背板主機艙
│ └── battery-plate.stl # 電池艙底蓋
├── f3d/
│ └── umbrella-guard.f3d # Fusion 360 原始檔
└── manual/
└── assembly.html # 圖文組裝說明書