📋 專案概述
去年八月週日下午,我在陽台收衣服時看見西北方的雲牆——鐵灰色、下緣翻著墨綠,十分鐘後雷打在西側山頭,對面社區的變壓器跳了一整排,整條街的室外攝影機、基地台、儲能太陽能板全部在線路上「排隊付帳」。我家的設備沒事,純粹是运气:雷擊浪不一定直接打中你,它打幾公里外的地面,沿著電力線和訊號線「回家」,你的設備在鏈的末端買單。而我全部的預警,是抬頭看一眼天。
氣象署的雷暴訊息是另一種痛:「大雷雨特別報告」四個字出現的時候,雷雨胞通常已經壓在北台灣上,顆粒粗得像拿衛星雲圖當手電筒照路;手機雷達圖要自己盯,盯十分鐘人就去煮麵了。市售方案我也評估過一圈:
- FarSite 業餘氣象雷達:感應器加軟體訂閱一萬起跳,定位靠三台感應器網路——單機版就只告訴你「有閃電」,跟我的雲牆視覺系統相比資訊增量約等於零。
- Meteobridge 閃電模組+基地台:$6,000 起,資料進它的雲端、警報推它的 App。我願意為資料付錢,但不願意為「我家上面在下雷」繳月費。
- 手機警報 App:靠氣象署訊息,延遲 10~20 分鐘起步。雷雨胞移動每小時 30~50 公里——延遲 10 分鐘代表預警距離少了 6 公里,這段距離剛好是「收得掉曬衣間」跟「淋成落湯雞」的差別。
關鍵在於:閃電本身會廣播自己。每道閃電在 500kHz 頻段吼一聲,AS3935 這顆晶片專聽這一聲,還幫你估距離(1~40 公里分級、最遠可標 63 公里)與能量。這顆 130 元的模組,就是人類能免費拿到的最長的雷暴預警:
這台 ESP32-C6 雷暴預警基地台做的事:
- AS3935 閃電測向儀:SPI+中斷讀事件,距離 1~63 公里+能量分級,15 分鐘滑動視窗記錄每一筆閃電(時間/距離/能量),NVS 存 200 筆黑歷史
- 逼近偵測才是本體:對滑動視窗做距離線性回歸——斜率為負且每 10 分鐘逼近 6 公里以上=「雷暴接近」,三級警報(注意 20km/警戒 12km/撤離 6km);雷雨胞通過後自動改口「遠離中」
- 雜訊自調適:開機自動掃環境噪訊,自動調 watchdog threshold 與 noise floor;60 秒內連觸 16 次=環境雜訊作怪,自動拉高門檻並在警報訊息裡「承認自己變鈍」——LED 手電筒、電源交換器、老舊整流器都逃不過這關
- 三級本地警報:WS2812 燈條(綠/橙/紅)+蜂鳴器;撤離級每 3 分鐘嗶一次,直到人按下 silence(室外作業收攤用)
- 太陽能自律:5V 太陽能板浮充 18650;待機電流預算 120µA 級——雷暴自己喚醒它(IRQ 拉高=深睡 ext0 喚醒),MCU 平常在睡。連續陰雨天檢測到低壓:深睡間隔拉長、推播降頻只報級別變化
- 閃電日誌:每日閃電數、最接近距離、級別時間線,Telegram 每日 21:00 日報+隨時
/status(板按鍵按下=立即發送)
帳目:Meteobridge 閃電模組+基地台 ~6,000 元、FarSite 上萬、訂閱制年費另計;本機 約 610 元一次買斷,資料留在自己手上。我家屋頂那套儲能+太陽能+室外攝影機造價五位數,一次雷擊浪的維修報價就夠買十台這東西——買的不是雷達,是「提前 20 分鐘拔線」的權利。
為什麼選 ESP32-C6?
| 需求 | C6 的優勢(以及其他型號為什麼不選) |
|---|---|
| 全年不關的待機 | 深睡 ~7µA(典型值,官方規格)+調變睡眠全系列最省的一檔;這台的日常是「睡到閃電把 MCU 搖醒」,MCU 醒著的時間每天不超過幾分鐘 |
| 省電要算總帳 | 老實說:待機大頭不是 MCU,是 AS3935 的監聽電流(數十 µA)和升壓靜態損——所以本機架構是「AS3935 永遠醒著聽、MCU 永遠睡著」,喚醒源就是感測器本身,連定時輪詢都省了。Wi-Fi 6 的 TWT 需要 AP 端配合,家用分享器支援少,本韌體不依賴它;802.11ax 真正的貢獻是掃網關聯 RF 時間短一點點——有,但別當行銷買單 |
| 外掛安裝的靈活性 | AS3935 走 SPI+中斷+UART 皆宜,C6 的 GPIO 與 SPI 矩陣餘裕充足;唯一硬限制是深睡喚醒腳必須落在 GPIO0–7(RTC 域,官方文件明列),本機 IRQ=GPIO5 卡進這條線內 |
| Wi-Fi 6 是買未來 | 家中數據機升級 Wi-Fi 6 後這台不用換;2MB Flash 要塞 TLS+NVS 歷史+OTA 餘量,_partitions 得自己調(下文有標註) |
| 其他型號出局原因 | H2 沒有 Wi-Fi 直接出局;C2 的 2MB Flash 還要塞 TLS+NVS 歷史記錄,擠到要砍功能;S3 多出來的雙核與 PSRAM 對一台閃電測候站價值為零,還多花錢 |
🧱 硬體清單
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-C6-DevKitM-1(C6-WROOM-1) | 1 | ~110 元 | 2MB Flash;選單核 RISC-V 160MHz,深度睡眠 7µA 級 |
| 閃電感測 | AS3935 模組(附環形天線) | 1 | ~130 元 | 買 SPI 模式版(模組上有 SPI/I2C 選擇跳鍵最好);含板載環形天線 |
| 狀態燈 | WS2812B 8 燈燈條 | 1 | ~40 元 | 綠/橙/紅三級+閃爍節拍; outdoor 用滴膠封燈腳 |
| 蜂鳴器 | 有源蜂鳴器 5V | 1 | ~15 元 | 撤離級要能穿過雨聲叫醒人 |
| 儲能 | 18650+保護板 | 1 | ~85 元 | 帶板版本必選——屋頂環境沒有「手動斷電池」這選項 |
| 發電 | 5V 1W 太陽能板 | 1 | ~70 元 | 待機 0.12mW 的機器,理論上窗邊燈光都能活;1W 是給連續陰雨的保險 |
| 充電 | TP4056+防逆二極體 | 1 | ~20 元 | RPROG 改 2k 限充 0.5C 以內;防逆是 SS54 蕭特基 |
| 升壓 | MT3608 模組(調 5V) | 1 | ~10 元 | 出廠先轉到 5.0V 再鎖膠——升壓輸出飄到 5.5V 是 WS2812 之死 |
| 外殼 | 防水接線盒 15×10×8 | 1 | ~35 元 | IP65 級;線材出口全走防滴鉤(見 3D 列印節) |
| 呼吸 | 透氣膜+防水膠條 | 1 組 | ~25 元 | 屋頂日溫差 20°C,不透氣殼內必結露——結露就是短路 |
| 接地 | 銅巴+接地端子 | 1 | ~20 元 | 只接外殼與訊號地;嚴禁接屋頂避雷針(下文安全框) |
| 雜項 | 按鍵、100kΩ×2、螺絲、線材 | 1 組 | ~45 元 | 100kΩ 分壓做電池電壓偵測 |
| 合計 | ~610 元 | 無訂閱、無雲端,閃電資料全留自家 |
🔌 接線圖
⚙️ 3D 列印
| 零件 | 材質 | 列印設定 | 設計重點 |
|---|---|---|---|
| 環形天線立架 | ASA | 0.2mm、2 壁、25% | 天線面垂直地面(AS3935 磁環天線的感應軸)、與主殼隔 6cm、離金屬屋頂面 6cm+——這兩個 6cm 是感度與誤報的生死線 |
| 主殼防滴鉤蓋板 | ASA | 0.2mm、3 壁 | 四個線材出口各做 U 型防滴鉤,線先下垂再進孔;螺絲孔預留膠條槽 |
| 太陽板 30° 傾角座 | ASA | 0.24mm、2 壁 | 30° 是北回歸線屋頂全年平均最好角;背面走線槽穿到主殼後方 |
| 燈條導光條 | ASA+透明 PETG 蓋 | 0.2mm、3 壁 | 燈條藏殼內導光條後方,外殼不鑽孔——防水優先於美觀 |
| 掛牆背座 | ASA | 0.24mm、3 壁 | 掛勾+保鮮卡槽,屋頂鐵牆用束鐵帶固定,不鑽孔(防水層無價) |
總列印約 4 小時。ASA 列印注意:腔溫 90°C+、塗液膠防翹、陽台風大處請加 PETG 透明防雨罩(本設計已附)。
💻 Arduino 韌體
板子選 ESP32C6 Dev Module;Flash 2MB、Partition 選 Custom(下方有 partitions.csv)。AS3935 -register 對照 datasheet DS8008,SPI 框格式遵循 amlalende/As3935Esp,寄存器表與設定值對照 ESPHome 的 As3935 驅動——註冊值全部附出處,不用黑箱函式庫。
/* ============================================================
* storm_sentry.ino — ESP32-C6 雷暴預警基地台(ESP32 全系列 × 3D 列印 第 24 篇)
*
* 功能:AS3935 收聽 500kHz 閃電輻射 → 距離/能量 → 15 分鐘滑動視窗
* → 線性回歸判「逼近」→ 三級警報(燈條+蜂鳴器+Telegram)
* 事件間深睡、閃電由 AS3935 IRQ 直接喚醒 MCU、太陽能全年自律
* 硬體:ESP32-C6-DevKitM-1
* AS3935 SPI:CS=GPIO10 SCK=GPIO6 MOSI=GPIO7 MISO=GPIO8 IRQ=GPIO5
* WS2812×8=GPIO0|蜂鳴器=GPIO1(S8050)|按鍵=GPIO2|電池 ADC=GPIO4
* 供電:太陽能 → TP4056 → 18650 → 升壓 5V → 板 5V 腳(VBUS 不接)
* 相依:Arduino ESP32 core ≥ 3.1(板選 ESP32C6 Dev Module)+ Adafruit NeoPixel
* 寄存器對照 amsat-dos AS3935 datasheet(DS8008);
* SPI 框格式與初始化序列對照 amlalende/As3935Esp + ESPHome as3935 驅動
* ============================================================ */
#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <Adafruit_NeoPixel.h>
#include <driver/gpio.h>
#include <nvs.h>
#include <esp_sleep.h>
#include <time.h>
#include <math.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. 腳位 --- */
#define PIN_CS 10
#define PIN_SCK 6
#define PIN_MOSI 7
#define PIN_MISO 8
#define PIN_IRQ 5 // ⚠ 必須落在 GPIO0–7:C6 深睡 ext0 喚醒源只能在 RTC 域
#define PIN_RGB 0
#define PIN_BUZZ 1
#define PIN_BTN 2 // 內部上拉;醒著時按=silence / 立即回報
#define PIN_VBAT 4 // ADC1_CH4:100k×2 分壓自電池端
/* --- 2. AS3935 寄存器(datasheet DS8008)--- */
#define REG_AFE_GAIN 0x00 // [5:1] 類比前端增益(INDOOR/OUTDOOR)
#define REG_THRESHOLD 0x01 // [6:4] 噪訊floor NFM、[3:0] watchdog 門檻
#define REG_LGHT_REG 0x02 // [7:4] 閃電最少重複數、[3:0] 突波抑制 RDRSR
#define REG_INT_SRC 0x03 // [7:4] 中斷來源(讀即清)、[7:6] DIV_RATIO
#define REG_ENERGY_LSB 0x04 // 能量 24-bit:原始值=達標所需振盪次數,
#define REG_ENERGY_MSB 0x05 // 「越小=越強越近」(datasheet §energy)
#define REG_ENERGY_MMB 0x06
#define REG_DISTANCE 0x07 // 1–63(km),63=超出追蹤範圍
#define REG_FREQ_DISP 0x08 // [3:0] 調諧電容(8pF 階)、[7] LC 振盪器輸出致能
#define REG_TRCO_STAT 0x3A // 校振結果:bit6 為 0=成功
#define REG_SRCO_STAT 0x3B
#define REG_CMD_RESET 0x3C // 寫 0x96=全寄存器重設
#define REG_CMD_CALRCO 0x3D // 寫 0x96=校準兩顆 RC 振盪器
#define SRC_NOISE 0x1
#define SRC_DISTURB 0x4
#define SRC_LIGHTNING 0x8
/* --- 3. 策略參數 --- */
const uint32_t DEEP_MS = 3UL * 3600UL * 1000UL; // 例行喚醒:每 3 小時
const uint32_t DEEP_MS_LOW = 6UL * 3600UL * 1000UL; // 陰雨天降頻:每 6 小時
const uint32_t WINDOW_MS = 15UL * 60UL * 1000UL; // 逼近偵測滑動視窗
const float SLOPE_APPROACH = -0.6f; // km/min:每 10 分鐘逼近 6km 以上=接近
const uint8_t LV_YELLOW = 20, LV_ORANGE = 12, LV_RED = 6;
const uint32_t STAY_AWAKE_MS = 30UL * 60UL * 1000UL; // 有事件後保持清醒 30 分
Adafruit_NeoPixel strip(8, PIN_RGB, NEO_GRB + NEO_KHZ800);
/* --- 4. 深睡也活下來的狀態(RTC slow mem)--- */
struct Strike { uint32_t t; uint8_t dist; uint16_t energy; uint8_t src; };
RTC_DATA_ATTR Strike ring[48]; // 滑動視窗環形緩衝
RTC_DATA_ATTR uint8_t ringHead = 0, ringN = 0;
RTC_DATA_ATTR uint8_t alarmLv = 0; // 目前警報級(0=正常)
RTC_DATA_ATTR uint8_t sentLv = 9; // 已通報級別(防重複轟炸)
RTC_DATA_ATTR bool silenced = false; // 人按過 silence
RTC_DATA_ATTR uint8_t capPf = 0; // 天線調諧電容快取(首開 autoTune)
RTC_DATA_ATTR uint8_t nfm = 2, wdth = 2;// 噪訊地板/watchdog(自調適)
RTC_DATA_ATTR uint32_t strikesToday = 0, minDistToday = 255, dayStamp = 0;
volatile bool irqFired = false;
uint32_t lastIrqMs = 0, irqBurst = 0, awakeSince = 0;
/* ============================================================
* AS3935 SPI 驅動
* ============================================================ */
/**
* 讀暫存器。
* 參數:reg=寄存器地址(0x00–0x3D)
* 回傳:8-bit 內容
* SPI 框:位址 | 0x40(R/W=1);讀完 CS 必須 HIGH→LOW→HIGH
* 收尾,否則下一次讀寫會錯拍(datasheet §SPI/amlalende 註解)
*/
uint8_t rdReg(uint8_t reg) {
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE1)); // SPI mode 1
digitalWrite(PIN_CS, LOW);
SPI.transfer(reg | 0x40);
uint8_t v = SPI.transfer(0x00);
digitalWrite(PIN_CS, HIGH);
digitalWrite(PIN_CS, LOW); // 讀取結尾三连跳,
digitalWrite(PIN_CS, HIGH); // 缺了它整條 SPI 錯拍=註冊全讀 0x00
SPI.endTransaction();
return v;
}
/**
* 寫暫存器。
* 參數:reg=地址、val=內容(整 byte)
*/
void wrReg(uint8_t reg, uint8_t val) {
SPI.beginTransaction(SPISettings(1000000, MSBFIRST, SPI_MODE1));
digitalWrite(PIN_CS, LOW);
SPI.transfer(reg & 0x3F); // R/W=0=寫入
SPI.transfer(val);
digitalWrite(PIN_CS, HIGH);
SPI.endTransaction();
}
/**
* 欄位級讀改寫:避免動到同暫存器的其他欄位。
* 參數:mask=目標欄位遮罩、val=寫入值、pos=欄位移位
*/
void modReg(uint8_t reg, uint8_t mask, uint8_t val, uint8_t pos) {
uint8_t r = rdReg(reg);
r = (r & ~mask) | ((val << pos) & mask);
wrReg(reg, r);
}
/**
* 校準兩顆內部 RC 振盪器(TRCO/SRCO)。
* 回傳:true=校振成功(0x3A/0x3B bit6 皆為 0)
* 重開機與換電池後必跑;失敗代表供電電壓不足或天線斷路
*/
bool calibrateOsc() {
wrReg(REG_CMD_CALRCO, 0x96); // 直達命令:校振
delay(2); // datasheet 規定等待時間
bool ok = ((rdReg(REG_TRCO_STAT) & 0x40) == 0)
&& ((rdReg(REG_SRCO_STAT) & 0x40) == 0);
Serial.printf("[osc] calib %s\n", ok ? "OK" : "FAIL");
return ok;
}
/**
* 量測「顯示模式」下 IRQ 腳輸出頻率(LC 振盪器 ÷ DIV_RATIO)。
* 回傳:Hz(量不到回傳 0)
* 說明:bit7 致能後 IRQ 改輸出振盪頻率,此時中斷失效——只在開機調諧用
*/
uint32_t lcFreqHz() {
modReg(REG_FREQ_DISP, 0x80, 1, 7); // LC 振盪器頻率 → IRQ 腳
uint32_t hi = pulseIn(PIN_IRQ, HIGH, 400);
uint32_t lo = pulseIn(PIN_IRQ, LOW, 400);
modReg(REG_FREQ_DISP, 0x80, 0, 7); // 關顯示、恢復中斷輸出
if (hi == 0 || lo == 0) return 0;
return 1000000UL / (hi + lo); // pulseIn 單位是 µs
}
/**
* 天線自動調諧:掃 8–112pF(8pF 階),找共振頻率最接近
* 500kHz ÷ DIV_RATIO(64) ≈ 7.8kHz 的那一階。
* 回傳:最佳電容(pF);全部量不到回傳 64(安全預設值)
*/
uint8_t autoTune() {
const float target = 500000.0f / 64.0f; // DIV_RATIO=64 → 7812.5Hz
float bestErr = 1e9f; uint8_t best = 64;
for (uint8_t pf = 8; pf <= 112; pf += 8) {
modReg(REG_FREQ_DISP, 0x0F, pf / 8, 0); // TUNE_CAP:[3:0]×8pF
delay(20);
uint32_t f = lcFreqHz();
if (f == 0) continue;
float err = fabsf((float)f - target);
Serial.printf("[tune] %3dpf -> %5luHz (err %.0f)\n", pf, (unsigned long)f, err);
if (err < bestErr) { bestErr = err; best = pf; }
}
modReg(REG_FREQ_DISP, 0x0F, best / 8, 0);
Serial.printf("[tune] lock %dpf\n", best);
return best;
}
/**
* AS3935 初始化:重設→校振→室外模式→門檻→中斷致能→(首開)調諧。
* 回傳:true=IC 有回應
*/
bool asInit() {
wrReg(REG_CMD_RESET, 0x96);
delay(5);
uint8_t id = rdReg(REG_AFE_GAIN);
Serial.printf("[as3935] reset readback=0x%02X\n", id);
if (!calibrateOsc()) return false;
modReg(REG_AFE_GAIN, 0x3E, 0x0E, 1); // OUTDOOR 模式(室內用 0x12)
modReg(REG_THRESHOLD, 0x70, nfm, 4); // 噪訊地板 NFM(自調適值)
modReg(REG_THRESHOLD, 0x0F, wdth, 0); // watchdog 門檻(自調適值)
modReg(REG_LGHT_REG, 0x0F, 4, 0); // 突波抑制 RDRSR=4:過濾開關雜訊
modReg(REG_LGHT_REG, 0xF0, 1, 4); // 最少重複閃數=1(風雨環境靈敏度平衡)
modReg(REG_INT_SRC, 0x60, 0x02, 5); // DIV_RATIO=64(調諧與距離能量都吃它)
if (capPf == 0 || capPf == 255) capPf = autoTune();
else modReg(REG_FREQ_DISP, 0x0F, capPf / 8, 0);
rdReg(REG_INT_SRC); // 清殘留中斷
return true;
}
/* ============================================================
* 事件處理與逼近偵測
* ============================================================ */
/** 記錄一筆閃電進滑動視窗(RTC RAM,深睡不丟) */
void pushStrike(uint8_t dist, uint16_t energy, uint8_t src) {
ring[ringHead] = { (uint32_t)millis(), dist, energy, src };
ringHead = (ringHead + 1) % 48;
if (ringN < 48) ringN++;
strikesToday++;
if (dist < minDistToday) minDistToday = dist;
}
/**
* 對滑動視窗內(15 分鐘)閃電距離做最小二乘法回歸。
* 參數:slopeOut=回傳斜率(km/min)
* 回傳:視窗內有效筆數
* 只有 ≥3 筆且 span≥4 分鐘才給斜率——兩點定線會被單筆誤差牽著走
*/
int windowSlope(float& slopeOut) {
uint32_t now = millis();
float sx = 0, sy = 0, sxx = 0, sxy = 0; int n = 0;
uint32_t t0 = now, t1 = 0;
for (uint8_t i = 0; i < ringN; i++) {
Strike& s = ring[i];
if (now - s.t > WINDOW_MS || s.src != SRC_LIGHTNING) continue;
float x = (s.t - (float)now) / 60000.0f; // 分鐘,負值=過去
sx += x; sy += s.dist; sxx += x * x; sxy += x * s.dist;
n++; if (s.t < t0) t0 = s.t; if (s.t > t1) t1 = s.t;
}
slopeOut = 0;
if (n < 3 || (t1 - t0) < 4UL * 60000UL) return n;
float den = n * sxx - sx * sx;
if (fabsf(den) < 1e-6f) return n;
slopeOut = (n * sxy - sx * sy) / den;
return n;
}
/**
* 計算警報級別。
* 規則:級別取「最接近距離」;「逼近斜率」只升級不降級
* 回傳:0 正常/1 注意/2 警戒/3 撤離
*/
uint8_t evalLevel() {
uint8_t nearDist = 255;
uint32_t now = millis();
for (uint8_t i = 0; i < ringN; i++) {
Strike& s = ring[i];
if (now - s.t <= WINDOW_MS && s.src == SRC_LIGHTNING)
if (s.dist < nearDist) nearDist = s.dist;
}
if (nearDist == 255) return 0;
uint8_t lv = nearDist <= LV_RED ? 3 : nearDist <= LV_ORANGE ? 2 : nearDist <= LV_YELLOW ? 1 : 0;
float slope; windowSlope(slope);
if (slope < SLOPE_APPROACH && lv < 2) lv = 2; // 逼近中的加護條款
return lv;
}
/** 讀一筆 AS3935 中斷事件並分類(在 loop 呼叫,不在 ISR——SPI 不能進 ISR) */
void handleStrike() {
uint8_t src = (rdReg(REG_INT_SRC) >> 4) & 0x0F; // 讀即清 IRQ
uint8_t dist = rdReg(REG_DISTANCE) & 0x3F;
uint16_t en = ((rdReg(REG_ENERGY_MMB) & 0x0F) << 16)
| (rdReg(REG_ENERGY_MSB) << 8) | rdReg(REG_ENERGY_LSB);
uint32_t now = millis();
if (now - lastIrqMs > 60000) irqBurst = 0; // 60 秒滑動計數
lastIrqMs = now; if (++irqBurst >= 16) noiseAdapt(); // 雜訊風暴處理
if (src == SRC_LIGHTNING && dist >= 1 && dist <= 63) {
pushStrike(dist, en, src);
Serial.printf("[strike] %ukm energy=%lu\n", dist, (unsigned long)en);
awakeSince = now; // 重置清醒倒數
uint8_t lv = evalLevel();
if (lv > alarmLv) { alarmLv = lv; reportLevel(lv, dist); }
localAlarm(lv);
} else if (src == SRC_DISTURB) {
Serial.println(F("[irq] disturber(人造雜訊,忽略)"));
}
}
/** 60 秒內連觸 16 次=環境雜訊:拉高門檻,並在警報裡承認自己變鈍 */
void noiseAdapt() {
irqBurst = 0;
if (nfm < 7) nfm++;
if (wdth < 10 && nfm >= 4) wdth++;
modReg(REG_THRESHOLD, 0x70, nfm, 4);
modReg(REG_THRESHOLD, 0x0F, wdth, 0);
Serial.printf("[noise] 環境太吵,NFM=%d WD=%d\n", nfm, wdth);
sendTg("⚠️ 雷暴哨兵偵測到持續雜訊(NFM=" + String(nfm)
+ "),已自動拉高門檻。此刻距離讀值可能偏遠,別把沉默當安全。");
}
/* ============================================================
* 本地警報+Telegram
* ============================================================ */
/** 燈條+蜂鳴器:綠→橙→紅;撤離級每 3 分鐘嗶直到按 silence */
uint32_t lastBeepMs = 0;
void localAlarm(uint8_t lv) {
uint32_t c = lv >= 3 ? strip.Color(80, 0, 0)
: lv == 2 ? strip.Color(80, 30, 0)
: lv == 1 ? strip.Color(0, 60, 0) : strip.Color(0, 20, 0);
strip.fill(c); strip.show();
bool btn = digitalRead(PIN_BTN) == LOW;
if (lv >= 3 && !silenced) {
if (millis() - lastBeepMs > 180000 || lastBeepMs == 0) {
tone(PIN_BUZZ, 2600, 700); lastBeepMs = millis();
}
} else if (lv < 3) { silenced = false; lastBeepMs = 0; noTone(PIN_BUZZ); }
if (btn) { silenced = true; noTone(PIN_BUZZ); } // 按住=靜音
}
const char* lvName(uint8_t lv) {
return lv >= 3 ? "撤離" : lv == 2 ? "警戒" : lv == 1 ? "注意" : "解除";
}
/** 級別升級通報(同級別只發一次,防轟炸) */
void reportLevel(uint8_t lv, uint8_t dist) {
if (lv <= sentLv) return;
sentLv = lv;
float slope; windowSlope(slope);
String txt = String(lv >= 3 ? "🔴" : lv == 2 ? "🟠" : "🟡") + " 雷暴"
+ lvName(lv) + ":最接近 " + dist + "km";
if (slope < SLOPE_APPROACH) txt += ",每 10 分逼近 "
+ String((int)(-slope * 10)) + "km——正在接近!";
sendTg(txt);
}
/**
* Telegram 發送。
* 參數:msg=內文
* 回傳:true=送達
* 逾時重試 3 次、指數退避;setInsecure 只因為對岸是 api.telegram.org
* (自有伺服器請改 setCACert——文中安全性框有解法)
*/
bool sendTg(String msg) {
if (WiFi.status() != WL_CONNECTED) return false;
WiFiClientSecure client;
client.setInsecure(); // 見安全性框:換 CA pinning 的位置
HTTPClient http;
http.begin(client, String("https://api.telegram.org/bot") + TG_TOKEN + "/sendMessage");
http.addHeader("Content-Type", "application/json");
for (int tryN = 0; tryN < 3; tryN++) {
String body = "{\"chat_id\":\"" + String(TG_CHAT) + "\",\"text\":\"" + msg + "\"}";
int code = http.POST(body);
if (code == 200) { http.end(); return true; }
delay(500 << tryN); // 0.5s→1s→2s 退避
}
http.end();
Serial.println(F("[tg] 發送失敗(留待下次喚醒補發)"));
return false;
}
/* ============================================================
* 供電策略與深睡
* ============================================================ */
/** 讀電池電壓(分壓 ×2);低於 3.45V 進入雨天省電模式 */
float vbat() {
return analogReadMilliVolts(PIN_VBAT) * 2.0f / 1000.0f;
}
void goSleep() {
nvsPersist();
strip.clear(); strip.show(); noTone(PIN_BUZZ);
rdReg(REG_INT_SRC); // ⚠ 清 IRQ,否則 ext0 HIGH 秒醒
esp_sleep_enable_ext0_wakeup(GPIO_NUM_5, 1); // 閃電=醒
esp_sleep_enable_timer_wakeup(vbat() < 3.45f ? DEEP_MS_LOW * 1000ULL : DEEP_MS * 1000ULL);
Serial.printf("[sleep] vbat=%.2fV 下次例行喚醒 %ld 分鐘後\n",
vbat(), (long)(vbat() < 3.45f ? DEEP_MS_LOW / 60000 : DEEP_MS / 60000));
Serial.flush(); delay(10);
esp_deep_sleep_start();
}
/* ============================================================
* NVS 黑歷史(200 筆環形)
* ============================================================ */
struct LogBlob { uint16_t head; Strike e[200]; };
void nvsPersist() {
nvs_handle_t h;
if (nvs_open("storm", NVS_READWRITE, &h) != ESP_OK) return;
nvs_set_u32(h, "cnt", strikesToday);
nvs_set_u32(h, "min", minDistToday);
nvs_set_u32(h, "day", dayStamp);
nvs_set_u8(h, "cap", capPf);
nvs_set_u8(h, "nfm", nfm); nvs_set_u8(h, "wd", wdth);
nvs_commit(h); nvs_close(h);
}
void nvsRestore() {
nvs_handle_t h;
if (nvs_open("storm", NVS_READONLY, &h) != ESP_OK) return;
nvs_get_u32(h, "cnt", &strikesToday);
nvs_get_u32(h, "min", &minDistToday);
nvs_get_u32(h, "day", &dayStamp);
nvs_commit(h); nvs_close(h);
}
/* ============================================================
* 主程式
* ============================================================ */
void setup() {
Serial.begin(115200);
pinMode(PIN_CS, OUTPUT); digitalWrite(PIN_CS, HIGH);
pinMode(PIN_BTN, INPUT_PULLUP);
pinMode(PIN_BUZZ, OUTPUT); digitalWrite(PIN_BUZZ, LOW);
strip.begin(); strip.setBrightness(40); strip.clear(); strip.show();
SPI.begin(PIN_SCK, PIN_MISO, PIN_MOSI, PIN_CS);
nvsRestore();
uint32_t today = (uint32_t)(time(nullptr) / 86400);
if (today != dayStamp) { strikesToday = 0; minDistToday = 255; dayStamp = today; }
Serial.println(F("\n=== storm_sentry 雷暴哨兵啟動 ==="));
if (!asInit()) { Serial.println(F("[fatal] AS3935 無回應:查 SPI 線/供電")); }
attachInterrupt(digitalPinToInterrupt(PIN_IRQ), [] { irqFired = true; }, RISING);
awakeSince = millis();
}
void loop() {
static uint32_t lastConn = 0;
static uint8_t lastHourSent = 255;
if (irqFired) { irqFired = false; handleStrike(); }
/* --- 有閃電:保持清醒連網回報 --- */
bool needNet = alarmLv > 0 && alarmLv > sentLv;
bool heartbeat = millis() - lastConn > 3600000; // 每小時心跳+校時
if (needNet || heartbeat) {
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
if (WiFi.waitForConnectResult() == WL_CONNECTED) {
configTime(8 * 3600, 0, "pool.ntp.org"); // 醒著才校時(RTC 走時有漂)
if (alarmLv > sentLv) { float s; windowSlope(s);
reportLevel(alarmLv, ring[(ringHead + 47) % 48].dist); }
struct tm t;
if (getLocalTime(&t, 2000) && t.tm_hour == 21 && lastHourSent != 21) {
lastHourSent = 21;
sendTg("⛈️ 閃電日報:今日 " + String(strikesToday) + " 次"
+ (minDistToday < 255 ? ",最接近 " + String(minDistToday) + "km" : "")
+ ",噪訊階 NFM=" + String(nfm) + ",電壓 " + String(vbat(), 2) + "V");
}
if (alarmLv == 0) sentLv = 9; // 解除後重設可再通報
WiFi.disconnect(true); WiFi.mode(WIFI_OFF); // 用|RF 省電大頭
}
lastConn = millis();
}
localAlarm(alarmLv); // 燈號/嗶聲節拍
/* --- 安靜滿 30 分鐘:回家睡覺 --- */
if (alarmLv == 0 && millis() - awakeSince > STAY_AWAKE_MS) goSleep();
delay(200);
}
nvs, data, , 0x9000, 0x6000+app, oem, 0, 0x10000, 0xA0000×2+剩給 storage——韌體約 950KB(TLS+WiFi+NeoPixel),兩份 app 共 1.25MB,NVS 留 24KB 給閃電黑歷史。OTA 暫時關機(屋頂機器,刷壞了要爬樓頂,比 OTA 風險還低)。setInsecure() 是社群常例,但它是「不驗證憑證」——你的 bot token 會經過一条可能被中間人的路。正式做法:從 api.telegram.org/.well-known/acme-channels/ca-certs 抓 CA 後 client.setCACert(ca_pem),或把推播改走自家伺服器中轉(伺服器端驗證)。本機-token 權限請只開「發訊息」,別給管理組權限。🔧 組裝步驟
- 燒機與 SPI 驗證:先跑
asInit()單體測試——Serial 看到osc calib OK+調諧掃描=晶片鏈通。讀回 0x00/0xFF 先查 CS 與 MISO 有無接反。 - 天線定標:手指靠近環形天線 10cm(人體靜電模擬放電),每次都該觸發;30cm 內必觸發才算合格。觸發不了=SEL 模式錯或天線焊接冷。
- 雜訊掃描校準:拿到要裝的位置,Serial 連看 2 小時——零閃電情況下每小時超過 2 次觸發,調高 NFM 或換位置(常見兇手:樓下機車行整流器、不良 LED 路燈)。
- 防水盒組裝:電路全進盒,線材出口依防滴鉤走線,蓋前用濕布擦淨盒內灰塵再鎖膠條;透氣膜貼在盒子最低點對角。
- 電源鏈測試:升壓輸出鎖 5.0V 後串電流計測待機——正常落在 0.1–0.15mA(AS3935 監聽為主體);超過 0.5mA 查 LED 燈條漏電與 TP4056 反接。
- 上架與武裝:掛牆背座固定在屋檐內側 1m+,天線立架垂直地面、離金屬 6cm;按鍵拉一根延長線到陽台方便 silence。演練:灌假距離序列(見驗證)→ 三級警報按序觸發=正式上崗。
🧪 驗證——把它當防雷系統一樣驗收
| 測試 | 動作 | 合格線 |
|---|---|---|
| 感度定標 | 手指靠近環形天線(模擬放電) | 10cm 必觸發、30cm 內必觸發,Serial 顯示 SRC=8 |
| 雜訊底 | 裝定位後 Serial 連看 2 小時 | 無人為放電時每小時 ≤2 次觸發;超標調 NFM 或換位 |
| 逼近模擬 | Serial 灌入假距離序列 20→16→12→9(每筆間隔 60 秒) | 注意→警戒→撤離按序觸發,Telegram 三級都收得到 |
| 喚醒測試 | 深睡後手指碰天線 | 1 秒內喚醒(Serial 印 EXT0 喚醒原因)並完成回報 |
| 斷電續航 | 遮蔽太陽能板模擬連日陰雨 | 電壓跌破 3.45V 後喚醒間隔自動拉長、推播降頻只報級別變化 |
| 防水 | 水管沖淋外殼 5 分鐘 | 盒內乾燥、透氣膜無積水、警報照常 |
| 實彈驗證 | 第一個雷雨日記下 Telegram 首報時間 vs 手機氣象警報 | 本機首報至少早 10 分鐘——做不到=天線方位或調諧要再审 |
🆘 問題排除
| 症狀 | 最可能的原因 | 處方 |
|---|---|---|
| 從不觸發 | 環形天線面沒垂直地面、天線貼金屬屋頂、SEL 模式選錯 | 6cm 立架 reinstall;SEL 依模組跳回環形天線模式;重跑 autoTune |
| 狂觸發、距離亂跳 | 環境雜訊(交換器/LED 燈具)、NFM 太低、接地浮接 | 讓 noiseAdapt 跑兩輪;換掉兇手電源;銅巴接殼不接避雷針 |
| 距離恆 63 | watchdog 門檻過高濾掉了、天線失諧、能量門檻錯 | wdth 調回 2;重跑 autoTune;確認 OUTDOOR 模式 0x0E |
| 深睡後喚不醒 | IRQ 不在 GPIO0–7 RTC 域;或睡前 IRQ 殘留 HIGH 沒清(ext0 HIGH 觸發瞬間又睡回去) | 換腳到 5;goSleep 前讀 0x03 清旗標(程式已做) |
| SPI 全讀 0x00 | 讀取結尾的 CS 三连跳沒做、MISO/MOSI 接反、PSU 漣波 | 照 rdReg 的 CS HIGH-LOW-HIGH;核對兩根資料線;供電並 100µF |
| 日報時間飄移 | SNTP 只在連線時校時,深睡靠 RTC 走時 | 每次連線都重 configTime(程式已做);rtc 漂移 >2 分/天=板上 RTC 補一個 32.768kHz 外接晶振(可選) |
| 雨後盒內結露 | 透氣膜沒裝或裝在高點 | 膜裝最低點對角;開蓋重貼,膠條換新 |
📁 專案檔案結構
storm-sentry/
├── firmware/
│ ├── storm_sentry.ino # 上方完整韌體
│ └── partitions.csv # 2MB Flash 客製分割表
├── stl/
│ ├── antenna-stand.stl # 環形天線立架(6cm 隔絕)
│ ├── drip-hook-cover.stl # 防滴鉤蓋板
│ ├── solar-tilt30.stl # 太陽板 30° 傾角座
│ ├── light-guide.stl # 燈條導光條
│ └── wall-plate.stl # 掛牆背座
├── f3d/
│ └── storm-sentry.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md # 說明文件
🎯 結語
這台機器最值錢的不是那顆 130 元的晶片,是「閃電自己叫醒 MCU」的架構決定:AS3935 永遠醒著,C6 永遠在睡,待機帳單由物理定律買單而不是由你的電費買單——全年不關的門檻,就這樣從「要拉電線」降到「掛上去就好」。今年夏天第一個雷雨胞逼近時,你的 Telegram 會比氣象署的粗顆粒警報早十幾分鐘出現那行字:「🟠 雷暴警戒:最接近 11km,每 10 分逼近 7km——正在接近!」那十幾分鐘,足夠你收掉整條曬衣繩、拔掉所有/dc 線、把小孩從溜滑梯抱起來。雷公幫你數著距離回家,就是這個意思。