周五晚上十一点,我说「小爱同学,我到家了」。廊灯亮、门锁咔哒一声,客厅摄像头却还在对着沙发转。灯听得懂回家,镜头听不懂。
后来我把门口、客厅、阳台三路乐橙从独立 App 里拽进 Home Assistant:预览进一张仪表盘,动检变成可触发的事件,离家开预览、到家关客厅隐私。灯、锁、镜头终于在同一套场景里。
一、为什么「App 能看」成不了全屋智能
智能家居玩到第三年,多数人会卡在同一处:灯、开关、人体传感器早就在 Home Assistant 里,摄像头却还住在厂商 App。结果是三套系统各管各的——看画面要切 App,开灯靠语音,离家模式只关灯不关镜头。
真正缺的不是「再装一台摄像头」,而是把镜头当成可编排的设备,而不是一个只能打开的播放器。全屋联动至少要同时站住四件事:
- 能看:Lovelace 里有 live,不用再掏手机。
- 能控:隐私遮罩、动检、人形、云台,能被开关和按钮驱动。
- 能感知:有人经过是事件,不是两分钟后才刷新的开关状态。
- 能编排:离家 / 到家 / 夜间 / 睡眠,和灯、锁、空调走同一条自动化。
官方 Home Assistant 的 Imou 集成(2026.7 起进入 Core,也可走 HACS 的 Imou Life)已经把第 1、2 条铺平:用开放平台的 AppId / AppSecret 发现设备,每个通道出 Live view SD / HD,机型支持的能力变成 switch / button / sensor。但它的 IoT class 是 Cloud Polling,默认大约 2 分钟拉一次设备列表和在线状态。
这就是第一处容易误判的地方:
「动检开关」是使能,不是事件。
你在 HA 里打开Motion detection,只是告诉设备「可以报」;人走过门口的那一下,并不会自动变成binary_sensor去亮廊灯。
秒级场景必须另接一条事件管:现行文档的 setMessageCallback(推送模式)。官方集成管「实体和画面」,回调管「此刻发生了什么」——两条腿才走得动。
乐橙摄像机 / 门铃
│ 云端(中国区网关 openapi.lechange.cn)
▼
┌──────────────────────────────────────────────────────────┐
│ 开放平台应用(AppId / AppSecret,accessToken 约 3 天) │
│ │
│ 路径 A 官方 Imou 集成(轮询 ≈2min) │
│ camera / switch / button / sensor │
│ │
│ 路径 B OpenAPI 桥接 │
│ bindDeviceLive / getLiveStreamInfo → HLS │
│ setMessageCallback → 先回 HTTP 200 → HA webhook │
│ setDeviceCameraStatus / controlMovePTZ │
└──────────────────────────────────────────────────────────┘
│
▼
Home Assistant:仪表盘预览 + 语音 + 离家/到家/夜间场景
选型可以压成一句话:只要预览和开关,走路径 A;要「人来开灯、离家开预览、到家关客厅」,A + B 一起上。下面按这个顺序落地。
二、从绑定设备到「回家关客厅、人来开廊灯」
2.1 准备:中国区应用、设备必须先在账下
Home Assistant 只是客户端。设备要先出现在开放平台应用资产里,集成才能发现,OpenAPI 才能签直播、订回调。
- 打开 open.imou.com 注册并创建应用,在控制台「我的应用 → 应用信息」抄下
appId/appSecret。 - 用乐橙 App 或控制台把摄像机绑到同一账号。列表请用现行接口
listDeviceDetailsByPage,不要去翻「旧版本协议」里的设备列表。 - 看返回里的
accessType:setDeviceCameraStatus只支持 PaaS。deviceStatus为online再谈出流。 - HA 里加 Imou 集成时,Server region 选 China(cn)。中国区账号填新加坡 / 欧洲,会直接变成「App ID 无效」或空设备。
- 路径 B 还要一台公网 HTTPS(联调用内网穿透),给回调用。
# sign_selftest.py —— 必须先过这一关,再谈业务接口
# 对照现行开发规范标准案例:https://open.imou.com/document/pages/c20750/
import hashlib, hmac, base64
def calc_sign(ts: int, nonce: str, app_secret: str) -> str:
raw = f"time:{ts},nonce:{nonce},appSecret:{app_secret}"
password = hashlib.sha256(app_secret.encode("utf-8")).hexdigest().lower()
digest = hmac.new(password.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
assert calc_sign(
1706511734,
"f5a1ae2d-c09c-4d39-a744-83a5c2c653c2",
"test123456789test123456789",
) == "xjhCQBoJ9hRDsCjyDcHjtDNzRZ3ZJezcawsfWeiaoxU="
print("sign ok")
现行签名是 HMAC-SHA256 + Base64,不是网上还能搜到的 32 位 MD5。标准案例对不上,后面所有接口都会 SN1001 / SN1002,别先怀疑设备。time 与服务器误差不能超过 5 分钟,nonce 5 分钟内不能复用(SN1005)。
# imou_client.py —— 现行请求壳:system + params + id
import hashlib, hmac, base64, json, time, uuid, urllib.request
OPENAPI = "https://openapi.lechange.cn/openapi"
def calc_sign(ts: int, nonce: str, app_secret: str) -> str:
raw = f"time:{ts},nonce:{nonce},appSecret:{app_secret}"
password = hashlib.sha256(app_secret.encode("utf-8")).hexdigest().lower()
digest = hmac.new(password.encode("utf-8"), raw.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(digest).decode("ascii")
def call_openapi(method: str, app_id: str, app_secret: str, params=None) -> dict:
ts = int(time.time())
nonce = str(uuid.uuid4())
body = {
"system": {
"ver": "1.0",
"appId": app_id,
"time": ts,
"nonce": nonce,
"sign": calc_sign(ts, nonce, app_secret),
},
"id": str(uuid.uuid4()),
"params": params or {},
}
req = urllib.request.Request(
f"{OPENAPI}/{method}",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=15) as resp:
payload = json.loads(resp.read().decode("utf-8"))
result = payload.get("result") or {}
if result.get("code") != "0":
raise RuntimeError(f"{method} failed: {result.get('code')} {result.get('msg')}")
return result.get("data") or {}
# get_token_and_devices.py
import os
from imou_client import call_openapi
APP_ID = os.environ["IMOU_APP_ID"]
APP_SECRET = os.environ["IMOU_APP_SECRET"]
token = call_openapi("accessToken", APP_ID, APP_SECRET, {})["accessToken"]
page = call_openapi("listDeviceDetailsByPage", APP_ID, APP_SECRET, {
"token": token,
"page": 1,
"pageSize": 20,
"source": "bindAndShare",
})
for dev in page.get("deviceList") or []:
print(dev.get("deviceId"), dev.get("deviceStatus"), dev.get("accessType"),
[(ch.get("channelId"), ch.get("channelName"), ch.get("cameraStatus"))
for ch in (dev.get("channelList") or [])])
accessToken 有效约 3 天(响应里的 expireTime 是剩余秒数)。遇到 TK1002 再刷新,不要每个 HA 动作都重新拿——官方集成本身就会轮询,额度是按 AppId 计的。
2.2 路径 A:官方集成先把实体拉进 HA
HA 2026.7+:设置 → 设备与服务 → 添加集成 → Imou,填 App ID、App Secret,区域选 China。老版本走 HACS 搜 Imou Life,域名填 https://openapi.lechange.cn。
设备出现后,每个有画面的通道通常会有:
| HA 实体类型 | 官方能力 | 全屋联动里干什么 |
|---|---|---|
camera.*_live_view_sd / _hd | 云直播标清 / 高清 | Lovelace 看一眼;辅码流给宫格,主码流给焦点 |
switch.*_privacy_mode | 隐私遮罩 | 到家关客厅,离家打开 |
switch.*_motion_detection | 动检使能 | 离家打开,在家关掉误报 |
switch.*_human_detection | 人形检测 | 夜间只认人,少被树影炸灯 |
button.*_ptz_* | 云台上 / 下 / 左 / 右 | 语音「看一下门口左边」 |
sensor.*_status | online / offline / sleep / upgrading | 离线仍可见,适合做「摄像头掉线」通知 |
实体 ID 以开发者工具里为准,设备名会进 slug。先把三路塞进一张仪表盘,确认 SD 能出图,再写自动化——画面都没有,场景脚本毫无意义。
# lovelace 卡片示例:门口看辅码流,点开再看高清
type: picture-entity
entity: camera.menkou_live_view_sd
camera_view: live
tap_action:
action: more-info
2.3 路径 B:HLS 兜底 + 使能开关,代码先跑通
官方播放器偶发出不来、或你想把流嵌进别的看板时,用设备直播模块。bindDeviceLive 会在后台准备主/辅码流、HTTP/HTTPS 四类地址,响应里只带回当前所选码流的 HTTP HLS;全量再调 getLiveStreamInfo。
# bind_live.py
import os
from imou_client import call_openapi
APP_ID, APP_SECRET = os.environ["IMOU_APP_ID"], os.environ["IMOU_APP_SECRET"]
DEVICE_ID = os.environ["DEVICE_ID"] # 序列号
CHANNEL_ID = os.environ.get("CHANNEL_ID", "0")
token = call_openapi("accessToken", APP_ID, APP_SECRET, {})["accessToken"]
live = call_openapi("bindDeviceLive", APP_ID, APP_SECRET, {
"token": token,
"deviceId": DEVICE_ID,
"channelId": CHANNEL_ID,
"streamId": 1, # 0 高清主码流,1 标清辅码流
"liveMode": "proxy",
})
print("liveToken:", live.get("liveToken"))
print("liveStatus:", live.get("liveStatus")) # 1 开启,2 暂停
print("hls:", (live.get("streams") or [{}])[0].get("hls"))
print("cover:", (live.get("streams") or [{}])[0].get("coverUrl"))
info = call_openapi("getLiveStreamInfo", APP_ID, APP_SECRET, {
"token": token, "deviceId": DEVICE_ID, "channelId": CHANNEL_ID,
})
for s in info.get("streams") or []:
print(s.get("streamId"), s.get("status"), s.get("hls"))
把 HTTPS 那条 HLS 写进 secrets.yaml,再挂 generic camera。直播地址一旦泄露,别人不用登录就能看——这是文档原文级的提醒,不要贴到公开仓库。
# secrets.yaml
imou_entry_hls: https://cmgw-vpc.lechange.com:8890/LCO/XXXX/0/1/....m3u8
imou_entry_cover: https://livecloudpic.lechange.cn/LCO/XXXX/0/1/....jpg
# configuration.yaml
camera:
- platform: generic
name: 门口乐橙 HLS
stream_source: !secret imou_entry_hls
still_image_url: !secret imou_entry_cover
verify_ssl: true
使能开关走 setDeviceCameraStatus,enableType 首字母小写,对照设备能力开关。能力集里没有对应项(文档能力名常写成 MotionDetect 这种首字母大写),强行开会失败——这是机型边界。
# enable_for_home.py
import os
from imou_client import call_openapi
APP_ID, APP_SECRET = os.environ["IMOU_APP_ID"], os.environ["IMOU_APP_SECRET"]
token = call_openapi("accessToken", APP_ID, APP_SECRET, {})["accessToken"]
device_id, channel_id = os.environ["DEVICE_ID"], os.environ.get("CHANNEL_ID", "0")
def set_enable(enable_type: str, enable: bool):
call_openapi("setDeviceCameraStatus", APP_ID, APP_SECRET, {
"token": token,
"deviceId": device_id,
"channelId": channel_id,
"enableType": enable_type,
"enable": enable,
})
st = call_openapi("getDeviceCameraStatus", APP_ID, APP_SECRET, {
"token": token, "deviceId": device_id, "channelId": channel_id, "enableType": enable_type,
})
print(enable_type, "->", st.get("status"))
# 踩坑:closeCamera 的 enable=true 表示「关闭摄像头 / 打开隐私」,不是打开画面
set_enable("closeCamera", True) # 客厅到家:遮罩开
set_enable("motionDetect", False) # 人在家,门口可以留,客厅动检先关
try:
set_enable("aiHuman", True) # 机型没有就跳过
except RuntimeError as e:
print("aiHuman 可能不支持:", e)
| enableType | 含义 | 作用域 | 家庭场景 |
|---|---|---|---|
closeCamera | 关闭摄像头(隐私遮罩) | channel | 到家开、离家关 |
motionDetect | 动检 | channel | 离家开 |
mobileDetect | 移动检测(动检与 PIR 合并) | channel | 部分门铃 / 电池机 |
aiHuman | 人形智能 | channel | 夜间少误报 |
hoveringAlarm | 徘徊 | channel | 楼道 / 车库 |
whiteLight / linkageWhiteLight | 白光 / 报警联动白光 | all / channel | 夜间补光 |
云台用 controlMovePTZ:operation 为 0 上、1 下、2 左、3 右、8 放大、9 缩小、10 停止;duration 单位毫秒。设备要有 PT / PTZ 能力集,否则按钮在官方集成里根本不会出现。
# ptz.py
import os
from imou_client import call_openapi
APP_ID, APP_SECRET = os.environ["IMOU_APP_ID"], os.environ["IMOU_APP_SECRET"]
token = call_openapi("accessToken", APP_ID, APP_SECRET, {})["accessToken"]
call_openapi("controlMovePTZ", APP_ID, APP_SECRET, {
"token": token,
"deviceId": os.environ["DEVICE_ID"],
"channelId": os.environ.get("CHANNEL_ID", "0"),
"operation": "2", # 左
"duration": "800",
})
HA 侧不要把 appSecret 写进 rest_command。让桥接进程守住密钥,HA 只打内网:
# configuration.yaml
rest_command:
imou_ptz:
url: http://127.0.0.1:8787/ptz
method: POST
content_type: application/json
payload: '{"deviceId":"{{ device_id }}","channelId":"{{ channel_id }}","operation":"{{ operation }}","duration":"{{ duration }}"}'
imou_enable:
url: http://127.0.0.1:8787/enable
method: POST
content_type: application/json
payload: '{"deviceId":"{{ device_id }}","channelId":"{{ channel_id }}","enableType":"{{ enable_type }}","enable":{{ enable }}}'
2.4 事件管:回调先回 200,再喂给 HA webhook
家庭场景里最常见的诉求是「门口有人,廊灯亮」。官方集成的 motion 开关做不到这一点。登记回调:
# set_callback.py
import os
from imou_client import call_openapi
APP_ID, APP_SECRET = os.environ["IMOU_APP_ID"], os.environ["IMOU_APP_SECRET"]
token = call_openapi("accessToken", APP_ID, APP_SECRET, {})["accessToken"]
call_openapi("setMessageCallback", APP_ID, APP_SECRET, {
"token": token,
"status": "on",
"callbackUrl": os.environ["CALLBACK_URL"], # https://bridge.example.com/imou/callback
"callbackFlag": "alarm,deviceStatus",
"basePush": "2", # 联调期减少和消费端 App 交叉干扰
})
print(call_openapi("getMessageCallback", APP_ID, APP_SECRET, {"token": token}))
callbackFlag 填大类:alarm / deviceStatus / iot……细分类在消息体 msgType。家里先订 alarm,deviceStatus 即可。普通告警体字段是 did / cid,不要写成 deviceId:
{
"id": 2447736561,
"appId": "lcdxxxxxxxxx",
"did": "TESTQWERXXXX",
"cid": 0,
"msgType": "videoMotion",
"time": 1475052555,
"cname": "门口",
"remark": ""
}
桥接的铁律只有一句:平台多次收不到 HTTP 200 就会停推。同步路径只做读 body + 入队 + 200,再异步打 HA。getMessageCallback 仍显示 status=on 不代表还在推。
# ha_bridge.py
import json, os, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HA_WEBHOOK = os.environ["HA_WEBHOOK"] # https://ha.example.com/api/webhook/imou_ha_alarm
COOLDOWN_MS = int(os.environ.get("COOLDOWN_MS", "90000"))
WATCH = {"videoMotion", "human", "mobileDetect", "hoveringAlarm", "online", "offline"}
last_fire = {}
def normalize(raw):
body = raw if isinstance(raw, dict) and raw.get("msgType") else (raw or {})
if isinstance(body, str):
body = json.loads(body)
inner = body.get("data") or body.get("msgBody") or body
if isinstance(inner, str):
inner = json.loads(inner)
return {
"msgId": str(inner.get("id") or inner.get("alarmId") or ""),
"did": inner.get("did") or inner.get("deviceId") or "unknown",
"cid": inner.get("cid") if inner.get("cid") is not None else inner.get("channelId", 0),
"msgType": inner.get("msgType"),
"cname": inner.get("cname") or inner.get("dname") or "",
"time": inner.get("time"),
}
def allowed(did, msg_type):
key = f"{did}:{msg_type}"
now = time.time() * 1000
if now - last_fire.get(key, 0) < COOLDOWN_MS:
return False
last_fire[key] = now
return True
def forward(event):
if event["msgType"] not in WATCH or not allowed(event["did"], event["msgType"]):
return
req = urllib.request.Request(
HA_WEBHOOK,
data=json.dumps(event).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
urllib.request.urlopen(req, timeout=5).read()
except Exception as exc:
print("forward fail", exc)
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n)
try:
payload = json.loads(raw.decode("utf-8") or "{}")
except json.JSONDecodeError:
payload = {}
self.send_response(200)
self.end_headers()
threading.Thread(target=forward, args=(normalize(payload),), daemon=True).start()
def log_message(self, *_args):
return
if __name__ == "__main__":
ThreadingHTTPServer(("0.0.0.0", 8788), Handler).serve_forever()
HA 自动化用 webhook 接,人来开灯;官方 switch 继续管「在不在家该不该报」:
# automations.yaml
- id: arrive_home_privacy
alias: 到家-关客厅镜头-停客厅动检
trigger:
- platform: state
entity_id: person.me
to: home
action:
- service: switch.turn_on
target: { entity_id: switch.keting_privacy_mode }
- service: switch.turn_off
target: { entity_id: switch.keting_motion_detection }
- id: leave_home_arm
alias: 离家-打开预览与动检
trigger:
- platform: state
entity_id: person.me
to: not_home
action:
- service: switch.turn_off
target:
entity_id:
- switch.keting_privacy_mode
- switch.menkou_privacy_mode
- service: switch.turn_on
target:
entity_id:
- switch.keting_motion_detection
- switch.menkou_motion_detection
- switch.menkou_human_detection
- id: night_motion_hallway
alias: 夜间门口动检-开廊灯
trigger:
- platform: webhook
webhook_id: imou_ha_alarm
allowed_methods: [POST]
local_only: false
condition:
- condition: time
after: "22:00:00"
before: "06:30:00"
- condition: template
value_template: >-
{{ trigger.json.msgType in ['videoMotion','human','mobileDetect']
and trigger.json.did == 'YOUR_ENTRY_DEVICE_ID' }}
action:
- service: light.turn_on
target: { entity_id: light.hallway }
data: { brightness_pct: 30 }
- delay: "00:03:00"
- service: light.turn_off
target: { entity_id: light.hallway }
- id: voice_ptz_left
alias: 语音-门口云台向左
trigger:
- platform: conversation
command:
- 看一下门口左边
- 门口往左看
action:
- service: button.press
target: { entity_id: button.menkou_ptz_left }
# 官方按钮没有时改走 rest_command.imou_ptz
语音不必另写一套协议。小爱 / HA Assist / Alexa 只要能打到 HA 服务,上面的 conversation 或「先到家再关隐私」就能复用。客厅摄像头从「永远在录」变成场景的一部分:人在就安静,人走才值班。
三、家庭环境里这几处最容易翻车
签名抄成旧 MD5。 现行开发规范是 SHA-256(appSecret) 派生 password,再 HMAC-SHA256 做 Base64。论坛和早期 Demo 里的 32 位 MD5 对标准案例对不上,不要再搬。
区域选错。 open.imou.com 对应中国区 openapi.lechange.cn。官方集成选 sg / eu / na,设备列表会是空的,看起来像「没绑定」。
把轮询当事件。 集成每 2 分钟刷新在线与开关。Motion detection 变 on 只说明使能开了。人来开灯必须走 setMessageCallback,或消息通道 pullMessages 做补拉(通道约留 2 天,需工单开通)。
closeCamera 语义反直觉。 enable: true = 关闭镜头 / 打开隐私。我第一次写成「到家 enable false」,结果人一进门客厅还在录,以为接口坏了。先 getDeviceCameraStatus 看 status 是 on 还是 off。
回调里同步打 HA。 HA 重启、证书过期、外网抖动,都会让你来不及回 200,平台停推。桥接进程和 HA 拆开,ACK 与业务不要抢同一条线程。
HLS 当对讲、当低延迟预览。 云直播 HLS 出流慢、延迟大约 8~10 秒,适合「看一眼」。要低延迟 Web 再走 createDeviceFlvLive 或轻应用 / OpenSDK,别在 generic camera 上较劲。地址对外等同公开画面。
额度被自己轮询吃掉。 文档写明每个 AppId 有月度免费调用额度(官方集成页写的是 3 万次量级,以控制台「我的资源」为准)。设备一多,2 分钟一轮加上你自己的 listDeviceDetailsByPage,额度会先于功能见底。token 缓存、直播地址复用、别在 automation 里每分钟重绑 live。
非 PaaS 去调使能。 setDeviceCameraStatus 备注写得很清楚:仅 accessType=PaaS。能力集没有 PTZ / CloseCamera / HoveringAlarm,对应实体不会出现,按钮会 unavailable——先查能力,再写场景。
隐私与家庭成员。 客厅到家必须遮罩;回放、直播链接不要进家庭群公告。sensor.*_status 掉 offline 可以推一条,但别把封面图 URL 明文塞进通知。
四、把镜头留在同一套场景里
这套做法能成立,不是因为又接了一个播放器,而是职责分开了:官方 Imou 集成负责发现设备和日常开关,OpenAPI 负责直播兜底和秒级事件,Home Assistant 负责把「我到家了」翻译成灯、锁、隐私遮罩的同一次动作。
如果还要往下做,这三块最值得接着看:
- 事件对接:推送模式 vs 消息通道,何时该补拉 2 天积压
- 设备直播:
bindDeviceLive/getLiveStreamInfo/ FLV 低延迟各管哪一段 - 设备能力开关:
closeCamera、motionDetect、aiHuman与机型能力集如何对齐
家庭这边把三路摄像机绑进同一应用后,Lovelace 能看、Assist 能转、离家/到家能自动切隐私,基本就闭环了。开发者账号和接口权限在 开放平台 申请:平台以视频技术和安全为核心,开放低代码开发组件,方便把预览、回放和设备控制接到自己的自动化里。按本文的签名自测、设备列表、回调 ACK 三步走,比一上来啃私有协议要快得多。