这份备忘是结合你从零踩坑到成功跑通的全过程整理的,涵盖了原理、概念、完整复现步骤、踩坑记录以及调参指南 。

一、 基本原理与架构
整个链路就像一个"全自动保安系统":
摄像头/视频 -> Python脚本(大脑) -> MQTT(对讲机) -> Home Assistant(控制台)。
- Python脚本(保安的大脑):读取视频画面,对比"没人时的背景"(背景减除算法 MOG2)。发现画面有差异(有东西在动),就画上绿框,算出运动面积占比。
- MQTT(对讲机):把算出的结果(带框的画面、状态、面积)打包,通过不同的"频道"(主题 Topic)广播出去。
- Home Assistant(控制台):订阅这些频道,把画面展示在网页上,把状态接入自动化。
数据流向与 MQTT 主题映射:
aiot/camera1/image(二进制JPEG) -> HA 的camera.camera1(显示画面)aiot/camera1/motion(ON/OFF) -> HA 的binary_sensor.camera1motion(触发自动化)aiot/camera1/motion_area(数字%) -> HA 的sensor.camera1motionarea(仪表盘展示)aiot/camera1/event(JSON) -> 备用,以后接 ThingsBoard 或存档。
二、 核心概念
- venv(虚拟环境) :独立沙盒,避免 Python 依赖包冲突导致 HA 崩溃。位置在
~/aiot-camera/.venv。每次操作前必须source .venv/bin/activate。 - camera vs binary_sensor :
camera是给"人眼"看的(图像流);binary_sensor是给"机器"用的(只判断 ON/OFF,用于自动化)。 - MQTT 发布/订阅:脚本只管"发布"消息,HA 只管"订阅"消息。只要有相同的 Topic 和 Broker 地址,两边就能对接。
- systemd:Linux 后台服务管家。把脚本交给他,就能实现开机自启、崩溃自动重启,不必一直开着终端窗口。
三、 完整复现步骤
1. 构建独立环境与依赖
bash
sudo apt install -y python3-venv mosquitto-clients
mkdir -p ~/aiot-camera && cd ~/aiot-camera
python3 -m venv .venv
source .venv/bin/activate
pip install opencv-python-headless paho-mqtt numpy
(把 camera_motion.py、make_demo_video.py 上传到该目录)
2. 测试脚本与 MQTT 链路(先不碰 HA)
-
终端1(生成视频并跑脚本) :
bashsource ~/aiot-camera/.venv/bin/activate cd ~/aiot-camera python make_demo_video.py python camera_motion.py demo_video.mp4 -
终端2(监听 MQTT) :
bashmosquitto_sub -h 127.0.0.1 -t "aiot/camera1/#" -v -
验收标准 :看到
motion ON、motion OFF和motion_area在跳,图片流显示乱码(正常二进制)。
3. 修改 HA 配置( 必须改对路径)
路径警告 :必须是 /home/dragon/.homeassistant/(带点的隐藏目录),不要改到 /homeassistant/ 无点的影子目录!
-
修改
configuration.yaml:在
mqtt:下方、与sensor:平级的位置,追加camera和binary_sensor块:yamlcamera: - name: "Camera1" unique_id: cam1_image topic: "aiot/camera1/image" availability_topic: "aiot/camera1/availability" binary_sensor: - name: "Camera1Motion" unique_id: cam1_motion state_topic: "aiot/camera1/motion" payload_on: "ON" payload_off: "OFF" device_class: motion availability_topic: "aiot/camera1/availability"⚠️ 追加到原有
sensor:列表末尾(不要新建第二个sensor:):yaml- unique_id: cam1_motion_area name: "Camera1MotionArea" state_topic: "aiot/camera1/motion_area" unit_of_measurement: "%" availability_topic: "aiot/camera1/availability"(可选) 添加自动化:在文件已有
automation:列表末尾追加:yaml- alias: "摄像头检测到运动" triggers: - trigger: state entity_id: binary_sensor.camera1motion to: "on" actions: - action: persistent_notification.create data: title: "视觉告警" message: "摄像头1检测到运动" -
修改
ui-lovelace.yaml:在
views:下新增一页(注意缩进对齐平级):yaml- title: 摄像头 path: camera icon: mdi:cctv cards: - type: picture-entity entity: camera.camera1 show_state: false - type: entities title: 运动检测 entities: - entity: binary_sensor.camera1motion - entity: sensor.camera1motionarea(避坑:HA 实体 ID 强制全小写,千万不能写
camera1motion之类的大小写混用)
4. 重启与验收
开发者工具 -> YAML -> 检查配置 -> 重启 HA -> Ctrl+F5 强制刷新网页 -> 进"摄像头"标签页。保持 Python 脚本运行,看到带绿框的画面和传感器跳动即为成功。
5. 后台常驻(交付使用)
创建服务文件:sudo nano /etc/systemd/system/aiot-camera.service
ini
[Unit]
Description=AIoT camera motion detector
After=network.target mosquitto.service
[Service]
User=dragon
WorkingDirectory=/home/dragon/aiot-camera
ExecStart=/home/dragon/aiot-camera/.venv/bin/python camera_motion.py 0
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
生效并启动:
bash
sudo systemctl daemon-reload
sudo systemctl enable --now aiot-camera
journalctl -u aiot-camera -f # 查看运行日志
四、 🚨踩坑记录
- 文件路径的"双胞胎"陷阱 :HA 真正读取的是
/home/dragon/.homeassistant/(隐藏目录)。你曾经改错到homeassistant/config/导致怎么改都没反应。以后改配置,一定要用grep验证,或使用绝对路径。 - YAML 格式错乱致死 :YAML 对缩进极其严格。多一个空格、用中文冒号、错误缩进(如把
binary_sensor写进了sensor列表),都会导致 HA 配置检查失败,整个新配置全部罢工。 - 集成冲突与 Token 失效 :小米集成
invalid refresh token报错会直接卡住 HA 启动,导致 MQTT 配置不加载。遇到莫名卡顿,去 设置 -> 设备与服务 里禁用/删除报错的集成。 - HA 重启无效的错觉 :如果是
configuration.yaml的改动,必须重启 HA 服务(设置 -> 系统 -> 重启)。仅仅刷新网页或重载 Lovelace 是不会生效的。 - USB 摄像头连接"独木桥" :
- 罗技 BRIO 这类高清摄像头,必须把 VMware 的 USB 控制器设为 USB 3.1,否则带宽不够,设备无法识别。
- 连接前先彻底关机虚拟机,调整设置后再开机。
- 插上摄像头后,在 VMware 菜单栏"虚拟机 -> 可移动设备"中手动点击 Logitech BRIO 才能穿透进虚拟机。
- 穿透后,Windows 本机(腾讯会议等)将无法使用该摄像头。
- 权限导致的"无头案" :执行过
sudo usermod -aG video dragon后,必须重启虚拟机(或注销重登) ,用户组权限才会生效。否则脚本会报Permission denied。
五、 调参修改指南(灵活调整)
如果后续更换视频源或需要优化检测效果,修改 camera_motion.py 顶部配置:
- 更换视频源 :
- 假视频:
python camera_motion.py demo_video.mp4 - USB 摄像头:
python camera_motion.py 0(修改 systemd 服务里的ExecStart也要改成0) - 手机IP摄像头:
python camera_motion.py "http://手机IP:8080/video" - 网络摄像头(RTSP):
python camera_motion.py "rtsp://用户:密码@IP:554/xxx"
- 假视频:
- 灵敏度调节 :
- 没人也乱触发 -> 调大
MIN_AREA_PCT(如 1.0)或调大NOISE_AREA_PCT(如 0.5)。 - 人走过没触发 -> 调小
MIN_AREA_PCT(如 0.2)。 - 状态来回闪烁 -> 调大
HOLD_SECONDS(如 5 秒)。
- 没人也乱触发 -> 调大
- 性能优化 :
- 虚拟机卡顿 -> 调大
IMG_INTERVAL(如 2.0),或调小WIDTH(如 480)和JPEG_QUALITY(如 50)。
- 虚拟机卡顿 -> 调大
- 未来升级(YOLO) :
- 只需要替换
camera_motion.py中"背景减除"那段算法为 YOLO 模型,输出人数person_count。MQTT 主题和 HA 配置文件完全不用动。
- 只需要替换
附录:
python
#!/usr/bin/env python3
"""
camera_motion.py ------ 摄像头运动检测 → MQTT → Home Assistant
用法:
python camera_motion.py # 使用下面 SOURCE 的默认值
python camera_motion.py demo_video.mp4 # 视频文件(循环播放,用来模拟摄像头)
python camera_motion.py 0 # USB 摄像头 /dev/video0
python camera_motion.py "http://手机IP:8080/video" # 手机 IP Webcam / 网络摄像头
python camera_motion.py "rtsp://用户:密码@摄像头IP:554/xxx"
发布的 MQTT 主题 (BASE 默认 aiot/camera1):
{BASE}/image JPEG 画面(带检测框) -> HA 的 mqtt camera
{BASE}/motion ON / OFF (retain) -> HA 的 mqtt binary_sensor
{BASE}/motion_area 运动面积占画面的百分比 -> HA 的 mqtt sensor
{BASE}/event JSON 事件(开始/结束) -> 以后转发给 ThingsBoard / 存档
{BASE}/availability online / offline (遗嘱消息)
"""
import json
import sys
import time
import cv2
import paho.mqtt.client as mqtt
# ==================== 配置(按需修改) ====================
SOURCE = "demo_video.mp4" # 视频源,命令行参数优先
MQTT_HOST = "127.0.0.1" # MQTT broker 地址(和 HA 里配置的一致)
MQTT_PORT = 1883
MQTT_USER = None # broker 有账号密码就填,如 "user"
MQTT_PASS = None
BASE = "aiot/camera1"
WIDTH = 640 # 处理前把画面缩放到这个宽度(越小越省 CPU)
MIN_AREA_PCT = 0.5 # 运动面积占画面 % 超过它,才算"有运动"
NOISE_AREA_PCT = 0.2 # 单个色块小于画面的这个 %,当作噪点忽略
HOLD_SECONDS = 3 # 运动消失后,保持 ON 的时间(防抖,避免开关闪烁)
WARMUP_FRAMES = 30 # 开头几帧用来学习背景,不判断
IMG_INTERVAL = 1.0 # 每隔多少秒发布一张画面/面积(太快会拖慢 HA)
JPEG_QUALITY = 70
# ========================================================
def make_client():
try: # paho-mqtt 2.x
client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
except AttributeError: # paho-mqtt 1.x
client = mqtt.Client()
if MQTT_USER:
client.username_pw_set(MQTT_USER, MQTT_PASS)
client.will_set(f"{BASE}/availability", "offline", retain=True)
client.connect(MQTT_HOST, MQTT_PORT, 60)
client.loop_start()
return client
def open_source(src):
cap = cv2.VideoCapture(src)
if not cap.isOpened():
raise RuntimeError(f"打不开视频源: {src}")
return cap
def parse_source():
src = sys.argv[1] if len(sys.argv) > 1 else SOURCE
if isinstance(src, str) and src.isdigit():
src = int(src) # "0" -> USB 摄像头编号
return src
def main():
src = parse_source()
is_file = isinstance(src, str) and not src.lower().startswith(("rtsp://", "http://", "https://"))
client = make_client()
client.publish(f"{BASE}/availability", "online", retain=True)
client.publish(f"{BASE}/motion", "OFF", retain=True)
cap = open_source(src)
fps = cap.get(cv2.CAP_PROP_FPS) or 25
bg = cv2.createBackgroundSubtractorMOG2(history=200, varThreshold=32, detectShadows=False)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
motion_on = False
last_motion_ts = 0.0
last_img_ts = 0.0
frame_count = 0
print(f"开始检测,视频源={src},MQTT={MQTT_HOST}:{MQTT_PORT},主题前缀={BASE}")
try:
while True:
ok, frame = cap.read()
if not ok:
if is_file: # 视频文件播完,回到开头循环
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
continue
print("读取画面失败,2 秒后重连...")
cap.release()
time.sleep(2)
try:
cap = open_source(src)
except RuntimeError as e:
print(e)
continue
h, w = frame.shape[:2]
frame = cv2.resize(frame, (WIDTH, int(h * WIDTH / w)))
frame_area = frame.shape[0] * frame.shape[1]
frame_count += 1
learning = frame_count <= WARMUP_FRAMES # 开头几帧只学习背景
# 1) 背景减除:得到"前景(运动)"掩膜,再去噪、膨胀
mask = bg.apply(frame)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
mask = cv2.dilate(mask, kernel, iterations=2)
# 2) 找轮廓,过滤小噪点,累计运动面积,画框
total = 0.0
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in ([] if learning else contours):
area = cv2.contourArea(c)
if area < frame_area * NOISE_AREA_PCT / 100:
continue
total += area
x, y, bw, bh = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x + bw, y + bh), (0, 255, 0), 2)
pct = round(total * 100 / frame_area, 2)
now = time.time()
# 3) 状态机:开始 -> 保持 -> 结束(带防抖)
if not learning:
if pct >= MIN_AREA_PCT:
last_motion_ts = now
if not motion_on:
motion_on = True
client.publish(f"{BASE}/motion", "ON", retain=True)
client.publish(f"{BASE}/event", json.dumps(
{"event": "motion_start", "area_pct": pct, "ts": int(now)}))
print(f"[{time.strftime('%H:%M:%S')}] 运动开始 area={pct}%")
elif motion_on and now - last_motion_ts > HOLD_SECONDS:
motion_on = False
client.publish(f"{BASE}/motion", "OFF", retain=True)
client.publish(f"{BASE}/event", json.dumps(
{"event": "motion_end", "ts": int(now)}))
print(f"[{time.strftime('%H:%M:%S')}] 运动结束")
# 4) 定时发布画面和面积(画面上的文字用英文,OpenCV 不支持中文)
if now - last_img_ts >= IMG_INTERVAL:
label = "MOTION" if motion_on else "IDLE"
color = (0, 0, 255) if motion_on else (0, 200, 0)
cv2.putText(frame, f"{label} {pct}%", (10, 28),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, color, 2)
ok2, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, JPEG_QUALITY])
if ok2:
client.publish(f"{BASE}/image", buf.tobytes())
client.publish(f"{BASE}/motion_area", str(pct))
last_img_ts = now
if is_file: # 视频文件按原帧率播放,模拟真实摄像头
time.sleep(1.0 / fps)
except KeyboardInterrupt:
print("收到 Ctrl+C,退出")
finally:
client.publish(f"{BASE}/availability", "offline", retain=True)
client.loop_stop()
client.disconnect()
cap.release()
if __name__ == "__main__":
main()
python
#!/usr/bin/env python3
"""
make_demo_video.py ------ 生成一段演示视频 demo_video.mp4(没有摄像头/没有素材时使用)
内容(20 秒,15 帧/秒,640x480):
0-5 秒 空场景
5-12 秒 一个"人"从左走到右
12-16 秒 空场景
16-20 秒 另一个"人"从右走到左
画面带轻微噪声,用来模拟真实摄像头。
"""
import cv2
import numpy as np
W, H, FPS, SECONDS = 640, 480, 15, 20
rng = np.random.default_rng(0)
# 背景:上下渐变的灰色,再画几条"地面线"
bg = np.tile(np.linspace(90, 140, H, dtype=np.uint8)[:, None], (1, W))
bg = cv2.cvtColor(bg, cv2.COLOR_GRAY2BGR)
for y in (300, 360, 420):
cv2.line(bg, (0, y), (W, y), (110, 110, 110), 1)
out = cv2.VideoWriter("demo_video.mp4", cv2.VideoWriter_fourcc(*"mp4v"), FPS, (W, H))
for i in range(FPS * SECONDS):
t = i / FPS
frame = bg.copy()
if 5 <= t < 12: # 从左到右
x = int(-60 + (t - 5) / 7 * (W + 120))
elif 16 <= t < 20: # 从右到左
x = int(W + 60 - (t - 16) / 4 * (W + 120))
else:
x = None
if x is not None:
cv2.rectangle(frame, (x, 230), (x + 60, 400), (40, 60, 200), -1) # 身体
cv2.circle(frame, (x + 30, 210), 22, (60, 120, 220), -1) # 头
noise = rng.normal(0, 3, frame.shape) # 轻微噪声
frame = np.clip(frame.astype(np.float32) + noise, 0, 255).astype(np.uint8)
out.write(frame)
out.release()
print("已生成 demo_video.mp4")
HA 自动化的原理:一句话就是"如果...就..."
专业说法叫 ECA 模型:触发器(Trigger)→ 条件(Condition,可选)→ 动作(Action)。
概述
HA 后台一直在监听所有实体的状态变化。每当某个实体的值变了(温度从 25 跳到 36,运动传感器从 OFF 变 ON),HA 就把这次变化拿去跟你写的每一条自动化规则做比对:
- 触发器:这次变化符不符合我要等的那个条件?比如"温度变化"这个事件发生了。
- 条件 (可选,用来加限制):现在还满足别的要求吗?比如"现在是不是白天"。不满足就不往下走,但不会重新等触发器,只是这次不执行。
- 动作:都满足,就去执行------开空调、发通知、关插座。
它就是:
当(Trigger)某件事发生了,并且(Condition)当前状态也符合,就去做(Action)某个动作。
关键点:动作之后会形成闭环
动作执行后,会改变某个设备的状态(比如插座被关掉),这个"状态改变"本身又是一次新的变化,可能触发别的自动化。这就是你之前理解的"状态变化→事件→动作→新状态→新事件"的循环,是对的。
举个项目里的例子
触发器:光照传感器数值 < 50
条件 :现在是晚上
动作 :打开房间灯
总结:光照一变暗,如果又是晚上,就开灯。白天光照也可能变暗(比如阴天、遮挡),但条件不满足,就不会误开灯------条件的作用就是防止触发器"误判"。