文章目录
  1. 1. 背景:为什么 RTSP 回放不够快
  2. 2. 方案一:RTSP 回放备份(兜底)
  3. 3. 前置准备
    1. 3.1. 萤石开放平台账号
    2. 3.2. HCNetSDK 下载
  4. 4. 方案二:HCNetSDK 原生下载(主力)
    1. 4.1. 登录凭据:萤石 APK 逆向的关键发现
    2. 4.2. SDK 下载流程
    3. 4.3. PS 流转 MP4
  5. 5. 片段枚举:萤石开放平台 API
  6. 6. 完整备份流程
  7. 7. 踩坑记录
  8. 8. 性能对比
  9. 9. 实测数据
  10. 10. 小结
  11. 11. 附录:SDK 直接枚举片段的可行性

家里的萤石摄像头(CS-S20)录的是事件触发型录像(人形/移动报警),不是全天连续。最近想把 SD 卡里的历史录像备份到 NAS 上,踩了不少坑,最终落地了两条路径:RTSP 回放流(兜底,1x 实时)和 HCNetSDK 原生文件下载(主力,18x 实时速度)。这篇文章完整记录:两种方式的原理、示例代码、以及怎么把萤石 APK 逆向出来的登录凭据用起来。

文中所有 IP 用 192.168.1.* 占位,密码/序列号用占位符,不包含真实凭证。

背景:为什么 RTSP 回放不够快

萤石摄像头支持标准 RTSP 协议,可以直接拉流:

1
rtsp://admin:验证码@192.168.1.100:554/Streaming/tracks/101?starttime=20261011T090000Z&endtime=20261011T100000Z

但这个「回放」本质上是流式播放——设备把录像当直播流推给你,码流被限制在 1x 实时速度。一天有 48 分钟的事件录像,你就得花 48 分钟下载。要备份一个月的录像,得挂一个月。

后来研究发现,萤石摄像头(海康代工)在 8000 端口还跑着海康私有 SDK 协议,其中的 GetFileByTime 接口是文件传输模式——设备把录像文件直接发给你,不受 1x 限制,实测 18x 实时速度(WiFi 下 2.14 MB/s)。

方案一:RTSP 回放备份(兜底)

最简单的方式,不需要任何额外依赖,ffmpeg 直接拉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import subprocess

def backup_rtsp_segment(ip, password, start_time, end_time, out_file):
"""用 RTSP 回放流下载一段录像。start_time/end_time 格式: 20261011T090000Z"""
url = (f"rtsp://admin:{password}@{ip}:554"
f"/Streaming/tracks/101"
f"?starttime={start_time}&endtime={end_time}")
cmd = [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-rtsp_transport", "udp", # 优先 UDP,失败再 TCP
"-use_wallclock_as_timestamps", "1", # 关键:修正回放流时间戳
"-i", url,
"-c", "copy", # 零重编码,直接转封装
"-movflags", "+faststart",
out_file,
]
r = subprocess.run(cmd, capture_output=True, timeout=900)
return r.returncode == 0

**关键参数 -use_wallclock_as_timestamps 1**:萤石回放流的 DTS/PTS 是从设备开机起算的 90kHz 时钟值(可能达到几十万小时),不修正的话 MP4 时长会错乱、fps 显示上万、播放器快进。

缺点:1x 实时速度,适合短片段兜底,不适合全量备份。

前置准备

萤石开放平台账号

使用开放平台 API 查询录像片段清单需要先注册萤石账号并创建应用获取 AppKey/AppSecret:

HCNetSDK 下载

SDK 库需要从海康威视官网手动下载(约 100MB,需浏览器访问,curl 被 403 拦截):

方案二:HCNetSDK 原生下载(主力)

登录凭据:萤石 APK 逆向的关键发现

海康 HCNetSDK 的 NET_DVR_Login_V30 需要用户名密码。海康 NVR 用 admin + 设备密码,但萤石消费级摄像头不是这套——admin + 验证码会被拒(错误码 1)。

通过读 pyezvizapi(一个 Python 萤石库)源码中的 APK 逆向注释,发现萤石 APP 局域网登录有三个候选凭据,按顺序尝试:

顺序 接口 端口 用户名 密码
1 Login_V40 (HTTPS) 8443 admin 验证码原文
2 Login_V30 8000 admin 验证码原文
3 Login_V30 8000 EZ_LOCAL_USER md5(验证码)[8:24]

候选 3 是成败关键:密码是 MD5 十六进制的中段 16 位(第 8~23 字符,小写),不是完整 32 位。

1
2
3
4
5
import hashlib

def ezviz_sdk_password(verification_code: str) -> str:
"""萤石 SDK 登录密码:md5(验证码) 十六进制中段 16 位。"""
return hashlib.md5(verification_code.encode()).hexdigest()[8:24].lower()

SDK 下载流程

HCNetSDK 是纯 C 库(libhcnetsdk.so),Python 用 ctypes 直接调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import ctypes
from ctypes import CDLL, Structure, byref, c_uint, c_byte, c_char_p, c_long

class NET_DVR_TIME(Structure):
_fields_ = [("dwYear", c_uint), ("dwMonth", c_uint), ("dwDay", c_uint),
("dwHour", c_uint), ("dwMinute", c_uint), ("dwSecond", c_uint)]

class NET_DVR_PLAYCOND(Structure):
_fields_ = [("dwChannel", c_uint),
("struStartTime", NET_DVR_TIME),
("struStopTime", NET_DVR_TIME),
("byDrawFrame", c_byte), ("byStreamType", c_byte),
("byStreamID", c_byte * 32), ("byCourseFile", c_byte),
("byDownload", c_byte), ("byOptimalStreamType", c_byte),
("byVODFileType", c_byte), ("byRes", c_byte * 26)]

class EzvizSdkDownloader:
def __init__(self, ip, verification_code, sdk_dir="/opt/hcnetsdk"):
sdk = CDLL(f"{sdk_dir}/libhcnetsdk.so")
# 必须指定组件目录和 OpenSSL 库,否则回放组件加载失败(err=110)
sdk.NET_DVR_SetSDKInitCfg(2, c_char_p(f"{sdk_dir}/HCNetSDKCom".encode()))
sdk.NET_DVR_SetSDKInitCfg(3, c_char_p(f"{sdk_dir}/libcrypto.so.3".encode()))
sdk.NET_DVR_SetSDKInitCfg(4, c_char_p(f"{sdk_dir}/libssl.so.3".encode()))
sdk.NET_DVR_Init()
sdk.NET_DVR_SetConnectTime(5000, 1)

info = ctypes.create_string_buffer(248)
uid = sdk.NET_DVR_Login_V30(
ip.encode(), 8000, b"EZ_LOCAL_USER",
ezviz_sdk_password(verification_code).encode(), byref(info))
if uid < 0:
raise RuntimeError(f"登录失败 err={sdk.NET_DVR_GetLastError()}")
self._sdk, self._uid = sdk, uid

def download(self, start_dt, end_dt, out_ps):
"""按时间下载录像为 PS 流文件,返回文件大小。"""
cond = NET_DVR_PLAYCOND()
cond.dwChannel = 1
cond.struStartTime = NET_DVR_TIME(start_dt.year, start_dt.month, start_dt.day,
start_dt.hour, start_dt.minute, start_dt.second)
cond.struStopTime = NET_DVR_TIME(end_dt.year, end_dt.month, end_dt.day,
end_dt.hour, end_dt.minute, end_dt.second)
cond.byDownload = 1
cond.byVODFileType = 0 # PS 流

dh = self._sdk.NET_DVR_GetFileByTime_V40(self._uid, out_ps.encode(), byref(cond))
if dh < 0:
raise RuntimeError(f"GetFileByTime 失败 err={self._sdk.NET_DVR_GetLastError()}")
try:
self._sdk.NET_DVR_PlayBackControl(dh, 1, 0, None) # PLAYSTART
pos = ctypes.c_uint(0)
while True:
self._sdk.NET_DVR_PlayBackControl(dh, 4, 0, byref(pos)) # GETPOS
if pos.value >= 100:
break
time.sleep(0.2)
finally:
self._sdk.NET_DVR_StopGetFile(dh)
return os.path.getsize(out_ps)

PS 流转 MP4

SDK 下载的产物是海康私有 PS 流(IMKH 头),ffmpeg 可以直接读:

1
2
3
4
5
def ps_to_mp4(ps_file, mp4_file):
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
"-fflags", "+genpts", "-i", ps_file,
"-c", "copy", "-movflags", "+faststart", mp4_file]
return subprocess.run(cmd, capture_output=True).returncode == 0

片段枚举:萤石开放平台 API

事件录像设备需要先知道「有哪些片段」,才能逐段下载。萤石开放平台提供免费 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import urllib.request
import urllib.parse
import json

def get_token(app_key, app_secret):
"""用 AppKey/AppSecret 换 accessToken。"""
data = urllib.parse.urlencode({
"appKey": app_key, "appSecret": app_secret
}).encode()
req = urllib.request.Request(
"https://open.ys7.com/api/lapp/token/get", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=25) as resp:
r = json.loads(resp.read().decode())
if str(r.get("code")) != "200":
raise RuntimeError(f"获取 token 失败: {r}")
return r["data"]["accessToken"]


def list_recordings(token, device_serial, channel, start_ms, end_ms):
"""查询 [start_ms, end_ms] 内的 SD 卡事件录像片段。"""
data = urllib.parse.urlencode({
"accessToken": token,
"deviceSerial": device_serial,
"channelNo": channel,
"startTime": start_ms,
"endTime": end_ms,
}).encode()
req = urllib.request.Request(
"https://open.ys7.com/api/lapp/video/by/time", data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=30) as resp:
r = json.loads(resp.read().decode())
if str(r.get("code")) == "200" and r.get("data"):
return r["data"] # 每项含 startTime/endTime(ms)、localType 等
return []

返回的片段列表类似:

1
2
3
4
[
{"startTime": 1694995200000, "endTime": 1694995260000, "localType": 1},
{"startTime": 1694995320000, "endTime": 1694995380000, "localType": 1}
]

localType 表示录像类型(1=事件录像),startTime/endTime 是毫秒时间戳。有了片段清单,就可以逐段精确下载。

完整备份流程

1
2
3
4
5
6
7
萤石开放平台 API(查片段清单)
└─ /api/lapp/video/by/time → 返回当日所有事件片段的时间段
└─ 逐段下载:
├─ SDK 后端(默认):HCNetSDK GetFileByTime → PS → ffmpeg 转 MP4(18x 速度)
└─ RTSP 后端(兜底):RTSP 回放流 → ffmpeg 直存 MP4(1x 速度)
└─ 每段下载前 validate_mp4 跳过已存在文件(断点续传)
└─ 每完成一天写 progress 文件,支持续备

踩坑记录

坑 原因 解决
SDK 登录报错误码 1 萤石消费机 SDK 密码独立于 RTSP 密码 用 EZ_LOCAL_USER + md5 中段 16 位
回放组件加载失败 err=110 LD_LIBRARY_PATH 在 Python 内设置无效 必须在进程启动前 export,或 re-exec
HTTPS 证书校验失败 SDK 目录的海康版 libssl 被 Python 优先加载 设 SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
连续下载 PLAYSTART err=12 消费机回放会话资源少,会话未释放 启动失败重试 3 次 + 段间静置 0.5s
设备重启后下载卡死 PLAYGETPOS 返回失败但文件仍在增长 忽略返回值,只认 pos.value >= 100 或文件增长停滞
RTSP 回放时长错乱 回放流时间戳是开机起算的 90kHz 时钟 加 -use_wallclock_as_timestamps 1

性能对比

方式 速度 一天 48 分钟录像
RTSP 回放 1x 实时(~0.2 MB/s) ~48 分钟
HCNetSDK 下载 18x 实时(~2.1 MB/s) ~2.7 分钟

实测数据

以 2026-10-11 全天事件录像为例(14 个片段,合计 48.5 分钟视频):

方式 平均速度 下载耗时 备注
RTSP 回放 1x 实时(~0.2 MB/s) ~48.5 分钟 码流被设备限制在 1x
HCNetSDK 下载 18x 实时(~2.1 MB/s) ~2.7 分钟 文件传输模式,WiFi 下实测

全量备份一个月(30 天,每天 ~48 分钟事件录像):

方式 预估总耗时
RTSP 回放 ~24 小时(连续不断)
HCNetSDK 下载 ~1.4 小时

小结

  • 片段枚举继续用萤石开放平台 API(免费、精确),下载换成 HCNetSDK 文件传输
  • 登录凭据是 EZ_LOCAL_USER + md5(验证码)[8:24],来自萤石 APK 逆向
  • SDK 库需要手动下载海康 Linux 版 HCNetSDK(约 100MB),用 ctypes 调用
  • 断连防护:忽略 PLAYGETPOS 返回值(不可靠),只认 pos 或文件增长停滞;时长校验 + 自动重连

附录:SDK 直接枚举片段的可行性

一个自然的疑问是:能否跳过开放平台 API,直接用 HCNetSDK 的 FindFile 枚举片段?

实测结果:**NET_DVR_FindFile_V40 对 EZ_LOCAL_USER 返回 0 文件**(该用户无检索权限)。因此片段枚举仍需依赖开放平台 API,SDK 只负责下载。这也解释了为什么萤石 APP 本地回放需要先走云端鉴权——本地 SDK 用户的权限被刻意限制了。

文章目录
  1. 1. 背景:为什么 RTSP 回放不够快
  2. 2. 方案一:RTSP 回放备份(兜底)
  3. 3. 前置准备
    1. 3.1. 萤石开放平台账号
    2. 3.2. HCNetSDK 下载
  4. 4. 方案二:HCNetSDK 原生下载(主力)
    1. 4.1. 登录凭据:萤石 APK 逆向的关键发现
    2. 4.2. SDK 下载流程
    3. 4.3. PS 流转 MP4
  5. 5. 片段枚举:萤石开放平台 API
  6. 6. 完整备份流程
  7. 7. 踩坑记录
  8. 8. 性能对比
  9. 9. 实测数据
  10. 10. 小结
  11. 11. 附录:SDK 直接枚举片段的可行性