📋 專案概述
三代同堂第二年,家裡的「呼叫系統」是吼的:三樓陽台曬被叫一樓吃飯,一樓廚房抽油煙機全開——吼到第四聲已經變吵架。長輩的智慧型手機學習曲線失敗:解鎖、找 App、等連線,等他們做完這套,菜涼了。對講機買過一對 400 元的兒童玩具:2.4G 私頻在透天厝只撐兩層,三樓收不到一樓,還天天被小孩帶去澡堂玩水。
智慧型手機不是解——它假設人人隨身帶機,但在家裡,長輩的手機在二樓充電、你的在書房桌上。家庭內部通訊的真正需求是「「地點無關的固定點」」:廚房牆上、客廳邊桌、父母床頭——走過去,按著,講。
本專案兩台 NT$470 的家用語音基地台,牆上一顆大蘑菇按鈕,按著講、放開送:
- 按住即說(Push-to-Talk):大按鈕按下去綠環亮、放開即送出,零解鎖零 App——長輩和三歲小孩都會用
- 串流播放:對方還在講,這邊已開始播(首包延遲 <200ms),不用等整段講完——對講的「即時感」全在這 200ms 裡
- 狀態燈環:綠=待機、橙=送出中、青=收話中、紅閃=夥伴離線——不用看螢幕就知道能不能講
- 離線兜底:夥伴機沒電/斷網時,自動推 Telegram「二樓 CALL 你(夥伴離線)」——訊息一定送達,只是換了管道
- 音量三段:夜間模式 40%——三樓嬰兒房睡著時,一樓的聲音進得了門、吵不到床
- 防嘯自鎖:播放時麥克風硬閂斷,兩台擺桌上挨著也不尖嘯
算盤:商用「Wi-Fi 對講廣播系統」都是大樓建商方案,NT$15,000 起、要佈線要主機;消費級藍牙對講機配對即斷、延遲半秒起。本機走家用 Wi-Fi+UDP,兩機 470 元,路由器 Wi-Fi 涵蓋之處即通話範圍——透天厝加一顆中繼 AP 就是全戶。
為什麼選 ESP32-S3?
| 需求 | S3 的對應 |
|---|---|
| 音訊採樣不卡網 | 雙核:核 1 跑 I2S 採樣/編解碼、核 0 給 Wi-Fi/UDP 協定棧——音訊管線最怕的就是協定棧搶時序,雙核物理隔離 |
| 語音緩衝 | PSRAM 當播放環形緩衝池(8 秒語音只要 256KB),靜態 RAM 留給協定棧 |
| 麥克風+喇叭同時有 | 兩組 I2S 控制器,輸入輸出各佔一組,時鐘互不打架 |
| 狀態燈環 | GPIO48 內接 WS2812(DevKitC 板載),外接燈環同腳位驅動 |
| 不需要的(攝像頭、BLE、Thread) | 本機是音訊專案不是影像專案——但 S3 是「有雙核+PSRAM+雙 I2S」的最小 ESP,沒有更省的選項 |
環境:arduino-esp32 core ≥ 3.0,開發板 ESP32S3 Dev Module(N8R2 以上),USB CDC On Boot: Enabled、PSRAM: OPI PSRAM。外部相依 Adafruit_NeoPixel ≥ 1.12(燈環)。
🧱 硬體清單(每機,共 ×2)
| 項目 | 型號 | 數量 | 預估單價 | 備註 |
|---|---|---|---|---|
| 主控板 | ESP32-S3 DevKitC-1 N8R2 | 2 | ~110 元 | 需 PSRAM 版(型號帶 R2) |
| 麥克風 | INMP441 I2S | 2 | ~40 元 | L/R 接 GND=左聲道 |
| 喇叭模組 | MAX98357A 3W I2S | 2 | ~45 元 | GAIN 懸空=6dB |
| 喇叭單體 | 8Ω 2W 28mm | 2 | ~20 元 | 單聲道,對講夠用 |
| PTT 按鈕 | 24mm 蘑菇頭輕觸 | 2 | ~12 元 | 按著講、放開送 |
| 燈環 | WS2812 8 環 | 2 | ~12 元 | 省錢可先用板載那顆 |
| 列印料 | PLA 40g/機 | — | ~12 元 | 天線淨空區禁金屬色絲 |
| 合計(兩機) | ~470 元 | 兒童對講機的錢,全戶覆蓋 |
🔌 接線圖(S3,兩機相同)
ASCII 對照表
🖨️ 3D 列印零件(每機)
零件清單
| 零件 | 數量 | 材質建議 | 顏色 | 列印時間 |
|---|---|---|---|---|
| 桌面音箱座(35° 傾角、MIC 孔背向喇叭) | 1 | PLA | 任意 | ~40 min |
| 壁掛框(卡片式,3M 背膠免鑽) | 2 | PLA | 任意 | ~15 min |
| PTT 蘑菇帽(加大掌心接觸面) | 2 | PLA+TPE 包邊更佳 | 螢光橙 | ~12 min |
| 燈環_diffuser 導光圈(霧面聚光) | 2 | PLA 白(2 層牆 8% 填充透光) | 白 | ~10 min |
| MIC 防風氈壓圈+喇叭導音腔(15mm 腔深) | 各 1 | PLA | 黑 | ~15 min |
| 走線夾 ×4 | 1 套 | PLA | 任意 | ~5 min |
聲學列印設定
- 喇叭腔體 2 層牆+頂部實心封頂:28mm 單體沒腔體就是蚊子音——15mm 腔深與後部密閉度決定了對講的「厚實感」,密封比裝飾要緊
- MIC 孔 φ3mm、孔後貼防風氈:廚房排氣扇、陽台風切噪聲——風噪是 VAD 誤觸發的第一殺手,氈比濾波演算法便宜
- 35° 傾角讓 MIC 指向嘴、喇叭指向耳、兩者背向:同一平面上做到的聲學隔離,勝過軟體處理十行
- 天線淨空區:板端天線頭 15mm 內只准純 PLA——碳纖絲、銅絲色(含金屬粉)會把 Wi-Fi 關進法拉第籠,這比任何參數調校都致命
💻 程式碼
/* ============================================================
* ESP32-S3 家庭對講機 | Home Intercom S3
* ------------------------------------------------------------
* 功能:PTT 按住即說;IMAD-PCM 8:1 壓縮;UDP 串流播放
* (首包 <200ms);VAD 尾音門 300ms;播放時麥硬閂防嘯;
* WS2812 燈環四態;夥伴離線 Telegram 兜底;音量三段。
* 硬體:ESP32-S3 DevKitC-1 N8R2 + INMP441(I2S0)
* + MAX98357A(I2S1) + 蘑菇 PTT(GPIO1) + WS2812(GPIO48)
* 供電:USB-C 5V/2A(喇叭峰值 600mA 留裕度)
* 相依:arduino-esp32 core >= 3.0;Adafruit_NeoPixel >= 1.12
* ============================================================ */
#include <WiFi.h>
#include <WiFiUdp.h>
#include <HTTPClient.h>
#include <Preferences.h>
#include <Adafruit_NeoPixel.h>
#include "driver/gpio.h"
/* ------------------------------------------------------------
* 1. 使用者設定區
* ------------------------------------------------------------ */
const char* WIFI_SSID = "YOUR_WIFI_SSID"; // 兩機須同一 2.4G SSID
const char* WIFI_PASSWORD = "YOUR_WIFI_PASSWORD";
const char* TG_BOT_TOKEN = "YOUR_BOT_TOKEN"; // 離線兜底用,可留空
const char* TG_CHAT_ID = "YOUR_CHAT_ID";
const char* NODE_NAME = "下樓"; // 自報家門(例:二樓)
const uint8_t PIN_PTT = 1, PIN_LED = 48;
const uint16_t PORT_RX = 8266; // 兩機同端口
uint8_t volLevel = 1;
const float volTable[3] = {1.0f, 0.7f, 0.4f}; // 高/中/夜間
Adafruit_NeoPixel ring(8, PIN_LED, NEO_GRB + NEO_KHZ800);
Preferences nvs;
WiFiUDP udp;
/* ------------------------------------------------------------
* 2. 音訊管線:16kHz/16bit,雙 I2S 控制器
* ------------------------------------------------------------ */
I2SClass i2sMic, i2sSpk;
const uint16_t SR = 16000;
const uint16_t FRAME = 160; // 10ms=160 樣本:延遲與流量的平衡點
int32_t rawBuf[FRAME];
int16_t pcmBuf[FRAME];
/* INMP441:32-bit slot、MSB 對齊——右移 14 位≈ 18-bit 動態壓回 16-bit,
再乘 2 補回標準接法的 −6dB(資料表推薦位元位移法)*/
void micToPcm() {
i2sMic.readBytes((char*)rawBuf, FRAME * 4);
for (uint16_t i = 0; i < FRAME; i++) {
int32_t v = rawBuf[i] >> 14;
pcmBuf[i] = constrain(v * 2, -32768, 32767);
}
}
/* ------------------------------------------------------------
* 3. IMAD-PCM 編解碼(8:1,對講機級清晰度)
* 為什麼不用 Opus:要 100KB 編碼器 RAM+授权費思維;
* ADPCM 40 行搞定,16kHz 人聲聽感差異在「聽得懂」之上不可分辨
* ------------------------------------------------------------ */
const int16_t STEP[16] = {7,8,16,24,40,64,112,176,288,464,740,1184,1888,3008,4800,7680};
const int8_t IDXT[8] = {-1,-1,2,4,2,4,2,7};
int8_t encIdx = 0; int16_t encPred = 0;
uint8_t adBuf[FRAME / 2]; // 160 樣本 → 80 bytes
uint8_t encodeNibble(int16_t samp) {
int16_t diff = samp - encPred;
int8_t nib = (diff < 0) ? 8 : 0;
diff = (diff < 0) ? -diff : diff;
int16_t step = STEP[encIdx], vp = step >> 1, mask = step;
for (int8_t bit = 4; bit >>= 1; ) { // 由高位到低位量化
int16_t vp2 = vp + mask;
if (diff >= vp2) { nib |= bit; diff -= vp2; }
mask >>= 1; vp >>= 1;
}
encIdx = constrain(encIdx + IDXT[nib & 7], 0, 18);
int16_t dq = ((nib & 8) ? -1 : 1) * (2 * vp + mask); // 重建預測值
encPred = constrain(encPred + dq, -32768, 32767);
return nib;
}
void adpcmEncode(int16_t* in, uint8_t* out, uint16_t n) {
for (uint16_t i = 0; i < n; i += 2) {
uint8_t hi = encodeNibble(in[i]), lo = encodeNibble(in[i + 1]);
out[i / 2] = (hi << 4) | lo;
}
}
int8_t decIdx = 0; int16_t decPred = 0;
int16_t decodeNibble(uint8_t nib) {
int16_t step = STEP[decIdx], vp = step >> 1, mask = step;
for (int8_t bit = 4; bit >>= 1; ) { int16_t vp2 = vp + mask; if (nib & bit) vp += vp2; mask >>= 1; vp >>= 1; }
decIdx = constrain(decIdx + IDXT[nib & 7], 0, 18);
decPred = constrain(decPred + ((nib & 8) ? -(2 * vp + mask) : (2 * vp + mask)), -32768, 32767);
return decPred;
}
void adpcmDecode(uint8_t* in, int16_t* out, uint16_t n) { // n=樣本數(偶)
for (uint16_t i = 0; i < n; i += 2) {
uint8_t b = in[i / 2];
out[i] = decodeNibble(b >> 4); out[i + 1] = decodeNibble(b & 0xF);
}
}
/* ------------------------------------------------------------
* 4. 播放環形緩衝(PSRAM)+串流狀態
* ------------------------------------------------------------ */
volatile int16_t* playBuf = nullptr;
const size_t PLAY_N = SR * 8; // 8 秒 ≈ 256KB,放進 PSRAM
volatile size_t pbHead = 0, pbTail = 0;
volatile bool streamActive = false;
volatile uint32_t lastRxMs = 0; // 逾時=對方講完了
volatile uint32_t peerOnlineMs = 0; // 最近收到對方任何包
/* 單生產者/單消費者 ring 免鎖——head/tail 只被單邊遞增,
未遞增差值恆小於一圈,無符號減法天然處理繞回 */
bool pbFull(size_t n) { return (pbHead - pbTail) + n > PLAY_N; }
bool pbAvail(size_t n) { return pbHead - pbTail >= n; }
/* 用途:寫入解碼後樣本(生產端:taskNet)。回傳:false=緩衝滿丟棄 */
bool pbWrite(int16_t* s, size_t n) {
if (pbFull(n)) return false;
for (size_t i = 0; i < n; i++) { playBuf[pbHead % PLAY_N] = s[i]; pbHead++; }
return true;
}
bool pbRead(int16_t* s, size_t n) {
if (!pbAvail(n)) return false;
for (size_t i = 0; i < n; i++) { s[i] = playBuf[pbTail % PLAY_N]; pbTail++; }
return true;
}
/* ------------------------------------------------------------
* 5. VAD:能量門 + 300ms 尾音門
* 為什麼要尾音:人講完最後一個字还有 200–300ms 的子音與呼吸,
* 閂太快會把「…吃飯」咬成「…吃」——對講機最討厭的瑕疵
* ------------------------------------------------------------ */
float rmsOf(int16_t* s, uint16_t n) {
uint64_t acc = 0;
for (uint16_t i = 0; i < n; i++) acc += (int32_t)s[i] * s[i];
return sqrtf((float)(acc / n));
}
const float VAD_TH = 500.0f; // 安靜房間調低、廚房調高
uint32_t vadTailMs = 0;
/* ------------------------------------------------------------
* 6. 送出任務(釘核 1:與 I2S 中斷同側,網棧留核 0)
* ------------------------------------------------------------ */
String peerIp = ""; uint16_t peerPort = 8266;
bool pttHeld = false, txStartPending = false;
uint16_t txSeq = 0;
void taskMic(void* arg) {
bool txOn = false;
for (;;) {
bool gate = pttHeld || (millis() < vadTailMs); // PTT 或 VAD 尾音期
micToPcm();
float rms = rmsOf(pcmBuf, FRAME);
if (rms > VAD_TH) vadTailMs = millis() + 300; // 尾音門刷新
if (gate && !txOn) { txOn = true; txStartPending = true; }
if (!gate) txOn = false;
if (txOn) {
adpcmEncode(pcmBuf, adBuf, FRAME);
uint8_t pkt[92];
pkt[0] = 'I'; pkt[1] = 'C'; pkt[2] = '1';
pkt[3] = txSeq & 0xFF; pkt[4] = txSeq >> 8;
pkt[5] = txStartPending ? 0x01 : 0x00; // START 旗標
txStartPending = false;
memcpy(pkt + 6, adBuf, FRAME / 2);
udp.beginPacket(peerIp, peerPort);
udp.write(pkt, 6 + FRAME / 2); udp.endPacket();
txSeq++;
} else vTaskDelay(2); // 未開麥讓出核 1 時間片
}
}
/* ------------------------------------------------------------
* 7. 接收任務(核 0 預設:跟 Wi-Fi 協定棧同側)
* ------------------------------------------------------------ */
int16_t decBuf[FRAME];
void taskNet(void* arg) {
for (;;) {
int sz = udp.parsePacket();
if (sz < 7 || sz > 92) { vTaskDelay(2); continue; }
uint8_t pkt[96]; udp.read(pkt, sz);
if (pkt[0] != 'I' || pkt[1] != 'C') continue; // 雜音包丟掉
lastRxMs = millis(); peerOnlineMs = millis(); // 任何包都刷新在線
if (pkt[5] & 0x02) { // 文字包:CALL/心跳
if (pkt[5] & 0x04) tgFallbackNotify(); // 對方CALL但我方離線兜底
continue;
}
if (pkt[5] & 0x01) { pbHead = pbTail = 0; } // START:清掉上一封殘留
adpcmDecode(pkt + 6, decBuf, FRAME);
pbWrite(decBuf, FRAME);
streamActive = true;
}
}
/* 播放任務(核 1):150ms 預緩衝吸收 jitter;300ms 無新包=結束 */
void taskSpk(void* arg) {
int16_t out[FRAME];
bool started = false;
for (;;) {
if (!streamActive) { vTaskDelay(5); started = false; continue; }
if (!started) {
if (pbHead - pbTail < SR * 150 / 1000) { vTaskDelay(2); continue; }
started = true; i2sSpk.begin(I2S_PHILIPS_MODE, SR, 16); // 播放期才開 AMP
}
if (pbRead(out, FRAME)) {
float vol = volTable[volLevel];
for (uint16_t i = 0; i < FRAME; i++) out[i] = (int16_t)(out[i] * vol);
i2sSpk.write((char*)out, FRAME * 2);
} else {
memset(out, 0, sizeof out); i2sSpk.write((char*)out, FRAME * 2); // 補靜音防卡
if (millis() - lastRxMs > 300) { streamActive = false; i2sSpk.end(); }
}
}
}
/* ------------------------------------------------------------
* 8. 在線偵測 / Telegram 兜底
* ------------------------------------------------------------ */
bool peerOnline() {
return millis() - peerOnlineMs < 12000; // 收過任何包(含心跳)視為在線
}
String urlencode(const String& s) {
String o;
for (unsigned int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (isalnum(c) || c == '-' || c == '.' || c == ' ') o += c;
else { char b[4]; sprintf(b, "%%%02X", (unsigned char)c); o += b; }
}
return o;
}
void tgPush(const String& m) {
if (String(TG_BOT_TOKEN).length() < 10) return;
HTTPClient h;
h.begin(String("https://api.telegram.org/bot") + TG_BOT_TOKEN + "/sendMessage");
h.addHeader("Content-Type", "application/x-www-form-urlencoded");
h.POST("chat_id=" + String(TG_CHAT_ID) + "&text=" + urlencode(m));
h.end();
}
void tgFallbackNotify() { // 收到 CALL 但對方聽不到我→由我推播
tgPush(String(NODE_NAME) + " 收到呼叫,但對方機器離線——請看 Telegram");
}
/* ------------------------------------------------------------
* 9. 燈環 / 按鈕 / 音量
* ------------------------------------------------------------ */
void ringShow() {
static uint32_t drawMs = 0; // 20fps 更新:NeoPixel 每刷 80 bytes,沒必要每毫秒來
if (millis() - drawMs < 50) return;
drawMs = millis();
uint32_t c;
if (!peerOnline()) c = ring.Color(80, 0, 0); // 紅
else if (pttHeld) c = ring.Color(255, 120, 0); // 橙:送出
else if (streamActive) c = ring.Color(0, 160, 160); // 青:收話
else c = ring.Color(0, 120, 40); // 綠:待機
if (!peerOnline() && (millis() / 300) % 2) c = 0; // 離線閃
for (uint8_t i = 0; i < ring.numPixels(); i++) ring.setPixelColor(i, c);
ring.setBrightness(150); // 59%:供電預算
ring.show();
}
bool pttLast = true; uint32_t pttEdgeMs = 0;
void handlePtt() {
bool now = gpio_get_level(PIN_PTT);
if (now != pttLast && millis() - pttEdgeMs > 30) { // 30ms 消抖
pttEdgeMs = millis();
if (pttLast && !now) { pttHeld = true; } // 按下
else if (!pttLast && now) { // 放開:發 CALL(節點名 6 bytes)
pttHeld = false;
uint8_t t[12] = {'I','C','1',0,0,0x06};
strlcpy((char*)t + 6, NODE_NAME, 6);
udp.beginPacket(peerIp, peerPort); udp.write(t, 12); udp.endPacket();
}
}
pttLast = now;
}
/* ------------------------------------------------------------
* 10. setup / loop
* ------------------------------------------------------------ */
void setup() {
Serial.begin(115200);
nvs.begin("intercom");
peerIp = nvs.getString("peer", "");
volLevel = nvs.getUChar("vol", 1);
gpio_set_direction((gpio_num_t)PIN_PTT, GPIO_MODE_INPUT);
gpio_pullup_en((gpio_num_t)PIN_PTT);
playBuf = (volatile int16_t*)ps_malloc(PLAY_N * sizeof(int16_t)); // PSRAM 緩衝池
i2sMic.setPins(14, 12, 13); i2sMic.begin(I2S_PHILIPS_MODE, SR, 32); // ws,bck,sd
i2sSpk.setPins(17, 16, 8); /* begin 延後:AMP 只在播放期開 */
udp.begin(PORT_RX);
ring.begin(); ring.setBrightness(150); ring.show();
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) { delay(300); Serial.print("."); }
Serial.printf("\nIP=%s peer=%s (cmd: peer=192.168.1.x / vol=1 / stat)\n",
WiFi.localIP().toString().c_str(), peerIp.c_str());
xTaskCreatePinnedToCore(taskMic, "mic", 6000, nullptr, 6, nullptr, 1);
xTaskCreatePinnedToCore(taskSpk, "spk", 6000, nullptr, 6, nullptr, 1);
xTaskCreatePinnedToCore(taskNet, "net", 8000, nullptr, 5, nullptr, 0);
}
void loop() {
handlePtt();
ringShow();
/* 心跳:每 5 秒告訴夥伴「我在」,也刷新對方燈環 */
static uint32_t hbMs = 0;
if (peerIp.length() > 7 && millis() - hbMs > 5000) {
hbMs = millis();
uint8_t t[6] = {'I','C','1',0,0,0x02};
udp.beginPacket(peerIp, peerPort); udp.write(t, 6); udp.endPacket();
}
/* 序列指令 */
if (Serial.available()) {
static String buf; char ch = Serial.read();
if (ch == '\n') {
buf.trim();
if (buf.startsWith("peer=")) { peerIp = buf.substring(5); nvs.putString("peer", peerIp); Serial.println("peer saved"); }
else if (buf.startsWith("vol=")) { volLevel = constrain(buf.substring(4).toInt(), 0, 2); nvs.putUChar("vol", volLevel); Serial.println("vol saved"); }
else if (buf == "stat") Serial.printf("ip=%s peer=%s online=%d stream=%d buf=%u ptt=%d\n",
WiFi.localIP().toString().c_str(), peerIp.c_str(), peerOnline(), streamActive,
(unsigned)(pbHead - pbTail), pttHeld);
buf = "";
} else buf += ch;
}
vTaskDelay(1);
}
🏗️ 組裝步驟
- 按鈕帽與機殼:先卡蘑菇頭按鈕再鎖主板——裝完板才發現按鈕孔偏 2mm 是這組裝的傳統事故
- 聲學密封:MIC 防風氈壓圈、喇叭導音腔熱熔膠全周密封——漏聲的腔體等於沒腔體
- 兩台配對:序列埠
peer=192.168.1.X互填對方 IP(路由器綁定靜態 lease,IP 漂了燈環變紅閃就知道) - 擺位防嘯:兩機錯層或間距 ≥1.5m;同層對講先試夜間音量
- 驗收:連續三通不丟包;拔電夥伴機測 Telegram 兜底推播送達
🔧 問題排除
| 問題 | 原因 | 解決方法 |
|---|---|---|
| 完全收不到 | 兩台不同 Wi-Fi/頻段 | stat 看 peer 與 online;確認同一 2.4G SSID(Mesh 路由的漫遊分區要關閉 AP 隔離) |
| 聲音破音失真 | I2S 時鐘接錯/位元槽錯 | BCK/WS 對調試一次;INMP441 位移量改 >> 12 或 >> 16 各試一次(不同批次出貨位元對齊有差) |
| 只有一台會講 | PTT 卡住/上拉失效 | stat 看 ptt 態是否恆 0;按鈕兩腳先短路測試開機自檢 |
| 通話中斷續 | 2.4G 頻道擁塞 | 路由器換頻道;或把 FRAME 加到 320 減半封包率(代價是延遲 +10ms) |
| 尖嘯 | 兩機距離太近 | 播放時麥已硬閂;再拉開 1m 或降夜間音量——物理永遠優先於韌體 |
| Wi-Fi 開機重啟循環 | 燈環從 3V3 取電壓垮 LDO | 燈環改 5V 分路供電;亮度保持 ≤150 |
| 燈環恆綠但講不通 | peer IP 過期(DHCP 重新分配) | 路由器綁定 MAC→靜態 lease;两台都要重發 peer= |
🎯 進階延伸
- 三機以上全戶版:peer 改 multicast 群播(255.255.255.255:8266),一台按鍵全屋響起、誰先答誰開麥——長輩房+一樓+車庫的真實配置
- 語音留言上雲:夥伴離線時把 ADPCM 直接 POST 到自家 NAS(第 30 篇 Strata 站就在旁邊),Telegram 只發連結——通話變留言收件匣
- 硬體隱私開關:PTT 按鈕串入 INMP441 的 VDD——未按時麥克風沒電,比任何韌體承諾都可驗證(長輩對「會聽」的電子設備最後一絲戒心,靠拆電池解決)
- 門禁聯動:加一顆第二按鈕=「開鐵捲門」指令包,車庫機收到推 Telegram+繼電器 3 秒——把對講機升級成宅邸門控台
- 嬰兒監護模式:三樓機開 VAD-only(免 PTT),能量觸發自動推流到一樓機——本機 VAD 管線現成,只差把 txOn 常開
📦 檔案清單
home-intercom/
├── src/
│ └── home-intercom.ino # 本頁完整韌體(兩機燒同一份,NODE_NAME 改字串)
├── stl/
│ ├── deskstand.stl # 桌面音箱座(35°)
│ ├── wallframe.stl # 壁掛框(卡片式)
│ ├── ptt-cap.stl # 蘑菇帽
│ ├── ring-diffuser.stl # 燈環導光圈
│ ├── mic-gasket.stl # MIC 防風氈壓圈
│ └── speaker-chamber.stl # 喇叭導音腔
├── f3d/
│ └── home-intercom.f3d # Fusion 360 原始檔
├── manual/
│ └── assembly.html # 圖文組裝說明書
└── README.md
🎯 結語
裝機那天下樓吃飯,阿嬤第一次没讓我「用手機傳訊息給你爸」。她按下橙色蘑菇、綠環變橙、對著它講「吃飯了」,一樓廚房那台亮青燈嗶一聲回話——整套動作比她按電子鍋複雜不了多少。後來她養成新習慣:飯菜上桌、按一顆、走人。好的家用科技不是讓人學會新東西,是把舊動作(按一顆按鈕)做得剛好够用。這系列寫到第 37 篇,我們做過會看鳥的、會量輻射的、會曬樹脂的——但這台是最常聽到「欸,這個好用」的一台。大概因為它解決的從來不是技術問題,是「三樓到一樓那 20 公尺,人喊破喉嚨、手機假裝沒看見」的距離。