📋 專案概述

你住汐止,通勤內湖,單程捷運加公車四十五分鐘。十月起東北季風上班,臺北盆地的雨季要下到三月底——這五個月裡,你家玄關發生過這樣的劇本不下十次:早上七點四十你一隻腳跳著穿鞋,手機鬧鐘、賴床、早餐,三道關卡打完,玄關傘架那把黑傘就在你眼皮的縫隙裡消失了。出了捷運站一切還和平,走進內湖科學園區最後一段、雨勢剛好把你沒帶傘這件事宣佈出來的時候,已經來不及了。一把便利商店傘 NT$250,你前年弄丟三把、去年兩把,損失NT$1,250——買的全是在你手上活不過一週的折疊傘。

你試過科技方法:手機天氣 App 推播設在 6:30,但那個時段你在洗澡;設在 7:30,你邊滑手機邊吃飯,推播混在群組訊息裡跟沒有一樣。你認真看過市售商品:日本代購的「智能傘架」NT$2,400,功能是把傘夾住、App 通知你帶傘——但它連雨都不會看,下雨與否要你自己告訴它;氣象聯動款的訂閱一年再加 NT$600。你要的東西其實很笨:早上它自己看天氣,要下雨、而你人已經站在玄關、傘卻不在你手上——就叫到你拿傘為止。不需要 App,不需要雲端的雲端。

市售商品:

本專案功能清單:

💰 成本效益:全機約 NT$310,不到日本傘架的八分之一,而且它真的會看天氣。一年少買兩把便利商店傘就回本——還不用算你淋雨後那杯感冒沖劑和請假半天的隱形成本。資料源用 Open-Meteo 免註冊免金鑰,備援中央氣象署-opendata 免費會員,零訂閱。

為什麼選 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 轉序列埠;省下的每顆元件都是電池壽命
⚠️ C2 的代價要講清楚:SuperMini 板上沒有 USB 序列埠晶片,燒錄要另接 USB-TTL(TX/RX/GND,依板子設計把 BOOT 腳拉低進燒錄模式,見組裝說明書步驟 1);且它是 2.4GHz Wi-Fi 4(802.11 b/g/n)不支援 5GHz——你家若是純 5GHz 物聯網 SSID 要先開 2.4G。

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C2 SuperMini1~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.1V1~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 SuperMini 5V ● 電源 3V3 ● 3.3V GND ● — GPIO0 霍爾 — GPIO1 按鈕 — GPIO2 電壓 — GPIO3 蜂鳴 — GPIO4 燈環 A3144 霍爾 VCC→5V(吃 4.5V 以上) OUT→GPIO0+10k 上拉3V3 磁鐵靠近 = LOW=傘在架上 WS2812 燈環 DIN←330Ω←GPIO4 VCC→5V,GND 按鈕+下拉 10k 按→3V3→GPIO1 深睡中按下去=喚醒 蜂鳴器模組 SIG←GPIO3(S8050) VCC→5V,GND 電池電壓監視 BAT+→100k→GPIO2→100k→GND 分壓一半進 ADC,韌體×2 電源鏈 18650→開關→MT3608→5V→板5V TP4056→1N5819→並入電池
圖說:全機五條訊號線。霍爾因為供電範圍 4.5V 起,VCC 吃 5V 軌、輸出經 10k 上拉到 3V3 再進 GPIO——不要直接上拉 5V 給 C2 的腳。
ESP32-C2 SuperMini 對照表(板別腳位請先對絲印,SuperMini 體積小易看錯): C2 腳位 連到 線色 備註 ───────────────────────────────────────────────────────── 5V MT3608 OUT+ 紅 霍爾/燈環/蜂鳴器共 5V 軌 3V3 霍爾 OUT 10k 上拉 藍 上拉電阻本體另一端也接 3V3 GPIO0 A3144 OUT 紫 傘偵測;深睡喚醒源(高準位) GPIO1 按鈕→3V3 綠 外掛 10k 下拉到 GND;喚醒源 GPIO2 100k+100k 分壓中點 黃 電池電壓監視(韌體讀值×2) GPIO3 蜂鳴器 SIG(S8050) 橘 tone() 驅動被動蜂鳴器 GPIO4 WS2812 DIN←330Ω 白 燈環資料 GND 全部 GND 黑 共地是全部開通的,別分地

⚡ 供電與待機預算

這臺機器九成九的人生在睡覺,待機電流就是產品成敗。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 小時
⚠️ 燈環是最耗電的對外介面:8 顆 WS2812 全亮白燈要吃 160mA。韌體把亮度釘在 60/255、警報最多 30 秒、其餘時間熄燈——想改成常亮燈的,先把電池加大或改 USB 供電。

🧊 3D 列印設計

傘座 Ø38 深 190 N52 磁鐵 A3144(側窗) 燈環透光罩 2 壁 燈絲 15%=柔光 主機艙 C2+燈環+蜂鳴器 18650 彈簧艙 底部開蓋更換 按鈕 滑動開關 背板(鎖牆/玄關鞋櫃側板)
圖說:三件式。傘座底部埋磁鐵、側窗對準傘座根部的霍爾——傘插進去磁鐵升高距離 18mm 以內即LOW;燈環罩用 2 壁+低填充做出柔光效果,整圈見不到燈珠。
列印件尺寸/數量材料與設定時間
傘座(含磁鐵槽、霍爾側窗)190mm 高×1PLA、0.2mm、壁 3 層、花紋 15%1h50m
燈環透光罩Ø62×14mm×1PLA 白色、2 壁、燈絲 12%(柔光關鍵)12m
背板主機艙(電池艙+開關孔+按鈕孔)120×95×35mm×1PLA、0.24mm、壁 3 層1h40m
電池艙底蓋(卡扣式)1PLA、卡扣公差 0.2mm15m

💻 韌體(完整程式)

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 慢速記憶體,深度睡眠不遺失;抽電池重插等於換了個新的它——重新抓過預報就是,設計上自癒。

😴 睡眠與喚醒邏輯

深度睡眠 35µA 定時器+EXT1(按鈕/霍爾) 06:45 / 07:45 按鈕按下 傘被拔走(EXT1 HIGH) 抓預報+評估 有雨且傘在架上→警報 乾天→綠燈 2 秒 即時等級 綠/青/紅 5 秒 +更新今日預報 事後攔截 今日有雨且已警報過 →紅燈+三短嗶,不抓網 重新掛喚醒源 → 深睡
圖說:事後攔截分支刻意「不開 Wi-Fi」——拔傘到關門只有五秒,來不及抓網,靠的是昨晚…早安存進 RTC 的旗標。

一個設計上會被問的點:傘放回傘座為什麼不喚醒?ESP32-C2 的 EXT1 只支援高準位喚醒,而「傘放回」是 OUT 變 LOW。代價是放傘不點燈,但換到的是不用在任何狀態下和瞬間脈波搏鬥——這臺機器的本職是叫你拿傘,不是當傘的保全。

🔧 組裝重點速覽

  1. 外接 USB-TTL 燒錄(板子絲印 TX/RX,接線時 TTL 端的 TX 接板的 RX);先用迴轉測試程式確認 TX/RX 沒接反。
  2. 霍爾與磁鐵的間隙抓 10–15mm:傘插到底磁鐵正好在霍爾側窗旁;用韌體開機綠燈當「磁場確認燈」。
  3. 升壓板先空載調到 5.1V 再焊上去——出廠預設可能衝到 6V 以上,燈環會燒給你。
  4. 燈環韌體先跑色彩測試再鎖進罩內,鎖進去才發現焊錯要拆全機。
  5. 排程把 06:45 暫時改成「兩分鐘後」實測整條警報鏈,再改回正式時間。
📘 八步驟圖文版(含燒錄接線、磁隙校準、喚醒演練、警報彩排)→ 雨天傘組裝說明書,附 A4 列印樣式,列印一份放工具箱。

🛠️ 問題排除

症狀真正原因處置
開機綠燈不亮供電鏈斷/燈環 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 腳拉低再上電

🚀 進階延伸

📦 檔案清單

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              # 圖文組裝說明書
← 上一篇:ESP32-C3 老冷氣紅外學習中控 📚 系列索引 下一篇:ESP32-C3 智慧登山杖障礙雷達 →