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. …/MotionAlarmExtra and a truncated …/CellMotionDetector are both rejected. No prefix-containment fuzziness.
  • State is read from the Data slot only. IsMotion entries in Source are ignored. If the state is absent or unparseable you get (MotionAlarm{}, false) — it does not guess.
  • Score is an optional vendor extension. Missing or garbage → 0, and Active is 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 / UnsubscribePush to the subscription's own manager endpoint (the address devices actually require, with a same-address fallback for firmware that collapses both roles), ErrPushNotSupported so you can probe and fall back to pull-point, and NotifyHandler as a ready consumer endpoint. Envelope shapes verified against Hikvision, Uniview, TP-Link and in-house firmware.
  • Typed device time sync — GetDeviceTime returns actual Go types (UTC + local wall clock, POSIX TZ, NTP/manual mode, DST flag) and goes over the unauthenticated GetSystemDateAndTime. 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. Plus ErrNoCredentials so 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/v2 vs …/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.