把環境數據放在一起看:阿明博士的 ESPHome 監測站
內容整理:台灣智能感測科技|導讀角色:阿明博士
前面已經能看溫濕度、CO₂、粉塵、照度,也能接收工具信標。接下來,阿明博士把這些資料整理到同一個地方:ESP32-S3 負責量測,MQTT 負責傳遞,Node-RED 負責顯示與記錄,整個流程不需要 Home Assistant。
ESP32-S3 可把 ESPHome 取得的溫濕度、CO₂、PM2.5、照度與 BLE 狀態送到 MQTT,再用 Node-RED 顯示和記錄。這套做法無須 Home Assistant,但 MQTT Broker 與 Node-RED 需要在另一台電腦或伺服器執行。
- 把五種環境/存在資訊放到同一頁
- 保留收件時間、最近紀錄與檔案
- 為多房間監測或後續系統整合建立起點
各做一件事,之後才好調整
你可以把 MQTT Broker 想成資料的轉運站。主板把資料送到指定的路徑,想使用這些資料的程式再來接收。它負責傳遞,不會自動幫你建立歷史資料庫。
本課由 Node-RED 接住資料,加上收件時間,放到簡單的網頁,也寫進檔案。日後想換成另一種儀表板或資料庫,可以沿著同一份資料往後接,不必把每一顆感測器重寫一次。
放在同一頁,仍要知道每個數字是誰量的
溫濕度採 SHT20,CO₂ 採 SCD41,懸浮微粒採 PMS5003T,照度採 BH1750,工具狀態則由 BLE 信標提供。範例用清楚的欄位名稱區分它們,方便閱讀,也方便日後加新用途。
每個量測欄位都帶有是否仍新鮮的資訊。感測器停更時,接收端會顯示無有效資料,而不是補一個看起來像正常的 0。主板還連著網路,也不代表每一顆感測器都在正常更新,兩件事要分開看。
看到即時值,也留下一段紀錄
隨附的 Node-RED 流程使用內建節點,提供一個簡單網頁,暫存最近 360 筆資料,並逐筆寫入 JSONL 檔案。JSONL 就是一行一筆 JSON,之後可以再匯入其他工具分析。
暫存的 360 筆在 Node-RED 重啟後會清空,檔案則保存在資料卷。若打算長期使用,再安排紀錄輪替、備份與保存期限,避免檔案一直長大。
網頁上的時間是伺服器收到資料的時間。這一版沒有提供斷線後的持久化補傳;網路中斷時,資料可能少一段。若用途要求不能漏樣本,就需要再加入主板端儲存、序號與時鐘同步。
多一個房間,先幫它取不同名字
換到第二個房間時,除了主板名稱,也要把範例中的 room-a 改成另一個路徑,再調整 Node-RED 的訂閱。否則兩台主板的資料擠進同一個位置,畫面看似有在更新,卻分不清來自哪裡。
附上的 Docker Compose 與 Node-RED 匯入檔,適合先在自己的電腦建立小型教學環境。主板使用那台電腦的區網 IP 連線;Node-RED 容器內則用 mqtt 這個服務名稱找 Broker,兩個位置的填法不同。
準備長期使用前,再看這些設定
ESPHome 官方仍將 ESP-IDF 的 MQTT 支援標為實驗性。本課固定使用 ESPHome 2026.10.1,啟用獨立 MQTT 發送任務,並關閉 MQTT 日誌轉送,減少網路狀況不佳時影響感測主迴圈的機會。
Broker 預設只綁本機。讓 ESP32 連入時,再改成電腦的區網 IP,並限制來源範圍。這份本地範例的 MQTT 1883 不含 TLS,帳密與資料沒有加密;跨不可信網路時,請另安排 TLS 與存取控制。Node-RED 管理介面保持本機使用,對外分享網頁時另做登入與 HTTPS。
先準備這些材料
| 材料 | 準備重點 |
|---|---|
| ESP32-S3 N16R8 | 主板、資料 USB 線、2.4GHz Wi-Fi |
| SHT20 I²C 模組 | 商品 6129,使用 3.3V 供電 |
| Grove SCD41 | 商品 60107;獨立穩定 3.3V,與主板共地 |
| PMS5003T | 穩定 5V 供電、3.3V UART;確認型號尾碼 T |
| BH1750 | 本文採 Adafruit 參考模組,以 3.3V 接到 VIN |
| 另一塊 ESP32-S3 | 作為 BLE 信標,只需 USB 供電 |
| 自己的電腦與 Docker | 執行附上的 MQTT Broker 與 Node-RED |
主板請確認為 WROOM-1-N16R8,範例使用 16MB Flash 與 Octal PSRAM。接線先斷電,表格中的 GPIO 是腳位名稱,不是排針從上往下數的位置。
照著圖,把線一條條接好
依名稱逐條對照接線

整合版的電源怎麼接

| 感測器接腳 | ESP32-S3/電源 | 核對事項 |
|---|---|---|
| SHT20 VCC | 主板 3V3 | 本課限 3.3V I²C 模組,先核對實物 |
| SHT20 GND | 主板 GND | 所有訊號參考共地 |
| SHT20 SDA | GPIO8 | 上拉至 3.3V |
| SHT20 SCL | GPIO9 | 100kHz;先用短線 |
| Grove SCD41 VCC/GND | 獨立穩定 3.3V/共地 | 商品 60107;勿與主板 3V3 輸出並接 |
| SCD41 SDA/SCL | GPIO8/GPIO9 | 位址 0x62;不要依排針順序猜接腳 |
| PMS5003T PIN1 VCC/PIN2 GND | 穩定 5V/共地 | 風扇不由 3V3 腳供電 |
| PMS5003T PIN5 TXD | GPIO18 RX | 3.3V UART |
| PMS5003T PIN4 RXD | GPIO17 TX | 3.3V UART |
| PMS5003T PIN3 SET/PIN6 RESET | 不接 | 依本型號內部上拉保持正常運作 |
| PMS5003T PIN7/PIN8 | 不接 | NC;從原廠接頭視圖辨識 PIN1 |
| BH1750 VIN/GND | 3V3/GND | 參考 Adafruit 模組 |
| BH1750 SDA/SCL | GPIO8/GPIO9 | ADDR 低時 0x23 |
所有模組都要與主板共地。若使用外接電源,不要把外接 5V 和 USB 5V 直接並接,也不要把獨立 3.3V 穩壓器的輸出接到主板 3V3 輸出。先量好電壓,再接入訊號線。
把設定放進主板
- 下載整個 examples 資料夾,保留 components;把 secrets.yaml.example 複製成 secrets.yaml,填入自己的 Wi-Fi 與密碼。
- 依照Windows 燒錄指南安裝 ESPHome 2026.10.1,選擇本課 YAML,先檢查設定、再編譯與 USB 燒錄。
- 在 USB 日誌或路由器裝置清單找到主板 IP,瀏覽器輸入該 IP,再用 secrets 裡的網頁帳密登入。
以下命令在 examples 資料夾執行,COM5 請換成自己的埠號。USB 接頭、日誌與後續 OTA 更新的操作,都整理在共用指南。
..\.venv\Scripts\python.exe -m esphome config 06-mqtt.yaml
..\.venv\Scripts\python.exe -m esphome compile 06-mqtt.yaml
..\.venv\Scripts\python.exe -m esphome upload 06-mqtt.yaml --device COM5
下載完整範例(含 06-mqtt.yaml) · 下載完整範例(含 secrets.yaml.example)
完整 YAML:點此展開
esphome:
name: course-06-mqtt
friendly_name: "MQTT 綜合環境閘道器"
min_version: 2026.10.1
compile_process_limit: 1
esp32:
board: esp32-s3-devkitc-1
flash_size: 16MB
framework:
type: esp-idf
psram:
mode: octal
speed: 80MHz
logger:
hardware_uart: USB_SERIAL_JTAG
level: DEBUG
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
power_save_mode: none
reboot_timeout: 0s
ota:
- platform: esphome
password: !secret ota_password
web_server:
port: 80
version: 3
local: true
auth:
type: digest
username: !secret web_username
password: !secret web_password
i2c:
id: sensor_bus
sda: GPIO8
scl: GPIO9
scan: true
frequency: 100kHz
external_components:
- source:
type: local
path: components
components: [sht20_local]
sensor:
- platform: sht20_local
id: sht20_driver
address: 0x40
update_interval: 10s
temperature:
id: sht_temperature
name: "SHT20 Temperature"
on_value:
then:
- lambda: |-
id(last_sht_ms) = millis();
id(sht_seen) = true;
humidity:
id: sht_humidity
name: "SHT20 Humidity"
on_value:
then:
- lambda: |-
id(last_rh_ms) = millis();
id(rh_seen) = true;
- platform: scd4x
id: scd41_driver
address: 0x62
measurement_mode: periodic
# Enable ASC only after evaluating the actual fresh-air exposure pattern.
automatic_self_calibration: false
update_interval: 10s
co2:
id: co2_value
name: "SCD41 CO2"
on_value:
then:
- lambda: |-
id(last_co2_ms) = millis();
id(co2_seen) = true;
temperature:
name: "SCD41 Internal Temperature"
humidity:
name: "SCD41 Internal Humidity"
- platform: pmsx003
type: PMS5003T
uart_id: pms_uart
# Continuous active mode; no sleep/wake cycling in this lesson.
update_interval: 0s
pm_1_0:
name: "PMS5003T PM1.0 Atmospheric"
filters:
- throttle: 10s
pm_2_5:
id: pm25_value
name: "PMS5003T PM2.5 Atmospheric"
on_raw_value:
then:
- lambda: |-
id(last_pm_ms) = millis();
id(pm_seen) = true;
filters:
- throttle: 10s
pm_10_0:
name: "PMS5003T PM10 Atmospheric"
filters:
- throttle: 10s
temperature:
name: "PMS5003T Internal Temperature"
filters:
- throttle: 10s
humidity:
name: "PMS5003T Internal Humidity"
filters:
- throttle: 10s
- platform: bh1750
id: light_level
name: "BH1750 Illuminance"
address: 0x23
update_interval: 5s
on_value:
then:
- lambda: |-
id(last_light_ms) = millis();
id(light_seen) = true;
globals:
- id: last_sht_ms
type: uint32_t
restore_value: false
initial_value: '0'
- id: sht_seen
type: bool
restore_value: false
initial_value: 'false'
- id: last_rh_ms
type: uint32_t
restore_value: false
initial_value: '0'
- id: rh_seen
type: bool
restore_value: false
initial_value: 'false'
- id: last_co2_ms
type: uint32_t
restore_value: false
initial_value: '0'
- id: co2_seen
type: bool
restore_value: false
initial_value: 'false'
- id: last_pm_ms
type: uint32_t
restore_value: false
initial_value: '0'
- id: pm_seen
type: bool
restore_value: false
initial_value: 'false'
- id: last_light_ms
type: uint32_t
restore_value: false
initial_value: '0'
- id: light_seen
type: bool
restore_value: false
initial_value: 'false'
binary_sensor:
- platform: template
name: "SHT20 Temperature Fresh"
lambda: |-
return id(sht_seen) && (uint32_t)(millis() - id(last_sht_ms)) < 35000;
- platform: template
name: "SHT20 Humidity Fresh"
lambda: |-
return id(rh_seen) && (uint32_t)(millis() - id(last_rh_ms)) < 35000;
- platform: template
name: "SCD41 Data Fresh"
lambda: |-
return id(co2_seen) && (uint32_t)(millis() - id(last_co2_ms)) < 35000;
- platform: template
name: "CO2 Ventilation Reminder"
lambda: |-
if (!id(co2_seen) || (uint32_t)(millis() - id(last_co2_ms)) >= 35000) return {};
if (id(co2_value).state >= 1000) return true;
if (id(co2_value).state <= 800) return false;
return {};
filters:
- delayed_on: 60s
- delayed_off: 60s
- platform: template
name: "PMS5003T Packet Fresh"
lambda: |-
return id(pm_seen) && (uint32_t)(millis() - id(last_pm_ms)) < 15000;
- platform: template
name: "PMS5003T Warmup Complete"
lambda: 'return millis() > 30000;'
- platform: template
name: "BH1750 Data Fresh"
lambda: |-
return id(light_seen) && (uint32_t)(millis() - id(last_light_ms)) < 20000;
- platform: ble_presence
name: "Demo Beacon Nearby"
id: beacon_nearby
ibeacon_uuid: "730eb196-4033-4c32-a7da-c8cf68e8ff01"
ibeacon_major: 1
ibeacon_minor: 1
timeout: 60s
uart:
id: pms_uart
tx_pin: GPIO17
rx_pin: GPIO18
baud_rate: 9600
data_bits: 8
parity: NONE
stop_bits: 1
rx_buffer_size: 512
esp32_ble_tracker:
scan_parameters:
active: false
interval: 320ms
window: 30ms
mqtt:
# Use a separate MQTT task so poor network conditions do not block sensor loops.
idf_send_async: true
log_topic: null
broker: !secret mqtt_broker
username: !secret mqtt_username
password: !secret mqtt_password
discovery: false
topic_prefix: course/room-a
reboot_timeout: 0s
birth_message:
topic: course/room-a/status
payload: online
retain: true
will_message:
topic: course/room-a/status
payload: offline
retain: true
interval:
- interval: 10s
then:
- mqtt.publish_json:
topic: course/room-a/telemetry
qos: 0
retain: false
payload: |-
root["uptime_s"] = millis() / 1000;
const bool t_ok = id(sht_seen) && (uint32_t)(millis() - id(last_sht_ms)) < 35000 && std::isfinite(id(sht_temperature).state);
const bool h_ok = id(rh_seen) && (uint32_t)(millis() - id(last_rh_ms)) < 35000 && std::isfinite(id(sht_humidity).state);
const bool c_ok = id(co2_seen) && (uint32_t)(millis() - id(last_co2_ms)) < 35000 && std::isfinite(id(co2_value).state);
const bool p_ok = id(pm_seen) && (uint32_t)(millis() - id(last_pm_ms)) < 15000 && millis() > 30000 && std::isfinite(id(pm25_value).state);
const bool l_ok = id(light_seen) && (uint32_t)(millis() - id(last_light_ms)) < 20000 && std::isfinite(id(light_level).state);
root["temperature_valid"] = t_ok;
root["humidity_valid"] = h_ok;
root["co2_valid"] = c_ok;
root["pm25_valid"] = p_ok;
root["lux_valid"] = l_ok;
if (t_ok && std::isfinite(id(sht_temperature).state)) root["temperature_c"] = id(sht_temperature).state;
if (h_ok && std::isfinite(id(sht_humidity).state)) root["humidity_pct"] = id(sht_humidity).state;
if (c_ok && std::isfinite(id(co2_value).state)) root["co2_ppm"] = id(co2_value).state;
if (p_ok && std::isfinite(id(pm25_value).state)) root["pm25_ug_m3"] = id(pm25_value).state;
if (l_ok && std::isfinite(id(light_level).state)) root["illuminance_lux"] = id(light_level).state;
root["beacon_nearby"] = id(beacon_nearby).state;
後端請依主題導覽的後端設定步驟建立;完整範例包已包含 Node-RED 流程與 Docker Compose。
接好後,先試這幾件事
先從正常顯示開始,再試一次拔除、恢復或更換環境條件。這些小步驟有助於確認你看到的資料,確實來自現在的接線與位置。
| 測試項目 | 應觀察的結果 |
|---|---|
| 正常整合 | 五種 valid 狀態符合實際更新;網頁與 JSONL 有新資料 |
| 停止 Broker | 本機網頁仍可看感測;重連後收到新樣本;不補傳中斷資料 |
| 拔除一顆感測器 | 該欄位 valid 失效,其他欄位持續更新 |
| 停止遙測但保留狀態 | 網頁 30 秒後顯示收件過期,不因 online 而當正常 |
| 重新啟動 Node-RED | 記憶體紀錄清空,JSONL 檔案保留,等待新樣本 |
遇到問題時,從這裡開始找
先查供電和共地,再核對端子名稱與訊號方向。I²C 裝置可看掃描位址;PMS5003T 走 UART,不會出現在 I²C 清單。接著確認設定的型號與最後一次更新時間,最後才檢查網頁或後端。資料停住時,不要先把它當成環境一直沒變。
材料與延伸閱讀
ESP32-S3 N16R8 主板 · SHT20 I²C 模組。依實際用途選擇所需感測器即可。
想看原廠文件與元件說明
常見問題
網路斷線的資料,重連後會全部補回來嗎?
這份範例沒有持久化離線補傳。QoS 0 的個別樣本可能遺失,恢復連線後繼續送新資料;若不能漏樣本,需要另外設計儲存、序號與時鐘同步。

發佈留言
很抱歉,必須登入網站才能發佈留言。