一切始于一个 401

前段时间我在给自己的开源项目 MiBeeNvr 做 ONVIF 相机接入。这是个纯 Go 的轻量 NVR,跑在香蕉派上,接海康、大华、IPC 和几台自制的 ESP32 相机。ONVIF 这层我没打算自己从 SOAP 开始造轮子——市面上现成的 Go 库里,onvif-go 是完成度最高的那个:设备发现、Profile 枚举、取流地址、PTZ、事件订阅、成像参数,甚至还有一个虚拟相机服务端用来做测试。文档全,例子多,我几乎没犹豫就选了它。

然后海康相机给了我一个下马威。

一台海康设备,凭据明明是对的,所有请求一律 sender not authorized。抓包、对账、翻协议文档,折腾了一个晚上,最后发现是相机时钟和 NVR 差了几分钟——WS-Security 的 UsernameToken 摘要把 Created 时间戳也算进了哈希,相机按自己的时钟校验重放窗口,差得多了就直接拒绝。这基本是海康系的老毛病,各家 SDK 都有自己的补法。

补法其实不复杂:先无鉴权调一次 GetSystemDateAndTime,算出偏差,再用"相机的现在"去生成摘要。我把修法做在了自己这边。后来又撞上一个:相机换 IP 之后,GetCapabilities 返回的 XAddr 还是旧地址——相机漫游到新地址后自己广播的还是老 IP,每次服务调用都打去一个不存在的机器。也修了。

而上游仓库的提交节奏早就慢了下来,issue 区是安静的。这两个补丁就只能挂在自己的 fork 里。

fork 不是终点,是债务的开始

对一个跑在生产上的项目来说,"等上游"不是一个选项。我先走了标准路线:fork 到组织账号,打两个 tag(v1.1.6、v1.1.7),NVR 的 go.mod 里挂一条 replace 指过去:

replace github.com/0x524a/onvif-go => github.com/Mi-Bee-Studio/onvif-go v1.1.7

能跑,但别扭。每次社区有人问"你这个 fork 改了什么",我都得写一小作文;fork 和上游的 diff 越攒越多,总有一天会合不动;而 replace 这种东西,用过的都懂——它是个一直悬在那里的技术债。

今年八月我想通了一件事:这个库对我的项目来说是基础设施,原作者显然已经没有精力维护了,那不如正经接手。原作者把仓库转给了我,MIT 协议,干净利落。紧接着我做了一件不算客气但很诚实的事——把它彻底改成了我想要的样子,发布了 v1.2.0。
在这里插入图片描述

(致谢还是要在的:这个库的地基是原作者打的,MIT 允许我这么干,README 和 LICENSE 里都留着致谢。开源世界的接力本来就是这么运转的。)

v1.2.0:既然要动,就动到位

接手之前我把整个库读了一遍,顺手做了个体检。数字不太好看:

  • 87 个 Go 文件、4.2 万行代码,239 个方法全部挂在一个 *Client——Device、Media、PTZ、Imaging、Events、DeviceIO 混作一团
  • 一个 3851 行的 media.go,83 个方法,130 个请求/响应结构体声明在函数体内部——没法复用、没法在测试里构造、服务端想引用一下都够不着
  • CI 里有 6 个一直红着的测试(是的,带着失败测试发布的)
  • 一堆历史遗留:误提交的二进制文件、40 个文件的重复数据目录、过时的 workflow
  • 依赖树里拖着一整套 RTSP 库,而库本身一行都没用到它

所以 v1.2.0 不是"换个 module 路径",而是一次完整的重造:

1. module 路径改为 github.com/mickeyzzc/onvif-go,replace 时代结束。
NVR 的 go.mod 里那行 replace 删掉了,直接 require。fork 的历史 tag(v1.0.0~v1.1.7)原样保留,谁在用旧版本都不受影响。

2. API 重构成服务门面。

client, _ := onvif.NewClient("http://192.168.1.100/onvif/device_service",
    onvif.WithCredentials("admin", "pass"))
_ = client.Initialize(ctx)

// 以前:239 个方法平铺在 Client 上
// 现在:按 ONVIF 的服务域分而治之
info, _ := client.Device().GetDeviceInformation(ctx)
profiles, _ := client.Media().GetProfiles(ctx)
uri, _ := client.Media().GetStreamURI(ctx, profiles[0].Token)
_ = client.PTZ().ContinuousMove(ctx, profiles[0].Token, speed, timeout)
_ = client.Events().CreatePullPointSubscription(ctx, ...)
// 设备发现是独立的包
devices, _ := discovery.Discover(ctx, 5*time.Second)

3. 巨文件拆掉,类型提升。
media.go 拆成 profiles / stream / encoder / audio / osd 五个文件,130 个函数内类型全部提升为包级——现在你可以在自己的测试里直接构造这些结构体了。

4. 工程化对齐我 NVR 项目的标准。
Go 1.26、gofumpt + goimports、golangci-lint v2 二十多个 linter 清零告警、CI 三件套(lint / test / build)全绿、main 受保护分支。顺手修了那 6 个失败测试——其中一个还牵出一个真 bug:StorageUri 的 XML 标签大小写和 ONVIF 规范不一致,导致存储配置的 URI 永远解析为空。

5. 零第三方依赖。
唯一那个只被命令行工具用到的 RTSP 依赖被摘掉了。现在整个 module 的 go.mod 长这样:

module github.com/mickeyzzc/onvif-go

go 1.26

就这两行。对一个基础库来说,这比什么都体面。

这个库能干什么

既然是宣传文,认真列一下能力面。ONVIF Profile S 覆盖度是这个库的强项:

能力说明
设备发现WS-Discovery 组播主动探测、网络接口选择(跨子网单播探测、被动 Hello 监听在路线图上)
媒体服务Profile 枚举/创建、RTSP/HTTP 取流地址、快照地址、编码参数读写、OSD、多播控制
PTZ连续/绝对/相对移动、预置位、状态查询
事件PullPoint 订阅、消息拉取、续订(托管式自动轮询/续订在路线图上)
成像曝光/白平衡/亮度/对比度/聚焦,含参数范围查询
设备管理信息、能力、网络配置、用户管理、存储、证书、WiFi
虚拟相机服务端自带一个 ONVIF 相机模拟器,不需要真机就能跑集成测试
抓包回归测试testdata 里存着 Axis/Bosch/Reolink 等真机的 SOAP 抓包,作为回归夹具回放

onvif-go

你的应用

组播/单播

模拟

业务代码

Client 连接/凭据/时钟偏差

Device

Media

PTZ

Events

Imaging

DeviceIO

Security

discovery 发现

server 虚拟相机

internal/soap — WS-Security digest

ONVIF 相机

(mermaid 在掘金和 GitHub 上直接渲染;知乎不认的话用 images/03-arch.png 截图版。)

接手之后修的几个"实战 bug"

这几个都是我在真实相机上撞出来的,比跑一万次 demo 都涨知识:

时钟偏差认证(海康) 就是开头那个 401。现在库里有 SetClockSkew,配合 GetSystemDateAndTime 测出的偏差生成摘要,海康的时钟漂移不再炸认证。后续计划做成 WithAutoClockSkew() 一个选项自动搞定。

陈旧 XAddr 相机换 IP 后,GetCapabilities 广播的还是旧地址,所有服务调用打向虚空。现在客户端会拿"我是从哪个地址够到你的"做基准,改写所有对不上的 XAddr。

僵尸相机入侵 有一天我的 NVR 里多了两台名叫 “ONVIF Camera” 的相机,地址长着 http://某主机名:5000/wsd/... 的样子——是局域网里的 Windows 主机广播 WS-Discovery Hello 被当成了相机入库,凭据为空、永远连不上,还会在 health 里报 error。修完发现主动发现路径早就有非 ONVIF 过滤,被动监听路径漏了——这类"两条路只有一条设卡"的 bug,不跑生产真发现不了。

StorageUri 大小写 上面提过的解析 bug,ONVIF 规范里一水的 Uri(StreamUri、SnapshotUri、StorageUri),字段名写成 StorageURI 就永远匹配不上。一个 xml tag 的事,但没人在意它就永远不会好。

路线图都排好了

接手时我把 NVR 这两年在 ONVIF 上踩的坑全部整理成了 issue(#1#12),按优先级排了 P0-P2:

  • P0:鉴权策略梯队(Digest/PasswordText/Basic/None——有些相机在不同服务上接受的鉴权方式还不一样)、GetStreamURI 的 StreamSetup 参数化、部分设备返回空 URI 的解析修复、跨子网单播探测
  • P1:托管式 PullPoint 订阅(自动轮询/续订)、主/子码流 Profile 选择助手、时钟偏差自动测量、被动发现结果过滤
  • P2:SetNetworkInterfaces 补齐、能力缓存、并发安全契约文档化

每一个都带着真实设备的复现背景和验收标准,不是许愿池。欢迎来提需求、报设备兼容性问题,或者直接交 PR——这个库现在是活的

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐