There's a class of bug that only exists because of what hardware you happen to own.
This one was reported by a user, genelee26, against MiBeeNvr — an NVR I maintain. His report, in short:
My TP-Link camera detects motion just fine. The ONVIF event panel shows the events. But recording never triggers, and nothing is ever logged as an error.
That combination — events arriving, nothing working, no error — is usually a parsing problem wearing a disguise. It was.
Two topics that mean the same thing
ONVIF defines the events service and then more or less lets vendors pick how to express "there is motion."
The classic topic is:
tns1:VideoSource/MotionAlarm with State = true | false
The standardized, rule-engine one is:
tns1:RuleEngine/CellMotionDetector/Motion with IsMotion = true | false
The camera in question — a TP-Link TL-IPC44AW-COLOR (fw 1.0.13) — advertises RuleEngine/CellMotionDetector/Motion, PeopleDetector, IntrusionDetector, LineCrossDetector, TamperDetector… and no VideoSource/MotionAlarm at all. It only ever speaks the second dialect.
The control case in the same report is a Hikvision DS-IPC-T12HV3-IA (V5.7.12), which publishes both.
That control case is the whole story of why this bug survived so long. If your desk has a Hikvision on it — and most people's does — you can't reproduce it. The camera quietly does the right thing anyway. Only someone whose primary camera is a TP-Link hits it, and once you hit it, it's hard to self-diagnose: the event panel has data in it. Only the recording doesn't happen.
The fix that would have been wrong
The user filed a solid PR against MiBeeNvr itself, adding recognition in the NVR's internal/onvif/motion.go. Test-first, verified on two physical cameras. Nothing wrong with the change.
I asked him to re-target it, and I want to be precise about why, because it's the part worth generalizing.
"Which ONVIF topics count as a motion signal" is protocol semantics. It is not NVR business logic. Every single person using the ONVIF library underneath is exposed to the same trap — they subscribe successfully, the subscription works, and one vendor family silently never fires. If that knowledge lives in one NVR's internal/ directory, the next developer reads the spec again, captures again, and re-derives the same table from scratch.
He agreed, and the change became onvif-go#131. The PR body opens with:
Re-targeted from Mi-Bee-Studio/MiBeeNvr#951, as agreed there.
That single line is, in my opinion, the ideal shape of a cross-project contribution: the reporter keeps credit, the fix lands where the next person will actually find it, and the NVR's in-tree copy can be deleted instead of maintained forever.
What it looks like in the library
Two pure functions in the events package:
type MotionAlarm struct {
Active bool
Score float64 // 0–100; 0 when the device does not provide one
Source string
}
func ParseMotionAlarm(msg NotificationMessage) (MotionAlarm, bool)
func IsMotionTopic(topic string) bool
The details are all things real captures taught us:
-
Topic matching is case-insensitive, and every path segment's namespace prefix is dropped before comparison.
tns1:,tnsalias:, and vendor-invented prefixes all compare equal. This matters more than it sounds: in a room full of captures, the same semantic topic shows up under at least three prefixes. -
Exact path matching.
…/MotionAlarmExtraand a truncated…/CellMotionDetectorare both rejected. No prefix-containment fuzziness. -
State is read from the
Dataslot only.IsMotionentries inSourceare ignored. If the state is absent or unparseable you get(MotionAlarm{}, false)— it does not guess. -
Scoreis an optional vendor extension. Missing or garbage →0, andActiveis unaffected. - Other rule-engine topics (
Tamper,LineCross,PeopleDetector) are deliberately not folded in. Scope discipline matters more than coverage here; silently mapping tamper to "motion" would corrupt the caller's recording logic.
The test I like most is TestParseMotionAlarmFromPullMessages. It runs a captured Hikvision PullMessages response body — raw, as the camera sent it — through the library's real decode path. Because that capture happens to contain both topics and real Source entries, the test asserts on what the library actually hands a caller, not on a hand-built ideal struct. Fixtures from real hardware beat fixtures from imagination.
go test -race ./... clean, golangci-lint clean, 91.6% coverage on the events package. Merged 06:40 UTC, shipped in v2.4.0 at 10:28 the same morning.
Why "in the library" is the whole argument
This is the part I'd want a library author to take away.
If you are writing a protocol library, your real product is not your API surface — it's the accumulated table of ways real hardware deviates from the spec, encoded so the next person doesn't have to rebuild it. Every quirk you leave undocumented is a bug someone re-derives with their own captures next quarter.
So the rule I've ended up applying to this codebase: if it takes a physical device and a packet capture to discover, it belongs in a regression fixture, not in the changelog. A changelog entry says "we fixed it." A fixture says "this will never come back, and here is the exact bytes that proved it."
The same version's other three changes are the same idea in different clothes:
-
WS-BaseNotification push receiving — the half of the events service where the device POSTs notifications to you, instead of you long-polling.
Subscribe/RenewPush/UnsubscribePushto the subscription's own manager endpoint (the address devices actually require, with a same-address fallback for firmware that collapses both roles),ErrPushNotSupportedso you can probe and fall back to pull-point, andNotifyHandleras a ready consumer endpoint. Envelope shapes verified against Hikvision, Uniview, TP-Link and in-house firmware. -
Typed device time sync —
GetDeviceTimereturns actual Go types (UTC + local wall clock, POSIX TZ, NTP/manual mode, DST flag) and goes over the unauthenticatedGetSystemDateAndTime. That's the important part: a drifted camera clock is exactly the condition that breaks digest auth, so the clock is what you need to read precisely when authenticated calls are failing. PlusErrNoCredentialsso a write path with no username fails fast instead of firing a guaranteed-401 request. - Discovery listener filtering — the passive listener now drops announcements with no ONVIF signal before your handler sees them. A Windows/WSD host broadcasting a generic Hello no longer becomes a permanently-pending ghost in your device list.
All four came from production pain in one NVR. All four now belong to every consumer of the library.
The part people skip: no hardware needed to test
A protocol fix like this is only useful if the next person can verify it without buying a TP-Link.
The repo ships a complete ONVIF virtual camera server (server/, with an in-memory reference provider in server/simulator/) — multi-profile, PTZ, imaging, device IO, Media2, and pull-point events, described in camera profiles you configure, with a PublishEvent seam to inject notifications from your own code. You can stand up a fake camera, run a recorder against it, and test the event path end to end in CI.
That's also how the client-side API stays honest: the library's own events client round-trips against its own server in tests.
cfg := server.DefaultConfig()
cfg.Port = 8081
srv, _ := server.New(cfg)
log.Fatal(srv.Start(ctx)) // a fully simulated ONVIF camera
If you've ever put off writing tests for a device-integration layer because "it needs hardware," this is the escape hatch: build the fake device once, then every future bug gets a reproduction that runs on every commit.
If you're building against ONVIF in Go
go get github.com/mickeyzzc/onvif-go/v2 # Go 1.26+, zero third-party dependencies
client, _ := onvif.NewClient(endpoint,
onvif.WithCredentials("admin", "pass"),
onvif.WithAutoClockSkew(), // survive cameras whose clock has drifted
)
// Works whether the camera sends VideoSource/MotionAlarm
// or RuleEngine/CellMotionDetector/Motion. Or both. Or neither.
sub, _ := client.Events().SubscribeEvents(ctx, func(msg events.NotificationMessage) {
if m, ok := events.ParseMotionAlarm(msg); ok && m.Active {
// start recording
}
}, nil)
A few honest notes about scope and tradeoffs, since nobody enjoys finding them out later:
- This library is ONVIF signaling only. It produces and consumes stream URIs (RTSP/HTTP/UDP); it does not transport media. Pair it with an RTSP server or client for the media plane. That's a deliberate dependency choice — the module has zero third-party dependencies, which matters if this ends up vendored into firmware or a long-running recorder process.
-
It's the actively-developed v2 lineage of
0x524a/onvif-go— a MIT-licensed fork with the v1 tag history preserved. The upstream project is alive and maintained; the two live at different module paths (…/onvif-go/v2vs…/onvif-go), so both can coexist in one build while you migrate service by service. Most v1 code compiles after the import-path swap; MIGRATION.md maps the rest. - Tests run on ubuntu, windows and macos, because the release artifacts are built for all three.
If you have a camera that connects, authenticates, and then behaves strangely anyway, please open an issue with the make and firmware version. Real-hardware weirdness is the most valuable thing you can hand a protocol library — the response time on these is usually a day or two, and the resolution is a fixture, not a patch note.
Top comments (0)