标签归档:ffmpeg

小米门铃视频“永久保存”那点事

最近入手了一个小米智能门铃 3S。这个门铃很有意思!它不支持本地存储,所有视频只存在小米云存储里面。但有个问题是,如果不订阅它的云存储服务的话,小米云只会保留最近 72 小时的视频,过期就直接删掉了。

门铃为了省电,大多数时间都是处于睡眠状态,只有在三种情况下才会被唤醒录像:

  1. 检测到门口有人
  2. 有人按了门铃
  3. 有人试图强行拆除门铃

这么看来,视频只放云上,也算是在省电和用户随时可以查看监控视频之间做出的一个相对完美的妥协。如果视频放在本地,门铃在睡眠状态下的时候,用户也无法查看监控视频了。显然,门铃因为会睡眠,也就不提供 RTSP / ONVIF 进行 24 小时不间断录像。刚巧了,群晖 Surveillance Station 随 NAS 只送了两个摄像头授权,早就被我的两个摄像头占满了。

下单之前,我想当然地以为 Home Assistant 这么庞大的社区,肯定早就有现成的 Integration 可以把门铃视频存下来了。结果到手一查,我一直在用的用来管理米家设备的 Xiaomi Miot Auto 并没有这个功能 😅。

求人不如求己,只能自己折腾了,我的基本思路是用 Home Assistant + Xiaomi Miot Auto 把云端视频自动下载、解密,再存到群晖 NAS 上。

简单研究了下,发现门铃每次被唤醒,都会录一段最长 30 秒的视频。每段监控视频都会带一个事件类型,比如有人经过是 Pass,有人在门前停留是 Pass:Stay,有人按门铃则是 Pass:Bell。

简单记录一下整个过程,以及中间踩的几个坑。

不想重复造轮子

之所以要基于 Home Assistant + Xiaomi Miot Auto 来实现,是因为它们本身就已经帮我搞定了小米账号的登录和 token 刷新,门铃的摄像头实体里面也能看到最新事件的时间(motion_video_time)、事件类型(motion_video_type)以及一个 m3u8 播放地址(stream_address)。

既然轮子都已经有了,我就不想重复造了,直接基于这个 Integration 来实现明显是最高效和简单的方案。看上去只要拿到 stream_address,用 ffmpeg 一转就完事了,对吧?

图样图森破。

四个坑

坑一:m3u8 是加密的

请求 stream_address 拿到的是一个 HLS 播放列表,也就是 m3u8 文件,本质上就是一份清单,指向好几段分片视频;这里面的每一段都用 AES-128 加了密:

#EXTM3U
#EXT-X-VERSION:7
#EXT-X-KEY:METHOD=AES-128,URI="https://sg.app.processor.smartcamera.api.io.mi.com/common/app/play/v1/key?did=<DEVICE_ID>&keyInfo=...",IV=0x<IV>
#EXTINF:3.931311
https://sg.app.processor.smartcamera.api.io.mi.com/common/app/play/v1/video?did=<DEVICE_ID>&stoId=...
#EXT-X-DISCONTINUITY
...
#EXT-X-ENDLISTCode language: PHP (php)

原理上倒不复杂:从 #EXT-X-KEY 的 URI 拿到 16 字节的密钥,再配合 IV,对每个分片做 AES-128-CBC 解密就好了。但要把这些细节一个一个测,本质上是一个非常无聊的反复试错的过程。

坦白说,这部分我是交给 AI 去做的。我用的是 Google Antigravity,把无聊的试错扔给它,我只负责拿结果 😂。

坑二:只有 4 秒的视频

解密成功之后,我很自然地把这些分片当成标准的 MPEG-TS(.ts)分片,也就是一段长视频里切出来的碎片,把解密后的字节依次追加到同一个文件 /tmp/test_doorbell.ts 里,再用 ffmpeg 转封装成 MP4:

ffmpeg -y -i /tmp/test_doorbell.ts -c copy /tmp/test_doorbell.mp4Code language: Bash (bash)

同一段视频在米家 App 里有 14 秒多,结果转出来只剩下 4 秒了,外加满屏的 Non-monotonous DTS 报错 🤦‍♂️:

[mp4 @ 0x5643e5ef0a00] Non-monotonous DTS in output stream 0:1; previous: 193133, current: 54751; changing to 193134. This may result in incorrect timestamps in the output file.
[mp4 @ 0x5643e5ef0a00] Non-monotonous DTS in output stream 0:1; previous: 193134, current: 55782; changing to 193135. This may result in incorrect timestamps in the output file.
...
frame=  213 fps=0.0 q=-1.0 Lsize=    2649kB time=00:00:04.02 bitrate=5393.4kbits/s speed= 104x

把每个解密后分片的前 8 个字节打印出来看了下,全部都是 0000002066747970:

Seg 0: enc=826720 dec=826717 magic=0000002066747970
Seg 1: enc=569072 dec=569064 magic=0000002066747970
Seg 2: enc=557232 dec=557222 magic=0000002066747970
Seg 3: enc=497008 dec=497007 magic=0000002066747970
Seg 4: enc=271360 dec=271354 magic=0000002066747970

简而言之,这个分片文件本来就是 MP4,不是 TS。为什么呢?MP4 文件是由一个个 box 组成的,每个 box 开头先是 4 个字节的大端序长度,后面紧跟 4 个字节的 ASCII 类型名:

  • 00 00 00 20:box 的长度,0x20 也就是 32 字节。
  • 66 74 79 70:box 的类型,按 ASCII 来看,0x66 是 f,0x74 是 t,0x79 是 y,0x70 是 p,连起来就是 ftyp。

ftyp(File Type Box)通常就是一个 MP4 文件开头的第一个 box。如果是 TS 分片的话,开头应该是同步字节 0x47 才对。

也就是说,小米的每个三秒左右的分片都是一个完整、独立的 MP4 文件,而不是常见的 TS 分片或者 fMP4 分片。每个分片的时间戳都从头开始,直接按字节拼接当然就乱套了。

解法是把每个分片单独存成临时文件,再用 ffmpeg 的 concat demuxer 无损拼接。这里的 -c copy 就是直接拷贝画面,不再重新编码:

ffmpeg -f concat -safe 0 -i concat_list.txt -c copy -movflags +faststart output.mp4Code language: Bash (bash)

这样拼出来的就是完整的 2K HEVC(也就是 H.265)视频,不重新编码,速度也飞快。

坑三:60 秒轮询会漏视频

Xiaomi Miot Auto 每 60 秒轮询一次,而且只取最新的一个事件。如果有人在门口停留,短时间内触发了两段视频,Home Assistant 下次轮询的时候只能看到后面那一段,前面那段就永远错过了。

所以脚本除了接收 Home Assistant 传过来的地址,还会直接用 Xiaomi Miot Auto 保存在 .storage/xiaomi_miot/auth-*.json 里的登录凭证,去调用小米云的事件列表接口(common/app/get/eventlist),把最近的事件都检查一遍,和 NAS 上已经下载的视频做对比,把还没下载的都补下载下来。

顺带的好处是,token 的刷新完全依赖 Home Assistant + Xiaomi Miot Auto,代码中完全不需要任何账户信息。

坑四:视频还没传完

门铃每个事件最多录 30 秒,边录边往云端上传。如果事件一出现就马上去下载,很可能只拿到前一两个分片。

解决方法也很简单粗暴:

  1. 在自动化里面,检测到 motion_video_time 变化后,先等 35 秒再调用脚本。
  2. 脚本里检查 m3u8 里面有没有 #EXT-X-ENDLIST。没有的话说明还在上传,等 10 秒再拉一次。

整体架构

整个流程大致如下:

//www.plantuml.com/plantuml/png/RP9VQzDG6CRlpwTutNrGD_yCBsGn4qH85NBZnQ24DTi2JLAQL6yxtDgcBIkjS_r3fTiaD8XRfsAPjRLV9kVav6fVmPCpuonu-_wUz-LvddFMR4VDEmKZYsuvEvgXWVh65goyQcvh2EKOqZVrd6ewi1Ir_UZTsoLGRLXV9FYY4Nxv5aoQ74gBy9MkMeOEicuv81OSYslaHSqp4MZv4wxwk7x4kRZ8WyiQdSteNe_qFtEkBFAKgF1n2g5r41w318yWkAm6qsDSFSQZ4gwKGhUyOLvDAy7yG_Y-2m-NWRacxV7Lr4LPoye16JNWmHq8Xoq482TNwEoSZav9lqYEBe99BVWvPbG2WM6POSDoTCjClzOpcfLsT5RVRR2TlAOQQJMJoMksZJGpW_WvyRzwtEYmq4XC5E6PYt_jmyfzxeIF5JQsTxHiDhrf6ORAxYc9FJBmcBrhOxZIfYS-kZONxp3NTDxyuvfdP3t5lT1vXtmz8NqtxEsnsEJyEnsMejCcYwRSr1TN3iPAOHLmgyvAZ5hUt-pn-N-hJJRZkVXK4UukhmhI4mdey2FJR9ZunpWyz65hoyXfsq1gKtBW9Jkfs6hT9MsFz0ViUV1P1-INztXnIb8BBdNnlXTMnwIu2y-L5wcRnd1v4XrssLEI0pygxaR5NZZmq1fRZt_rRm
门铃视频归档的时序图
  1. 门铃把加密的分片上传到小米云(保留 72 小时)。
  2. Xiaomi Miot Auto 每 60 秒轮询一次,更新门铃实体的属性。
  3. Home Assistant 自动化在 motion_video_time 变化后等 35 秒,通过 shell_command 调用下载脚本;另外在 Home Assistant 启动时和每 30 分钟也会各跑一次,用来兜底。
  4. 脚本读取最新的认证文件,查询事件列表,下载 m3u8 和密钥,解密分片,用 ffmpeg 拼接。
  5. 视频按日期保存到 /volume1/surveillance/Xiaomi Doorbell 3S/YYYY-MM-DD/,文件名类似 2026-10-30_21-20-29_Pass-Stay.mp4。之所以选这个目录,是因为它也是 Surveillance Station 存放监控录像的地方,这样查看视频的时候都在一个地方,很方便。
  6. 脚本把运行状态写进一个 JSON 文件,Home Assistant 用一个 Sensor 去读取它,一旦出错就推送通知。

下载脚本

下载脚本是用 Python 写的,直接跑在群晖 Home Assistant 套件自带的 Python 3.13 上。脚本用到的 requests、cryptography,以及 Xiaomi Miot Auto 用到的 micloud 都已经内置了,不需要额外 pip install 任何东西。

脚本里最核心的就是解密这几行:先用 AES-128-CBC 解开每个分片,再去掉 PKCS7 填充:

def decrypt_aes_cbc(data: bytes, key: bytes, iv: bytes) -> bytes:
    """Decrypt AES-128-CBC segment and remove PKCS7 padding."""
    cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
    decryptor = cipher.decryptor()
    raw = decryptor.update(data) + decryptor.finalize()
    try:
        unpadder = padding.PKCS7(128).unpadder()
        return unpadder.update(raw) + unpadder.finalize()
    except ValueError:
        return rawCode language: Python (python)

configuration.yaml

在 configuration.yaml 里面加上允许访问的目录、两个 shell_command(一个给事件触发用,一个给定时同步用),以及一个读取状态 JSON 的 command_line 传感器:

homeassistant:
  allowlist_external_dirs:
    - "/volume1/surveillance/Xiaomi Doorbell 3S"

shell_command:
  download_doorbell_video: >-
    /var/packages/HomeAssistant/target/bin/python3.13
    /var/packages/HomeAssistant/home/.homeassistant/download_doorbell_video.py
    --url "{{ stream_address }}"
    --time "{{ video_time }}"
    --type "{{ video_type }}"
    --limit 8
  sync_doorbell_cloud: >-
    /var/packages/HomeAssistant/target/bin/python3.13
    /var/packages/HomeAssistant/home/.homeassistant/download_doorbell_video.py
    --limit 15Code language: YAML (yaml)

限于篇幅,这里没有贴完整代码,只简单说明一下用到的几个参数:

  • --url、--time、--type:最新事件的播放地址、时间和事件类型。
  • --limit:从小米云事件列表里取最近多少个事件来检查。NAS 上已经有的会跳过,只下载还没下载的。事件触发时用 8,定时同步时用 15,都不算大,是为了让脚本在 shell_command 的 60 秒超时之内跑完。
  • --backfill:补齐模式。脚本会按照 --limit 的数值分页,一页一页往前翻,把小米云上还留着的视频都检查一遍。这个模式比较耗时,建议手动跑,不要放进自动化里。

automations.yaml

自动化有三个触发器:事件更新、Home Assistant 启动、每 30 分钟一次。事件触发的分支里先等 35 秒:

triggers:
  - id: motion_event
    trigger: state
    entity_id: camera.madv_mi3sg_xxxx_video_doorbell
    attribute: motion_video_time
  - id: ha_startup
    trigger: homeassistant
    event: start
  - id: periodic_catchup
    trigger: time_pattern
    minutes: "/30"Code language: YAML (yaml)

另外再加一个自动化:状态 Sensor 变成 ERROR 的时候,Home Assistant 会推送一条通知。这里就不再赘述了。

补齐云端已有的视频

配置完成后,手动带上 --backfill 跑一次脚本,就能把小米云上 72 小时内的视频全部拉下来了。日志大概是这样的:

[INFO] Downloading cloud event: time=2026-10-30 21:20:29 type=Pass:Stay fileId=...
[INFO] Saved /volume1/surveillance/Xiaomi Doorbell 3S/2026-10-30/2026-10-30_21-20-29_Pass-Stay.mp4 (2655.5 KB, 5 segments)

踩坑小结

  • 别想当然地以为 m3u8 里都是 TS 分片。 先看看解密后分片的前 8 个字节,ftyp 一出来,问题就一目了然了。
  • Home Assistant 的 shell_command 有 60 秒超时。 自动运行时,--limit(一次检查的事件数)要设小一点;大批量补下载的话,建议手动跑 --backfill。
  • 小米云偶尔会卡住。 给每个 HTTP 请求加上重试,能省掉不少莫名其妙的失败。
  • 群晖上的文件权限。 手动通过 SSH 测试时用的是自己的管理员账号,而 Home Assistant 是以 HomeAssistant 用户运行的,所以将代码交接给 HomeAssistant 运行的时候,如果遇到问题,要多留意下日志、状态文件和目录的权限。

现在门铃每次触发,视频都会自动躺进 NAS 里,再也不用担心 72 小时后就没了。本身家门进出人也不多,而且 30 秒视频也很小,所以我并没有在脚本里面设置什么最长保留时间。

今天就先写到这了。