回家说了「我到家」,灯亮了,客厅摄像头却还在录?我用 Home Assistant 把乐橙做成了全屋联动

0 阅读13分钟

周五晚上十一点,我说「小爱同学,我到家了」。廊灯亮、门锁咔哒一声,客厅摄像头却还在对着沙发转。灯听得懂回家,镜头听不懂。

后来我把门口、客厅、阳台三路乐橙从独立 App 里拽进 Home Assistant:预览进一张仪表盘,动检变成可触发的事件,离家开预览、到家关客厅隐私。灯、锁、镜头终于在同一套场景里。


一、为什么「App 能看」成不了全屋智能

智能家居玩到第三年,多数人会卡在同一处:灯、开关、人体传感器早就在 Home Assistant 里,摄像头却还住在厂商 App。结果是三套系统各管各的——看画面要切 App,开灯靠语音,离家模式只关灯不关镜头。

真正缺的不是「再装一台摄像头」,而是把镜头当成可编排的设备,而不是一个只能打开的播放器。全屋联动至少要同时站住四件事:

  1. 能看:Lovelace 里有 live,不用再掏手机。
  2. 能控:隐私遮罩、动检、人形、云台,能被开关和按钮驱动。
  3. 能感知:有人经过是事件,不是两分钟后才刷新的开关状态。
  4. 能编排:离家 / 到家 / 夜间 / 睡眠,和灯、锁、空调走同一条自动化。

官方 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 才能签直播、订回调。

  1. 打开 open.imou.com 注册并创建应用,在控制台「我的应用 → 应用信息」抄下 appId / appSecret
  2. 用乐橙 App 或控制台把摄像机绑到同一账号。列表请用现行接口 listDeviceDetailsByPage,不要去翻「旧版本协议」里的设备列表。
  3. 看返回里的 accessTypesetDeviceCameraStatus 只支持 PaaSdeviceStatusonline 再谈出流。
  4. HA 里加 Imou 集成时,Server region 选 China(cn)。中国区账号填新加坡 / 欧洲,会直接变成「App ID 无效」或空设备。
  5. 路径 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.*_statusonline / 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

使能开关走 setDeviceCameraStatusenableType 首字母小写,对照设备能力开关。能力集里没有对应项(文档能力名常写成 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夜间补光

云台用 controlMovePTZoperation0 上、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 detectionon 只说明使能开了。人来开灯必须走 setMessageCallback,或消息通道 pullMessages 做补拉(通道约留 2 天,需工单开通)。

closeCamera 语义反直觉。 enable: true = 关闭镜头 / 打开隐私。我第一次写成「到家 enable false」,结果人一进门客厅还在录,以为接口坏了。先 getDeviceCameraStatusstatuson 还是 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.*_statusoffline 可以推一条,但别把封面图 URL 明文塞进通知。


四、把镜头留在同一套场景里

这套做法能成立,不是因为又接了一个播放器,而是职责分开了:官方 Imou 集成负责发现设备和日常开关,OpenAPI 负责直播兜底和秒级事件,Home Assistant 负责把「我到家了」翻译成灯、锁、隐私遮罩的同一次动作。

如果还要往下做,这三块最值得接着看:

  • 事件对接:推送模式 vs 消息通道,何时该补拉 2 天积压
  • 设备直播:bindDeviceLive / getLiveStreamInfo / FLV 低延迟各管哪一段
  • 设备能力开关:closeCameramotionDetectaiHuman 与机型能力集如何对齐

家庭这边把三路摄像机绑进同一应用后,Lovelace 能看、Assist 能转、离家/到家能自动切隐私,基本就闭环了。开发者账号和接口权限在 开放平台 申请:平台以视频技术和安全为核心,开放低代码开发组件,方便把预览、回放和设备控制接到自己的自动化里。按本文的签名自测、设备列表、回调 ACK 三步走,比一上来啃私有协议要快得多。