📋 專案概述

去年蘇拉颱風警報,我人在北投工作,太太傳訊息:「陽台那盆兩千塊的天堂鳥要不要收?」我看著氣象署 App 的「最大陣風風向:東北東、陣風 9 級」——那是全北台灣的平均值。我住汐止河谷側的 14 樓,陽台朝西北、正好吃峽谷效應的風口。收,還是賭?賭輸的結算單:天堂鳥 2,000+陶盆 800+鐵窗防墜網被砸的 1,500,外加樓下鄰居的車。而那顆「要不要收」的決策,氣象署永遠答不了——因為它量的是 10 公尺高的標準場,不是你家陽台。

風這種東西殘酷在它的在地性:氣象站測到 9 級,你的轉角可能是 11 級——陽台的陣風可以是開闊地的 1.5 倍,而砸花盤的恰好就是多出來的那 2 級。反之也真實:警報報得嚇死人、你家陽台風平浪靜,收了一下午的花盆純屬白工。「你家陽台的風」和「氣象站的風」根本是兩個世界,中間隔著整棟建築的渦流。

市售方案搜過一圈:

  1. Davis Vantage 級專業氣象站:兩萬起跳,風向風速計要架屋頂拉線——管理員第一個不答應,租房族直接出局。
  2. 業餘「風速計模組+樹莓派」:光風杯+風向計模組就要 900 元,樹莓派再 2,000,然後你獲得一台 5W 的伺服器只為了讀兩顆感測器——這是在用遊艇送快餐。
  3. 問鄰居:資訊延遲取決於鄰居的閒暇時間,準確度取決於他對「很大風」三個字的詞彙量。

這台 ESP32-C6 陽台風速風向塔把決策還給你:自己印風杯、用乾簧片數脈衝、可變電阻讀風向,整組 400 元有找。功能清單:

帳目:一盆天堂鳥的賭注是 4,300 元起跳(還沒算樓下的車);本機 約 730 元。一次「提前四十分鐘收花」的正確決策就回本一半,剩下的年份當它免費。

為什麼選 ESP32-C6?

需求C6 的優勢(以及其他型號為什麼不選)
「有風就醒、沒風裝死、颱風夜全程清醒」light sleep+ext1 低電位喚醒切換快(微秒級回到取樣迴圈),脈衝中斷腳落在 GPIO0–7 RTC 域剛剛好——⚠ 官方文件明列 C6 的 ext0/ext1 喚醒源只有 GPIO0–7,選腳時就要卡位
省電預算深睡 7µA 級;平靜模式 light sleep 輪詢平均電流壓在個位 mA,1W 太陽能板養得活。H2 沒有 Wi-Fi(推播直接出局);C2 的 2MB 塞不下風速歷史+TLS+WS2812 驅動
系列共用同一根陽台桿上,第 24 篇雷暴哨兵已經是 C6——兩台共用分享器設定檔與電源架構,維修只用一條序列線。C3 當然也做得動,但同家族維護成本最低
脚位限制(誠實欄)⚠ 脈衝腳別選 GPIO9(Boot 腳,重設期間的毛刺會被當成風脈衝——你會在每次插拔 USB 時「颳起一陣 60m/s 的颱風」)。本篇風速=G4(喚醒+計數)、風向=G2、WS2812=G0、蜂鳴器=G1、按鍵=G3、電池=G6,全避開禁區

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C6-DevKitM-1(ESP32-C6-WROOM-1)1~110 元8MB Flash 版;與第 24 篇同板同電源架構
風速計模組風杯+乾簧片(市售分解版)1~150 元或買「風速風向一體」分解只留風速;自印風杯見列印表
風向計10kΩ 線性可變電阻式風向模塊1~130 元尾翼式;不要買編碼器盤式——貴一倍且接線更挑
風力燈WS2812B 8 燈條1~40 元風向牌立桿上環一圈,三樓外看的到的那種存在感
蜂鳴器5V 有源+S8050+10kΩ1~15 元紅色警報級才嗶;颶風級長嗶——叫醒你收衣服的那聲
儲能18650+保護板1~85 元與第 24 篇同款備料,買二備一
發電5V 1W 太陽能板1~70 元本機平均 <3mA(平靜模式),1W 飽餘
充電TP4056+SS54 防逆二極體1~20 元RPROG 改 2kΩ
升壓MT3608(調 5.0V)1~10 元先調好再鎖膠
外殼防水接線盒 12×8×61~35 元IP65,裝立桿底部(配重位置)
呼吸透氣膜+防水膠條1 組~25 元桿底盒日夜溫差大,不透氣=盒內下雨
人機按鍵1~5 元按下=立即回報風況
雜項100kΩ×2、10kΩ×2、330Ω、100nF、不鏽鋼螺絲/束帶1 組~45 元100nF 並風向滑片到地濾震盪;室外件全部不鏽鋼
合計~730 元無訂閱;資料留自家

🔌 接線圖

C6 DevKitM-1 ESP32-C6 · Wi-Fi 6 GPIO4 風速脈衝 GPIO2 風向 ADC GPIO0 WS2812 GPIO1 蜂鳴器 GPIO6 VBAT 3V3/GND GPIO3 BTN 5V ⚠ C6 ext1 喚醒源限 GPIO0–7;脈衝腳勿用 GPIO9(Boot 毛刺) 風杯+磁鐵 乾簧片 脈衝→G4(內部上拉,下降緣;兼 ext1 喚醒) 尾翼+10kΩ 可變電阻 滑片→G2(ADC1)+100nF 到地 100nF WS2812B ×8 DI←330Ω←GPIO0 風向牌立桿環一圈 蜂鳴器(有源 5V) GPIO1─10k─S8050(紅級才嗶) 按鍵→GND 電源鏈(立桿底部盒內) 太陽能 5V → TP4056+SS54 → 18650+保護板 → MT3608 5.0V → 板 5V|VBUS 不接 100k×2 分壓 → GPIO6(ADC1)測電池 5V
圖一:整機接線。紅線(GPIO4 脈衝)同時是時鐘、麥克風和鬧鐘——白天數風、夜間喚醒裝睡的主控。
風速計模組 ESP32-C6-DevKitM1 備註 ───────────── ────────────────── ───────────────── 脈衝(乾簧片) ─────── GPIO4 內部上拉;下降緣中斷;兼 light sleep ext1 喚醒 (⚠ ext0/ext1 限 GPIO0–7;⚠ 勿用 GPIO9=Boot 腳毛刺) SIG/VCC ──────────── 3.3V(模組內部上拉版走 3.3V) GND ────────────── GND 風向計 ESP32-C6 ───────────── ────────────────── 滑片 ──┬─100nF─GND── GPIO2 ADC1_CH2;100nF 濾尾翼震盪造成的滑片抖動 端A ──────────── 3.3V 端B ──────────── GND 指示 ESP32-C6 ───────────── ────────────────── WS2812 DI ←330Ω── GPIO0 供電並聯 470µF 在燈條端 蜂鳴器 ←10k← S8050 GPIO1 集極接蜂鳴器−、發射極 GND、二極體跨線圈 BTN ─── 內部上拉 ── GPIO3 按下=GND=立即回報 VBAT ──100k─┬─100k─ GND └────── GPIO6 ADC1 分壓中點 電源鏈同第 24 篇:太陽能 → TP4056+防逆 → 18650 → MT3608 5V → 板 5V,VBUS 不接。
⚠️ 三條軍規級小提醒:① 風速脈衝腳是整台機器最講究的腳位——必須在 GPIO0–7(ext1 喚醒域)、且不能是 GPIO9(Boot 腳,重設毛刺會偽造陣風)。GPIO4 是兩個條件的交集,也是本篇的預設。② 室外所有五金用不鏽鋼——颱風天生鏽螺絲斷掉的畫面,就是樓下車的畫面。③ 風杯軸承是這台機器的生命:拆開市售模組換 3mm 磁珠軸承或縫紉機油重潤,手撥空轉要 >8 秒——摩擦力大的風杯在你要它工作的那天(颱風)恰好不工作。

⚙️ 3D 列印

零件材質列印設定設計重點
風杯 ×3ASA0.2mm、3 壁、頂底實心嚴禁 PLA——紫外線一年脆化、低溫玻璃化,颱風裡會變成塑膠碎片發射器;三杯對稱度直接決定高風速下的震動
轉軸套筒ASA0.16mm、4 壁內嵌 3mm 磁珠軸承座+磁鐵槽;軸向間隙留 0.1mm,壓死就卡
尾翼(風向牌)ASA 薄壁0.2mm、3 層壁面積對轉軸要夠大(尾翼面積 > 風杯投影 1.5 倍才轉得動);可順手印成門牌號碼,鄰居會以為是氣象站
軸承立架ASA/PETG0.24mm、4 壁雙軸承跨式;感測器頂端離牆面 >1.5 倍尾翼半徑,防建築擾流污染風向
防水底蓋+太陽板夾座ASA0.2mm、3 壁底蓋走線孔朝下做防滴錐;夾座 30° 仰角朝直射位

總列印約 5 小時。整塔裝在 1.2m 不鏽鋼立桿上,桿底夾陽台欄桿內側+配重袋——這台機器自己不能先成為墜落物:所有夾具用 M5 不鏽鋼螺栓+防鬆螺母,風杯端用開口銷保底,每週擰一圈是它的保養。

💻 Arduino 韌體

板子選 ESP32C6 Dev Module;Partition 用預設即可(8MB 吃不完)。WS2812 走 Adafruit_NeoPixel 的 RMT 輸出。校準常數 K 存 NVS,改 K 不用重燒。

/* ============================================================
 * balcony_wind_tower.ino — ESP32-C6 陽台風速風向塔(ESP32 全系列 × 3D 列印 第 26 篇)
 *
 * 功能:風杯乾簧片測風速(1 秒陣風+10 秒持續雙軌)+可變電阻八方位風向 →
 *       WS2812 四級風力燈+蜂鳴器+Telegram 各級推播/21:00 日報/颶風通報;
 *       無風 light sleep 輪詢+ext1 脈衝喚醒、有風持續取樣、橙級以上颶風模式常醒
 * 硬體:ESP32-C6-DevKitM-1
 *       風速脈衝=GPIO4(ext1 喚醒,⚠限 GPIO0–7)|風向 ADC=GPIO2
 *       WS2812=GPIO0|蜂鳴器=GPIO1|按鍵=GPIO3|電池 ADC=GPIO6
 * 供電:太陽能 → TP4056+SS54 → 18650 → MT3608 5V → 板 5V(VBUS 不接)
 * 相依:Arduino ESP32 core ≥ 3.1(板選 ESP32C6 Dev Module)
 *       Adafruit_NeoPixel(RMT 輸出);TLS 走 core 內建 WiFiClientSecure
 * ============================================================ */
#include <Arduino.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Adafruit_NeoPixel.h>
#include <nvs.h>
#include <esp_sleep.h>
#include <time.h>

/* --- 0. 使用者設定 --- */
const char* WIFI_SSID = "你家分享器";
const char* WIFI_PASS = "密碼";
const char* TG_TOKEN  = "000000:換成你的_bot_token";   // @BotFather
const char* TG_CHAT   = "換成你的_chat_id";

/* --- 1. 腳位(pulse 腳的選腳理由見文章,動它之前先讀兩遍)--- */
#define PIN_PULSE 4     // 風速脈衝:⚠ 必須 GPIO0–7(ext1)且非 GPIO9(Boot)
#define PIN_DIR   2     // 風向 ADC1_CH2
#define PIN_LED   0     // WS2812
#define PIN_BUZZ  1     // 蜂鳴器(S8050)
#define PIN_BTN   3     // 按鍵(內部上拉)
#define PIN_VBAT  6     // 電池 ADC1

/* --- 2. 策略參數 --- */
float    WIND_K   = 2.7f;    // 校準常數:m/s ÷ 轉頻Hz(騎車標定法改這裡→NVS)
const float ZERO_FLOOR = 0.5f;  // m/s 零死區:軸承摩擦造成的假風歸零
const uint32_t CALM_MS   = 2000;    // 平靜模式 light sleep 輪詢週期
const uint32_t ACTIVE_MS = 180000;  // 有風持續取樣窗(3 分鐘無風回落平靜)

/* --- 3. 四級警戒(蒲福風級臨界:4/7/10/14 級)--- */
const float LV_YELLOW = 10.8f, LV_ORANGE = 17.2f,
            LV_RED    = 24.5f, LV_HURR    = 32.7f;
const char* LV_NAMES[5] = { "平靜", "黃威", "橙強", "紅暴", "颶風" };

Adafruit_NeoPixel strip(8, PIN_LED, NEO_GRB + NEO_KHZ800);

/* --- 4. 中斷域狀態(volatile 是誠實問題不是選配)--- */
volatile uint32_t pulseCnt = 0;
RTC_DATA_ATTR uint16_t dayMaxGustX10 = 0;   // 今日最大陣風×10(跨重設保留)
RTC_DATA_ATTR uint32_t dayKey = 0;          // 跨日鍵
uint32_t lastWindMs = 0;                    // 上次「有意義風速」時刻
uint8_t  curLevel = 0;                      // 目前級別(推播去重基準)

/* ============================================================
 * 風速:脈衝中斷 → 轉頻 → m/s
 * ============================================================ */

/**
 * 乾簧片脈衝 ISR(下降緣觸發)。
 * 只做一件事:計數。消抖不在中斷裡做——中斷裡 sleep 是犯罪,
 * 機械彈跳的雙計由「1 秒視窗+中位數」稀釋,代價可接受(見問題排除)。
 */
void IRAM_ATTR onPulse() { pulseCnt++; }

/**
 * 讀 1 秒風速。
 * 回傳:陣風風速(m/s),已扣零死區
 * 算法:1 秒脈衝數 ÷ 脈衝/轉(=1) × K。風杯每轉一脈衝是本篇自印件的規格;
 *       買現成模組常是 1 轉 N 脈衝,改 PPR 常數即可。
 */
const float PPR = 1.0f;      // pulses per revolution
float gust1s() {
  noInterrupts();
  uint32_t n = pulseCnt; pulseCnt = 0;
  interrupts();
  float v = (n / PPR) * WIND_K;
  return (v < ZERO_FLOOR) ? 0.0f : v;
}

/* ============================================================
 * 風向:ADC → 八方位(中位數+滯帶)
 * ============================================================ */
const char* DIR_NAMES[8] = { "北", "東北", "東", "東南", "南", "西南", "西", "西北" };
// 八方位 ADC 中值校準表(mV):開機八方位轉一圈後把實測值貼回這裡
uint16_t dirCal[8] = { 270, 700, 1150, 1600, 2000, 2450, 2900, 3260 };
int8_t dirLatched = -1;

/**
 * 讀風向。
 * 回傳:0–7(北起順時針);-1=無效
 * 濾波:9 次取樣去頭去尾取平均(ADC 滑片震盪是高頻雜訊,平均有效);
 * 滯帶:新舊方位 ADC 差距 <120mV 時維持舊值——沒有它,微風天風向
 *       每秒在兩方位間跳,推播變成刷屏。
 */
int8_t readDir() {
  uint16_t s[9];
  for (uint8_t i = 0; i < 9; i++) { s[i] = analogReadMilliVolts(PIN_DIR); delay(6); }
  // 泡沫排序去極值(9 個數,引入 qsort 才是過度工程)
  for (uint8_t i = 0; i < 8; i++)
    for (uint8_t 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; }
  uint32_t mv = 0;
  for (uint8_t i = 2; i < 7; i++) mv += s[i];
  mv /= 5;
  // 找最近的校準點
  int8_t best = -1; uint16_t bd = 0xFFFF;
  for (uint8_t i = 0; i < 8; i++) {
    uint16_t d = abs((int)mv - (int)dirCal[i]);
    if (d < bd) { bd = d; best = i; }
  }
  if (dirLatched >= 0 && abs((int)mv - (int)dirCal[dirLatched]) < 120)
    return dirLatched;                       // 滯帶:沒走出 120mV 就不換向
  dirLatched = best;
  return best;
}

/* ============================================================
 * 顯示與聲音
 * ============================================================ */

/**
 * 按級別刷燈。
 * 參數:lv=0–4;dir=風向 0–7(燈條兼風向箭頭:對應燈亮起)
 * 燈語:0 綠/1 黃/2 橙/3 紅呼吸/4 紅白閃——從陽台往下看就是交通號誌。
 */
void showLevel(uint8_t lv, int8_t dir) {
  uint32_t c[5] = { strip.Color(0, 40, 0), strip.Color(80, 80, 0),
                    strip.Color(90, 30, 0), strip.Color(120, 0, 0),
                    strip.Color(150, 150, 150) };
  for (uint8_t i = 0; i < 8; i++) strip.setPixelColor(i, c[lv]);
  if (dir >= 0) { strip.setPixelColor(dir & 7, strip.Color(255, 255, 255));
                  strip.setPixelColor((dir + 1) & 7, strip.Color(255, 255, 255)); }
  strip.setBrightness(lv ? 120 : 20);
  strip.show();
  digitalWrite(PIN_BUZZ, lv >= 3);   // 紅級以上才嗶;颶風級的長嗶在推播迴路裡加碼
}

/* ============================================================
 * Telegram(與第 24/25 篇同款套路:要說話才開射頻)
 * ============================================================ */

/** 發送 Telegram(逾時重試 3 次;setInsecure 安全性見第 24 篇框註) */
bool sendTg(String msg) {
  if (WiFi.status() != WL_CONNECTED) return false;
  WiFiClientSecure client;
  client.setInsecure();
  HTTPClient http;
  http.begin(client, String("https://api.telegram.org/bot") + TG_TOKEN + "/sendMessage");
  http.addHeader("Content-Type", "application/json");
  for (int t = 0; t < 3; t++) {
    String body = "{\"chat_id\":\"" + String(TG_CHAT) + "\",\"text\":\"" + msg + "\"}";
    int code = http.POST(body);
    if (code == 200) { http.end(); return true; }
    delay(500 << t);
  }
  http.end();
  return false;
}

/** 連網→校時→發訊息→射頻關光光 */
bool talk(String msg) {
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  bool sent = false;
  if (WiFi.waitForConnectResult() == WL_CONNECTED) {
    configTime(8 * 3600, 0, "pool.ntp.org");
    getLocalTime(nullptr, 2000);
    sent = sendTg(msg);
    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);
  }
  return sent;
}

/* ============================================================
 * 狀態與持久化
 * ============================================================ */

/** 校準常數 K 從 NVS 載入(改了不用重燒,Serial 指令 k=2.65 即存) */
void loadK() {
  nvs_handle_t h;
  uint32_t kx100 = 0;
  if (nvs_open("wind", NVS_READONLY, &h) == ESP_OK) {
    if (nvs_get_u32(h, "kx100", &kx100) == ESP_OK && kx100 > 50 && kx100 < 1000)
      WIND_K = kx100 / 100.0f;          // 範圍保險:K 落在 0.5–10 之外視為髒值
    nvs_close(h);
  }
}

/** 級別判定(輸入平均風與陣風,紅級以上看陣風) */
uint8_t levelOf(float avg, float gust) {
  if (gust >= LV_HURR) return 4;
  if (gust >= LV_RED)  return 3;
  if (avg  >= LV_ORANGE) return 2;
  if (avg  >= LV_YELLOW) return 1;
  return 0;
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_BUZZ, OUTPUT); digitalWrite(PIN_BUZZ, LOW);
  pinMode(PIN_BTN, INPUT_PULLUP);
  pinMode(PIN_PULSE, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(PIN_PULSE), onPulse, FALLING);
  strip.begin(); strip.show();
  loadK();
  Serial.println(F("\n=== balcony_wind_tower 風向塔啟動 ==="));
}

void loop() {
  /* --- 1. 1 秒取樣、10 秒滑動平均(环形緩衝,10 個 float 是全部家當)--- */
  static float win[10]; static uint8_t wIdx = 0;
  float gust = gust1s();
  win[wIdx] = gust; wIdx = (wIdx + 1) % 10;
  float avg = 0; for (uint8_t i = 0; i < 10; i++) avg += win[i]; avg /= 10;
  if (gust > ZERO_FLOOR) lastWindMs = millis();

  /* --- 2. 跨日重置今日極值(RTC 值跨重設,但跨日要人來清)--- */
  uint32_t today = (uint32_t)(time(nullptr) / 86400);
  if (today != dayKey && today > 20000) { dayKey = today; dayMaxGustX10 = 0; }
  if (gust * 10 > dayMaxGustX10) dayMaxGustX10 = (uint16_t)(gust * 10);

  /* --- 3. 風向+級別顯示 --- */
  int8_t dir = readDir();
  uint8_t lv = levelOf(avg, gust);
  showLevel(lv, dir);

  /* --- 4. 級別變化推播:進入與解除各一次(同級不轟炸)--- */
  if (lv != curLevel) {
    String tag[5] = { "🍃", "💛", "🧡", "❤️", "🌀" };
    String msg = String(tag[lv]) + " 陽台風力進入「" + LV_NAMES[lv]
               + "」(前級 " + LV_NAMES[curLevel] + "):" + String(gust, 1)
               + "m/s 陣風/" + String(avg, 1) + "m/s 平均·風向 "
               + (dir >= 0 ? DIR_NAMES[dir] : "?")
               + "·今日最大陣風 " + String(dayMaxGustX10 / 10.0f, 1) + "m/s";
    talk(msg);
    if (lv == 4) talk("🌀🌀 颶風通報:陽台陣風已達 32.7m/s。人別出去了,陽台的东西也别要了。");
    curLevel = lv;
  }

  /* --- 5. 每日 21:00 日報(跨日鍵去重)--- */
  struct tm t;
  static uint32_t reportDay = 0;
  if (getLocalTime(&t, 200) && t.tm_hour == 21 && reportDay != today) {
    reportDay = today;
    talk("🌬️ 風況日報:今日最大陣風 " + String(dayMaxGustX10 / 10.0f, 1)
       + "m/s·目前 " + String(avg, 1) + "m/s·電池 " + String(analogReadMilliVolts(PIN_VBAT) * 2.0f / 1000.0f, 2) + "V");
  }

  /* --- 6. 按鍵即詢 --- */
  if (digitalRead(PIN_BTN) == LOW) {
    while (digitalRead(PIN_BTN) == LOW) delay(10);
    talk("📟 即時風況:" + String(gust, 1) + "m/s 陣風·" + String(avg, 1)
       + "m/s 平均·風向 " + (dir >= 0 ? DIR_NAMES[dir] : "?") + "(級別:"
       + LV_NAMES[lv] + ")");
  }

  /* --- 7. 睡眠策略:三檔清醒度 ---
   * 橙級以上=颶風模式常醒+10 分鐘推最高陣風(lastPushMs);
   * 有風=全速取樣;平靜=light sleep 2 秒,脈衝 ext1 中斷提前喚醒。
   * light sleep 保留 RAM:脈衝計數器與滑動窗都不死,醒來無縫接軌。
   */
  static uint32_t lastPushMs = 0;
  if (curLevel >= 2) {
    if (millis() - lastPushMs > 600000UL) {
      lastPushMs = millis();
      talk("🌪️ 颶風模式更新:10 分鐘內最高陣風 " + String(dayMaxGustX10 / 10.0f, 1) + "m/s,持續監控中。");
    }
    delay(1000);
    return;
  }
  bool windy = (millis() - lastWindMs) < ACTIVE_MS;
  if (windy) { delay(1000); return; }          // 有風:全速 1 秒迴圈

  // 平靜:arm ext1(G4 低電位喚醒)+ 2 秒定時,雙保險進 light sleep
  esp_sleep_enable_ext1_wakeup(1ULL << PIN_PULSE, ESP_EXT1_WAKEUP_ANY_LOW);
  esp_sleep_enable_timer_wakeup(CALM_MS * 1000ULL);
  Serial.flush();
  esp_light_sleep_start();
  // 醒來:若是脈衝喚醒,代表風回來了——重連中斷計數繼續轉(中斷未拆,pulseCnt 累積中)
}
⚠️ 睡眠×中斷的邊界條件(本篇最容易翻車處):① C6 的 ext0/ext1 喚醒源只有 GPIO0–7,官方文件白纸黑字——風速脈衝腳選 GPIO4 是硬約束不是浪漫。② 別把消抖放進中斷或拿消抖視窗去濾「喚醒脈衝」:那樣風來了機器卻睡死(問題排除表第一行就是它的訃聞)。③ light sleep 保留 RAM、深睡不保留——本篇用 light sleep 換「醒來無縫接軌」,多花的 µA 由 1W 太陽能板買單,這是明碼標價的取捨。
📌 騎車標定法(本篇的 K 值來源):找條安全直路,騎車穩住 25km/h=6.94m/s,把風速計模組朝前舉 30 秒(手臂伸直、身體蹲低避開人肉風擋),Serial 讀平均轉頻;K = 6.94 ÷ 平均轉頻。跑兩次取平均,誤差通常壓在 10% 內。寫進 NVS 後重燒都不用再管。怕曬的替代方案:電風扇最大檔對著已知轉速的扇葉數圈數,精度差一點但聊勝於無。

🔧 組裝步驟

  1. 軸承手術:拆風速計模組,換磁珠軸承或縫紉機油重潤+0.1mm 軸向間隙調整。手撥空轉 >8 秒才算合格——這 8 秒是這台機器未來十年最準的校準。
  2. 風杯平衡:三杯裝上轉塔後空轉,看有沒有單側偏摆(印件重量差異);重的那杯砂紙磨 0.1g 級別地修。高轉速下的不平衡=軸承壽命削減計畫。
  3. 脈衝驗證:手撥一圈、Serial 計數必 +1;輕撥到停全過程無漏計。漏計先修機械(磁鐵距離/乾簧片靈敏度),不要調大消抖視窗硬拗。
  4. 風向校準:把風向牌依序指向北/東北/…八方位(指南 App 對北),每方位讀 10 秒 ADC 中值,貼回 dirCal[] 重燒。誤差 ≤1 方位過關。
  5. 警報鏈演練:Serial 灌假風速序列 11→18→25→33(把 gust1s() 暫時改回傳常數),四級按序觸發、燈語對、Telegram 收得到、解除也各推一則。
  6. 立桿上架:盒體在桿底(配重)、風杯尾翼在桿頂,感測端離牆 >1.5 倍尾翼半徑;所有夾具 M5 不鏽鋼+開口銷保底;太陽能板夾欄桿。拉一遍「搖晃測試」:整桿用力搖,不能有任意一顆螺絲有聲音。
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到陽台 → 開啟 HTML 組裝說明書

🧪 驗證

測試動作合格線
脈衝完整性手撥一圈+輕撥到停計數 +1 正確;全程無漏計(抖動由 30ms 軟體消抖+視窗稀釋)
K 值標定騎車 25km/h 法兩次兩次 K 值差 <5%,風速誤差 <10%
風向八方位逐方位對北讀 10 秒誤差 ≤1 方位;兩方位交界吹氣不反覆橫跳(滯帶生效)
警報鏈假風速序列 11→18→25→33四級按序觸發、燈語正確、推播進入+解除各一則
平靜待機無風 10 分鐘,串萬用表平均 <6mA(Wi-Fi 關閉的 light sleep 輪詢)
喚醒測試平靜模式下對風杯吹氣2 秒內復甦並開始計數(ext1 中斷喚醒)
微氣候比對強風日記錄 vs 氣象署最近測站陽台 ≥ 測站正常;比值 >2.5 =裝設位在渦流區,換位

🆘 問題排除

症狀最可能的原因處方
風大不計數乾簧片靈敏度不足(磁鐵退磁/距離遠);上拉太弱邊沿爬升慢修機械優先;上拉改 4.7k 提速;消抖視窗調大反而漏計——那是把風關在門外
插 USB 就「颳起颱風」脈衝腳接到 GPIO9(Boot 腳重設毛刺)或其他非 RTC 域腳位換回 GPIO4;重設後 pulseCnt = 0 清帳
風向亂跳沒燒校準表(用預設值);100nF 沒焊;尾翼被太陽板支架擋出擾流重做八方位校準;補焊 100nF;感測端移离遮挡物
陣風值永遠偏低拿 10 秒平均冒充陣風;取樣窗太長把峰值稀釋陣風必須 1 秒滑動(本篇架構已對);驗收時用电風扇脈衝拍對照
light sleep 醒不來脈衝腳不在 GPIO0–7;或消抖把喚醒脈衝吃掉喚醒走中斷、消抖留軟體(邊界框第二條);換腳重測
零風時常有 0.3m/s 讀值軸承摩擦力矩讓風杯停在微斜位置,緩慢蠕動正常物理;ZERO_FLOOR 0.5m/s 歸零死區已處理,別追殺它
燈條閃爍/跑燈升壓飄壓;330Ω 漏焊;燈條端沒有 470µF重調 MT3608 5.0V;補 330Ω 與大電容;燈條與板子同點共地

📁 專案檔案結構

balcony-wind-tower/
├── firmware/
│   └── balcony_wind_tower.ino    # 上方完整韌體
├── stl/
│   ├── anemometer-cup.stl        # 風杯(ASA ×3)
│   ├── shaft-bushing.stl         # 轉軸套筒(磁珠軸承座)
│   ├── wind-vane-fin.stl         # 尾翼風向牌
│   ├── mast-bracket.stl          # 軸承立架
│   └── solar-clip.stl            # 太陽能板夾座
├── f3d/
│   └── wind-tower.f3d            # Fusion 360 原始檔
├── manual/
│   └── assembly.html             # 圖文組裝說明書
└── README.md                     # 說明文件

🎯 結語

這台風向塔最動人的輸出不是那個 730 元,是某一句它替你發的訊息:「陽台現況陣風 11 級,那盆天堂鳥今天必收。」它不預測天氣——預測天氣是氣象署的工作,而且他們做得比你好;它做的事是把你家轉角這十平方公尺的風變成可以決策的數字。天氣應用裡最昂貴的從來不是感測器,是「那裡的風到底有多強」這個資訊在你需要的當下的可得性——市售兩萬塊的氣象站賣你整條數據曲線,這台 400 塊的風杯塔只賣你一個當下能拍板的句子。下次警報特報來的時候,看著陽台燈柱從橙轉紅、手機同時震動,你會確認一件事:雷公和風伯現在都替你上著班,而你只需要負責收花。

← 上一篇:ESP32-C2 陽台雨水智慧量表 📚 系列索引 下一篇:ESP32-C5 雙頻 Wi-Fi 訊號偵測筆 →