“妹妹出没”这个名字来自一个很具体的家庭需求:想知道孩子或住在同一屋檐下的妹妹几点到家、什么时候出门,又不想在门口安装摄像头。把它做成技术项目,本质就是一套“人体存在检测 + 出入事件记录 + 手机消息推送”的家庭自动化小系统。本文会带你把这条链路从零跑通:用一块 ESP32 开发板配合人体感应模块检测门口是否有人,把状态变化通过 MQTT 发布到本地 Broker,再让 Python 服务订阅消息、写入 SQLite 并推送到手机,最后用一个简单的 Flask 页面查看历史出没记录。适合有一定 Arduino 或 Python 基础、想尝试硬件与服务端联调的读者。
读完这条实践链路,你能掌握 PIR 人体感应模块怎么接 ESP32、MQTT 的发布与订阅模型怎么落地、消息如何落库和推送,以及断电重连、误报排查和生产部署时要注意什么。整条链路用到的组件都很常见,成本在几十元到一百多元之间,适合作为家庭自动化入门项目反复调优。
1. 先确认需求边界:出没检测到底检测什么
1.1 把“谁、几点、进还是出”拆成可检测的事件
一个容易犯的错误,是拿到“妹妹出没”这个标题后直接想做一个“能识别是谁回家”的系统。这类需求一旦拆开,会发现它包含三个难度完全不同的子问题:
- 时间段:几点发生。
- 状态:门口区域有没有人。
- 身份:这个人是谁。
第一和第二个问题,用一只人体感应传感器加一块单片机就能解决。第三个问题则完全不同,它需要摄像头加人脸识别、手机定位地理围栏,或者多个传感器配合做方向判断。用单只 PIR 模块无法知道“是谁”,只能知道“有人”或“没人”。把预期放在正确的层级上,后面的技术选型才不会跑偏。
因此本文把“妹妹出没”收敛为:检测门口或某个公共区域是否存在人体,记录状态发生变化的时刻,并在变化时推送通知。这是一个明确、可测试、可验证的目标。至于“识别身份”,放到扩展方向里讨论。
1.2 三条技术路线的取舍
| 路线 | 识别能力 | 硬件成本 | 隐私风险 | 适合场景 |
|---|---|---|---|---|
| ESP32 + PIR/毫米波 + MQTT | 区域有人/无人,不识别身份 | 低,几十到一百多元 | 低,不采集图像 | 家庭出入提醒、房间占用检测 |
| 树莓派 + 摄像头 + OpenCV | 可统计人体活动,扩展可识别人 | 中高,需要摄像头和算力 | 高,涉及图像采集 | 进阶学习、行为分析 |
| 手机定位地理围栏 | 可直接识别“谁”到了位置 | 中低,主要靠手机 | 中,后台定位和权限敏感 | 儿童/老人到家提醒,需要安装 App |
本文选择第一条路线。理由很直接:它不依赖手机权限,不采集图像,数据可以全部留在本地,而且 MQTT 这条消息链路对后续扩展同样成立。就算以后换成毫米波雷达或摄像头方案,Broker、数据库、推送、看板这些服务端部分仍然可以复用。
2. 硬件连接和环境准备:ESP32、人体感应模块和 MQTT Broker
2.1 硬件清单
搭建这套最小系统需要以下材料:
- ESP32 开发板一块,推荐 DevKitC 或 NodeMCU-32S,带 USB 接口。
- HC-SR501 PIR 人体感应模块一个,或 LD2410 毫米波人体存在模块。
- 杜邦线若干,母对公即可。
- 5V 供电的 USB 电源和数据线。
- 一台可以运行 Linux 的机器,可以是树莓派、旧笔记本、云服务器或本地虚拟机,用来跑 MQTT Broker 和 Python 服务。
- 路由器或交换机组成的局域网,保证 ESP32 能访问到 Broker 所在机器。
HC-SR501 是最常见的入门款,价格低,输出逻辑简单,适合验证整条链路。LD2410 是毫米波方案,能检测静止存在,误报更少,但通信协议更复杂。下面先用 HC-SR501 把系统跑通,它的数字输出直接接 GPIO 即可。
2.2 接线方式与注意事项
| ESP32 引脚 | HC-SR501 模块 | 说明 |
|---|---|---|
| 5V | VCC | 模块供电,部分型号需 5V |
| GND | GND | 必须与 ESP32 共地 |
| GPIO4 | OUT | 数字信号输出,检测到人时为高电平 |
接线时有几个细节值得注意。第一,HC-SR501 通常需要 5V 供电,而它的 OUT 输出高电平接近 3.3V,可以直接接入 ESP32 的 GPIO,不需要电平转换。第二,不同批次的模块输出电平可能有差异,如果发现 GPIO 读数异常,先用万用表确认 OUT 在触发时是否为 3.3V 左右。第三,接线完成后不要急着上电,先检查有没有接反或短路。
2.3 安装 Arduino 环境并准备开发板支持
在 Arduino IDE 2.x 中,先打开“文件 -> 首选项”,在“附加开发板管理器网址”里填入 ESP32 官方支持包地址:
https://espressif.github.io/arduino-esp32/package_esp32_index.json保存后,在“工具 -> 开发板 -> 开发板管理器”里搜索 esp32,安装由 Espressif 提供的开发板支持包。接着在库管理器里安装 PubSubClient 库,这个库负责 MQTT 的发布与订阅。
安装完成后,选择开发板为 ESP32 Dev Module,并选择 ESP32 对应的串口。这里有一个常见问题:Windows 下如果串口不出现,多半是缺少 USB 驱动,需要下载对应芯片的驱动;Mac 和 Linux 下一般插上就能识别。
2.4 安装 MQTT Broker 并验证收发
Broker 选择 Mosquitto,轻量、稳定、命令行工具齐全。在 Linux 上安装:
sudo apt update sudo apt install -y mosquitto mosquitto-clients sudo systemctl enable --now mosquitto默认配置下 Mosquitto 只允许本机访问。为了让 ESP32 能从局域网连接,需要新增一个监听配置。在/etc/mosquitto/conf.d/local.conf中写入:
listener 1883 0.0.0.0 allow_anonymous false password_file /etc/mosquitto/passwdallow_anonymous false表示所有客户端必须使用用户名密码。在家庭局域网里,这可以防止同一网络中的其他设备随意向主题发布消息。创建账号:
sudo mosquitto_passwd -c /etc/mosquitto/passwd esp32上面的命令会创建密码文件,并添加 esp32 用户。如果需要再加用户,去掉-c参数。重启服务:
sudo systemctl restart mosquitto验证 Broker 是否正常收发。开两个终端,一个订阅:
mosquitto_sub -h 127.0.0.1 -t 'test/hello' -v -u esp32 -P '你设置的密码'另一个发布:
mosquitto_pub -h 127.0.0.1 -t 'test/hello' -m 'hello' -u esp32 -P '你设置的密码'订阅端能打印出test/hello hello,说明 Broker 工作正常。这一步很重要,后面的固件和服务端调试都需要以 Broker 可用为前提。
3. ESP32 固件:读取传感器状态并发布 MQTT 消息
3.1 固件要做的事
ESP32 固件承担三件事:连接 WiFi、读取 PIR 引脚电平、把状态变化通过 MQTT 发布到指定主题。这里不要在每次读取到高电平时都发消息,而只在状态从“无人”变为“有人”、或从“有人”变为“无人”时发布,这样服务端收到的是一条条有意义的事件,而不是一堆重复数据。
为了让服务端在重启后也能知道当前门口状态,发布时可以使用 MQTT 的 retained 消息。retained 消息会被 Broker 保留,新订阅者一上线就能收到最近一次状态。
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 所在机器 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; // 传感器预热时间 const unsigned long STATE_HOLD_MS = 5000; // 状态保持时间,避免抖动 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); // 传感器上电后有预热期,等待 30 秒后再读取初始状态 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", ...)中的第一个参数是 MQTT client ID,在同一 Broker 下必须唯一,否则后连接的客户端会挤掉先连接的。WiFi 账号密码和 MQTT 密码不要直接写死在共享代码里,本地练习可以先用常量,准备上传到公开仓库前必须改成配置文件或环境变量。
3.3 关键逻辑说明
预热期的处理是很多人忽略的一步。HC-SR501 上电后约 20 到 60 秒内可能持续输出高电平,如果不对预热期做处理,系统启动时会立刻上报一次“有人”,产生一条假事件。代码里在 setup 中等待 30 秒再读取初始状态,是为了让传感器稳定下来。
状态保持时间的处理同样重要。PIR 在高电平持续时间可调,如果传感器在边界反复触发,GPIO 电平会在短时间内抖动。代码里只有在状态变化且距离上一次变化超过STATE_HOLD_MS时才发布,能过滤掉大部分短时抖动。
发布时使用 retained 消息,服务端订阅后能立即拿到当前状态,而不是等到下次变化。要注意,retained 消息的语义是“Broker 保留最近一条”,如果设备离线时间很长,新的订阅者可能会收到过期的状态。更严谨的做法是发布时加一个时间戳字段,服务端根据时间判断是否过期。对于家庭提醒场景,retained 已经够用。
3.4 编译烧录与串口验证
在 Arduino IDE 中选择 ESP32 Dev Module,选择正确的串口,编译上传。打开串口监视器,波特率选 115200。能看到 WiFi 连接成功、MQTT 连接成功,并且在传感器前摆手时打印publish ok: present,人离开一段时间后打印publish ok: away。
同时可以在电脑上订阅主题来确认消息确实到了 Broker:
mosquitto_sub -h 127.0.0.1 -t 'home/presence/door' -v -u esp32 -P '你的MQTT密码'如果能看到home/presence/door present和home/presence/door away,说明硬件到 Broker 这一段已经通了,可以进入服务端开发。
4. Python 服务端:订阅消息、写入 SQLite 并推送手机通知
4.1 服务端职责与依赖
服务端要做三件事:订阅 MQTT 主题、把事件写入 SQLite、调用推送服务通知手机。用 Python 写最直接,依赖只有 paho-mqtt、requests 和 flask。安装:
pip install paho-mqtt requests flask如果是 paho-mqtt 2.x 版本,不同版本在Client构造和回调签名上略有差异。安装后可以先打印版本,如果遇到 API 兼容提示,按官方示例调整Client的构造参数即可,回调逻辑不变。
4.2 SQLite 表结构
出没记录表只需要记录设备、事件类型和发生时间。SQLite 单文件、零配置,非常适合这个规模的项目。
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两种值,分别对应有人进入和人员离开。created_at使用本地时间字符串,便于直接阅读,也方便 SQL 按天统计。如果以后接入多个传感器,device字段可以区分门口、客厅、走廊等位置。
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 = "妹妹出没:检测到有人进入" desp = f"{created_at} 门口区域检测到人员进入" else: title = "妹妹出没:检测到人员离开" desp = f"{created_at} 门口区域人员已离开" url = f"https://sctapi.ftqq.com/{SENDKEY}.send" try: resp = requests.post(url, data={"title": title, "desp": desp}, timeout=10) 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(errors="ignore").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) )