📋 專案概述
二手拍賣上的「夜光復古玻璃杯」越來越兇:帖文寫著「手邊出清、紫外燈下螢光超美」,附圖那圈螢光綠確實美得讓人想按下搶購按鈕。問題是——古董玻璃會自己發螢光,十之七八是鈾玻璃。鈾-238 的衰變鏈就躺在杯壁裡,活度從「放十年也人畜無害」到「放四十分鐘能把底片霧化」都有,而賣家端一律以「復古美感」帶過。你無法用眼睛、鼻子或 UV 手電分辨哪一隻安全——分辨它們的只有一台蓋格計數器。
同一台機器還替你回答家裡的另外三件事:阿嬤留下來釉色鮮豔的傳家餐盤要不要繼續裝酸性食物(釉上彩是鎘與釷的傳統載體);網購號稱「負離子」「遠紅外線」的床被寢具(部分真貨的負離子粉就是釷化合物);以及新聞每隔幾年重播一次的漁獲銫-137 疑慮——你有沒有想過,家戶輻射篩檢在臺灣是完全空白的市場:市售輻射儀 NT$6,000 起跳,買菜用不著、一年用兩次、租用更沒有。
於是一台 NT$290 的家用蓋格計數器:J305 蓋格管+400V 升壓模組+ESP32-C3。功能清單:
- 自動基線學習:開機前 30 分鐘統計你家自然背景(臺灣室內常見 20–45 CPM,隨氡濃度浮動),之後一切以「相對基線倍率」判斷——不用背「多少算危險」的表格,機器自己知道你家什麼叫正常
- 三態遲滯判讀:正常 → 留意(≥3× 基線,你在掃可疑物品)→ 警報(≥5× 且 ≥150 CPM,蜂鳴器警報+Telegram 推播);進階靠倍率、退階靠 2.2×,不會在門檻上反蓋橫跳
- 搜尋模式:長按按鈕進入,每個脈衝一聲短嗶——邊走邊掃,嗶聲密度就是輻射地圖,峰值自動持記。找輻射源的體驗跟金屬探測器一樣直覺
- 0.91" OLED 三頁:大字 CPM+60 秒滑動條圖 / µSv/h+基線倍率 / 狀態頁(管齡計數、基線、狀態)
- 每日戰報推 Telegram:24 小時 48 格分佈、峰值時刻、累計管齡——順帶記錄你家的氡濃度節律(雨天前後能差一倍,這是數據探知的冷知識)
- KCl 鹽驗機法:低鈉鹽的成分就是氯化鉀,天然鉀含 0.012% 的放射性鉀-40——1kg KCl ≈ 16.6 kBq,就是一個免費、合法、隨手可得的標準測試源。本機貼近鹽罐應見到 +10~30 CPM 抬升,驗機不用買任何放射源
算盤:市售便攜輻射儀最便宜的入門款約 NT$6,000,功能多是「計數+螢幕」兩件事;本機 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 SuperMini | 1 | ~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 亦可,程式自動偵測高度 |
| 蜂鳴器 | 有源蜂鳴器帶板 5V | 1 | ~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 |
🔌 接線圖
ASCII 對照表
🖨️ 3D 列印零件
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 計數腔(管窗朝下、腔壁 1.2mm) | 1 | 純 PLA | 白(好觀察髒污) | ~40 min |
| 主機盒(高壓獨立隔艙+螺絲柱) | 1 | 純 PLA | 黑 | ~55 min |
| 高壓艙警示蓋(浮雕 ⚡+「400V 勿觸」) | 1 | PLA 2.4mm 壁厚 | 黃 | ~15 min |
| OLED 面框 | 1 | 純 PLA | 黑 | ~10 min |
| 桌面展示架(30° 仰角) | 1 | 純 PLA | 任意 | ~20 min |
| KCl 標準源量杯(100g 刻線) | 1 | PETG(可清洗) | 透明 | ~15 min |
列印設定與輻射感測要點
- 計數腔壁 ≤1.2mm、3 層牆、20% 填充:β 粒子(如鉀-40、銫-137 的 β)在 PLA 裡走不了 2mm——腔壁每多 0.5mm,β 感度掉一截。γ 不受影響,但你買 J305 那扇雲母窗就是為了 β
- 腔體內壁朝管窗那一面開 20×20mm 淨空窗:讓被測物貼到離雲母窗 ≤10mm——距離是 β 感測的第一因子(10→30mm, 計數掉七成)
- 高壓艙與計數腔物理分艙:升壓板的磁致振動與洩漏脈衝是假計數頭號來源,分艙+單點共地比任何濾波程式都有效
- 展示架預留 6mm 鉛玻璃位(選配):把被測物壓在窗正上方的定位槽,每次量測幾何位置一致——基線比較才有意義
- KCl 量杯做 100g 刻線:固定品質、固定幾何,它就是你的「每日驗機標準源」
💻 程式碼
/* ============================================================
* 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 的鉗位二極體打出漏電,之後基線會詭異地慢慢爬坡。🏗️ 組裝步驟
- 高壓板先測再裝:EN 對地短接上電,萬用表 600V 檔測 HV-GND(只碰 GND 與 HV,人不要碰板);電壓 380–450V 皆正常,然後斷電等 10 秒再動手
- 訊號鏈預焊:100pF→10k→BAT54 直接在洞洞板上焊成一小段,縮到離 GPIO2 ≤3cm;訊號線與 HV 線各走機殼兩側
- 蓋格管入腔:端窗朝下對淨空窗,泡棉圈避震——雲母窗比你想像的嬌貴(指甲就能戳凹),管身用雙面膠不鎖螺絲(熱脹應力會裂玻璃)
- 驗機三連發:空桌背景 → 100g KCl 量杯貼窗(應 +10~30 CPM)→ 移開應回落。三步全過才算組裝完成
- 30 分鐘基線學習後固定擺位:之後每天同一個位置,氡濃度的「你家節律」才可比
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| CPM 恆為 0 | EN 未拉高/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 |
🎯 進階延伸
- 管齡管理:J305 壽命約 10⁹ 計數,狀態頁的 tube M 計數到 800M 就該留意;換管只是字串指令的事——耗材化的探頭是自建儀器的浪漫
- 氡日誌:每天 08:00 戰報連續拉三個月,你會第一次「看見」自家氡濃度的季節與氣壓節律(地下室/第一樓尤其值得做)
- 電池巡掃版:180° 轉 TP4056+18650,搜尋模式自動關 Wi-Fi、計數改閃紅 LED——拿出門掃古玩市集的完全體
- 雙管符合電路:兩顆管反 coincide 判讀壓光電雜訊——從「玩具」升級到「儀器」的經典改裝,程式只要加一個 AND 門中斷
- Home Assistant 橋接:
stat輸出改 MQTT 一行,CPM 進儀表板與自動化(「連續 10 分鐘 >3× 就推手機+開警報燈」)
📦 檔案清單
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 鹽驗機的晚上我想到一件好事:這個物理系大一就教的知識,讓每台自建蓋格計數器都有了一位免費的「校計員」——放射性無處不在,所以「多出來的部分」才是故事。