onvif-gov2.4.0 今天发了。这一版的内容大半不是我写的——是一位外部贡献者带来的。而顺着这条线,我意外撞见了一件更值得写的事:我一直以为已经停更的上游,其实活得很好。
一、一台从不触发录像的 TP-Link
10 月 11 日,genelee26 在 MiBeeNvr 提了 issue #950:
用
motion_source: camera:onvif,只有发tns1:VideoSource/MotionAlarm(State)的相机才会驱动录像。很多相机根本不发这个主题——它们的移动侦测只以 ONVIF 标准规则引擎主题tns1:RuleEngine/CellMotionDetector/Motion出现,布尔值在IsMotion下面。对这类相机,订阅是通的,ONVIF 事件面板里也看得到事件,但录像永远不触发,而且日志里没有任何告警(因为那条 WARN 只找motionalarm)。
他给的现场数据很扎实:
- TP-Link TL-IPC44AW-COLOR(固件 1.0.13 Build 250616):
GetEventProperties列出RuleEngine/CellMotionDetector/Motion、PeopleDetector/People、IntrusionDetector、LineCrossDetector、TamperDetector、TPSmartEventDetector——没有VideoSource/MotionAlarm。拉到的消息形如tns1:RuleEngine/CellMotionDetector/Motion,Data 是IsMotion=true|false。 - 作为对照,海康 DS-IPC-T12HV3-IA(V5.7.12)两个主题都发。
这个对照其实解释了为什么这个 bug 能活这么久:手上有海康的人永远复现不了,因为海康"顺便"也发了老主题。只有以 TP-Link 为主的人才会撞上,而且撞上了还很难自查——事件面板明明有数据,只是不录像。
二、这个东西不该长在 NVR 里
他很快提了 PR #951,在 MiBeeNvr 自己的internal/onvif/motion.go里加识别。改动是对的,测试也是先写的(在main上红、改完绿),还在两台真机上验证过。
但我看完建议换一个地方提:这个逻辑不该住在 NVR 里,应该下沉到依赖库。
理由很直接——"哪些 ONVIF 主题算移动信号"是协议语义,不是 MiBeeNvr 的业务。任何用onvif-go的人都会撞上同一个坑:他以为订阅成功就完事了,结果 TP-Link 家族静悄悄地不工作。这个知识如果只躺在某个 NVR 的internal/目录里,下一个用这个库的人还得重新踩一遍,重新读一遍规范,重新拿真机抓包验证一遍。
他同意了,把改动改投到onvif-go,也就是 PR #131。PR 描述第一行写着:
Re-targeted from Mi-Bee-Studio/MiBeeNvr#951, as agreed there.
三、库里长的样子
#131 给events包加了两个纯函数:
typeMotionAlarmstruct{ActiveboolScorefloat64// 0–100; 0 when the device does not provide oneSourcestring}funcParseMotionAlarm(msg NotificationMessage)(MotionAlarm,bool)funcIsMotionTopic(topicstring)bool几个设计点和真机对得上:
- 主题匹配大小写不敏感,且每个路径段的命名空间前缀都丢掉——所以
tns1:、tnsalias:和厂商自定义前缀比较时相等。这一点很重要,抓包里同一个语义的主题前缀五花八门。 - 精确路径匹配。
…/MotionAlarmExtra和…/CellMotionDetector(缺最后一段)都会被拒绝,不做前缀包含判断。 - 状态只读 Data 槽。Source 里的
IsMotion项忽略;状态缺失或解析不出来就返回(MotionAlarm{}, false),不猜。 - Score 是可选的厂商扩展,缺失或垃圾值给 0,不影响
Active。 - 其他规则引擎主题(Tamper、LineCross、PeopleDetector)按 issue 的范围故意不收。
测试是先写的(在main上包都编译不过)。除了各种边界,还有一条我很喜欢的:TestParseMotionAlarmFromPullMessages拿一段海康 DS-IPC-T12HV3-IA 的抓包原文,跑通真实的PullMessages解码路径——因为那份抓包里两个主题都在,Source 项也是真的,所以测的是"库实际交付给调用方的东西",而不是一个手搓的理想结构。
零第三方依赖,go test -race ./...过,golangci-lint0 issue,events包覆盖率 91.6%。
#131 在 10 月 12 日 06:40 合并,今天 10:28 随v2.4.0发出。从合并到发布,不到四个小时。
四、顺手看了一眼上游,然后愣住了
改投这事发生在我的 fork 上。处理过程中我顺手打开了上游0x524a/onvif-go—— 纯粹是习惯性确认一下"它是不是还那样"。
我在自己那篇《拆出来的四个协议库》里写过一句:
0x524a/onvif-go (29★) 2026 年 7 月停滞,就是 onvif-go 接管的那条线。
这句话现在是错的。实际情况:
- 自
2026-07-01以来,上游有176 次提交 2026-10-10一天合并了#127 到 #140 十几个 PR:仓库整理、README 重写、依赖致谢、站点 API 参考、-version修复- 同日正式发布v1.2.0
- 星数从 29 涨到40
也就是说,我 7 月 fork 出去之后,上游不但没停,反而在 10 月做了一轮不小的整理并发了版。这句话已经在我自己的博客里改掉了。
对我来说这其实是好消息。我 fork 的起点是"这条线的代码值得继续投入",上游还在投入说明这个判断没错;而它最近整理的那些东西(README、站点、依赖致谢)对整个 Go ONVIF 生态都是加分——在这个小众领域里,多一个活跃的库比多一场口水有用得多。
五、既然都打开了,就认真比一次
既然上游是活的,"我的 fork 和它到底差在哪"就重新变成一个真问题。我把两边最近的发布版拉下来,用本地 Go 1.27 真编译、真跑测试,不看 README。
| mickeyzzc/onvif-go v2.4.0(10-12) | 0x524a/onvif-go v1.2.0(10-10) | |
|---|---|---|
| Go 文件 / 总行数 | 232 / 68,994 | 126 / 55,241 |
| 导出函数 / 类型 | 112 / 622 | 53 / 383 |
| 测试(Test / Fuzz) | 850 / 8 | 600 /0 |
| 覆盖率(同口径,排除 cmd/examples/tools) | 90.8% | 90.4% |
| 第三方依赖 | 0 | 16(RTSP 栈) |
| CI 测试平台 | ubuntu + windows + macos | ubuntu 仅 |
| Windows 平台测试失败 | 0 | 2(确定性) |
| API 结构 | 门面 Client(24 方法)+ 按服务分包 | 230 方法平铺在单个 Client |
| 内置虚拟相机服务端 | 有(server/+server/simulator) | 无 |
| 最新发布 | v2.4.0(10-12) | v1.2.0(10-10) |
几点值得单独说的:
覆盖率几乎一样(90.8% vs 90.4%),但测的不是同一回事。上游用 14 个 Example 撑起覆盖率;我这边额外有 8 个 Fuzz、3 个 hygiene 测试、11 处 metrics 钩子、6 处定向 HTTP 探测。数字接近,覆盖面不同。
CI 覆盖范围的差别,会在用户机器上变成真实故障。上游的测试只在 ubuntu 跑,而它发三平台的预编译二进制:TestMakeRelativePath挂在 Windows 路径分隔符,TestResolveNetworkInterface写死了 macOS 的lo0——两个都是确定性失败。这两个用例来自双方共有的cmd/generate-tests,我这版已经用filepath.Rel和接口 flag 回退修掉了,并且在三平台上都跑。
线协议上有一处硬差异。我在internal/soap里加了CreatedVariant,用来容忍被拼错的 WS-Security utility 命名空间(oasis-200401-wss-wssecurity-utility-1.0.xsd)——大量社区设备和客户端都写错。解码时 canonical 和拼错两种都能容错;上游只认 canonical。另外我按 local name 解析 Fault,兼容 SOAP 1.1 和 1.2 两种布局。这类东西不会出现在任何功能清单里,但它决定你能不能连上一台"不太规范"的真机。
依赖树是两种取舍。上游引入了 RTSP 工具链(16 个依赖模块),能直接把流拉起来;我这棵依赖树是零——因为它的目标场景是长期驻留在 NVR 进程里、跑在固件上,依赖树就是审计成本和升级风险。想要拉流,你可以自己挑 RTSP 库;不想被绑,就别被绑。
API 形状决定了你写代码时的手感。上游把几百个操作方法平铺在Client上,这个类型同时管连接、SOAP、下载传输;我这边client.Device()/client.Media()/client.PTZ()/client.Events()按 ONVIF 的服务模型切开,Client只留连接、凭据、时钟偏移、下载四件事。IDE 补全的时候,差别很明显。
没有相机也能开发。server/里有一个完整的 ONVIF 虚拟相机服务端(虚拟多镜头、PTZ、Imaging、DeviceIO、Media2、pull-point 事件),状态由可插拔的 provider 提供,默认实现就是内存模拟器server/simulator。做 NVR、做接入、写测试,都不用先去买一台真机。
迁移门槛比想象中低。两边的 module path 不同(…/onvif-go/v2vs…/onvif-go),所以可以在同一个 build 里共存,逐个服务慢慢换。v1 的大部分源码换个 import 路径就能编译——client 包把移走的符号都做了别名重导出,MIGRATION.md 逐条列了需要改的地方。
六、v2.4.0 里还有什么
这一版是「events & operations」能力包(issue #126–#130 + 外部贡献 #131):
- Events:WS-BaseNotification 推送接收(#127)。
Subscribe/RenewPush/UnsubscribePush/ParseNotifyEvents/NotifyHandler/SubscribePushEvents托管续订循环(半 TTL 续订,2 秒下限,防止短 TTL 设备把续订变成忙循环)。推送侧 SOAP fault 映射成新的ErrPushNotSupported,调用方可以探测 push、失败就退回 Pull-Point。信封形状在海康 / 宇视 / TP-Link / 自研固件上都验过。 - Events:标准移动主题解析(#131,外部贡献)。就是上面那节。
- Device:类型化时间同步原语(#129)。
GetDeviceTime把相机时钟读成 Go 类型(UTC + 本地墙钟、POSIX TZ、Manual/NTP 模式、DST 标志),走未认证的GetSystemDateAndTime——这点很关键:相机时钟漂了、摘要认证已经挂了的时候,你恰恰最需要能观测到它的时间。SetDeviceTime/SetNTPServer,以及新增的ErrNoCredentials哨兵:写路径在没有用户名时快速失败,而不是发一个注定 401 的请求。老的interface{}版GetSystemDateAndTime已废弃并委托给类型化版本。 - Discovery:监听器过滤(#128)。被动 Listener 现在会在进 handler 之前丢掉没有 ONVIF 信号的宣告——默认开启(
WithoutONVIFFilter可关)。一台 Windows/WSD 主机发个通用 Hello,不会再在你的设备清单里变成一个永远 pending 的幽灵设备。WithListenerTypes和WithListenerScopePrefix可以继续收窄(比如只看一个站点的相机)。
CHANGELOG 里那句话是这一版的主题:
the MiBeeNvr production capabilitiessunk into the library… Consumers can adopt the library API andretire their in-tree implementations(NVR 的 motion / push / time-sync 迁移)。
下沉。MiBeeNvr 里跑顺了的东西,搬进库;然后 NVR 里那份可以删掉。这次的 #131 是第一次由外部贡献者推动的下沉——他本来只想修自己的录像不触发,最后改的是库。
这也是我对这个库的一个承诺:你在一台真机上撞出来的怪问题,我不只想帮你修一次,我想把它固化成回归夹具,让下一个遇到的人不再撞。#131 那条 test 里躺着的海康抓包就是这么来的。
七、两条线,各取所需
上游活着,那 fork 的理由就得重写一遍。它不是"上游死了所以我接管",而是方向不同:
- 它把几百个操作方法平铺在
Client上,同时这个类型还管连接、SOAP、下载传输;我要client.Device()/client.Media()/client.PTZ()这样的门面分层,Client 只留连接、凭据、时钟偏移、下载四件事。 - 它引入了 RTSP 工具链(16 个依赖模块);我要这棵依赖树是零——这个库要跑在固件里、要在一个 NVR 里长期驻留。
- 它只在 ubuntu 上跑测试;我要 win/mac/linux 三平台都跑,因为我发三平台的二进制。
- 我要把真机上撞出来的坑(时钟漂移、拼错的命名空间、Media2 走错 endpoint、_storage URI 标签大小写)固化成回归夹具,而不是修一次就过去。
- 我还想要一个能嵌入你自己二进制的虚拟相机服务端,让没有硬件的人也能把 ONVIF 接入写完。
这些差别没有对错,是取舍。上游活跃是好事——它证明这条线上还有人在投入,我的 fork 也不是在守着一座废墟。
所以按你要什么来选就行:
| 你要的是… | 拿 |
|---|---|
| 按服务分层的门面客户端、认证阶梯、主动+被动+定向发现 | 这里(/v2) |
| 一个能嵌入自己二进制的虚拟相机服务端 | 这里(/v2) |
| 零第三方依赖、三平台 CI、真机回归夹具 | 这里(/v2) |
一个已经在用github.com/0x524a/onvif-go且够用的旧工程 | 上游v1.1.7继续用,完全没问题 |
| 上面这些 v2 能力,但代码是 v1 的 | 这里(/v2),MIGRATION.md 逐条对应 |
八、三十秒跑起来
go get github.com/mickeyzzc/onvif-go/v2@latest# Go 1.26+,零第三方依赖读一台真机:
client,err:=onvif.NewClient("http://192.168.1.100/onvif/device_service",onvif.WithCredentials("admin","password"),onvif.WithTimeout(30*time.Second),onvif.WithAutoClockSkew(),// 相机时钟漂了导致摘要认证失败时救命)iferr!=nil{log.Fatal(err)}info,_:=client.Device().GetDeviceInformation(ctx)profiles,_:=client.Media().GetProfiles(ctx)订阅移动事件——不管对面发的是VideoSource/MotionAlarm还是RuleEngine/CellMotionDetector/Motion:
stream,err:=client.Events().SubscribeEvents(ctx,func(msg events.NotificationMessage){ifm,ok:=events.ParseMotionAlarm(msg);ok&&m.Active{// 这里触发录像}},nil)手上没有相机?起一台虚拟的:
cfg:=server.DefaultConfig()cfg.Port=8081srv,err:=server.New(cfg)iferr!=nil{log.Fatal(err)}log.Fatal(srv.Start(ctx))// 一台 ONVIF 虚拟相机,含 PTZ / Imaging / 事件从上游 v1 迁过来:换 import 路径,剩下的看 MIGRATION.md;两个 module path 不同,可以共存着慢慢换。
如果你的相机在这上面连不上、认证过不去、事件收不到——开个 issue,把厂商和固件版本带上。这种问题我一般能在一两天里定位,然后把那台设备的行为写成夹具留在仓库里。这也是这个库现在最值钱的部分。
相关链接
- 库:mickeyzzc/onvif-go ·
go get github.com/mickeyzzc/onvif-go/v2(Go 1.26+,零第三方依赖) - 迁移:MIGRATION.md
- 上游:0x524a/onvif-go(v1.2.0,活跃维护中)
- 本次贡献链:MiBeeNvr#950 → MiBeeNvr#951 → onvif-go#131