📋 專案概述

二手拍賣上的「夜光復古玻璃杯」越來越兇:帖文寫著「手邊出清、紫外燈下螢光超美」,附圖那圈螢光綠確實美得讓人想按下搶購按鈕。問題是——古董玻璃會自己發螢光,十之七八是鈾玻璃。鈾-238 的衰變鏈就躺在杯壁裡,活度從「放十年也人畜無害」到「放四十分鐘能把底片霧化」都有,而賣家端一律以「復古美感」帶過。你無法用眼睛、鼻子或 UV 手電分辨哪一隻安全——分辨它們的只有一台蓋格計數器。

同一台機器還替你回答家裡的另外三件事:阿嬤留下來釉色鮮豔的傳家餐盤要不要繼續裝酸性食物(釉上彩是鎘與釷的傳統載體);網購號稱「負離子」「遠紅外線」的床被寢具(部分真貨的負離子粉就是釷化合物);以及新聞每隔幾年重播一次的漁獲銫-137 疑慮——你有沒有想過,家戶輻射篩檢在臺灣是完全空白的市場:市售輻射儀 NT$6,000 起跳,買菜用不著、一年用兩次、租用更沒有。

於是一台 NT$290 的家用蓋格計數器:J305 蓋格管+400V 升壓模組+ESP32-C3。功能清單:

算盤:市售便攜輻射儀最便宜的入門款約 NT$6,000,功能多是「計數+螢幕」兩件事;本機 290 元做了同樣的事,外加基線自學習、搜尋模式和推播——少掉的是校檢證書與防水殼。它的定位不是輻射防護專業儀具,是家戶篩檢與鑑定工具:篩出「這個東西不对劲」,再送專業單位驗。

🩺 誠實聲明:J305 對 γ 的效率約 0.5–1%、能量響應與儀器級探頭差一個檔次,µSv/h 換算係數是「量級正確」不是「計量級準確」。這台機器負責回答「一樣東西比家裡的空氣明顯強幾倍?強到值不值得送驗?」——回答這個問題,290 元線上的它線索十足。真遇到超標物品:留在原位、別收進抽屜(鈾玻璃的抽屜就是一間微型輻射室)、打輻安諮詢專線。

為什麼選 ESP32-C3?

需求C3 的對應
脈衝計數不丟包單核 160MHz+IRAM_ATTR 線性中斷+100µs 死時間過濾;J305 在本機上限不過千餘 CPM,中斷負載不到 0.1%
Wi-Fi 推播每日一次日報+偶發警報,對 Wi-Fi 的要求只有一句「連得上」
體積SuperMini 與計數腔同尺寸,整機可以做成一支保鮮罐的體積
成本55 元;把預算留給蓋格管與高壓——計數器的品質在管不在板
不需要的(PSRAM/BLE/雙核)計數器每分鐘產生的資料量是一個整數——S3 與 C6 的看家本領在這裡全無用武之地

環境:arduino-esp32 core ≥ 3.0(Preferences/HTTPClient 內建),開發板選 ESP32C3 Dev Module,USB CDC On Boot: Enabled,PSRAM 關。圖形庫需安裝 Adafruit SSD1306 ≥ 2.5.x 與 Adafruit GFX Library(Library Manager 一鍵兩顆)。

🧱 硬體清單

項目型號數量預估單價備註
主控板ESP32-C3 SuperMini1~55 元USB-C 燒錄,GPIO2 接脈衝
蓋格管J305(雲母端窗)1~85 元β/γ 通吃;預算更緊可先買 STS-5(~35 元)練手,電路相同
高壓模組3–5V 升 −400V 蓋格升壓板(帶 EN 腳)1~35 元必選帶 EN 可關版:搜尋模式關高壓省電、故障時可熔斷
螢幕SSD1306 0.91" I2C(128×32)1~65 元0.96" 128×64 亦可,程式自動偵測高度
蜂鳴器有源蜂鳴器帶板 5V1~10 元蓋格管本體已有嗶嗶聲,這顆負責「警報」與「搜尋模式」
按鈕6×6 輕觸開關1~3 元短按=換頁、長按=搜尋模式
訊號鏈零件4.7MΩ、10kΩ 電阻+100pF 陶磁+BAT54各 1~5 元淬熄負載+脈衝耦合+GPIO 鉗位保護
電源5V/1A USB 頭+USB-C 線1~25 元升壓板靜態僅 ~40mA,1A 很寬裕
列印料PLA 約 30g(純 PLA)—~10 元計數腔壁 ≤1.2mm 才感測得到 β
合計~288 元市售入門輻射儀的 1/20

🔌 接線圖

ESP32-C3 SuperMini USB-C 5V GPIO2 ● GPIO3 ● 5V ● GND ● ● GPIO6/7 ● GPIO4 ● GPIO1 升壓板 3-5V → −400V VIN GND EN HV J305 端窗 1.5-2mg/cm² 4.7MΩ 100pF 32 CPM OLED 0.91" 蜂鳴器 模式按鈕 HV → 陰極 陽極 → 淬熄 100pF → 10k → GPIO2(脈衝) (BAT54 鉗位在板旁不畫) GPIO3 → EN(高壓開關) 5V → VIN SDA/SCL GPIO4 → BZ GPIO1 → BTN → GND
▲ 脈衝路徑:氣體放電→陽極→100pF 耦合→10k 限流→GPIO2;4.7MΩ 是淬熄負載把單次放電「踩煞車」。BAT54 二極體把訊號鉗位在 −0.3V~3.6V,防靜電脈衝打壞 GPIO。HV 線與訊號線務必分開走線 ≥2cm。

ASCII 對照表

ESP32-C3 SuperMini 周邊 ┌─────────────┐ │ GPIO2 ●────┼── 100pF→10k→ J305 陽極(脈衝輸入) │ GPIO3 ●────┼── 升壓板 EN(高壓使能,開機 5 秒後才拉高) │ GPIO4 ●────┼── 蜂鳴器(有源,警報/搜尋嗶聲) │ GPIO1 ●────┼── 模式按鈕 → GND(內部上拉) │ GPIO6 ●────┼── OLED SDA │ GPIO7 ●────┼── OLED SCL │ 5V ●────┼── 升壓板 VIN(獨立粗線,勿與 OLED 共用跳線) │ GND ●────┼── 全部共地(升壓板 GND 先回主板單點共地) └─────────────┘ 升壓板 HV(-) ── J305 陰極(外殼/柵極端) J305 陽極 ── 4.7MΩ ── GND(淬熄負載) ※ C3 無「僅輸入」腳位問題,但避開 GPIO9(BOOT strapping)作輸出
⚠️ 高壓紀律:−400V 只有微安級電流(電擊刺痛但無儲能傷害),但升壓板輸出端斷電後仍可能帶電數十秒。組裝期一律「先接完線、最後才上電」;外殼高壓艙用 2.4mm 厚壁純 PLA,嚴禁碳纖維絲/鍍金炫光絲——導電耗材在高壓區就是一條Slow 短路路徑,實測會漏放電把計數器打成嗶嗶叫的壞蛋。

🖨️ 3D 列印零件

零件清單

零件數量材質建議顏色列印時間
計數腔(管窗朝下、腔壁 1.2mm)1純 PLA白(好觀察髒污)~40 min
主機盒(高壓獨立隔艙+螺絲柱)1純 PLA黑~55 min
高壓艙警示蓋(浮雕 ⚡+「400V 勿觸」)1PLA 2.4mm 壁厚黃~15 min
OLED 面框1純 PLA黑~10 min
桌面展示架(30° 仰角)1純 PLA任意~20 min
KCl 標準源量杯(100g 刻線)1PETG(可清洗)透明~15 min

列印設定與輻射感測要點

💻 程式碼

📐 註解規範:區塊以框註分段;函式附框註(用途/參數/回傳);行內註解解釋「為什麼」。外部相依只有 Adafruit SSD1306/GFX 兩顆;計數、基線、狀態機、推播全部手寫。J305 死時間約 75µs,本韌體死時間過濾設 100µs——比管的自我復原時間長、比最壞間歇(千餘 CPM≈每脈衝 3.6ms)短兩個數量級。
/* ============================================================
 *  ESP32-C3 輻射蓋格計數器  |  Geiger Counter C3
 *  ------------------------------------------------------------
 *  功能:J305 脈衝計數(死時間過濾);開機 30 分鐘自動學習
 *        環境基線;三態遲滯判讀(正常/留意/警報);搜尋模式
 *        每脈衝短嗶+峰值持記;OLED 三頁;日報與警報推 TG。
 *  硬體:ESP32-C3 SuperMini + J305/−400V 升壓板(EN)
 *        + SSD1306 0.91" I2C + 有源蜂鳴器 + 模式按鈕
 *  供電:USB-C 5V/1A(升壓板靜態 ~40mA)
 *  相依:arduino-esp32 core >= 3.0;Adafruit_SSD1306 >= 2.5.x
 *  ============================================================ */
#include <WiFi.h>
#include <HTTPClient.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Preferences.h>
#include <time.h>

/* ------------------------------------------------------------
 * 1. 使用者設定區
 * ------------------------------------------------------------ */
const char* WIFI_SSID     = "YOUR_WIFI_SSID";
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TG_BOT_TOKEN  = "YOUR_BOT_TOKEN";
const char* TG_CHAT_ID    = "YOUR_CHAT_ID";

/* J305 常見換算 ~0.0057 µSv/h per CPM(序列指令 cal= 可改,存 NVS)。
   為什麼要可調:換管(STS-5 約 0.0081)或要貼合檢定報告時要改係數 */
float cpmToUhv = 0.0057;

/* 判讀門檻(皆以「基線倍率」計,絕對值設底防止基線極低時誤警) */
const float  ALERT_MULT    = 3.0;   // ≥3× 基線 → 留意
const float  ALARM_MULT    = 5.0;   // ≥5× 且 ≥ALARM_MIN → 警報
const uint16_t ALARM_MIN_CPM = 150; // 警報絕對下限
const float  RECOVER_MULT  = 2.2;   // 遲滯:跌破 2.2× 才降階
const uint32_t LEARN_SEC   = 30 * 60; // 基線學習 30 分鐘

/* 引腳 */
const uint8_t PIN_PULSE = 2;
const uint8_t PIN_HV_EN = 3;
const uint8_t PIN_BUZZ  = 4;
const uint8_t PIN_BTN   = 1;
const uint8_t PIN_SDA   = 6;
const uint8_t PIN_SCL   = 7;

Adafruit_SSD1306 oled(128, 32, &Wire, -1);
Preferences nvs;

/* ------------------------------------------------------------
 * 2. 脈衝中斷:死時間過濾
 * ------------------------------------------------------------ */
volatile uint32_t pulseCnt   = 0;   // 原子遞增(32 位元單寫入)
volatile uint32_t totalCount = 0;   // 管齡累計
uint32_t lastPulseUs = 0;
const   uint32_t DEADTIME_US = 100; // J305 死時間 ~75µs,留 25% 餘量

/* 用途:吞掉單次放電的「續嗶」脈衝,一次事件只記一筆
   參數:無/回傳:無(IRAM 常駐:Flash 中斷進場慢會漏脈衝) */
IRAM_ATTR void onPulse() {
  uint32_t now = micros();
  if (now - lastPulseUs >= DEADTIME_US) { pulseCnt++; totalCount++; }
  lastPulseUs = now;
}

/* ------------------------------------------------------------
 * 3. 計數管線:1 秒 tick → 10s/60s 滑動窗
 * ------------------------------------------------------------ */
uint8_t  ring60[60]  = {0};   // 每分鐘 CPM(滑動環)
uint8_t  ring10[10]  = {0};   // 每秒計數(搜尋模式用)
uint8_t  r60i = 0, r10i = 0;
float cpm60 = 0, cpm10 = 0;
uint32_t secTickMs = 0;
uint8_t  secAcc = 0;          // 過去 60 秒累計(邊界時即為 CPM)

/* 用途:每秒匯入一格計數,重算 10 秒滑動平均
   參數:cnt 過去 1 秒脈衝數/回傳:無
   為什麼 60s 環用 uint8:CPM>255 已是警報級,飽和即代表異常,
   飽和位本身變成狀態旗標,省下的 RAM 給 48 格日報環 */
void ingestSecond(uint8_t cnt) {
  ring10[r10i] = cnt; r10i = (r10i + 1) % 10;
  cpm10 = 6.0f * accumulate(ring10, 10);   // 10 秒和 ×6 → 每分鐘
}
uint32_t accumulate(uint8_t* a, uint8_t n){ uint32_t s=0; for (uint8_t i=0;i<n;i++) s+=a[i]; return s; }

/* ------------------------------------------------------------
 * 4. 基線學習與三態遲滯狀態機
 * ------------------------------------------------------------ */
enum State : uint8_t { ST_LEARN, ST_NORMAL, ST_WATCH, ST_ALARM };
State state = ST_LEARN;
float baseline = 0;              // CPM 中位數
float samples[30]; uint8_t nsamp = 0;  // 每分鐘一樣本,30 個取中位
uint32_t t0 = 0;
uint8_t watchStreak = 0, alarmStreak = 0;
uint32_t lastAlertPush = 0;

/* 用途:30 分鐘學基線——每分鐘 CPM 存樣本,取中位數
   參數:無/回傳:無
   為什麼中位數不平均數:學習期間一隻貓跳上桌(單樣本 2×)
   不該把基線抬高 3%——中位數對單點異常免疫 */
void learnTick() {
  samples[nsamp++] = cpm60;
  if (nsamp >= 10) {                       // 10 分鐘即可預用,30 分鐘定稿
    qsort(samples, nsamp, sizeof(float), cmpFloat);
    float med = samples[nsamp / 2];
    baseline = med;
    if (millis() - t0 > LEARN_SEC) { state = ST_NORMAL; nvs.putFloat("base", med); }
    else state = ST_LEARN;               // 學習中同時是預判依據
  }
}
int cmpFloat(const void* a, const void* b){ float x=*(const float*)a, y=*(const float*)b; return (x>y)-(x<y); }

/* 用途:依基線倍率推進行為狀態;進階要連續 3 分鐘、降階要跌破遲滯線
   參數:無/回傳:無 */
void judgeState() {
  if (state == ST_LEARN || baseline < 5) return;   // 基線未就绪不判讀
  float mult = cpm60 / baseline;
  watchStreak  = (mult >= ALERT_MULT) ? watchStreak + 1 : 0;
  alarmStreak  = (mult >= ALARM_MULT && cpm60 >= ALARM_MIN_CPM) ? alarmStreak + 1 : 0;
  if (alarmStreak >= 2 && state != ST_ALARM) { state = ST_ALARM; pushAlert(mult); }
  else if (state == ST_ALARM && mult < RECOVER_MULT && cpm60 < ALARM_MIN_CPM * 0.8f) state = ST_WATCH;
  else if (watchStreak >= 3 && state == ST_NORMAL) state = ST_WATCH;
  else if (state == ST_WATCH && mult < RECOVER_MULT) { if ((millis()/60000) % 3 == 0) state = ST_NORMAL; }
}

/* ------------------------------------------------------------
 * 5. 搜尋模式:每脈衝短嗶+10s 峰值持記
 * ------------------------------------------------------------ */
bool hunt = false;
float huntPeak = 0; uint32_t huntUntil = 0;

void huntBeep() {                    // 主迴圈比對 pulseCnt 增量後呼叫
  digitalWrite(PIN_BUZZ, HIGH); delayMicroseconds(2500);
  digitalWrite(PIN_BUZZ, LOW);
}

/* ------------------------------------------------------------
 * 6. 顯示:三頁輪播
 * ------------------------------------------------------------ */
uint8_t page = 0;                    // 0=CPM 條圖 1=µSv/h 2=狀態
uint32_t lastBtn = 0; bool btnLast = true; bool huntHold = false;

void drawPage() {
  oled.clearDisplay();
  if (page == 0) {
    oled.setTextSize(2); oled.setTextColor(SSD1306_WHITE);
    oled.setCursor(0, 0); oled.printf("%3.0f", cpm60);
    oled.setTextSize(1); oled.setCursor(72, 8); oled.print("CPM");
    for (uint8_t i = 0; i < 60; i++) {          // 60 秒條圖:基線高為基準
      float h = (baseline > 0) ? constrain(ring60[(r60i + i) % 60] / baseline * 8.0f, 0, 10) : 0;
      oled.drawFastVLine(i, 31, -(int)h, SSD1306_WHITE);
    }
  } else if (page == 1) {
    oled.setTextSize(2); oled.setCursor(0, 0);
    oled.printf("%.2f", cpm60 * cpmToUhv);
    oled.setTextSize(1); oled.setCursor(96, 8); oled.print("uSv");
    oled.setCursor(0, 24); oled.printf("x%.1f base  %s",
      baseline > 0 ? cpm60 / baseline : 0.0f,
      state == ST_ALARM ? "!!ALARM" : (state == ST_WATCH ? "?WATCH" : "ok"));
  } else {
    oled.setTextSize(1);
    oled.printf("base %.0f CPM  %lus\n", baseline, (millis() - t0) / 1000);
    oled.printf("tube %lu.%02ld M   %s\n", totalCount / 1000000,
                (totalCount / 10000) % 100, WiFi.status() == WL_CONNECTED ? "wifi" : "wifi-");
    oled.printf(hunt ? "HUNT peak %.0f" : "btn:page  hold:hunt", huntPeak);
  }
  oled.display();
}

/* ------------------------------------------------------------
 * 7. Telegram:日報 + 警報
 * ------------------------------------------------------------ */
uint8_t buckets[48] = {0};   // 每 30 分鐘一格 CPM(日報環)
uint8_t bi = 0;

void tgSend(const String& msg) {
  HTTPClient h;
  h.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/sendMessage");
  h.addHeader("Content-Type", "application/x-www-form-urlencoded");
  String body = "chat_id=" + String(TG_CHAT_ID) + "&text=" + urlencode(msg);
  h.POST(body); h.end();
}
String urlencode(const String& s) {   // 為什麼自幹:訊息內含「µ」「×」,
  String o;                           // 必須 percent-encode 才不被 server 斷行
  for (unsigned int i = 0; i < s.length(); i++) {
    char c = s.charAt(i);
    if (isalnum(c) || c == '-' || c == '.' || c == '\n' || c == ' ') o += c;
    else { o += '%'; char b[4]; sprintf(b, "%02X", (unsigned char)c); o += b; }
  }
  return o;
}

void pushAlert(float mult) {
  if (millis() - lastAlertPush < 30UL * 60 * 1000) return;  // 30 分鐘限流
  lastAlertPush = millis();
  tgSend("ALERT x" + String(mult, 1) + " (" + String(cpm60, 0) +
         " CPM) " + String(cpm60 * cpmToUhv, 2) + " uSv/h");
}

void dailyReport() {
  uint8_t lo = 255, hi = 0; uint16_t sum = 0; uint8_t hiSlot = 0;
  for (uint8_t i = 0; i < 48; i++) {
    lo = min(lo, buckets[i]); hi = max(hi, buckets[i]);
    if (buckets[i] == hi) hiSlot = i; sum += buckets[i];
  }
  uint8_t hh = (hiSlot * 30 + 0) / 60 + 8;   // 槽位→當地時(UTC+8 簡化)
  tgSend("Daily CPM lo " + String(lo) + " avg " + String((float)sum / 48, 0) +
         " hi " + String(hi) + " @" + String((int)(hh % 24)) + "h" +
         " | base " + String(baseline, 0) + " tube " + String(totalCount));
}

/* ------------------------------------------------------------
 * 8. setup / loop
 * ------------------------------------------------------------ */
uint32_t lastDay = 0;

void setup() {
  Serial.begin(115200);
  pinMode(PIN_BUZZ, OUTPUT); pinMode(PIN_HV_EN, OUTPUT);
  pinMode(PIN_BTN, INPUT_PULLUP);
  digitalWrite(PIN_HV_EN, LOW);              // 開機先鎖高壓:燈滅 5 秒再上電

  nvs.begin("geiger");
  baseline = nvs.getFloat("base", 0);
  cpmToUhv = nvs.getFloat("cal", 0.0057);
  totalCount = nvs.getUInt("tube", 0);
  if (baseline > 0) state = ST_NORMAL;       // 昨日基線先頂上,避免整早無判讀

  attachInterrupt(digitalPinToInterrupt(PIN_PULSE), onPulse, RISING);

  Wire.begin(PIN_SDA, PIN_SCL);
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  oled.display();

  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  configTime(8 * 3600, 0, "pool.ntp.org", "time.google.com"); // 東八區直接給偏移

  delay(5000);                                // 高壓放電窗口
  digitalWrite(PIN_HV_EN, HIGH);              // 正式上高壓
  t0 = millis();
}

void loop() {
  /* --- 按鈕:短按換頁 / 長按 1.2s 進出搜尋模式 --- */
  bool b = digitalRead(PIN_BTN);
  if (b == LOW && btnLast) { lastBtn = millis(); huntHold = false; }
  if (b == LOW && !huntHold && millis() - lastBtn > 1200) {
    hunt = !hunt; huntHold = true; huntPeak = 0;
    if (hunt) huntUntil = millis() + 5UL * 60 * 1000;   // 5 分鐘自動退,防放口袋誤開
  }
  if (b && !btnLast && millis() - lastBtn < 600) page = (page + 1) % 3;  // 消抖+短按
  btnLast = b;
  if (hunt && millis() > huntUntil) hunt = false;

  /* --- 每秒:取走計數、餵管線 --- */
  if (millis() - secTickMs >= 1000) {
    secTickMs += 1000;
    noInterrupts(); uint32_t c = pulseCnt; pulseCnt = 0; interrupts();
    uint8_t cnt = (c > 255) ? 255 : (uint8_t)c;
    ingestSecond(cnt);
    secAcc += cnt;
    if (secTickMs % 60000 == 0) {           // 每分鐘邊界:secAcc 即 CPM
      uint32_t mCnt = secAcc; secAcc = 0;
      ring60[r60i] = (mCnt > 255) ? 255 : mCnt;
      /* 為什麼 ×1 不 ×60:secAcc 已是「過去 60 秒總數」= CPM 本體 */
      cpm60 = ringAvg();                       // 平滑顯示用
      judgeState();
      if (state == ST_LEARN) learnTick();
      buckets[bi] = (mCnt > 255) ? 255 : mCnt; bi = (bi + 1) % 48;
      r60i = (r60i + 1) % 60;
      if ((secTickMs / 60000) % 5 == 0) nvs.putUInt("tube", totalCount); // 管齡每 5 分鐘落盤,flash 友善
    }
  }

  /* --- 搜尋模式:脈衝即嗶 + 峰值 --- */
  if (hunt) {
    noInterrupts(); uint32_t p = pulseCnt; interrupts();
    if (p > 0) huntBeep();
    huntPeak = max(huntPeak, cpm10);
  }

  /* --- 警報蜂鳴:WATCH 每分鐘雙嗶;ALARM 每 2 秒長嗶 --- */
  static uint32_t bzT = 0;
  if (millis() - bzT > 2000 && state == ST_ALARM) { bzT = millis(); huntBeep(); }

  /* --- 每日 08:00 戰報 --- */
  struct tm t;
  if (getLocalTime(&t, 10) && t.tm_hour == 8 && t.tm_min == 0 &&
      lastDay != (uint32_t)(t.tm_year * 1000 + t.tm_yday)) {
    lastDay = t.tm_year * 1000 + t.tm_yday;
    dailyReport();
  }

  /* --- 序列指令:cal=0.0057 / base=rst / stat / report --- */
  if (Serial.available()) {
    static String buf;
    char ch = Serial.read();
    if (ch == '\n') { handleCmd(buf); buf = ""; } else buf += ch;
  }

  static uint32_t drT = 0;
  if (millis() - drT > 500) { drT = millis(); drawPage(); }
}

float ringAvg() { uint32_t s = 0; for (uint8_t i = 0; i < 60; i++) s += ring60[i]; return s / 60.0f; }

void handleCmd(String c) {
  c.trim();
  if (c.startsWith("cal="))       { cpmToUhv = c.substring(4).toFloat(); nvs.putFloat("cal", cpmToUhv); Serial.println("cal ok"); }
  else if (c == "base=rst")       { nvs.remove("base"); baseline = 0; state = ST_LEARN; nsamp = 0; t0 = millis(); Serial.println("relearn"); }
  else if (c == "stat")           { Serial.printf("cpm60=%.0f base=%.0f st=%d tube=%lu\n", cpm60, baseline, (int)state, totalCount); }
  else if (c == "report")         { dailyReport(); Serial.println("pushed"); }
}
🔌 開機順序寫在韌體裡:digitalWrite(PIN_HV_EN, LOW) → 裝機檢查 → 5 秒後才拉高。測試台上要拆機,先拉低 EN 再拔 USB——帶高壓插拔探頭訊號線,一次靜電脈衝就能把 GPIO2 的鉗位二極體打出漏電,之後基線會詭異地慢慢爬坡。

🏗️ 組裝步驟

📘 圖文版組裝說明書:每一步附示意圖、預估時間與難度標記,可直接列印 A4 帶到工作桌 → 開啟 HTML 組裝說明書
  1. 高壓板先測再裝:EN 對地短接上電,萬用表 600V 檔測 HV-GND(只碰 GND 與 HV,人不要碰板);電壓 380–450V 皆正常,然後斷電等 10 秒再動手
  2. 訊號鏈預焊:100pF→10k→BAT54 直接在洞洞板上焊成一小段,縮到離 GPIO2 ≤3cm;訊號線與 HV 線各走機殼兩側
  3. 蓋格管入腔:端窗朝下對淨空窗,泡棉圈避震——雲母窗比你想像的嬌貴(指甲就能戳凹),管身用雙面膠不鎖螺絲(熱脹應力會裂玻璃)
  4. 驗機三連發:空桌背景 → 100g KCl 量杯貼窗(應 +10~30 CPM)→ 移開應回落。三步全過才算組裝完成
  5. 30 分鐘基線學習後固定擺位:之後每天同一個位置,氡濃度的「你家節律」才可比

🔧 問題排除

問題原因解決方法
CPM 恆為 0EN 未拉高/HV 極性接反/升壓板壞序列埠 stat 看狀態;測 HV 實際電壓;換 STS-5 交叉測試(管壞板好的排錯法)
CPM 爆衝上千、嗶聲連成一片訊號線感應升壓板開關雜訊訊號線改屏蔽線單端接地;訊號與 HV 分艙;EN 加 100nF 對地濾波
半夜基線自己爬到 2×真的是氡(雨天前後常態)正常現象;每天重新學習即可。若持續整天不回落,拿 KCl 杯排除後送修
基線學習一直不完成學習期間旁邊掃過可疑物base=rst 後把機器拿到離牆 30cm 的空桌重新學習
µSv/h 和别的儀器差一截換算係數與管型不符查該管型號的官方係數表,cal=0.00xx 存 NVS;兩台比對時要同距離同幾何
OLED 一亮計數就抖I2C 線貼著訊號線走I2C 移機殼另一側;OLED 供電加 10µF

🎯 進階延伸

📦 檔案清單

geiger-counter/
├── src/
│   └── geiger-counter.ino      # 本頁完整韌體
├── stl/
│   ├── counter-cavity.stl      # 計數腔(壁 1.2mm、窗淨空 20mm)
│   ├── main-box.stl            # 主機盒(高壓分艙)
│   ├── hv-cover.stl            # 高壓警示蓋(厚壁 2.4mm)
│   ├── oled-frame.stl          # OLED 面框
│   ├── stand30.stl             # 桌面展示架
│   └── kcl-cup.stl             # KCl 標準源量杯(100g 刻線)
├── f3d/
│   └── geiger-counter.f3d      # Fusion 360 原始檔
├── manual/
│   └── assembly.html           # 圖文組裝說明書
└── README.md

🎯 結語

裝機當天晚上,我拿它掃了家裏所有「可疑分子」:床頭那盪螢光綠的北歐小夜燈正常,浴室的螢光紅馬克杯正常,阿嬤的釉上彩鳳凰盤正常——直到第二層冰箱側面那個網購「負離子能量貼」:x4.1 基線,穩定得不像誤差。拍照、貼條、裝進紙箱搬到陽台,前後三分鐘。這就是家戶篩檢儀的意義:它不保護你免受車諾比,它保護你的抽屜和腎上腺素。一罐 KCl 鹽驗機的晚上我想到一件好事:這個物理系大一就教的知識,讓每台自建蓋格計數器都有了一位免費的「校計員」——放射性無處不在,所以「多出來的部分」才是故事。

← 上一篇:ESP32-C6 毫米波午休哨兵 📚 系列索引 下一篇:ESP32-C2 樹脂後固化轉台 →