fix(onvif): ignore subscription state replays as recording triggers

A camera replays the current state of every property topic as
PropertyOperation=Initialized whenever a pull-point subscription is
created. dispatchEvent looked only at Kind and State, so any motion
property that happened to be active at that moment counted as a fresh
trigger — meaning every reconnect restarts a recording, and a flapping
subscription manufactures motion with no motion.

Observed on a camera whose pull-point was being recreated every ~18s:
each recreate replayed ~90 property events, and once real motion made
the VMD property active the replays kept re-triggering it.

Rejects Initialized specifically rather than accepting only Changed.
PropertyOperation is optional per WS-Notification and absent on many
non-property events, which decode reports as PropertyUnknown; those are
real events and must still trigger.
This commit is contained in:
T. Tradesman
2026-07-23 13:05:24 +02:00
parent 57cfc90c4b
commit 91194f5c1a
2 changed files with 55 additions and 0 deletions

View File

@@ -123,6 +123,13 @@ func dispatchEvent(ctx context.Context, ev stream.Event, configuration *models.C
if ev.State != stream.StateActive {
return
}
// A camera replays every property topic's current state as
// Initialized on each new subscription, so treating that as a
// trigger lets a flapping pull-point manufacture motion.
if ev.Operation == stream.PropertyInitialized {
log.Log.Debug("onvif.dispatchEvent(): subscription state replay, not a trigger: topic=" + ev.Topic)
return
}
if configuration.Config.Capture.Recording == "false" {
return
}

View File

@@ -123,6 +123,54 @@ func TestDispatchEvent_LogsTheTriggeringTopic(t *testing.T) {
"the dispatched event's Kind must appear in the log")
}
// TestDispatchEvent_PropertyOperation — a camera replays the current
// state of every property topic as Initialized whenever a pull-point
// subscription is created. If that counts as a trigger, every
// reconnect restarts a recording for any motion property that happens
// to be active, and a flapping subscription manufactures motion out of
// nothing. Only reject Initialized specifically: PropertyOperation is
// optional per WS-Notification and absent on many non-property events,
// which decode reports as PropertyUnknown.
func TestDispatchEvent_PropertyOperation(t *testing.T) {
tests := []struct {
name string
op stream.PropertyOperation
wantSend bool
}{
{"changed is a real transition", stream.PropertyChanged, true},
{"absent attribute still counts", stream.PropertyUnknown, true},
{"initialized is a subscription state replay", stream.PropertyInitialized, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := makeConfig("true", "true", "cam-1")
comm := makeCommunication(1)
ev := stream.Event{
Kind: stream.KindMotion,
State: stream.StateActive,
Operation: tt.op,
Topic: "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1",
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
dispatchEvent(ctx, ev, cfg, comm)
select {
case <-comm.HandleMotion:
if !tt.wantSend {
t.Fatalf("%v must not trigger a recording", tt.op)
}
case <-time.After(100 * time.Millisecond):
if tt.wantSend {
t.Fatalf("%v must trigger a recording", tt.op)
}
}
})
}
}
func TestDispatchEvent_RecordingDisabled_DoesNotSend(t *testing.T) {
cfg := makeConfig("false", "true", "cam-1")
comm := makeCommunication(1)