📋 專案概述

去年八月週日下午,我在陽台收衣服時看見西北方的雲牆——鐵灰色、下緣翻著墨綠,十分鐘後雷打在西側山頭,對面社區的變壓器跳了一整排,整條街的室外攝影機、基地台、儲能太陽能板全部在線路上「排隊付帳」。我家的設備沒事,純粹是运气:雷擊浪不一定直接打中你,它打幾公里外的地面,沿著電力線和訊號線「回家」,你的設備在鏈的末端買單。而我全部的預警,是抬頭看一眼天。

氣象署的雷暴訊息是另一種痛:「大雷雨特別報告」四個字出現的時候,雷雨胞通常已經壓在北台灣上,顆粒粗得像拿衛星雲圖當手電筒照路;手機雷達圖要自己盯,盯十分鐘人就去煮麵了。市售方案我也評估過一圈:

  1. FarSite 業餘氣象雷達:感應器加軟體訂閱一萬起跳,定位靠三台感應器網路——單機版就只告訴你「有閃電」,跟我的雲牆視覺系統相比資訊增量約等於零。
  2. Meteobridge 閃電模組+基地台:$6,000 起,資料進它的雲端、警報推它的 App。我願意為資料付錢,但不願意為「我家上面在下雷」繳月費。
  3. 手機警報 App:靠氣象署訊息,延遲 10~20 分鐘起步。雷雨胞移動每小時 30~50 公里——延遲 10 分鐘代表預警距離少了 6 公里,這段距離剛好是「收得掉曬衣間」跟「淋成落湯雞」的差別。

關鍵在於:閃電本身會廣播自己。每道閃電在 500kHz 頻段吼一聲,AS3935 這顆晶片專聽這一聲,還幫你估距離(1~40 公里分級、最遠可標 63 公里)與能量。這顆 130 元的模組,就是人類能免費拿到的最長的雷暴預警:

⚠️ 誠實定位:AS3935 偵測的是「雲對地閃電的電磁波」,它能告訴你「半徑 40 公里內有閃電、大概多遠、有沒有在靠近」,不能告訴你「雷會不會打中你家」,也收不到氣象署的细胞警报(那是另一條路,需要手機基頻)。這台機器的正經用途是:把「抬頭看天」的模糊五感,換成「雷公現在距你 X 公里、每 10 分鐘接近 6 公里」的具體決策——收衣服、拔太陽能/dc 線、叫小孩從溜滑梯回來。

這台 ESP32-C6 雷暴預警基地台做的事:

帳目: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 用滴膠封燈腳
蜂鳴器有源蜂鳴器 5V1~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×81~35 元IP65 級;線材出口全走防滴鉤(見 3D 列印節)
呼吸透氣膜+防水膠條1 組~25 元屋頂日溫差 20°C,不透氣殼內必結露——結露就是短路
接地銅巴+接地端子1~20 元只接外殼與訊號地;嚴禁接屋頂避雷針(下文安全框)
雜項按鍵、100kΩ×2、螺絲、線材1 組~45 元100kΩ 分壓做電池電壓偵測
合計~610 元無訂閱、無雲端,閃電資料全留自家

🔌 接線圖

C6 DevKitM-1 WROOM-1 · 2MB GPIO5 ⚡IRQ GPIO6 SCK GPIO7 MOSI GPIO8 MISO GPIO4 VBAT 3V3/GND GPIO0 RGB GPIO1 BUZZ GPIO2 BTN GPIO10 CS 5V ⚠ GPIO0–7=RTC 域:深睡喚醒腳只能選這裡(官方文件明列) AS3935 環形天線面垂直地面 與主體隔 6cm(ASA 立架) SPI 束:CS=10 SCK=6 MOSI=7 MISO=8 IRQ=5(上升緣+喚醒) WS2812×8 330Ω 蜂鳴器+S8050 按鍵→GND 18650(保護板) 100k/100k 分壓 中點→GPIO4(ADC1) 電壓偵測(雨天降頻依據) 電源鏈 太陽能 5V → TP4056+防逆 → 18650 → MT3608 升壓 5V → 板 5V 腳 VBUS 不接!升壓輸出先鎖 5.0V 5V 銅巴→外殼+訊號地(⚠ 不接避雷針)
圖一:整機接線。紅框是深睡喚醒的硬限制區——IRQ 必須落在 GPIO0–7。
AS3935 模組 ESP32-C6-DevKitM1 備註 ───────────── ───────────────── ───────────── SCLK ──────────────── GPIO6 MOSI ──────────────── GPIO7 MISO ──────────────── GPIO8 CS ──────────────── GPIO10 IRQ ──────────────── GPIO5 ⚠ RTC 域腳:兼深睡 ext0 喚醒 LED ──────────────── GPIO3 (可選)看門狗脈衝,可接示波器驗諧 SEL / IN 依模組預設=環形天線模式,不佔腳 3.3V / GND ─────────── 3V3 / GND 模組側並 10µF+100nF 警報 ESP32-C6 ───────────── ───────────────── WS2812 DIN ──330Ω───── GPIO0 BUZZ(+) ──── S8050 ─── GPIO1 基極 10kΩ,續流二極體跨接 BTN ──────── 內部上拉 ─ GPIO2 按下=GND;silence / 即時回報 VBAT ──100k──┬──100k── GND └───────── GPIO4 分壓中點,ADC1_CH4 電源鏈(全部盒內,只有太陽能板在外面): 太陽能 5V → TP4056(RPROG=2k) + SS54 防逆 → 18650+保護板 → MT3608 升壓 5.0V → 板 5V 腳。VBUS 絕不接。
⚠️ 屋頂安全三條:① 本機全低壓(5V),不碰市電、不碰室外電源線——它的建議動作是「通知你拔線」,不是替你拔線。② 外殼接地銅巴只接機殼與訊號地,嚴禁綁上屋頂避雷針或鋼構——雷擊瞬間那條線會幫你家重新裝潢。③ 安裝點選屋檐內側 1m 以上、避開避雷導線路徑;天線立架用 ASA 不用 PLA(半年曬成餅乾屑)。

⚙️ 3D 列印

零件材質列印設定設計重點
環形天線立架ASA0.2mm、2 壁、25%天線面垂直地面(AS3935 磁環天線的感應軸)、與主殼隔 6cm、離金屬屋頂面 6cm+——這兩個 6cm 是感度與誤報的生死線
主殼防滴鉤蓋板ASA0.2mm、3 壁四個線材出口各做 U 型防滴鉤,線先下垂再進孔;螺絲孔預留膠條槽
太陽板 30° 傾角座ASA0.24mm、2 壁30° 是北回歸線屋頂全年平均最好角;背面走線槽穿到主殼後方
燈條導光條ASA+透明 PETG 蓋0.2mm、3 壁燈條藏殼內導光條後方,外殼不鑽孔——防水優先於美觀
掛牆背座ASA0.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);
}
📌 partitions.csv(2MB Flash 的生存術):預設 1.3MB×2 OTA 分割會擠死 NVS。本機改用 nvs, data, , 0x9000, 0x6000+app, oem, 0, 0x10000, 0xA0000×2+剩給 storage——韌體約 950KB(TLS+WiFi+NeoPixel),兩份 app 共 1.25MB,NVS 留 24KB 給閃電黑歷史。OTA 暫時關機(屋頂機器,刷壞了要爬樓頂,比 OTA 風險還低)。
🔐 setInsecure 的诚实话:連 Telegram 官方 API 用 setInsecure() 是社群常例,但它是「不驗證憑證」——你的 bot token 會經過一条可能被中間人的路。正式做法:從 api.telegram.org/.well-known/acme-channels/ca-certs 抓 CA 後 client.setCACert(ca_pem),或把推播改走自家伺服器中轉(伺服器端驗證)。本機-token 權限請只開「發訊息」,別給管理組權限。

🔧 組裝步驟

  1. 燒機與 SPI 驗證:先跑 asInit() 單體測試——Serial 看到 osc calib OK+調諧掃描=晶片鏈通。讀回 0x00/0xFF 先查 CS 與 MISO 有無接反。
  2. 天線定標:手指靠近環形天線 10cm(人體靜電模擬放電),每次都該觸發;30cm 內必觸發才算合格。觸發不了=SEL 模式錯或天線焊接冷。
  3. 雜訊掃描校準:拿到要裝的位置,Serial 連看 2 小時——零閃電情況下每小時超過 2 次觸發,調高 NFM 或換位置(常見兇手:樓下機車行整流器、不良 LED 路燈)。
  4. 防水盒組裝:電路全進盒,線材出口依防滴鉤走線,蓋前用濕布擦淨盒內灰塵再鎖膠條;透氣膜貼在盒子最低點對角。
  5. 電源鏈測試:升壓輸出鎖 5.0V 後串電流計測待機——正常落在 0.1–0.15mA(AS3935 監聽為主體);超過 0.5mA 查 LED 燈條漏電與 TP4056 反接。
  6. 上架與武裝:掛牆背座固定在屋檐內側 1m+,天線立架垂直地面、離金屬 6cm;按鍵拉一根延長線到陽台方便 silence。演練:灌假距離序列(見驗證)→ 三級警報按序觸發=正式上崗。
📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶上樓頂 → 開啟 HTML 組裝說明書

🧪 驗證——把它當防雷系統一樣驗收

測試動作合格線
感度定標手指靠近環形天線(模擬放電)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 跑兩輪;換掉兇手電源;銅巴接殼不接避雷針
距離恆 63watchdog 門檻過高濾掉了、天線失諧、能量門檻錯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 線、把小孩從溜滑梯抱起來。雷公幫你數著距離回家,就是這個意思。

← 上一篇:ESP32-S3 列印失敗哨兵 📚 系列索引 下一篇:ESP32-C2 陽台雨水智慧量表 →