把環境數據放在一起看:阿明博士的 ESPHome 監測站

把環境數據放在一起看:阿明博士的 ESPHome 監測站

把環境數據放在一起看:阿明博士的 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 是腳位名稱,不是排針從上往下數的位置。

照著圖,把線一條條接好

依名稱逐條對照接線

把環境數據放在一起看:阿明博士的 ESPHome 監測站:接線對照
電源、共地與訊號方向分開標示;實物依模組絲印與端子定義辨識。 開啟接線大圖

整合版的電源怎麼接

整合版供電與共地
各正電源維持分開,共地提供訊號參考;請同時閱讀本文電源規劃。 開啟接線大圖
感測器接腳ESP32-S3/電源核對事項
SHT20 VCC主板 3V3本課限 3.3V I²C 模組,先核對實物
SHT20 GND主板 GND所有訊號參考共地
SHT20 SDAGPIO8上拉至 3.3V
SHT20 SCLGPIO9100kHz;先用短線
Grove SCD41 VCC/GND獨立穩定 3.3V/共地商品 60107;勿與主板 3V3 輸出並接
SCD41 SDA/SCLGPIO8/GPIO9位址 0x62;不要依排針順序猜接腳
PMS5003T PIN1 VCC/PIN2 GND穩定 5V/共地風扇不由 3V3 腳供電
PMS5003T PIN5 TXDGPIO18 RX3.3V UART
PMS5003T PIN4 RXDGPIO17 TX3.3V UART
PMS5003T PIN3 SET/PIN6 RESET不接依本型號內部上拉保持正常運作
PMS5003T PIN7/PIN8不接NC;從原廠接頭視圖辨識 PIN1
BH1750 VIN/GND3V3/GND參考 Adafruit 模組
BH1750 SDA/SCLGPIO8/GPIO9ADDR 低時 0x23

所有模組都要與主板共地。若使用外接電源,不要把外接 5V 和 USB 5V 直接並接,也不要把獨立 3.3V 穩壓器的輸出接到主板 3V3 輸出。先量好電壓,再接入訊號線。

把設定放進主板

  1. 下載整個 examples 資料夾,保留 components;把 secrets.yaml.example 複製成 secrets.yaml,填入自己的 Wi-Fi 與密碼。
  2. 依照Windows 燒錄指南安裝 ESPHome 2026.10.1,選擇本課 YAML,先檢查設定、再編譯與 USB 燒錄。
  3. 在 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 的個別樣本可能遺失,恢復連線後繼續送新資料;若不能漏樣本,需要另外設計儲存、序號與時鐘同步。

分享到社群

發佈留言


您已經成功購買本項商品: