實戰(zhàn):ESP32人體感應(yīng)與MQTT消息推送)
“妹妹出沒”這個名字來自一個很具體的家庭需求想知道孩子或住在同一屋檐下的妹妹幾點到家、什么時候出門又不想在門口安裝攝像頭。把它做成技術(shù)項目本質(zhì)就是一套“人體存在檢測 出入事件記錄 手機(jī)消息推送”的家庭自動化小系統(tǒng)。本文會帶你把這條鏈路從零跑通用一塊 ESP32 開發(fā)板配合人體感應(yīng)模塊檢測門口是否有人把狀態(tài)變化通過 MQTT 發(fā)布到本地 Broker再讓 Python 服務(wù)訂閱消息、寫入 SQLite 并推送到手機(jī)最后用一個簡單的 Flask 頁面查看歷史出沒記錄。適合有一定 Arduino 或 Python 基礎(chǔ)、想嘗試硬件與服務(wù)端聯(lián)調(diào)的讀者。讀完這條實踐鏈路你能掌握 PIR 人體感應(yīng)模塊怎么接 ESP32、MQTT 的發(fā)布與訂閱模型怎么落地、消息如何落庫和推送以及斷電重連、誤報排查和生產(chǎn)部署時要注意什么。整條鏈路用到的組件都很常見成本在幾十元到一百多元之間適合作為家庭自動化入門項目反復(fù)調(diào)優(yōu)。1. 先確認(rèn)需求邊界出沒檢測到底檢測什么1.1 把“誰、幾點、進(jìn)還是出”拆成可檢測的事件一個容易犯的錯誤是拿到“妹妹出沒”這個標(biāo)題后直接想做一個“能識別是誰回家”的系統(tǒng)。這類需求一旦拆開會發(fā)現(xiàn)它包含三個難度完全不同的子問題時間段幾點發(fā)生。狀態(tài)門口區(qū)域有沒有人。身份這個人是誰。第一和第二個問題用一只人體感應(yīng)傳感器加一塊單片機(jī)就能解決。第三個問題則完全不同它需要攝像頭加人臉識別、手機(jī)定位地理圍欄或者多個傳感器配合做方向判斷。用單只 PIR 模塊無法知道“是誰”只能知道“有人”或“沒人”。把預(yù)期放在正確的層級上后面的技術(shù)選型才不會跑偏。因此本文把“妹妹出沒”收斂為檢測門口或某個公共區(qū)域是否存在人體記錄狀態(tài)發(fā)生變化的時刻并在變化時推送通知。這是一個明確、可測試、可驗證的目標(biāo)。至于“識別身份”放到擴(kuò)展方向里討論。1.2 三條技術(shù)路線的取舍路線識別能力硬件成本隱私風(fēng)險適合場景ESP32 PIR/毫米波 MQTT區(qū)域有人/無人不識別身份低幾十到一百多元低不采集圖像家庭出入提醒、房間占用檢測樹莓派 攝像頭 OpenCV可統(tǒng)計人體活動擴(kuò)展可識別人中高需要攝像頭和算力高涉及圖像采集進(jìn)階學(xué)習(xí)、行為分析手機(jī)定位地理圍欄可直接識別“誰”到了位置中低主要靠手機(jī)中后臺定位和權(quán)限敏感兒童/老人到家提醒需要安裝 App本文選擇第一條路線。理由很直接它不依賴手機(jī)權(quán)限不采集圖像數(shù)據(jù)可以全部留在本地而且 MQTT 這條消息鏈路對后續(xù)擴(kuò)展同樣成立。就算以后換成毫米波雷達(dá)或攝像頭方案Broker、數(shù)據(jù)庫、推送、看板這些服務(wù)端部分仍然可以復(fù)用。2. 硬件連接和環(huán)境準(zhǔn)備ESP32、人體感應(yīng)模塊和 MQTT Broker2.1 硬件清單搭建這套最小系統(tǒng)需要以下材料ESP32 開發(fā)板一塊推薦 DevKitC 或 NodeMCU-32S帶 USB 接口。HC-SR501 PIR 人體感應(yīng)模塊一個或 LD2410 毫米波人體存在模塊。杜邦線若干母對公即可。5V 供電的 USB 電源和數(shù)據(jù)線。一臺可以運行 Linux 的機(jī)器可以是樹莓派、舊筆記本、云服務(wù)器或本地虛擬機(jī)用來跑 MQTT Broker 和 Python 服務(wù)。路由器或交換機(jī)組成的局域網(wǎng)保證 ESP32 能訪問到 Broker 所在機(jī)器。HC-SR501 是最常見的入門款價格低輸出邏輯簡單適合驗證整條鏈路。LD2410 是毫米波方案能檢測靜止存在誤報更少但通信協(xié)議更復(fù)雜。下面先用 HC-SR501 把系統(tǒng)跑通它的數(shù)字輸出直接接 GPIO 即可。2.2 接線方式與注意事項ESP32 引腳HC-SR501 模塊說明5VVCC模塊供電部分型號需 5VGNDGND必須與 ESP32 共地GPIO4OUT數(shù)字信號輸出檢測到人時為高電平接線時有幾個細(xì)節(jié)值得注意。第一HC-SR501 通常需要 5V 供電而它的 OUT 輸出高電平接近 3.3V可以直接接入 ESP32 的 GPIO不需要電平轉(zhuǎn)換。第二不同批次的模塊輸出電平可能有差異如果發(fā)現(xiàn) GPIO 讀數(shù)異常先用萬用表確認(rèn) OUT 在觸發(fā)時是否為 3.3V 左右。第三接線完成后不要急著上電先檢查有沒有接反或短路。2.3 安裝 Arduino 環(huán)境并準(zhǔn)備開發(fā)板支持在 Arduino IDE 2.x 中先打開“文件 - 首選項”在“附加開發(fā)板管理器網(wǎng)址”里填入 ESP32 官方支持包地址https://espressif.github.io/arduino-esp32/package_esp32_index.json保存后在“工具 - 開發(fā)板 - 開發(fā)板管理器”里搜索 esp32安裝由 Espressif 提供的開發(fā)板支持包。接著在庫管理器里安裝 PubSubClient 庫這個庫負(fù)責(zé) MQTT 的發(fā)布與訂閱。安裝完成后選擇開發(fā)板為 ESP32 Dev Module并選擇 ESP32 對應(yīng)的串口。這里有一個常見問題Windows 下如果串口不出現(xiàn)多半是缺少 USB 驅(qū)動需要下載對應(yīng)芯片的驅(qū)動Mac 和 Linux 下一般插上就能識別。2.4 安裝 MQTT Broker 并驗證收發(fā)Broker 選擇 Mosquitto輕量、穩(wěn)定、命令行工具齊全。在 Linux 上安裝sudo apt update sudo apt install -y mosquitto mosquitto-clients sudo systemctl enable --now mosquitto默認(rèn)配置下 Mosquitto 只允許本機(jī)訪問。為了讓 ESP32 能從局域網(wǎng)連接需要新增一個監(jiān)聽配置。在/etc/mosquitto/conf.d/local.conf中寫入listener 1883 0.0.0.0 allow_anonymous false password_file /etc/mosquitto/passwdallow_anonymous false表示所有客戶端必須使用用戶名密碼。在家庭局域網(wǎng)里這可以防止同一網(wǎng)絡(luò)中的其他設(shè)備隨意向主題發(fā)布消息。創(chuàng)建賬號sudo mosquitto_passwd -c /etc/mosquitto/passwd esp32上面的命令會創(chuàng)建密碼文件并添加 esp32 用戶。如果需要再加用戶去掉-c參數(shù)。重啟服務(wù)sudo systemctl restart mosquitto驗證 Broker 是否正常收發(fā)。開兩個終端一個訂閱mosquitto_sub -h 127.0.0.1 -t test/hello -v -u esp32 -P 你設(shè)置的密碼另一個發(fā)布mosquitto_pub -h 127.0.0.1 -t test/hello -m hello -u esp32 -P 你設(shè)置的密碼訂閱端能打印出test/hello hello說明 Broker 工作正常。這一步很重要后面的固件和服務(wù)端調(diào)試都需要以 Broker 可用為前提。3. ESP32 固件讀取傳感器狀態(tài)并發(fā)布 MQTT 消息3.1 固件要做的事ESP32 固件承擔(dān)三件事連接 WiFi、讀取 PIR 引腳電平、把狀態(tài)變化通過 MQTT 發(fā)布到指定主題。這里不要在每次讀取到高電平時都發(fā)消息而只在狀態(tài)從“無人”變?yōu)椤坝腥恕?、或從“有人”變?yōu)椤盁o人”時發(fā)布這樣服務(wù)端收到的是一條條有意義的事件而不是一堆重復(fù)數(shù)據(jù)。為了讓服務(wù)端在重啟后也能知道當(dāng)前門口狀態(tài)發(fā)布時可以使用 MQTT 的 retained 消息。retained 消息會被 Broker 保留新訂閱者一上線就能收到最近一次狀態(tài)。3.2 完整固件代碼#include WiFi.h #include PubSubClient.h const char* WIFI_SSID 你的WiFi名稱; const char* WIFI_PASSWORD 你的WiFi密碼; const char* MQTT_HOST 192.168.1.100; // 改成 Broker 所在機(jī)器 IP const uint16_t MQTT_PORT 1883; const char* MQTT_USER esp32; const char* MQTT_PASSWORD 你的MQTT密碼; const char* MQTT_TOPIC home/presence/door; const int PIR_PIN 4; const unsigned long WARMUP_MS 30000; // 傳感器預(yù)熱時間 const unsigned long STATE_HOLD_MS 5000; // 狀態(tài)保持時間避免抖動 WiFiClient wifiClient; PubSubClient mqttClient(wifiClient); int lastState -1; unsigned long lastStateChangeAt 0; void setup() { Serial.begin(115200); pinMode(PIR_PIN, INPUT); WiFi.mode(WIFI_STA); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); while (WiFi.status() ! WL_CONNECTED) { delay(500); Serial.print(.); } Serial.println(\nwifi connected); Serial.print(ip: ); Serial.println(WiFi.localIP()); mqttClient.setServer(MQTT_HOST, MQTT_PORT); // 傳感器上電后有預(yù)熱期等待 30 秒后再讀取初始狀態(tài) Serial.println(warming up sensor...); delay(WARMUP_MS); lastState digitalRead(PIR_PIN); Serial.printf(initial state: %s\n, lastState HIGH ? present : away); } void connectMQTT() { while (!mqttClient.connected()) { Serial.print(connect mqtt...); if (mqttClient.connect(esp32-door-01, MQTT_USER, MQTT_PASSWORD)) { Serial.println(ok); } else { Serial.printf(fail rc%d, retry 5s\n, mqttClient.state()); delay(5000); } } } void publishState(int state) { const char* payload (state HIGH) ? present : away; boolean retained true; if (mqttClient.publish(MQTT_TOPIC, payload, retained)) { Serial.printf(publish ok: %s\n, payload); } else { Serial.printf(publish fail: %s\n, payload); } } void loop() { if (!mqttClient.connected()) { connectMQTT(); } mqttClient.loop(); int state digitalRead(PIR_PIN); unsigned long now millis(); if (state ! lastState (now - lastStateChangeAt) STATE_HOLD_MS) { lastStateChangeAt now; publishState(state); lastState state; } delay(200); }代碼里有兩個客戶端 ID 要特別注意。mqttClient.connect(esp32-door-01, ...)中的第一個參數(shù)是 MQTT client ID在同一 Broker 下必須唯一否則后連接的客戶端會擠掉先連接的。WiFi 賬號密碼和 MQTT 密碼不要直接寫死在共享代碼里本地練習(xí)可以先用常量準(zhǔn)備上傳到公開倉庫前必須改成配置文件或環(huán)境變量。3.3 關(guān)鍵邏輯說明預(yù)熱期的處理是很多人忽略的一步。HC-SR501 上電后約 20 到 60 秒內(nèi)可能持續(xù)輸出高電平如果不對預(yù)熱期做處理系統(tǒng)啟動時會立刻上報一次“有人”產(chǎn)生一條假事件。代碼里在 setup 中等待 30 秒再讀取初始狀態(tài)是為了讓傳感器穩(wěn)定下來。狀態(tài)保持時間的處理同樣重要。PIR 在高電平持續(xù)時間可調(diào)如果傳感器在邊界反復(fù)觸發(fā)GPIO 電平會在短時間內(nèi)抖動。代碼里只有在狀態(tài)變化且距離上一次變化超過STATE_HOLD_MS時才發(fā)布能過濾掉大部分短時抖動。發(fā)布時使用 retained 消息服務(wù)端訂閱后能立即拿到當(dāng)前狀態(tài)而不是等到下次變化。要注意retained 消息的語義是“Broker 保留最近一條”如果設(shè)備離線時間很長新的訂閱者可能會收到過期的狀態(tài)。更嚴(yán)謹(jǐn)?shù)淖龇ㄊ前l(fā)布時加一個時間戳字段服務(wù)端根據(jù)時間判斷是否過期。對于家庭提醒場景retained 已經(jīng)夠用。3.4 編譯燒錄與串口驗證在 Arduino IDE 中選擇 ESP32 Dev Module選擇正確的串口編譯上傳。打開串口監(jiān)視器波特率選 115200。能看到 WiFi 連接成功、MQTT 連接成功并且在傳感器前擺手時打印publish ok: present人離開一段時間后打印publish ok: away。同時可以在電腦上訂閱主題來確認(rèn)消息確實到了 Brokermosquitto_sub -h 127.0.0.1 -t home/presence/door -v -u esp32 -P 你的MQTT密碼如果能看到home/presence/door present和home/presence/door away說明硬件到 Broker 這一段已經(jīng)通了可以進(jìn)入服務(wù)端開發(fā)。4. Python 服務(wù)端訂閱消息、寫入 SQLite 并推送手機(jī)通知4.1 服務(wù)端職責(zé)與依賴服務(wù)端要做三件事訂閱 MQTT 主題、把事件寫入 SQLite、調(diào)用推送服務(wù)通知手機(jī)。用 Python 寫最直接依賴只有 paho-mqtt、requests 和 flask。安裝pip install paho-mqtt requests flask如果是 paho-mqtt 2.x 版本不同版本在Client構(gòu)造和回調(diào)簽名上略有差異。安裝后可以先打印版本如果遇到 API 兼容提示按官方示例調(diào)整Client的構(gòu)造參數(shù)即可回調(diào)邏輯不變。4.2 SQLite 表結(jié)構(gòu)出沒記錄表只需要記錄設(shè)備、事件類型和發(fā)生時間。SQLite 單文件、零配置非常適合這個規(guī)模的項目。CREATE TABLE IF NOT EXISTS presence_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, device TEXT NOT NULL, event TEXT NOT NULL, created_at TEXT NOT NULL );event字段只存in和out兩種值分別對應(yīng)有人進(jìn)入和人員離開。created_at使用本地時間字符串便于直接閱讀也方便 SQL 按天統(tǒng)計。如果以后接入多個傳感器device字段可以區(qū)分門口、客廳、走廊等位置。4.3 訂閱與推送代碼import sqlite3 import time import requests import paho.mqtt.client as mqtt MQTT_HOST 127.0.0.1 MQTT_PORT 1883 MQTT_TOPIC home/presence/door SENDKEY 你的Server醬SendKey DB_PATH /opt/presence/presence.db def init_db(): conn sqlite3.connect(DB_PATH) conn.execute( CREATE TABLE IF NOT EXISTS presence_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, device TEXT NOT NULL, event TEXT NOT NULL, created_at TEXT NOT NULL ) ) conn.commit() conn.close() def push_notify(event, created_at): if event in: title 妹妹出沒檢測到有人進(jìn)入 desp f{created_at} 門口區(qū)域檢測到人員進(jìn)入 else: title 妹妹出沒檢測到人員離開 desp f{created_at} 門口區(qū)域人員已離開 url fhttps://sctapi.ftqq.com/{SENDKEY}.send try: resp requests.post(url, data{title: title, desp: desp}, timeout10) print(push response:, resp.text[:200]) except Exception as exc: print(push error:, exc) def on_connect(client, userdata, flags, rc): print(mqtt connected, rc , rc) client.subscribe(MQTT_TOPIC) def on_message(client, userdata, msg): payload msg.payload.decode(errorsignore).strip() if payload present: event in elif payload away: event out else: print(unknown payload:, payload) return created_at time.strftime(%Y-%m-%d %H:%M:%S) conn sqlite3.connect(DB_PATH) conn.execute( INSERT INTO presence_log(device, event, created_at) VALUES (?, ?, ?), (door, event, created_at) )