DEV Community

Cover image for Your IP camera says it supports motion events. It might be lying
ZhengZhiCong
ZhengZhiCong

Posted on

Your IP camera says it supports motion events. It might be lying

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
Enter fullscreen mode Exit fullscreen mode

The standardized, rule-engine one is:

tns1:RuleEngine/CellMotionDetector/Motion   with IsMotion = true | false
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)