📋 專案概述
去年蘇拉颱風警報,我人在北投工作,太太傳訊息:「陽台那盆兩千塊的天堂鳥要不要收?」我看著氣象署 App 的「最大陣風風向:東北東、陣風 9 級」——那是全北台灣的平均值。我住汐止河谷側的 14 樓,陽台朝西北、正好吃峽谷效應的風口。收,還是賭?賭輸的結算單:天堂鳥 2,000+陶盆 800+鐵窗防墜網被砸的 1,500,外加樓下鄰居的車。而那顆「要不要收」的決策,氣象署永遠答不了——因為它量的是 10 公尺高的標準場,不是你家陽台。
風這種東西殘酷在它的在地性:氣象站測到 9 級,你的轉角可能是 11 級——陽台的陣風可以是開闊地的 1.5 倍,而砸花盤的恰好就是多出來的那 2 級。反之也真實:警報報得嚇死人、你家陽台風平浪靜,收了一下午的花盆純屬白工。「你家陽台的風」和「氣象站的風」根本是兩個世界,中間隔著整棟建築的渦流。
市售方案搜過一圈:
- Davis Vantage 級專業氣象站:兩萬起跳,風向風速計要架屋頂拉線——管理員第一個不答應,租房族直接出局。
- 業餘「風速計模組+樹莓派」:光風杯+風向計模組就要 900 元,樹莓派再 2,000,然後你獲得一台 5W 的伺服器只為了讀兩顆感測器——這是在用遊艇送快餐。
- 問鄰居:資訊延遲取決於鄰居的閒暇時間,準確度取決於他對「很大風」三個字的詞彙量。
這台 ESP32-C6 陽台風速風向塔把決策還給你:自己印風杯、用乾簧片數脈衝、可變電阻讀風向,整組 400 元有找。功能清單:
- 風杯+乾簧片脈衝計數:自印 ASA 風杯、磁珠軸承;脈衝→轉頻→風速(校準常數 K 用「騎車 25km/h 標定法」實地量,文章給完整流程);1 秒陣風值+10 秒持續風值雙軌——陣風永遠不能用 10 秒平均冒充,那是砸花盤的那一級
- 可變電阻風向牌:10kΩ 線性可變電阻+ADC 讀八方位校準表,中位數+滯帶濾波,風向不會在「西北/北」之間反覆橫跳
- 四級風力警報:平均 ≥10.8m/s 黃、≥17.2 橙、陣風 ≥24.5 紅、≥32.7 颶風通報;WS2812 燈條+蜂鳴器同級跟燈,陽台走過就知道今天能不能晒衣
- 三種清醒策略:無風=light sleep 2 秒輪詢+脈衝 ext1 中斷喚醒(裝死但耳朵豎著);有風=持續取樣;3 分鐘無風自動回落。颱風模式:級別到橙以上轉常醒,每 10 分鐘推最高陣風——暴風夜它比你清醒
- Telegram:各級「進入/解除」各推一次(同級不轟炸)、每日 21:00 風況日報、按鍵即詢;配第 24 篇雷暴哨兵讀就是完整的天際線情報網:雷公管天頂,風伯管水平
- 風速歷史:RTC RAM 10 分鐘滑動窗+NVS 每日極值,斷電不丢當日紀錄——颱風走後你問「最兇幾點」,它有答案
帳目:一盆天堂鳥的賭注是 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×6 | 1 | ~35 元 | IP65,裝立桿底部(配重位置) |
| 呼吸 | 透氣膜+防水膠條 | 1 組 | ~25 元 | 桿底盒日夜溫差大,不透氣=盒內下雨 |
| 人機 | 按鍵 | 1 | ~5 元 | 按下=立即回報風況 |
| 雜項 | 100kΩ×2、10kΩ×2、330Ω、100nF、不鏽鋼螺絲/束帶 | 1 組 | ~45 元 | 100nF 並風向滑片到地濾震盪;室外件全部不鏽鋼 |
| 合計 | ~730 元 | 無訂閱;資料留自家 |
🔌 接線圖
⚙️ 3D 列印
| 零件 | 材質 | 列印設定 | 設計重點 |
|---|---|---|---|
| 風杯 ×3 | ASA | 0.2mm、3 壁、頂底實心 | 嚴禁 PLA——紫外線一年脆化、低溫玻璃化,颱風裡會變成塑膠碎片發射器;三杯對稱度直接決定高風速下的震動 |
| 轉軸套筒 | ASA | 0.16mm、4 壁 | 內嵌 3mm 磁珠軸承座+磁鐵槽;軸向間隙留 0.1mm,壓死就卡 |
| 尾翼(風向牌) | ASA 薄壁 | 0.2mm、3 層壁 | 面積對轉軸要夠大(尾翼面積 > 風杯投影 1.5 倍才轉得動);可順手印成門牌號碼,鄰居會以為是氣象站 |
| 軸承立架 | ASA/PETG | 0.24mm、4 壁 | 雙軸承跨式;感測器頂端離牆面 >1.5 倍尾翼半徑,防建築擾流污染風向 |
| 防水底蓋+太陽板夾座 | ASA | 0.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 累積中)
}
K = 6.94 ÷ 平均轉頻。跑兩次取平均,誤差通常壓在 10% 內。寫進 NVS 後重燒都不用再管。怕曬的替代方案:電風扇最大檔對著已知轉速的扇葉數圈數,精度差一點但聊勝於無。🔧 組裝步驟
- 軸承手術:拆風速計模組,換磁珠軸承或縫紉機油重潤+0.1mm 軸向間隙調整。手撥空轉 >8 秒才算合格——這 8 秒是這台機器未來十年最準的校準。
- 風杯平衡:三杯裝上轉塔後空轉,看有沒有單側偏摆(印件重量差異);重的那杯砂紙磨 0.1g 級別地修。高轉速下的不平衡=軸承壽命削減計畫。
- 脈衝驗證:手撥一圈、Serial 計數必 +1;輕撥到停全過程無漏計。漏計先修機械(磁鐵距離/乾簧片靈敏度),不要調大消抖視窗硬拗。
- 風向校準:把風向牌依序指向北/東北/…八方位(指南 App 對北),每方位讀 10 秒 ADC 中值,貼回
dirCal[]重燒。誤差 ≤1 方位過關。 - 警報鏈演練:Serial 灌假風速序列 11→18→25→33(把
gust1s()暫時改回傳常數),四級按序觸發、燈語對、Telegram 收得到、解除也各推一則。 - 立桿上架:盒體在桿底(配重)、風杯尾翼在桿頂,感測端離牆 >1.5 倍尾翼半徑;所有夾具 M5 不鏽鋼+開口銷保底;太陽能板夾欄桿。拉一遍「搖晃測試」:整桿用力搖,不能有任意一顆螺絲有聲音。
🧪 驗證
| 測試 | 動作 | 合格線 |
|---|---|---|
| 脈衝完整性 | 手撥一圈+輕撥到停 | 計數 +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 塊的風杯塔只賣你一個當下能拍板的句子。下次警報特報來的時候,看著陽台燈柱從橙轉紅、手機同時震動,你會確認一件事:雷公和風伯現在都替你上著班,而你只需要負責收花。