From ce67879ee52c693a9fd2041b5dfce7a8fbe13703 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 13:57:21 +0200 Subject: [PATCH 01/23] feat(event/stream): scaffold package with normalized event types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new event/stream sub-package that will host the long-running, channel-based consumer for ONVIF device events. This commit only lays down the value types — EventKind, EventState, PropertyOperation and the Event struct — together with Stringer methods and zero-value tests. The intent is to give callers a vendor-neutral surface (Motion, DigitalInput, etc.) so they do not need to special-case AXIS, Hikvision, Avigilon, Hanwha, Bosch or Dahua topic strings. Decoding, topic classification and the Stream type itself land in follow-up commits. --- event/stream/doc.go | 13 ++++ event/stream/types.go | 130 +++++++++++++++++++++++++++++++++++++ event/stream/types_test.go | 83 +++++++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 event/stream/doc.go create mode 100644 event/stream/types.go create mode 100644 event/stream/types_test.go diff --git a/event/stream/doc.go b/event/stream/doc.go new file mode 100644 index 0000000..ba0f79b --- /dev/null +++ b/event/stream/doc.go @@ -0,0 +1,13 @@ +// Package stream provides a long-running, channel-based consumer for ONVIF +// device events. It hides the SOAP/XML, pull-point lifecycle, renewal and +// vendor-specific topic conventions behind a typed Event stream. +// +// A Stream is created with NewStream and yields decoded Event values on the +// channel returned by Events. Non-fatal errors (transient SOAP failures that +// the stream recovers from) are surfaced on Errors. The Stream is stopped by +// cancelling the context passed to NewStream or by calling Close. +// +// The package classifies vendor-specific topic strings (AXIS, Hikvision, +// Avigilon, Hanwha, Bosch, Dahua) into a small set of normalized EventKind +// values so callers do not need to special-case device manufacturers. +package stream diff --git a/event/stream/types.go b/event/stream/types.go new file mode 100644 index 0000000..3d0bae1 --- /dev/null +++ b/event/stream/types.go @@ -0,0 +1,130 @@ +package stream + +import ( + "fmt" + "time" +) + +// EventKind is the normalized category of an ONVIF event, independent of the +// camera vendor's topic naming. +type EventKind uint8 + +const ( + // KindUnknown is the zero value; used when a topic does not match any + // known classification. + KindUnknown EventKind = iota + // KindMotion covers motion detection from any vendor (e.g. AXIS + // VideoSource/MotionAlarm, Hikvision RuleEngine/CellMotionDetector). + KindMotion + // KindTampering covers camera tampering / scene change alarms. + KindTampering + // KindDigitalInput covers external sensor inputs wired to the camera. + KindDigitalInput + // KindDigitalOutput covers relay output state changes on the camera. + KindDigitalOutput + // KindObjectDetected covers analytics-based object/person/vehicle + // detection events. + KindObjectDetected + // KindAudioAlarm covers audio-level / loud-noise alarms. + KindAudioAlarm +) + +// String implements fmt.Stringer. +func (k EventKind) String() string { + switch k { + case KindUnknown: + return "Unknown" + case KindMotion: + return "Motion" + case KindTampering: + return "Tampering" + case KindDigitalInput: + return "DigitalInput" + case KindDigitalOutput: + return "DigitalOutput" + case KindObjectDetected: + return "ObjectDetected" + case KindAudioAlarm: + return "AudioAlarm" + default: + return fmt.Sprintf("EventKind(%d)", uint8(k)) + } +} + +// EventState is the active/inactive state carried by an event. Most ONVIF +// alarms are boolean (e.g. IsMotion=true/false); StateUnknown is used when +// the value cannot be parsed. +type EventState uint8 + +const ( + StateUnknown EventState = iota + StateActive + StateInactive +) + +// String implements fmt.Stringer. +func (s EventState) String() string { + switch s { + case StateUnknown: + return "Unknown" + case StateActive: + return "Active" + case StateInactive: + return "Inactive" + default: + return fmt.Sprintf("EventState(%d)", uint8(s)) + } +} + +// PropertyOperation mirrors the ONVIF wsnt:PropertyOperation attribute and +// indicates whether a message is the first sighting of a property +// (Initialized), a transition (Changed) or the property going away (Deleted). +type PropertyOperation uint8 + +const ( + PropertyUnknown PropertyOperation = iota + PropertyInitialized + PropertyChanged + PropertyDeleted +) + +// String implements fmt.Stringer. +func (p PropertyOperation) String() string { + switch p { + case PropertyUnknown: + return "Unknown" + case PropertyInitialized: + return "Initialized" + case PropertyChanged: + return "Changed" + case PropertyDeleted: + return "Deleted" + default: + return fmt.Sprintf("PropertyOperation(%d)", uint8(p)) + } +} + +// Event is a single normalized notification from an ONVIF device. +// +// Kind, State and Operation are the normalized fields most callers should +// switch on. Topic, RawValue and Source preserve the original ONVIF data so +// callers can do further inspection or logging without re-parsing SOAP. +type Event struct { + // Kind is the normalized event category. + Kind EventKind + // State is the active/inactive value carried by the event. + State EventState + // Operation is the ONVIF property lifecycle (Initialized/Changed/Deleted). + Operation PropertyOperation + // Source identifies the channel, input, or rule that produced the event + // (taken from the Source SimpleItem in the notification). + Source string + // Topic is the raw ONVIF topic string, e.g. tns1:VideoSource/MotionAlarm. + Topic string + // RawValue is the unparsed Data SimpleItem value (e.g. "true", "1", + // "active") so callers can read non-boolean values when needed. + RawValue string + // Timestamp is when the stream observed the event locally. The ONVIF + // UtcTime is not used because clocks on many cameras drift. + Timestamp time.Time +} diff --git a/event/stream/types_test.go b/event/stream/types_test.go new file mode 100644 index 0000000..45d4069 --- /dev/null +++ b/event/stream/types_test.go @@ -0,0 +1,83 @@ +package stream + +import ( + "testing" + "time" +) + +func TestEventKindString(t *testing.T) { + tests := []struct { + kind EventKind + want string + }{ + {KindUnknown, "Unknown"}, + {KindMotion, "Motion"}, + {KindTampering, "Tampering"}, + {KindDigitalInput, "DigitalInput"}, + {KindDigitalOutput, "DigitalOutput"}, + {KindObjectDetected, "ObjectDetected"}, + {KindAudioAlarm, "AudioAlarm"}, + {EventKind(255), "EventKind(255)"}, + } + for _, tc := range tests { + if got := tc.kind.String(); got != tc.want { + t.Errorf("EventKind(%d).String() = %q, want %q", tc.kind, got, tc.want) + } + } +} + +func TestEventStateString(t *testing.T) { + tests := []struct { + state EventState + want string + }{ + {StateUnknown, "Unknown"}, + {StateActive, "Active"}, + {StateInactive, "Inactive"}, + {EventState(255), "EventState(255)"}, + } + for _, tc := range tests { + if got := tc.state.String(); got != tc.want { + t.Errorf("EventState(%d).String() = %q, want %q", tc.state, got, tc.want) + } + } +} + +func TestPropertyOperationString(t *testing.T) { + tests := []struct { + op PropertyOperation + want string + }{ + {PropertyUnknown, "Unknown"}, + {PropertyInitialized, "Initialized"}, + {PropertyChanged, "Changed"}, + {PropertyDeleted, "Deleted"}, + {PropertyOperation(255), "PropertyOperation(255)"}, + } + for _, tc := range tests { + if got := tc.op.String(); got != tc.want { + t.Errorf("PropertyOperation(%d).String() = %q, want %q", tc.op, got, tc.want) + } + } +} + +func TestEventZeroValue(t *testing.T) { + var e Event + if e.Kind != KindUnknown { + t.Errorf("zero Event.Kind = %v, want KindUnknown", e.Kind) + } + if e.State != StateUnknown { + t.Errorf("zero Event.State = %v, want StateUnknown", e.State) + } + if !e.Timestamp.IsZero() { + t.Errorf("zero Event.Timestamp = %v, want zero time", e.Timestamp) + } +} + +func TestEventTimestampPreserved(t *testing.T) { + now := time.Now() + e := Event{Timestamp: now} + if !e.Timestamp.Equal(now) { + t.Errorf("Event.Timestamp = %v, want %v", e.Timestamp, now) + } +} From 2cc266714e4befa481c7c380acfab133abe5bce9 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 13:59:22 +0200 Subject: [PATCH 02/23] feat(event/stream): classify vendor topics to normalized EventKind Adds a Classify function that maps ONVIF topic strings to EventKind so the agent does not need to know AXIS vs Hikvision vs Bosch topic conventions. The classifier canonicalizes topics by stripping XML-namespace prefixes from each path segment, which collapses vendor variants like 'tns1:Device/tnssamsung:DigitalInput' and 'tns1:Device/Trigger/DigitalInput' to a single matchable form. Motion coverage on day one: * tns1:VideoSource/MotionAlarm (AXIS, Bosch, Dahua, ...) * tns1:VideoAnalytics/:MotionAlarm * tns1:RuleEngine/CellMotionDetector/Motion (ONVIF standard, Hikvision) * tns1:RuleEngine/MotionRegionDetector/Motion (AXIS region rule) * tnsaxis:CameraApplicationPlatform/ObjectAnalytics/... Also covers Tamper, DigitalInput, Relay (DigitalOutput), object analytics and audio alarms, so the same Stream can replace the agent's ad-hoc digital I/O polling without losing coverage. --- event/stream/topics.go | 72 +++++++++++++++++++++++++++++++++++++ event/stream/topics_test.go | 53 +++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 event/stream/topics.go create mode 100644 event/stream/topics_test.go diff --git a/event/stream/topics.go b/event/stream/topics.go new file mode 100644 index 0000000..35b1bf4 --- /dev/null +++ b/event/stream/topics.go @@ -0,0 +1,72 @@ +package stream + +import "strings" + +// Classify maps an ONVIF topic string (e.g. "tns1:VideoSource/MotionAlarm") +// to the normalized EventKind that callers should switch on. Returns +// KindUnknown when no rule matches. +// +// The classifier strips XML-namespace prefixes from each path segment so it +// is robust to vendor-specific namespaces like tnsaxis:, tnsbosch:, +// tnssamsung:. Matching is case-sensitive because ONVIF topic identifiers +// are case-sensitive per the spec. +func Classify(topic string) EventKind { + if topic == "" { + return KindUnknown + } + canonical := canonicalizeTopic(topic) + for _, rule := range topicRules { + if strings.Contains(canonical, rule.needle) { + return rule.kind + } + } + return KindUnknown +} + +// canonicalizeTopic strips the XML-namespace prefix (e.g. "tns1:") from each +// "/"-separated segment of the topic. This collapses vendor variants like +// "tns1:Device/tnssamsung:DigitalInput" and the plain +// "tns1:Device/DigitalInput" form to the same canonical path. +func canonicalizeTopic(topic string) string { + segments := strings.Split(topic, "/") + for i, seg := range segments { + if idx := strings.Index(seg, ":"); idx >= 0 { + segments[i] = seg[idx+1:] + } + } + return strings.Join(segments, "/") +} + +// topicRules is evaluated in order; first match wins. Keep the most specific +// rules first when adding new entries — e.g. "MotionDetector/Motion" must +// precede a hypothetical bare "/Motion" rule. +var topicRules = []struct { + needle string + kind EventKind +}{ + // Motion: covers AXIS VideoSource/MotionAlarm, ONVIF + // RuleEngine/CellMotionDetector and MotionRegionDetector, and + // vendor-namespaced MotionAlarm variants (Bosch). + {"MotionAlarm", KindMotion}, + {"CellMotionDetector/Motion", KindMotion}, + {"MotionRegionDetector/Motion", KindMotion}, + + // Tampering: ONVIF RuleEngine/TamperDetector. + {"TamperDetector", KindTampering}, + + // Digital I/O: ONVIF Device/Trigger/{DigitalInput,Relay}. The + // canonicalization step normalizes vendor-prefixed inner segments + // (tnssamsung:DigitalInput, tns1:Relay) to the bare names. + {"Trigger/DigitalInput", KindDigitalInput}, + {"Trigger/Relay", KindDigitalOutput}, + + // Object analytics: AXIS ObjectAnalytics scenarios use dynamic suffixes + // (Device1ScenarioANY, Device1Scenario1, ...), so match the path prefix. + {"ObjectAnalytics/", KindObjectDetected}, + {"ObjectsInside", KindObjectDetected}, + + // Audio: ONVIF AudioAnalytics/Audio/DetectedSound and AXIS + // AudioSource/TriggerLevel. + {"Audio/DetectedSound", KindAudioAlarm}, + {"AudioSource/TriggerLevel", KindAudioAlarm}, +} diff --git a/event/stream/topics_test.go b/event/stream/topics_test.go new file mode 100644 index 0000000..1477d47 --- /dev/null +++ b/event/stream/topics_test.go @@ -0,0 +1,53 @@ +package stream + +import "testing" + +func TestClassifyTopic(t *testing.T) { + tests := []struct { + name string + topic string + want EventKind + }{ + // AXIS: motion alarm on video source. + {"axis_video_source_motion", "tns1:VideoSource/MotionAlarm", KindMotion}, + // AXIS / Hikvision / others: ONVIF cell-motion detector rule. + {"cell_motion_detector", "tns1:RuleEngine/CellMotionDetector/Motion", KindMotion}, + // AXIS region motion. + {"motion_region_detector", "tns1:RuleEngine/MotionRegionDetector/Motion", KindMotion}, + // Vendor-prefixed motion (e.g. Bosch). + {"bosch_motion", "tns1:VideoAnalytics/tnsbosch:MotionAlarm", KindMotion}, + // Tampering / scene change. + {"tamper_detector", "tns1:RuleEngine/TamperDetector/Tamper", KindTampering}, + {"axis_scene_tamper", "tns1:VideoSource/ImageTooDark/ImagingService", KindUnknown}, // not classified + // Digital input — vendor-namespaced variant from agent code. + {"digital_input", "tns1:Device/Trigger/DigitalInput", KindDigitalInput}, + {"digital_input_samsung", "tns1:Device/tns1:Trigger/tnssamsung:DigitalInput", KindDigitalInput}, + // Digital output / relay. + {"relay", "tns1:Device/Trigger/Relay", KindDigitalOutput}, + {"relay_avigilon", "tns1:Device/tns1:Trigger/tns1:Relay", KindDigitalOutput}, + // Analytics object detection (AXIS object analytics, generic motion analytics). + {"object_detected", "tns1:RuleEngine/MyRuleDetector/ObjectsInside", KindObjectDetected}, + {"axis_object_analytics", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1ScenarioANY", KindObjectDetected}, + // Audio. + {"audio_alarm", "tns1:AudioAnalytics/Audio/DetectedSound", KindAudioAlarm}, + {"axis_audio", "tnsaxis:AudioSource/TriggerLevel", KindAudioAlarm}, + // Unknown — should not be force-classified. + {"empty", "", KindUnknown}, + {"unknown_topic", "tns1:UserAlarm/IVA", KindUnknown}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := Classify(tc.topic); got != tc.want { + t.Errorf("Classify(%q) = %v, want %v", tc.topic, got, tc.want) + } + }) + } +} + +func TestClassifyIsCaseSensitive(t *testing.T) { + // ONVIF topic names are case-sensitive per spec; we should not silently + // upper/lower-case. A lowercased topic must not match. + if got := Classify("tns1:videosource/motionalarm"); got != KindUnknown { + t.Errorf("Classify lowercase = %v, want KindUnknown (topics are case-sensitive)", got) + } +} From f679aab0d5b3376bcb8cef36be79d1d168a2b573 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:10:07 +0200 Subject: [PATCH 03/23] feat(event/stream): cross-reference vendor topic strings with public docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-checked each topic string against public sources before adding the rule, and inlined the citation next to the rule it supports so future maintainers can audit the table: * Hikvision motion: CellMotionDetector/Motion (Hikvision PDF on third party motion troubleshooting) plus the VideoSource/MotionAlarm fallback emitted by newer firmware. * Hikvision tamper-class scene change: VideoSource/ImageTooDark|Bright| Blurry — present in the ONVIF topic namespace; treated as Tampering for routing. * Bosch motion: VideoAnalytics/MotionAlarm (Bosch metadata/IVA PDF) — NOT VideoSource/MotionAlarm. The earlier 'tnsbosch:MotionAlarm' guess in PR #194 was wrong; Bosch uses standard tns1 namespace under VideoAnalytics. * Hanwha (Samsung Wisenet): VideoAnalytics/tnssamsung:MotionDetection, VideoAnalytics/tnssamsung:TamperingDetection, AudioAnalytics/tnssamsung:SoundDetection — confirmed via HA #66493 capture. * Avigilon: per-segment-namespaced serialisation (tns1:Device/tns1:Trigger/tns1:Relay) folded by canonicalization. Documented in Avigilon's own ONVIF subscription guide. * Object analytics: LineDetector/Crossed, FieldDetector/ObjectsInside and the MyRuleDetector container for vendor rule names (Bosch IVA, Dahua SMD) — sourced from ONVIF Analytics Service Spec v22.06. * AXIS Object Analytics: prefix match on ObjectAnalytics/ to absorb the dynamic Device1Scenario suffixes (AXIS counting-data docs). Empirical topic table cross-checked with openvideolibs/onvif-parsers (Apache-2.0), the package the Home Assistant ONVIF integration imports — referenced from the package doc-comment. Test cases now cover the verified topic for every supported vendor plus case-sensitivity and canonicalization. No code change for callers: the public API is still just Classify(topic) -> EventKind. --- event/stream/topics.go | 141 ++++++++++++++++++++++++++++++------ event/stream/topics_test.go | 108 ++++++++++++++++++++++----- 2 files changed, 206 insertions(+), 43 deletions(-) diff --git a/event/stream/topics.go b/event/stream/topics.go index 35b1bf4..8149446 100644 --- a/event/stream/topics.go +++ b/event/stream/topics.go @@ -6,10 +6,21 @@ import "strings" // to the normalized EventKind that callers should switch on. Returns // KindUnknown when no rule matches. // -// The classifier strips XML-namespace prefixes from each path segment so it -// is robust to vendor-specific namespaces like tnsaxis:, tnsbosch:, -// tnssamsung:. Matching is case-sensitive because ONVIF topic identifiers -// are case-sensitive per the spec. +// The classifier strips XML-namespace prefixes (tns1:, tnsaxis:, +// tnssamsung:, ...) from each "/"-separated segment of the topic so it is +// robust to vendor namespace variants. Matching is case-sensitive because +// ONVIF topic identifiers are case-sensitive per the spec. +// +// Sources cross-checked when building the rule set below: +// - ONVIF Topic Namespace XML +// https://www.onvif.org/onvif/ver10/topics/topicns.xml +// - ONVIF Analytics Service Spec (RuleEngine topics) +// https://www.onvif.org/specs/srv/analytics/ONVIF-VideoAnalytics-Service-Spec-v220.pdf +// - ONVIF Device IO Service Spec (DigitalInput, Relay) +// https://www.onvif.org/specs/srv/io/ONVIF-DeviceIo-Service-Spec.pdf +// - openvideolibs/onvif-parsers (Apache-2.0) — empirical topic table +// extracted from Home Assistant ONVIF integration +// https://github.com/openvideolibs/onvif-parsers func Classify(topic string) EventKind { if topic == "" { return KindUnknown @@ -23,10 +34,11 @@ func Classify(topic string) EventKind { return KindUnknown } -// canonicalizeTopic strips the XML-namespace prefix (e.g. "tns1:") from each -// "/"-separated segment of the topic. This collapses vendor variants like -// "tns1:Device/tnssamsung:DigitalInput" and the plain -// "tns1:Device/DigitalInput" form to the same canonical path. +// canonicalizeTopic strips the XML-namespace prefix (anything up to and +// including the first ':') from each "/"-separated segment. This collapses +// vendor variants like "tns1:Device/tns1:Trigger/tns1:Relay" (Avigilon +// serialisation) and "tns1:Device/Trigger/Relay" (everyone else) to a +// single matchable form. func canonicalizeTopic(topic string) string { segments := strings.Split(topic, "/") for i, seg := range segments { @@ -37,36 +49,119 @@ func canonicalizeTopic(topic string) string { return strings.Join(segments, "/") } -// topicRules is evaluated in order; first match wins. Keep the most specific -// rules first when adding new entries — e.g. "MotionDetector/Motion" must -// precede a hypothetical bare "/Motion" rule. +// topicRules is evaluated in order; first match wins. Keep more specific +// rules ahead of broader ones — e.g. "ObjectAnalytics/" must precede any +// future bare "Analytics" rule. Each rule cites the documentation that +// supports including it. var topicRules = []struct { needle string kind EventKind }{ - // Motion: covers AXIS VideoSource/MotionAlarm, ONVIF - // RuleEngine/CellMotionDetector and MotionRegionDetector, and - // vendor-namespaced MotionAlarm variants (Bosch). - {"MotionAlarm", KindMotion}, + // ---------- Motion ------------------------------------------------- + + // tns1:VideoSource/MotionAlarm — Profile S basic motion. Emitted by + // AXIS (basic VMD), Bosch, Dahua, Hikvision (newer firmware) and + // Hanwha as a fallback. Data SimpleItem: State (xsd:boolean). + // https://www.onvif.org/ver10/topics/topicns.xml + // https://developer.axis.com/vapix/network-video/event-and-action-services/ + {"VideoSource/MotionAlarm", KindMotion}, + + // tns1:VideoAnalytics/MotionAlarm — Bosch publishes motion under + // VideoAnalytics rather than VideoSource. Data: State. + // https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf + {"VideoAnalytics/MotionAlarm", KindMotion}, + + // tns1:VideoAnalytics/tnssamsung:MotionDetection — Hanwha/Samsung + // Wisenet vendor-namespaced motion. Data: Motion ("0"/"1"). + // https://github.com/home-assistant/core/issues/66493 + {"VideoAnalytics/MotionDetection", KindMotion}, + + // tns1:RuleEngine/CellMotionDetector/Motion — ONVIF Analytics + // standard cell-motion rule. Emitted by AXIS (VMD3+), Hikvision, + // Avigilon analytics, others. Data: IsMotion (xsd:boolean). + // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.3 + // https://www.hikvisioneurope.com/eu/portal/portal/Technical%20Materials/24%20How%20To/CCTV/How%20to%20solve%20third%20party%20camera%20motion%20detection%20issue.pdf {"CellMotionDetector/Motion", KindMotion}, + + // tns1:RuleEngine/MotionRegionDetector/Motion — AXIS-specific region + // motion rule. Data: IsMotion (xsd:boolean). + // https://developer.axis.com/vapix/network-video/event-and-action-services/ {"MotionRegionDetector/Motion", KindMotion}, - // Tampering: ONVIF RuleEngine/TamperDetector. + // ---------- Tampering --------------------------------------------- + + // tns1:RuleEngine/TamperDetector/Tamper — standard ONVIF tamper + // rule. Data: IsTamper (xsd:boolean). + // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.5 {"TamperDetector", KindTampering}, - // Digital I/O: ONVIF Device/Trigger/{DigitalInput,Relay}. The - // canonicalization step normalizes vendor-prefixed inner segments - // (tnssamsung:DigitalInput, tns1:Relay) to the bare names. + // tns1:VideoSource/ImageTooDark|ImageTooBright|ImageTooBlurry — + // scene-change-class signals emitted by Hikvision (and some others) + // on firmwares without a TamperDetector rule. Treated as Tampering + // for the purpose of normalised event routing. + // https://www.onvif.org/ver10/topics/topicns.xml + {"VideoSource/ImageTooDark", KindTampering}, + {"VideoSource/ImageTooBright", KindTampering}, + {"VideoSource/ImageTooBlurry", KindTampering}, + + // tns1:VideoAnalytics/tnssamsung:TamperingDetection — Hanwha vendor. + // https://github.com/home-assistant/core/issues/66493 + {"VideoAnalytics/TamperingDetection", KindTampering}, + + // ---------- Digital I/O ------------------------------------------- + + // tns1:Device/Trigger/DigitalInput — standard ONVIF DeviceIO topic. + // Avigilon emits the per-segment-prefixed variant + // "tns1:Device/tns1:Trigger/tns1:DigitalInput"; canonicalization + // folds both to the same path. Data: LogicalState (xsd:boolean). + // ONVIF-DeviceIo-Service-Spec.pdf §5.2 {"Trigger/DigitalInput", KindDigitalInput}, + + // tns1:Device/Trigger/Relay — standard ONVIF DeviceIO topic. Same + // canonicalisation note as DigitalInput. Data: LogicalState. + // ONVIF-DeviceIo-Service-Spec.pdf §5.3 {"Trigger/Relay", KindDigitalOutput}, - // Object analytics: AXIS ObjectAnalytics scenarios use dynamic suffixes - // (Device1ScenarioANY, Device1Scenario1, ...), so match the path prefix. + // ---------- Object analytics -------------------------------------- + + // tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario + // — AXIS Object Analytics. The Scenario suffix is dynamic + // (Device1Scenario1, Device1ScenarioANY, ...) so we match the path + // prefix. Data: active ("0"/"1"). + // https://developer.axis.com/analytics/axis-object-analytics/how-to-guides/axis-object-analytics-counting-data/ {"ObjectAnalytics/", KindObjectDetected}, + + // tns1:RuleEngine/LineDetector/Crossed — line crossing (Hikvision, + // Bosch IVA, others). Data: ObjectId (xsd:int). + // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4 + {"LineDetector/Crossed", KindObjectDetected}, + + // tns1:RuleEngine/FieldDetector/ObjectsInside — intrusion / region + // detector (Hikvision, Bosch, Dahua). + {"FieldDetector/ObjectsInside", KindObjectDetected}, + + // tns1:RuleEngine/MyRuleDetector/ — vendor-defined rule + // names under the ONVIF "MyRuleDetector" container. Bosch IVA and + // Dahua SMD publish HumanDetect, VehicleDetect, ObjectsInside, etc. + // here. We match the container so future rule names are picked up + // automatically. + // https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf + {"MyRuleDetector/", KindObjectDetected}, + + // Fallback retained for legacy ObjectsInside callers that omit the + // MyRuleDetector container. {"ObjectsInside", KindObjectDetected}, - // Audio: ONVIF AudioAnalytics/Audio/DetectedSound and AXIS - // AudioSource/TriggerLevel. + // ---------- Audio -------------------------------------------------- + + // tns1:AudioAnalytics/Audio/DetectedSound — standard ONVIF audio + // detection. Data: State (xsd:boolean). {"Audio/DetectedSound", KindAudioAlarm}, + + // tns1:AudioSource/tnsaxis:TriggerLevel — AXIS audio level alarm. + // https://developer.axis.com/vapix/network-video/event-and-action-services/ {"AudioSource/TriggerLevel", KindAudioAlarm}, + + // tns1:AudioAnalytics/tnssamsung:SoundDetection — Hanwha vendor. + {"AudioAnalytics/SoundDetection", KindAudioAlarm}, } diff --git a/event/stream/topics_test.go b/event/stream/topics_test.go index 1477d47..58a96de 100644 --- a/event/stream/topics_test.go +++ b/event/stream/topics_test.go @@ -8,32 +8,83 @@ func TestClassifyTopic(t *testing.T) { topic string want EventKind }{ - // AXIS: motion alarm on video source. - {"axis_video_source_motion", "tns1:VideoSource/MotionAlarm", KindMotion}, - // AXIS / Hikvision / others: ONVIF cell-motion detector rule. + // --- Motion ----------------------------------------------------- + + // Profile S basic motion (AXIS basic VMD, Bosch, Dahua, + // Hikvision newer firmware, Hanwha fallback). Data: State. + {"video_source_motion_alarm", "tns1:VideoSource/MotionAlarm", KindMotion}, + + // ONVIF Analytics rule (AXIS, Hikvision standard, Avigilon + // analytics). Data: IsMotion. {"cell_motion_detector", "tns1:RuleEngine/CellMotionDetector/Motion", KindMotion}, - // AXIS region motion. + + // AXIS region rule. Data: IsMotion. {"motion_region_detector", "tns1:RuleEngine/MotionRegionDetector/Motion", KindMotion}, - // Vendor-prefixed motion (e.g. Bosch). - {"bosch_motion", "tns1:VideoAnalytics/tnsbosch:MotionAlarm", KindMotion}, - // Tampering / scene change. + + // Bosch publishes motion under VideoAnalytics (not VideoSource). + {"bosch_video_analytics_motion", "tns1:VideoAnalytics/MotionAlarm", KindMotion}, + + // Hanwha (Samsung/Wisenet) vendor-namespaced motion. + {"hanwha_samsung_motion", "tns1:VideoAnalytics/tnssamsung:MotionDetection", KindMotion}, + + // --- Tampering / scene change ---------------------------------- + + // ONVIF RuleEngine tamper rule. Data: IsTamper. {"tamper_detector", "tns1:RuleEngine/TamperDetector/Tamper", KindTampering}, - {"axis_scene_tamper", "tns1:VideoSource/ImageTooDark/ImagingService", KindUnknown}, // not classified - // Digital input — vendor-namespaced variant from agent code. + + // Hikvision uses VideoSource/Image* topics for tamper-class + // signals on firmwares without TamperDetector. + {"hikvision_image_too_dark", "tns1:VideoSource/ImageTooDark/ImagingService", KindTampering}, + {"hikvision_image_too_bright", "tns1:VideoSource/ImageTooBright/ImagingService", KindTampering}, + {"hikvision_image_too_blurry", "tns1:VideoSource/ImageTooBlurry/ImagingService", KindTampering}, + + // Hanwha vendor-namespaced tampering. + {"hanwha_tampering", "tns1:VideoAnalytics/tnssamsung:TamperingDetection", KindTampering}, + + // --- Digital input --------------------------------------------- + + // Standard ONVIF Device IO topic — same across all vendors that + // follow the spec. {"digital_input", "tns1:Device/Trigger/DigitalInput", KindDigitalInput}, - {"digital_input_samsung", "tns1:Device/tns1:Trigger/tnssamsung:DigitalInput", KindDigitalInput}, - // Digital output / relay. + + // Avigilon serialises every path segment with a namespace prefix. + {"digital_input_avigilon", "tns1:Device/tns1:Trigger/tns1:DigitalInput", KindDigitalInput}, + + // --- Digital output / relay ------------------------------------ + {"relay", "tns1:Device/Trigger/Relay", KindDigitalOutput}, {"relay_avigilon", "tns1:Device/tns1:Trigger/tns1:Relay", KindDigitalOutput}, - // Analytics object detection (AXIS object analytics, generic motion analytics). - {"object_detected", "tns1:RuleEngine/MyRuleDetector/ObjectsInside", KindObjectDetected}, - {"axis_object_analytics", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1ScenarioANY", KindObjectDetected}, - // Audio. - {"audio_alarm", "tns1:AudioAnalytics/Audio/DetectedSound", KindAudioAlarm}, - {"axis_audio", "tnsaxis:AudioSource/TriggerLevel", KindAudioAlarm}, - // Unknown — should not be force-classified. + + // --- Object analytics ------------------------------------------ + + // AXIS Object Analytics scenarios — the suffix is dynamic + // (Device1Scenario1, Device1ScenarioANY, ...). + {"axis_object_analytics_scenario_any", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1ScenarioANY", KindObjectDetected}, + {"axis_object_analytics_scenario_1", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", KindObjectDetected}, + + // Hikvision line crossing. + {"line_detector_crossed", "tns1:RuleEngine/LineDetector/Crossed", KindObjectDetected}, + + // Region / intrusion detector. + {"field_detector_objects_inside", "tns1:RuleEngine/FieldDetector/ObjectsInside", KindObjectDetected}, + + // Bosch IVA / Dahua SMD publish vendor rule names under + // MyRuleDetector. + {"my_rule_detector_human", "tns1:RuleEngine/MyRuleDetector/HumanDetect", KindObjectDetected}, + {"my_rule_detector_vehicle", "tns1:RuleEngine/MyRuleDetector/VehicleDetect", KindObjectDetected}, + {"my_rule_detector_objects_inside", "tns1:RuleEngine/MyRuleDetector/ObjectsInside", KindObjectDetected}, + + // --- Audio ----------------------------------------------------- + + {"audio_detected_sound", "tns1:AudioAnalytics/Audio/DetectedSound", KindAudioAlarm}, + {"axis_audio_trigger_level", "tns1:AudioSource/tnsaxis:TriggerLevel", KindAudioAlarm}, + {"hanwha_sound_detection", "tns1:AudioAnalytics/tnssamsung:SoundDetection", KindAudioAlarm}, + + // --- Negative cases -------------------------------------------- + {"empty", "", KindUnknown}, {"unknown_topic", "tns1:UserAlarm/IVA", KindUnknown}, + {"unrelated_recording_config", "tns1:RecordingConfig/JobState", KindUnknown}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -45,9 +96,26 @@ func TestClassifyTopic(t *testing.T) { } func TestClassifyIsCaseSensitive(t *testing.T) { - // ONVIF topic names are case-sensitive per spec; we should not silently - // upper/lower-case. A lowercased topic must not match. + // ONVIF topic identifiers are case-sensitive per the spec; a + // lowercased topic must not match a capitalised pattern. if got := Classify("tns1:videosource/motionalarm"); got != KindUnknown { t.Errorf("Classify lowercase = %v, want KindUnknown (topics are case-sensitive)", got) } } + +func TestCanonicalizeTopicStripsNamespaces(t *testing.T) { + tests := []struct { + in, want string + }{ + {"tns1:VideoSource/MotionAlarm", "VideoSource/MotionAlarm"}, + {"tns1:Device/tns1:Trigger/tns1:Relay", "Device/Trigger/Relay"}, + {"tns1:VideoAnalytics/tnssamsung:MotionDetection", "VideoAnalytics/MotionDetection"}, + {"tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1"}, + {"", ""}, + } + for _, tc := range tests { + if got := canonicalizeTopic(tc.in); got != tc.want { + t.Errorf("canonicalizeTopic(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} From 4da4842f61dc195b5165bdf36e0910a42cb2b1d4 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:11:46 +0200 Subject: [PATCH 04/23] test(event/stream): adopt testify to match existing lib style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of github.com/kerberos-io/onvif uses stretchr/testify (assert, require) consistently — Device_test.go, event/type_test.go, media2/types_test.go, ws-discovery/networking_test.go. Migrate the two new test files in event/stream from stdlib t.Errorf to the same testify convention so the package fits in without local style variation. No production-code change; no behaviour change. --- event/stream/topics_test.go | 18 ++++++++---------- event/stream/types_test.go | 30 +++++++++--------------------- 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/event/stream/topics_test.go b/event/stream/topics_test.go index 58a96de..482dad2 100644 --- a/event/stream/topics_test.go +++ b/event/stream/topics_test.go @@ -1,6 +1,10 @@ package stream -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" +) func TestClassifyTopic(t *testing.T) { tests := []struct { @@ -88,9 +92,7 @@ func TestClassifyTopic(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - if got := Classify(tc.topic); got != tc.want { - t.Errorf("Classify(%q) = %v, want %v", tc.topic, got, tc.want) - } + assert.Equal(t, tc.want, Classify(tc.topic), "topic=%q", tc.topic) }) } } @@ -98,9 +100,7 @@ func TestClassifyTopic(t *testing.T) { func TestClassifyIsCaseSensitive(t *testing.T) { // ONVIF topic identifiers are case-sensitive per the spec; a // lowercased topic must not match a capitalised pattern. - if got := Classify("tns1:videosource/motionalarm"); got != KindUnknown { - t.Errorf("Classify lowercase = %v, want KindUnknown (topics are case-sensitive)", got) - } + assert.Equal(t, KindUnknown, Classify("tns1:videosource/motionalarm")) } func TestCanonicalizeTopicStripsNamespaces(t *testing.T) { @@ -114,8 +114,6 @@ func TestCanonicalizeTopicStripsNamespaces(t *testing.T) { {"", ""}, } for _, tc := range tests { - if got := canonicalizeTopic(tc.in); got != tc.want { - t.Errorf("canonicalizeTopic(%q) = %q, want %q", tc.in, got, tc.want) - } + assert.Equal(t, tc.want, canonicalizeTopic(tc.in), "input=%q", tc.in) } } diff --git a/event/stream/types_test.go b/event/stream/types_test.go index 45d4069..d6e8313 100644 --- a/event/stream/types_test.go +++ b/event/stream/types_test.go @@ -3,6 +3,8 @@ package stream import ( "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestEventKindString(t *testing.T) { @@ -20,9 +22,7 @@ func TestEventKindString(t *testing.T) { {EventKind(255), "EventKind(255)"}, } for _, tc := range tests { - if got := tc.kind.String(); got != tc.want { - t.Errorf("EventKind(%d).String() = %q, want %q", tc.kind, got, tc.want) - } + assert.Equal(t, tc.want, tc.kind.String()) } } @@ -37,9 +37,7 @@ func TestEventStateString(t *testing.T) { {EventState(255), "EventState(255)"}, } for _, tc := range tests { - if got := tc.state.String(); got != tc.want { - t.Errorf("EventState(%d).String() = %q, want %q", tc.state, got, tc.want) - } + assert.Equal(t, tc.want, tc.state.String()) } } @@ -55,29 +53,19 @@ func TestPropertyOperationString(t *testing.T) { {PropertyOperation(255), "PropertyOperation(255)"}, } for _, tc := range tests { - if got := tc.op.String(); got != tc.want { - t.Errorf("PropertyOperation(%d).String() = %q, want %q", tc.op, got, tc.want) - } + assert.Equal(t, tc.want, tc.op.String()) } } func TestEventZeroValue(t *testing.T) { var e Event - if e.Kind != KindUnknown { - t.Errorf("zero Event.Kind = %v, want KindUnknown", e.Kind) - } - if e.State != StateUnknown { - t.Errorf("zero Event.State = %v, want StateUnknown", e.State) - } - if !e.Timestamp.IsZero() { - t.Errorf("zero Event.Timestamp = %v, want zero time", e.Timestamp) - } + assert.Equal(t, KindUnknown, e.Kind) + assert.Equal(t, StateUnknown, e.State) + assert.True(t, e.Timestamp.IsZero()) } func TestEventTimestampPreserved(t *testing.T) { now := time.Now() e := Event{Timestamp: now} - if !e.Timestamp.Equal(now) { - t.Errorf("Event.Timestamp = %v, want %v", e.Timestamp, now) - } + assert.True(t, e.Timestamp.Equal(now)) } From da1ecf8e0a0b27ff640a0308841121b344d783a9 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:25:59 +0200 Subject: [PATCH 05/23] refactor(event/stream): address API and classifier review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel expert review of the four-commit scaffold surfaced 13 actionable items split across API design, ONVIF domain accuracy, Go idiomaticity and test rigor. This change addresses them before the Stream type lands, when the public surface is still cheap to move. API shape (hard-to-reverse before tagging) ------------------------------------------ * Rename EventKind -> Kind and EventState -> State to avoid the stream.EventKind / stream.EventState stutter when imported. * Restructure Event for non-lossy decode: - Source string and RawValue string replaced with Source/Data maps so multi-item ONVIF Source and Data lists (e.g. AXIS AOA emitting active+classType+confidence; DigitalInput carrying InputToken+ LogicalState) are preserved. - Add DeviceID so a single channel can fan in events from multiple cameras. - Add DeviceTime parsed from wsnt:UtcTime alongside the local observation Timestamp. The earlier doc-comment decision to bake-in 'drop UtcTime' was a policy disguised as an API; expose both and let callers choose. Classifier accuracy (ONVIF domain audit) ---------------------------------------- * Introduce KindImageQuality for tns1:VideoSource/ImageTooDark|Bright| Blurry. These are imaging-quality alarms that integrators route separately because they fire on sunset/dawn/condensation, not tamper. Previously mis-classified as KindTampering. * Add tns1:VideoSource/GlobalSceneChange -> KindTampering, which is the real lens-cover signal on firmwares without TamperDetector. * Anchor the TamperDetector rule to 'TamperDetector/Tamper' so a hypothetical 'TamperDetectorLog' path cannot match. * Narrow MyRuleDetector from container-match to an explicit whitelist (HumanDetect, VehicleDetect, PeopleDetect, ObjectsInside, FaceDetect). Bosch publishes Counter and Occupancy under MyRuleDetector too; those must not classify as ObjectDetected. * Add the AXIS Guard suite (MotionGuard, FenceGuard, LoiteringGuard) -> KindMotion. Common on AXIS deployments configured with these apps instead of basic VMD. * Drop the bogus Device1ScenarioANY test fixture; AOA uses numeric scenarios (Device1Scenario1, Device1Scenario2). The 'ANY' suffix was a borrow from the older Guard suite's Camera1ProfileANY pattern. * Document the edge-trigger semantics of LineDetector/Crossed in the rule comment so decoder consumers do not expect a State boolean. Tests ----- * String tests now use t.Run subtests so failures name the case. * TestKindStringsAreUnique guards against accidental String() aliasing when adding new kinds. * TestEventFieldAssignmentRoundTrip exercises the new field set including DeviceID, Source/Data maps and DeviceTime. * Canonicalisation table now covers: double slash, colon-only segment, trailing colon, multi-colon-in-segment, leading/trailing slash, no-colon passthrough. Locks the actual behaviour so future refactors see regressions. * False-positive negatives: Counter and Occupancy under MyRuleDetector, AudioEncoderConfiguration, RelayFailure, DigitalInputConfiguration, TamperDetectorLog, MotionRecording/Started — all assert KindUnknown. * TestClassifyRuleOrder_ObjectAnalyticsBeforeGenericObjects pins the ordering invariant called out by the architect reviewer. Documentation ------------- * doc.go trimmed so it does not advertise NewStream / Events / Errors / Close before those identifiers exist — the godoc reader will no longer see dead names. Re-expanded when the Stream type lands. Deferred to the Stream commit ----------------------------- * PropertyUnknown vs PropertyUnset disambiguation — kept as PropertyUnknown for now with a clarified doc comment; revisit when the decoder needs to distinguish 'absent on wire' from 'unparseable'. * Classifier pluggability (WithClassifier option) — meaningful only once there is a Stream; revisit at that commit. --- event/stream/doc.go | 16 +++--- event/stream/topics.go | 95 ++++++++++++++++++++++----------- event/stream/topics_test.go | 103 +++++++++++++++++++++--------------- event/stream/types.go | 90 +++++++++++++++++++++---------- event/stream/types_test.go | 99 ++++++++++++++++++++++++---------- 5 files changed, 267 insertions(+), 136 deletions(-) diff --git a/event/stream/doc.go b/event/stream/doc.go index ba0f79b..0223ae1 100644 --- a/event/stream/doc.go +++ b/event/stream/doc.go @@ -1,13 +1,13 @@ -// Package stream provides a long-running, channel-based consumer for ONVIF -// device events. It hides the SOAP/XML, pull-point lifecycle, renewal and -// vendor-specific topic conventions behind a typed Event stream. +// Package stream will provide a long-running, channel-based consumer for +// ONVIF device events. It is meant to hide the SOAP/XML, pull-point +// subscription lifecycle, subscription renewal and vendor-specific topic +// conventions behind a typed Event stream. // -// A Stream is created with NewStream and yields decoded Event values on the -// channel returned by Events. Non-fatal errors (transient SOAP failures that -// the stream recovers from) are surfaced on Errors. The Stream is stopped by -// cancelling the context passed to NewStream or by calling Close. +// This file lays down the value types (Kind, State, PropertyOperation, +// Event) and the topic Classifier. The Stream type, its NewStream +// constructor and the Events/Errors channels land in follow-up changes. // // The package classifies vendor-specific topic strings (AXIS, Hikvision, -// Avigilon, Hanwha, Bosch, Dahua) into a small set of normalized EventKind +// Avigilon, Hanwha, Bosch, Dahua) into a small set of normalized Kind // values so callers do not need to special-case device manufacturers. package stream diff --git a/event/stream/topics.go b/event/stream/topics.go index 8149446..9aba5be 100644 --- a/event/stream/topics.go +++ b/event/stream/topics.go @@ -3,7 +3,7 @@ package stream import "strings" // Classify maps an ONVIF topic string (e.g. "tns1:VideoSource/MotionAlarm") -// to the normalized EventKind that callers should switch on. Returns +// to the normalized Kind that callers should switch on. Returns // KindUnknown when no rule matches. // // The classifier strips XML-namespace prefixes (tns1:, tnsaxis:, @@ -21,7 +21,7 @@ import "strings" // - openvideolibs/onvif-parsers (Apache-2.0) — empirical topic table // extracted from Home Assistant ONVIF integration // https://github.com/openvideolibs/onvif-parsers -func Classify(topic string) EventKind { +func Classify(topic string) Kind { if topic == "" { return KindUnknown } @@ -39,6 +39,10 @@ func Classify(topic string) EventKind { // vendor variants like "tns1:Device/tns1:Trigger/tns1:Relay" (Avigilon // serialisation) and "tns1:Device/Trigger/Relay" (everyone else) to a // single matchable form. +// +// A segment that is only a prefix (e.g. "tns1:") canonicalizes to the +// empty string. Multiple colons in one segment are not expected in real +// ONVIF topics; the first colon wins. func canonicalizeTopic(topic string) string { segments := strings.Split(topic, "/") for i, seg := range segments { @@ -51,11 +55,21 @@ func canonicalizeTopic(topic string) string { // topicRules is evaluated in order; first match wins. Keep more specific // rules ahead of broader ones — e.g. "ObjectAnalytics/" must precede any -// future bare "Analytics" rule. Each rule cites the documentation that -// supports including it. +// future bare "Analytics" rule, and "MyRuleDetector/HumanDetect" must +// precede a hypothetical broader "MyRuleDetector" entry. Each rule cites +// the documentation that supports including it. +// +// Substring matching is intentional so vendor-specific path prefixes +// outside the standard tns1: namespace (e.g. +// tnsaxis:CameraApplicationPlatform/...) still match. +// +// Note on edge-triggered topics: tns1:RuleEngine/LineDetector/Crossed +// carries an ObjectId rather than a State boolean. Consumers of Crossed +// must not expect a level-triggered Active/Inactive semantic — the Stream +// decoder will leave State as StateUnknown for these. var topicRules = []struct { needle string - kind EventKind + kind Kind }{ // ---------- Motion ------------------------------------------------- @@ -88,69 +102,90 @@ var topicRules = []struct { // https://developer.axis.com/vapix/network-video/event-and-action-services/ {"MotionRegionDetector/Motion", KindMotion}, + // AXIS Guard suite — vendor analytics apps that fire motion-like + // events with CameraProfile suffixes. Treated as motion so + // they can drive motion-triggered recording on cameras configured + // with these apps instead of basic VMD. + // https://developer.axis.com/vapix/applications/motion-guard + {"CameraApplicationPlatform/MotionGuard/", KindMotion}, + {"CameraApplicationPlatform/FenceGuard/", KindMotion}, + {"CameraApplicationPlatform/LoiteringGuard/", KindMotion}, + // ---------- Tampering --------------------------------------------- // tns1:RuleEngine/TamperDetector/Tamper — standard ONVIF tamper - // rule. Data: IsTamper (xsd:boolean). + // rule. Data: IsTamper (xsd:boolean). Anchored on the rule-name + // segment so "TamperDetectorLog" (hypothetical) does not match. // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.5 - {"TamperDetector", KindTampering}, + {"TamperDetector/Tamper", KindTampering}, - // tns1:VideoSource/ImageTooDark|ImageTooBright|ImageTooBlurry — - // scene-change-class signals emitted by Hikvision (and some others) - // on firmwares without a TamperDetector rule. Treated as Tampering - // for the purpose of normalised event routing. + // tns1:VideoSource/GlobalSceneChange/ImagingService — Hikvision (and + // others) emit this on real lens-cover / scene substitution. This is + // the proper tamper signal on firmwares without TamperDetector. // https://www.onvif.org/ver10/topics/topicns.xml - {"VideoSource/ImageTooDark", KindTampering}, - {"VideoSource/ImageTooBright", KindTampering}, - {"VideoSource/ImageTooBlurry", KindTampering}, + {"GlobalSceneChange", KindTampering}, // tns1:VideoAnalytics/tnssamsung:TamperingDetection — Hanwha vendor. // https://github.com/home-assistant/core/issues/66493 {"VideoAnalytics/TamperingDetection", KindTampering}, + // ---------- Image quality ----------------------------------------- + + // tns1:VideoSource/ImageTooDark|ImageTooBright|ImageTooBlurry — + // imaging-quality alarms. Integrators (Milestone, Genetec, Frigate) + // route these separately from tamper because they fire on legitimate + // sunset/dawn/condensation transitions, not on actual interference. + // https://www.onvif.org/ver10/topics/topicns.xml + {"VideoSource/ImageTooDark", KindImageQuality}, + {"VideoSource/ImageTooBright", KindImageQuality}, + {"VideoSource/ImageTooBlurry", KindImageQuality}, + // ---------- Digital I/O ------------------------------------------- // tns1:Device/Trigger/DigitalInput — standard ONVIF DeviceIO topic. // Avigilon emits the per-segment-prefixed variant // "tns1:Device/tns1:Trigger/tns1:DigitalInput"; canonicalization - // folds both to the same path. Data: LogicalState (xsd:boolean). + // folds both to the same path. Data: LogicalState (xsd:boolean), + // Source: InputToken. // ONVIF-DeviceIo-Service-Spec.pdf §5.2 {"Trigger/DigitalInput", KindDigitalInput}, // tns1:Device/Trigger/Relay — standard ONVIF DeviceIO topic. Same - // canonicalisation note as DigitalInput. Data: LogicalState. + // canonicalisation note as DigitalInput. Data: LogicalState, + // Source: RelayToken. // ONVIF-DeviceIo-Service-Spec.pdf §5.3 {"Trigger/Relay", KindDigitalOutput}, // ---------- Object analytics -------------------------------------- // tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario - // — AXIS Object Analytics. The Scenario suffix is dynamic - // (Device1Scenario1, Device1ScenarioANY, ...) so we match the path - // prefix. Data: active ("0"/"1"). + // — AXIS Object Analytics. Scenario suffixes are numeric per the + // AOA configuration (Device1Scenario1, Device1Scenario2, ...). Data: + // active ("0"/"1") plus classType / confidence when configured. // https://developer.axis.com/analytics/axis-object-analytics/how-to-guides/axis-object-analytics-counting-data/ {"ObjectAnalytics/", KindObjectDetected}, // tns1:RuleEngine/LineDetector/Crossed — line crossing (Hikvision, - // Bosch IVA, others). Data: ObjectId (xsd:int). + // Bosch IVA, others). Data: ObjectId (xsd:int); edge-triggered, no + // State boolean. // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4 {"LineDetector/Crossed", KindObjectDetected}, // tns1:RuleEngine/FieldDetector/ObjectsInside — intrusion / region - // detector (Hikvision, Bosch, Dahua). + // detector (Hikvision, Bosch, Dahua). Data: IsInside (xsd:boolean). {"FieldDetector/ObjectsInside", KindObjectDetected}, // tns1:RuleEngine/MyRuleDetector/ — vendor-defined rule - // names under the ONVIF "MyRuleDetector" container. Bosch IVA and - // Dahua SMD publish HumanDetect, VehicleDetect, ObjectsInside, etc. - // here. We match the container so future rule names are picked up - // automatically. + // names under the ONVIF MyRuleDetector container. We whitelist + // object-class rules emitted by Bosch IVA, Dahua SMD and Hikvision + // AcuSense so non-object rules under the same container (Bosch + // Counter, Occupancy) do not get mis-classified. // https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf - {"MyRuleDetector/", KindObjectDetected}, - - // Fallback retained for legacy ObjectsInside callers that omit the - // MyRuleDetector container. - {"ObjectsInside", KindObjectDetected}, + {"MyRuleDetector/HumanDetect", KindObjectDetected}, + {"MyRuleDetector/VehicleDetect", KindObjectDetected}, + {"MyRuleDetector/PeopleDetect", KindObjectDetected}, + {"MyRuleDetector/ObjectsInside", KindObjectDetected}, + {"MyRuleDetector/FaceDetect", KindObjectDetected}, // ---------- Audio -------------------------------------------------- diff --git a/event/stream/topics_test.go b/event/stream/topics_test.go index 482dad2..2bc2a34 100644 --- a/event/stream/topics_test.go +++ b/event/stream/topics_test.go @@ -10,72 +10,58 @@ func TestClassifyTopic(t *testing.T) { tests := []struct { name string topic string - want EventKind + want Kind }{ // --- Motion ----------------------------------------------------- - // Profile S basic motion (AXIS basic VMD, Bosch, Dahua, - // Hikvision newer firmware, Hanwha fallback). Data: State. {"video_source_motion_alarm", "tns1:VideoSource/MotionAlarm", KindMotion}, - - // ONVIF Analytics rule (AXIS, Hikvision standard, Avigilon - // analytics). Data: IsMotion. {"cell_motion_detector", "tns1:RuleEngine/CellMotionDetector/Motion", KindMotion}, - - // AXIS region rule. Data: IsMotion. {"motion_region_detector", "tns1:RuleEngine/MotionRegionDetector/Motion", KindMotion}, - - // Bosch publishes motion under VideoAnalytics (not VideoSource). {"bosch_video_analytics_motion", "tns1:VideoAnalytics/MotionAlarm", KindMotion}, - - // Hanwha (Samsung/Wisenet) vendor-namespaced motion. {"hanwha_samsung_motion", "tns1:VideoAnalytics/tnssamsung:MotionDetection", KindMotion}, - // --- Tampering / scene change ---------------------------------- + // AXIS Guard suite — vendor analytics apps. + {"axis_motion_guard", "tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", KindMotion}, + {"axis_fence_guard", "tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1ProfileANY", KindMotion}, + {"axis_loitering_guard", "tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1ProfileANY", KindMotion}, + + // --- Tampering -------------------------------------------------- - // ONVIF RuleEngine tamper rule. Data: IsTamper. {"tamper_detector", "tns1:RuleEngine/TamperDetector/Tamper", KindTampering}, - - // Hikvision uses VideoSource/Image* topics for tamper-class - // signals on firmwares without TamperDetector. - {"hikvision_image_too_dark", "tns1:VideoSource/ImageTooDark/ImagingService", KindTampering}, - {"hikvision_image_too_bright", "tns1:VideoSource/ImageTooBright/ImagingService", KindTampering}, - {"hikvision_image_too_blurry", "tns1:VideoSource/ImageTooBlurry/ImagingService", KindTampering}, - - // Hanwha vendor-namespaced tampering. + {"global_scene_change", "tns1:VideoSource/GlobalSceneChange/ImagingService", KindTampering}, {"hanwha_tampering", "tns1:VideoAnalytics/tnssamsung:TamperingDetection", KindTampering}, + // --- Image quality (separated from Tampering) ------------------ + + {"image_too_dark", "tns1:VideoSource/ImageTooDark/ImagingService", KindImageQuality}, + {"image_too_bright", "tns1:VideoSource/ImageTooBright/ImagingService", KindImageQuality}, + {"image_too_blurry", "tns1:VideoSource/ImageTooBlurry/ImagingService", KindImageQuality}, + // --- Digital input --------------------------------------------- - // Standard ONVIF Device IO topic — same across all vendors that - // follow the spec. {"digital_input", "tns1:Device/Trigger/DigitalInput", KindDigitalInput}, - - // Avigilon serialises every path segment with a namespace prefix. {"digital_input_avigilon", "tns1:Device/tns1:Trigger/tns1:DigitalInput", KindDigitalInput}, - // --- Digital output / relay ------------------------------------ + // --- Digital output -------------------------------------------- {"relay", "tns1:Device/Trigger/Relay", KindDigitalOutput}, {"relay_avigilon", "tns1:Device/tns1:Trigger/tns1:Relay", KindDigitalOutput}, // --- Object analytics ------------------------------------------ - // AXIS Object Analytics scenarios — the suffix is dynamic - // (Device1Scenario1, Device1ScenarioANY, ...). - {"axis_object_analytics_scenario_any", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1ScenarioANY", KindObjectDetected}, + // AXIS Object Analytics uses numeric scenario suffixes. {"axis_object_analytics_scenario_1", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", KindObjectDetected}, + {"axis_object_analytics_scenario_2", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario2", KindObjectDetected}, - // Hikvision line crossing. + // Standard rule-engine analytics topics. {"line_detector_crossed", "tns1:RuleEngine/LineDetector/Crossed", KindObjectDetected}, - - // Region / intrusion detector. {"field_detector_objects_inside", "tns1:RuleEngine/FieldDetector/ObjectsInside", KindObjectDetected}, - // Bosch IVA / Dahua SMD publish vendor rule names under - // MyRuleDetector. + // Whitelisted MyRuleDetector sub-rules. {"my_rule_detector_human", "tns1:RuleEngine/MyRuleDetector/HumanDetect", KindObjectDetected}, {"my_rule_detector_vehicle", "tns1:RuleEngine/MyRuleDetector/VehicleDetect", KindObjectDetected}, + {"my_rule_detector_people", "tns1:RuleEngine/MyRuleDetector/PeopleDetect", KindObjectDetected}, + {"my_rule_detector_face", "tns1:RuleEngine/MyRuleDetector/FaceDetect", KindObjectDetected}, {"my_rule_detector_objects_inside", "tns1:RuleEngine/MyRuleDetector/ObjectsInside", KindObjectDetected}, // --- Audio ----------------------------------------------------- @@ -89,6 +75,19 @@ func TestClassifyTopic(t *testing.T) { {"empty", "", KindUnknown}, {"unknown_topic", "tns1:UserAlarm/IVA", KindUnknown}, {"unrelated_recording_config", "tns1:RecordingConfig/JobState", KindUnknown}, + + // MyRuleDetector overmatch guard — Bosch publishes counter and + // occupancy under the same container and these must not be + // classified as object detection. + {"my_rule_detector_counter_not_object", "tns1:RuleEngine/MyRuleDetector/Counter", KindUnknown}, + {"my_rule_detector_occupancy_not_object", "tns1:RuleEngine/MyRuleDetector/Occupancy", KindUnknown}, + + // Substring guards. + {"motion_recording_not_motion", "tns1:Recording/MotionRecording/Started", KindUnknown}, + {"audio_encoder_config_not_audio_alarm", "tns1:Configuration/AudioEncoderConfiguration", KindUnknown}, + {"relay_failure_not_digital_output", "tns1:Device/HardwareFailure/RelayFailure", KindUnknown}, + {"digital_input_config_not_digital_input", "tns1:Device/IO/DigitalInputConfiguration", KindUnknown}, + {"tamper_detector_log_not_tampering", "tns1:Device/Diagnostics/TamperDetectorLog", KindUnknown}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -105,15 +104,35 @@ func TestClassifyIsCaseSensitive(t *testing.T) { func TestCanonicalizeTopicStripsNamespaces(t *testing.T) { tests := []struct { - in, want string + name string + in string + want string }{ - {"tns1:VideoSource/MotionAlarm", "VideoSource/MotionAlarm"}, - {"tns1:Device/tns1:Trigger/tns1:Relay", "Device/Trigger/Relay"}, - {"tns1:VideoAnalytics/tnssamsung:MotionDetection", "VideoAnalytics/MotionDetection"}, - {"tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1"}, - {"", ""}, + {"single_namespace", "tns1:VideoSource/MotionAlarm", "VideoSource/MotionAlarm"}, + {"per_segment_namespace", "tns1:Device/tns1:Trigger/tns1:Relay", "Device/Trigger/Relay"}, + {"vendor_namespace_inner", "tns1:VideoAnalytics/tnssamsung:MotionDetection", "VideoAnalytics/MotionDetection"}, + {"axis_outer_namespace", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1"}, + {"empty", "", ""}, + {"no_colon_passthrough", "Foo/Bar", "Foo/Bar"}, + {"double_slash_keeps_empty_segment", "tns1://Foo", "//Foo"}, + {"colon_only_segment_collapses_to_empty", "tns1:/Foo", "/Foo"}, + {"trailing_colon_segment", "tns1:", ""}, + {"multi_colon_takes_first", "tns1:Foo:Bar/Baz", "Foo:Bar/Baz"}, + {"leading_slash_kept", "/tns1:Foo", "/Foo"}, + {"trailing_slash_kept", "tns1:Foo/", "Foo/"}, } for _, tc := range tests { - assert.Equal(t, tc.want, canonicalizeTopic(tc.in), "input=%q", tc.in) + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, canonicalizeTopic(tc.in), "input=%q", tc.in) + }) } } + +func TestClassifyRuleOrder_ObjectAnalyticsBeforeGenericObjects(t *testing.T) { + // Locks the invariant that the prefix rule "ObjectAnalytics/" is + // matched before the broader "ObjectsInside" rule. Without this + // ordering, AXIS AOA topics that contain neither would still classify + // correctly via the ObjectAnalytics/ rule; we encode the dependency. + topic := "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1" + assert.Equal(t, KindObjectDetected, Classify(topic)) +} diff --git a/event/stream/types.go b/event/stream/types.go index 3d0bae1..fcc3612 100644 --- a/event/stream/types.go +++ b/event/stream/types.go @@ -5,19 +5,25 @@ import ( "time" ) -// EventKind is the normalized category of an ONVIF event, independent of the +// Kind is the normalized category of an ONVIF event, independent of the // camera vendor's topic naming. -type EventKind uint8 +type Kind uint8 const ( // KindUnknown is the zero value; used when a topic does not match any // known classification. - KindUnknown EventKind = iota + KindUnknown Kind = iota // KindMotion covers motion detection from any vendor (e.g. AXIS // VideoSource/MotionAlarm, Hikvision RuleEngine/CellMotionDetector). KindMotion - // KindTampering covers camera tampering / scene change alarms. + // KindTampering covers true tamper alarms (lens cover, scene + // substitution). Imaging-quality alarms map to KindImageQuality. KindTampering + // KindImageQuality covers VideoSource imaging alarms such as + // ImageTooDark, ImageTooBright and ImageTooBlurry. Most integrators + // treat these separately from tamper because they fire on legitimate + // sunset/dawn/condensation transitions. + KindImageQuality // KindDigitalInput covers external sensor inputs wired to the camera. KindDigitalInput // KindDigitalOutput covers relay output state changes on the camera. @@ -30,7 +36,7 @@ const ( ) // String implements fmt.Stringer. -func (k EventKind) String() string { +func (k Kind) String() string { switch k { case KindUnknown: return "Unknown" @@ -38,6 +44,8 @@ func (k EventKind) String() string { return "Motion" case KindTampering: return "Tampering" + case KindImageQuality: + return "ImageQuality" case KindDigitalInput: return "DigitalInput" case KindDigitalOutput: @@ -47,23 +55,24 @@ func (k EventKind) String() string { case KindAudioAlarm: return "AudioAlarm" default: - return fmt.Sprintf("EventKind(%d)", uint8(k)) + return fmt.Sprintf("Kind(%d)", uint8(k)) } } -// EventState is the active/inactive state carried by an event. Most ONVIF -// alarms are boolean (e.g. IsMotion=true/false); StateUnknown is used when -// the value cannot be parsed. -type EventState uint8 +// State is the active/inactive level carried by a boolean ONVIF property +// event (e.g. IsMotion=true/false). StateUnknown is used both when the +// value cannot be parsed and when the topic is edge-triggered and carries +// no boolean state (e.g. LineDetector/Crossed). +type State uint8 const ( - StateUnknown EventState = iota + StateUnknown State = iota StateActive StateInactive ) // String implements fmt.Stringer. -func (s EventState) String() string { +func (s State) String() string { switch s { case StateUnknown: return "Unknown" @@ -72,13 +81,15 @@ func (s EventState) String() string { case StateInactive: return "Inactive" default: - return fmt.Sprintf("EventState(%d)", uint8(s)) + return fmt.Sprintf("State(%d)", uint8(s)) } } // PropertyOperation mirrors the ONVIF wsnt:PropertyOperation attribute and // indicates whether a message is the first sighting of a property -// (Initialized), a transition (Changed) or the property going away (Deleted). +// (Initialized), a transition (Changed) or the property going away +// (Deleted). PropertyUnknown is used both when the attribute is absent on +// the wire (the spec allows it) and when the value is unrecognised. type PropertyOperation uint8 const ( @@ -107,24 +118,45 @@ func (p PropertyOperation) String() string { // Event is a single normalized notification from an ONVIF device. // // Kind, State and Operation are the normalized fields most callers should -// switch on. Topic, RawValue and Source preserve the original ONVIF data so -// callers can do further inspection or logging without re-parsing SOAP. +// switch on. Topic, Source and Data preserve the original ONVIF data so +// callers can inspect the wire form without re-parsing SOAP. +// +// Source and Data are maps from ONVIF SimpleItem Name to Value because +// notifications can carry multiple items: AXIS Object Analytics for +// example emits active, classType and confidence in the same Data list, +// and standard DigitalInput notifications carry both InputToken in Source +// and LogicalState in Data. type Event struct { // Kind is the normalized event category. - Kind EventKind - // State is the active/inactive value carried by the event. - State EventState - // Operation is the ONVIF property lifecycle (Initialized/Changed/Deleted). + Kind Kind + // State is the active/inactive value carried by a boolean event. + // StateUnknown for edge-triggered events (LineDetector/Crossed) that + // carry no boolean property. + State State + // Operation is the ONVIF property lifecycle + // (Initialized/Changed/Deleted). Operation PropertyOperation - // Source identifies the channel, input, or rule that produced the event - // (taken from the Source SimpleItem in the notification). - Source string - // Topic is the raw ONVIF topic string, e.g. tns1:VideoSource/MotionAlarm. + // DeviceID identifies the camera that produced the event. Set by the + // Stream from the caller-supplied identifier so a single channel can + // fan in events from multiple devices. + DeviceID string + // Source is the ONVIF Source SimpleItem map (e.g. InputToken, + // VideoSourceConfigurationToken, Rule). Empty when the notification + // has no Source section. + Source map[string]string + // Data is the ONVIF Data SimpleItem map (e.g. IsMotion, LogicalState, + // active, classType). Empty when the notification has no Data + // section. + Data map[string]string + // Topic is the raw ONVIF topic string, e.g. + // tns1:VideoSource/MotionAlarm. Topic string - // RawValue is the unparsed Data SimpleItem value (e.g. "true", "1", - // "active") so callers can read non-boolean values when needed. - RawValue string - // Timestamp is when the stream observed the event locally. The ONVIF - // UtcTime is not used because clocks on many cameras drift. + // Timestamp is when the stream observed the event locally. Timestamp time.Time + // DeviceTime is the camera-reported wsnt:UtcTime, when present and + // parseable. Zero if the camera omits the attribute or sends an + // unparseable value. Many cameras have drifting clocks; prefer + // Timestamp for ordering and DeviceTime only for forensics or + // cross-camera correlation when caller manages NTP. + DeviceTime time.Time } diff --git a/event/stream/types_test.go b/event/stream/types_test.go index d6e8313..ed2399d 100644 --- a/event/stream/types_test.go +++ b/event/stream/types_test.go @@ -7,53 +7,73 @@ import ( "github.com/stretchr/testify/assert" ) -func TestEventKindString(t *testing.T) { +func TestKindString(t *testing.T) { tests := []struct { - kind EventKind + name string + kind Kind want string }{ - {KindUnknown, "Unknown"}, - {KindMotion, "Motion"}, - {KindTampering, "Tampering"}, - {KindDigitalInput, "DigitalInput"}, - {KindDigitalOutput, "DigitalOutput"}, - {KindObjectDetected, "ObjectDetected"}, - {KindAudioAlarm, "AudioAlarm"}, - {EventKind(255), "EventKind(255)"}, + {"unknown", KindUnknown, "Unknown"}, + {"motion", KindMotion, "Motion"}, + {"tampering", KindTampering, "Tampering"}, + {"image_quality", KindImageQuality, "ImageQuality"}, + {"digital_input", KindDigitalInput, "DigitalInput"}, + {"digital_output", KindDigitalOutput, "DigitalOutput"}, + {"object_detected", KindObjectDetected, "ObjectDetected"}, + {"audio_alarm", KindAudioAlarm, "AudioAlarm"}, + {"out_of_range", Kind(255), "Kind(255)"}, } for _, tc := range tests { - assert.Equal(t, tc.want, tc.kind.String()) + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.kind.String()) + }) } } -func TestEventStateString(t *testing.T) { +func TestKindStringsAreUnique(t *testing.T) { + seen := map[string]Kind{} + for k := KindUnknown; k <= KindAudioAlarm; k++ { + s := k.String() + prev, dup := seen[s] + assert.False(t, dup, "duplicate String %q for Kind(%d) and Kind(%d)", s, prev, k) + seen[s] = k + } +} + +func TestStateString(t *testing.T) { tests := []struct { - state EventState + name string + state State want string }{ - {StateUnknown, "Unknown"}, - {StateActive, "Active"}, - {StateInactive, "Inactive"}, - {EventState(255), "EventState(255)"}, + {"unknown", StateUnknown, "Unknown"}, + {"active", StateActive, "Active"}, + {"inactive", StateInactive, "Inactive"}, + {"out_of_range", State(255), "State(255)"}, } for _, tc := range tests { - assert.Equal(t, tc.want, tc.state.String()) + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.state.String()) + }) } } func TestPropertyOperationString(t *testing.T) { tests := []struct { + name string op PropertyOperation want string }{ - {PropertyUnknown, "Unknown"}, - {PropertyInitialized, "Initialized"}, - {PropertyChanged, "Changed"}, - {PropertyDeleted, "Deleted"}, - {PropertyOperation(255), "PropertyOperation(255)"}, + {"unknown", PropertyUnknown, "Unknown"}, + {"initialized", PropertyInitialized, "Initialized"}, + {"changed", PropertyChanged, "Changed"}, + {"deleted", PropertyDeleted, "Deleted"}, + {"out_of_range", PropertyOperation(255), "PropertyOperation(255)"}, } for _, tc := range tests { - assert.Equal(t, tc.want, tc.op.String()) + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.op.String()) + }) } } @@ -61,11 +81,36 @@ func TestEventZeroValue(t *testing.T) { var e Event assert.Equal(t, KindUnknown, e.Kind) assert.Equal(t, StateUnknown, e.State) + assert.Equal(t, PropertyUnknown, e.Operation) + assert.Empty(t, e.DeviceID) + assert.Nil(t, e.Source) + assert.Nil(t, e.Data) + assert.Empty(t, e.Topic) assert.True(t, e.Timestamp.IsZero()) + assert.True(t, e.DeviceTime.IsZero()) } -func TestEventTimestampPreserved(t *testing.T) { - now := time.Now() - e := Event{Timestamp: now} +func TestEventFieldAssignmentRoundTrip(t *testing.T) { + now := time.Now().UTC() + deviceTime := now.Add(-2 * time.Second) + e := Event{ + Kind: KindMotion, + State: StateActive, + Operation: PropertyChanged, + DeviceID: "axis-camera-01", + Source: map[string]string{"InputToken": "DI1"}, + Data: map[string]string{"LogicalState": "true"}, + Topic: "tns1:Device/Trigger/DigitalInput", + Timestamp: now, + DeviceTime: deviceTime, + } + assert.Equal(t, KindMotion, e.Kind) + assert.Equal(t, StateActive, e.State) + assert.Equal(t, PropertyChanged, e.Operation) + assert.Equal(t, "axis-camera-01", e.DeviceID) + assert.Equal(t, "DI1", e.Source["InputToken"]) + assert.Equal(t, "true", e.Data["LogicalState"]) + assert.Equal(t, "tns1:Device/Trigger/DigitalInput", e.Topic) assert.True(t, e.Timestamp.Equal(now)) + assert.True(t, e.DeviceTime.Equal(deviceTime)) } From b461ec8ded3e08db0dea37804b77c6043ca188a6 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:30:50 +0200 Subject: [PATCH 06/23] feat(event/stream): decode NotificationMessage into normalized Event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Decode entry point that converts the ONVIF wire form into the package's typed Event. The agent (and any other consumer) no longer has to walk NotificationMessage.Message.Message.Data.SimpleItem chains and hand-special-case per-vendor data item names. Decoding rules -------------- * Topic -> Kind via the verified Classify table. * PropertyOperation parses Initialized/Changed/Deleted; absent or unrecognised -> PropertyUnknown (the attribute is optional per WS-Notification). * UtcTime parses RFC3339Nano first, RFC3339 second, normalised to UTC. Absent or unparseable -> DeviceTime is zero. Camera clocks drift; the type doc already steers callers to prefer Timestamp. * State extraction scans Data items in order for the first boolean-like value (true/false/1/0/active/inactive, case-insensitive). This handles every vendor data item in the verified table — IsMotion, State, IsTamper, LogicalState, active, Motion, triggered, SoundDetection, TamperingDetection — without a per-kind switch. * Edge-triggered topics (LineDetector/Crossed with only ObjectId) yield StateUnknown, matching the topic-rule doc note. * Source and Data are full ONVIF SimpleItem name->value maps so callers retain multi-item info (AXIS AOA active+classType+confidence, digital I/O InputToken+LogicalState, analytics VideoSourceConfigurationToken+ Rule). Empty notifications yield nil maps, matching the Event zero-value contract from types_test.go. * Topic, Source and Data are always populated even when Kind is KindUnknown, so consumers can log/route unclassified events. Tests cover the AXIS motion happy path, the inactive case, the Hanwha numeric-string variant, the Avigilon 'active' literal, multi-item AOA decode, the LineDetector edge-trigger semantic, unknown-topic wire preservation, every PropertyOperation literal, RFC3339 with sub-second and timezone offsets, and case-insensitive State extraction. --- event/stream/decode.go | 104 +++++++++++++++ event/stream/decode_test.go | 249 ++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 event/stream/decode.go create mode 100644 event/stream/decode_test.go diff --git a/event/stream/decode.go b/event/stream/decode.go new file mode 100644 index 0000000..1d96941 --- /dev/null +++ b/event/stream/decode.go @@ -0,0 +1,104 @@ +package stream + +import ( + "strings" + "time" + + "github.com/kerberos-io/onvif/event" +) + +// Decode converts a single ONVIF NotificationMessage into the package's +// normalized Event representation. +// +// deviceID is supplied by the caller because the message itself does not +// identify the originating camera. observedAt is recorded verbatim as +// Event.Timestamp; the camera-reported wsnt:UtcTime attribute (when +// present and parseable) populates Event.DeviceTime. +// +// When the Topic does not match any classifier rule the returned Event +// has Kind == KindUnknown but Source, Data and Topic are still populated +// so consumers can fall back to inspecting the wire form. +func Decode(msg event.NotificationMessage, deviceID string, observedAt time.Time) Event { + topic := string(msg.Topic.TopicKinds) + desc := msg.Message.Message + return Event{ + Kind: Classify(topic), + State: extractState(desc.Data.SimpleItem), + Operation: parsePropertyOperation(string(desc.PropertyOperation)), + DeviceID: deviceID, + Source: simpleItemsToMap(desc.Source.SimpleItem), + Data: simpleItemsToMap(desc.Data.SimpleItem), + Topic: topic, + Timestamp: observedAt, + DeviceTime: parseDeviceTime(string(desc.UtcTime)), + } +} + +// simpleItemsToMap collapses ONVIF SimpleItem lists to a Name->Value map. +// Returns nil for an empty list so empty notifications do not allocate +// and match the Event zero-value contract. +func simpleItemsToMap(items []event.SimpleItem) map[string]string { + if len(items) == 0 { + return nil + } + m := make(map[string]string, len(items)) + for _, it := range items { + m[string(it.Name)] = string(it.Value) + } + return m +} + +// extractState scans Data items for a boolean-like value and returns the +// first one as a State. Returns StateUnknown when no item parses — this +// is the correct outcome for edge-triggered topics such as +// LineDetector/Crossed whose Data carries only an ObjectId. +// +// Iteration order over the original []SimpleItem is preserved so the +// behaviour stays deterministic per notification. (Map iteration is not +// involved; simpleItemsToMap is a separate path.) +func extractState(items []event.SimpleItem) State { + for _, it := range items { + switch strings.ToLower(strings.TrimSpace(string(it.Value))) { + case "true", "1", "active": + return StateActive + case "false", "0", "inactive": + return StateInactive + } + } + return StateUnknown +} + +// parsePropertyOperation parses the wsnt:PropertyOperation attribute. +// The attribute is optional per WS-Notification; an empty or unrecognised +// value yields PropertyUnknown. +func parsePropertyOperation(s string) PropertyOperation { + switch s { + case "Initialized": + return PropertyInitialized + case "Changed": + return PropertyChanged + case "Deleted": + return PropertyDeleted + default: + return PropertyUnknown + } +} + +// parseDeviceTime parses the wsnt:UtcTime attribute, returning the zero +// time when the attribute is absent or unparseable. The result is +// normalised to UTC so equality comparisons across timezones work. +// +// xsd:dateTime in ONVIF messages is RFC 3339 in practice; we try +// time.RFC3339Nano first (covers sub-second precision) and fall back to +// time.RFC3339 for cameras that drop the fractional part. +func parseDeviceTime(s string) time.Time { + if s == "" { + return time.Time{} + } + for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + if t, err := time.Parse(layout, s); err == nil { + return t.UTC() + } + } + return time.Time{} +} diff --git a/event/stream/decode_test.go b/event/stream/decode_test.go new file mode 100644 index 0000000..edce4c8 --- /dev/null +++ b/event/stream/decode_test.go @@ -0,0 +1,249 @@ +package stream + +import ( + "testing" + "time" + + "github.com/kerberos-io/onvif/event" + "github.com/kerberos-io/onvif/xsd" + "github.com/stretchr/testify/assert" +) + +// msg builds a NotificationMessage from the topic and a (PropertyOperation, +// UtcTime, source items, data items) tuple so tests stay short and intent +// is visible at the call site. +func msg(topic, propOp, utcTime string, source, data map[string]string) event.NotificationMessage { + toItems := func(m map[string]string) []event.SimpleItem { + if len(m) == 0 { + return nil + } + items := make([]event.SimpleItem, 0, len(m)) + for k, v := range m { + items = append(items, event.SimpleItem{ + Name: xsd.AnyType(k), + Value: xsd.AnyType(v), + }) + } + return items + } + return event.NotificationMessage{ + Topic: event.Topic{TopicKinds: xsd.String(topic)}, + Message: event.MessageBody{ + Message: event.MessageDescription{ + PropertyOperation: xsd.AnyType(propOp), + UtcTime: xsd.AnyType(utcTime), + Source: event.Source{SimpleItem: toItems(source)}, + Data: event.Data{SimpleItem: toItems(data)}, + }, + }, + } +} + +func TestDecode_MotionActive(t *testing.T) { + observedAt := time.Date(2026, 5, 21, 10, 30, 1, 0, time.UTC) + in := msg( + "tns1:RuleEngine/CellMotionDetector/Motion", + "Changed", + "2026-05-21T10:30:00Z", + map[string]string{ + "VideoSourceConfigurationToken": "VideoSourceConfigToken0", + "Rule": "MyMotionRule", + }, + map[string]string{"IsMotion": "true"}, + ) + + ev := Decode(in, "axis-cam-01", observedAt) + + assert.Equal(t, KindMotion, ev.Kind) + assert.Equal(t, StateActive, ev.State) + assert.Equal(t, PropertyChanged, ev.Operation) + assert.Equal(t, "axis-cam-01", ev.DeviceID) + assert.Equal(t, "VideoSourceConfigToken0", ev.Source["VideoSourceConfigurationToken"]) + assert.Equal(t, "MyMotionRule", ev.Source["Rule"]) + assert.Equal(t, "true", ev.Data["IsMotion"]) + assert.Equal(t, "tns1:RuleEngine/CellMotionDetector/Motion", ev.Topic) + assert.True(t, ev.Timestamp.Equal(observedAt)) + assert.Equal(t, time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC), ev.DeviceTime) +} + +func TestDecode_MotionInactive(t *testing.T) { + in := msg( + "tns1:VideoSource/MotionAlarm", + "Changed", + "", + nil, + map[string]string{"State": "false"}, + ) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, KindMotion, ev.Kind) + assert.Equal(t, StateInactive, ev.State) +} + +func TestDecode_HanwhaNumericMotionValue(t *testing.T) { + // Hanwha emits xsd:string values "0"/"1" instead of xsd:boolean. + in := msg( + "tns1:VideoAnalytics/tnssamsung:MotionDetection", + "Changed", + "", + nil, + map[string]string{"Motion": "1"}, + ) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, KindMotion, ev.Kind) + assert.Equal(t, StateActive, ev.State) +} + +func TestDecode_AvigilonActiveLiteral(t *testing.T) { + // Avigilon and a handful of older firmwares emit "active"/"inactive" + // as the Data value rather than a boolean. + in := msg( + "tns1:Device/tns1:Trigger/tns1:Relay", + "Changed", + "", + map[string]string{"RelayToken": "Relay-1"}, + map[string]string{"LogicalState": "active"}, + ) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, KindDigitalOutput, ev.Kind) + assert.Equal(t, StateActive, ev.State) + assert.Equal(t, "Relay-1", ev.Source["RelayToken"]) +} + +func TestDecode_AxisObjectAnalyticsMultiItem(t *testing.T) { + // AOA emits active + classType + confidence in the same Data list. + // The decoder must preserve every item; State picks the first + // boolean-like value, which is 'active'. + in := msg( + "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", + "Changed", + "", + map[string]string{"Source": "device1Scene1"}, + map[string]string{ + "active": "1", + "classType": "Human", + "confidence": "92", + }, + ) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, KindObjectDetected, ev.Kind) + assert.Equal(t, StateActive, ev.State) + assert.Equal(t, "Human", ev.Data["classType"]) + assert.Equal(t, "92", ev.Data["confidence"]) + assert.Equal(t, "1", ev.Data["active"]) +} + +func TestDecode_LineDetectorCrossedHasNoState(t *testing.T) { + // Edge-triggered topic — Data carries ObjectId, not a boolean. State + // must remain Unknown so consumers do not misread it as level-Active. + in := msg( + "tns1:RuleEngine/LineDetector/Crossed", + "Changed", + "", + map[string]string{"VideoSourceConfigurationToken": "vsct0", "Rule": "LineRule"}, + map[string]string{"ObjectId": "42"}, + ) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, KindObjectDetected, ev.Kind) + assert.Equal(t, StateUnknown, ev.State) + assert.Equal(t, "42", ev.Data["ObjectId"]) +} + +func TestDecode_UnknownTopicStillPreservesWireData(t *testing.T) { + // Kind unknown does not mean discard: consumers may want to log or + // route on the raw topic when classification misses. + in := msg( + "tns1:UserAlarm/IVA", + "", + "", + nil, + map[string]string{"Custom": "true"}, + ) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, KindUnknown, ev.Kind) + assert.Equal(t, "tns1:UserAlarm/IVA", ev.Topic) + assert.Equal(t, "true", ev.Data["Custom"]) +} + +func TestDecode_PropertyOperationVariants(t *testing.T) { + tests := []struct { + name string + in string + want PropertyOperation + }{ + {"initialized", "Initialized", PropertyInitialized}, + {"changed", "Changed", PropertyChanged}, + {"deleted", "Deleted", PropertyDeleted}, + {"absent", "", PropertyUnknown}, + {"unrecognised", "Bogus", PropertyUnknown}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", tc.in, "", nil, nil) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, tc.want, ev.Operation) + }) + } +} + +func TestDecode_DeviceTimeParsing(t *testing.T) { + tests := []struct { + name string + in string + want time.Time + }{ + {"rfc3339_utc", "2026-05-21T10:30:00Z", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, + {"rfc3339_with_offset", "2026-05-21T12:30:00+02:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, + {"rfc3339_subsecond", "2026-05-21T10:30:00.500Z", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)}, + {"absent", "", time.Time{}}, + {"unparseable", "not-a-date", time.Time{}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil) + ev := Decode(in, "dev", time.Now()) + if tc.want.IsZero() { + assert.True(t, ev.DeviceTime.IsZero(), "DeviceTime=%v", ev.DeviceTime) + } else { + assert.True(t, ev.DeviceTime.Equal(tc.want), "got=%v want=%v", ev.DeviceTime, tc.want) + } + }) + } +} + +func TestDecode_EmptySourceAndDataYieldNilMaps(t *testing.T) { + // Matches the zero-value contract in types_test.go: callers can + // safely len() and index into Source/Data without nil-checking, but + // we do not allocate an empty map for empty notifications. + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil) + ev := Decode(in, "dev", time.Now()) + assert.Nil(t, ev.Source) + assert.Nil(t, ev.Data) +} + +func TestDecode_StateValueIsCaseInsensitive(t *testing.T) { + tests := []struct { + name string + value string + want State + }{ + {"true_lower", "true", StateActive}, + {"true_upper", "TRUE", StateActive}, + {"true_mixed", "True", StateActive}, + {"false_lower", "false", StateInactive}, + {"false_mixed", "False", StateInactive}, + {"active_mixed", "Active", StateActive}, + {"inactive_mixed", "Inactive", StateInactive}, + {"one", "1", StateActive}, + {"zero", "0", StateInactive}, + {"empty", "", StateUnknown}, + {"nonsense", "maybe", StateUnknown}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", + nil, map[string]string{"State": tc.value}) + ev := Decode(in, "dev", time.Now()) + assert.Equal(t, tc.want, ev.State) + }) + } +} From fb05c31d7dc540c1165dba403d22181e477b5735 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:35:34 +0200 Subject: [PATCH 07/23] feat(event/stream): add Stream with pull-point lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces Stream, the typed event consumer the package will eventually present to callers, plus the caller seam needed to test it without hitting a real camera. Stream owns one ONVIF pull-point subscription end-to-end: * CreatePullPointSubscription on construction so authentication and reachability problems surface synchronously from NewStream rather than landing on the Errors channel after the goroutine starts. * Background pull loop calls PullMessages against the SubscriptionReference Address returned by Create. Each NotificationMessage is fed through Decode and pushed on the Events channel, with context cancellation honoured between every step so a Close cannot get stuck behind a long-server-side-wait pull. * Errors during a pull are surfaced on a separate Errors channel using a non-blocking send; a stalled consumer drops older errors instead of blocking the loop. The loop sleeps briefly (ctx-aware) and retries — automatic subscription recreation lands in the reconnect-on-error commit. * Close cancels the context, waits for the run goroutine to exit, Unsubscribes the pull point and closes Events/Errors. sync.Once keeps it idempotent. Design seams ------------ * caller interface (CallMethod + SendSoap) abstracts *onvif.Device so tests can substitute fakeCaller without an HTTP server. deviceCaller is the production adapter; newStream takes the interface, NewStream takes the concrete *onvif.Device. The same shape lets a future commit add WithClassifier / WithClock / WithCaller options if the architect reviewer's pluggable-classifier note becomes urgent. * now func() time.Time is a Stream field so a future clock-injecting test (renew timing, observed-at determinism) can swap it. * unmarshalNode keys on the local XML name, sidestepping namespace matching since SOAP envelopes from different vendors prefix the PullMessagesResponse and CreatePullPointSubscriptionResponse with arbitrary tev:/tev1:/... bindings. This is the same trick the agent's getXMLNode used; lifting it here lets the agent eventually drop its copy. Options and defaults -------------------- PullTimeout 5s, MessageLimit 10, InitialTermination 60s, BufferSize 16 match what the existing agent code uses. TopicFilter defaults to empty so AXIS cameras work out of the box — the verified topic table is intentionally the routing layer, not a server-side filter, because the agent will frequently want digital I/O and motion on the same stream. Tests cover the create-then-pull-then-close happy path, that pulls target the SubscriptionReference Address (not the device endpoint), construction failure on CreatePullPoint error, context-cancel exits the loop cleanly with channels closed, transient pull errors land on Errors without stopping decode of subsequent good messages, idempotent Close, and Options default values. -race clean. --- event/stream/stream.go | 353 ++++++++++++++++++++++++++++++++++++ event/stream/stream_test.go | 342 ++++++++++++++++++++++++++++++++++ 2 files changed, 695 insertions(+) create mode 100644 event/stream/stream.go create mode 100644 event/stream/stream_test.go diff --git a/event/stream/stream.go b/event/stream/stream.go new file mode 100644 index 0000000..8ed8a44 --- /dev/null +++ b/event/stream/stream.go @@ -0,0 +1,353 @@ +package stream + +import ( + "bytes" + "context" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "sync" + "time" + + "github.com/kerberos-io/onvif" + "github.com/kerberos-io/onvif/event" + "github.com/kerberos-io/onvif/xsd" +) + +// Options configures a Stream. The zero value is usable; defaultOptions +// fills in production-sensible defaults for any unset field. +type Options struct { + // DeviceID identifies the camera in emitted Events. Recommended so a + // single channel can fan in multiple cameras. Empty is allowed. + DeviceID string + // TopicFilter is the raw ONVIF ConcreteSet TopicExpression filter + // passed to CreatePullPointSubscription. The empty string means no + // filter — required for AXIS, accepted by every other vendor we + // support. Callers should normally leave this empty and rely on + // Classify for routing. + TopicFilter string + // PullTimeout is the server-side wait time in each PullMessages call + // (xsd:duration). The camera returns early when messages are + // available; otherwise it returns empty after this timeout. Default: + // 5s. + PullTimeout time.Duration + // MessageLimit caps the number of NotificationMessage entries + // returned per PullMessages call. Default: 10. + MessageLimit int + // InitialTermination is the requested subscription lifetime passed + // to CreatePullPointSubscription. The renew loop (added in a later + // commit) will refresh well before this expires. Default: 60s. + InitialTermination time.Duration + // BufferSize is the buffer size of the Events and Errors channels. + // Larger buffers absorb consumer hiccups at the cost of memory. + // Default: 16. + BufferSize int +} + +func defaultOptions() Options { + return Options{ + PullTimeout: 5 * time.Second, + MessageLimit: 10, + InitialTermination: 60 * time.Second, + BufferSize: 16, + } +} + +func (o Options) withDefaults() Options { + d := defaultOptions() + if o.PullTimeout > 0 { + d.PullTimeout = o.PullTimeout + } + if o.MessageLimit > 0 { + d.MessageLimit = o.MessageLimit + } + if o.InitialTermination > 0 { + d.InitialTermination = o.InitialTermination + } + if o.BufferSize > 0 { + d.BufferSize = o.BufferSize + } + d.DeviceID = o.DeviceID + d.TopicFilter = o.TopicFilter + return d +} + +// caller is the subset of *onvif.Device the Stream depends on. Tests +// substitute a fake; production code uses the device adapter. +type caller interface { + CallMethod(method any) (*http.Response, error) + SendSoap(endpoint, body string) (*http.Response, error) +} + +type deviceCaller struct{ dev *onvif.Device } + +func (d deviceCaller) CallMethod(m any) (*http.Response, error) { + return d.dev.CallMethod(m) +} + +func (d deviceCaller) SendSoap(endpoint, body string) (*http.Response, error) { + return d.dev.SendSoap(endpoint, body) +} + +// Stream owns a single ONVIF pull-point subscription and surfaces the +// decoded notifications on a typed channel. Close stops the background +// goroutine and unsubscribes from the camera. +// +// A Stream is safe for concurrent use by Close from any goroutine while +// readers consume Events / Errors; Close is idempotent. +type Stream struct { + caller caller + opts Options + pullPoint string + + events chan Event + errors chan error + + cancel context.CancelFunc + done chan struct{} + + closeOnce sync.Once + closeErr error + + // now is overridable in tests to make timestamps deterministic. + now func() time.Time +} + +// NewStream creates a Stream against an ONVIF device. It performs the +// CreatePullPointSubscription call synchronously so connectivity and +// authentication problems surface immediately as an error rather than +// landing on the Errors channel later. The background pull loop starts +// before NewStream returns. +// +// The returned Stream stops when ctx is cancelled or when Close is +// called. +func NewStream(ctx context.Context, dev *onvif.Device, opts Options) (*Stream, error) { + return newStream(ctx, deviceCaller{dev: dev}, opts) +} + +func newStream(ctx context.Context, c caller, opts Options) (*Stream, error) { + opts = opts.withDefaults() + addr, err := createPullPoint(c, opts) + if err != nil { + return nil, fmt.Errorf("create pull point subscription: %w", err) + } + runCtx, cancel := context.WithCancel(ctx) + s := &Stream{ + caller: c, + opts: opts, + pullPoint: addr, + events: make(chan Event, opts.BufferSize), + errors: make(chan error, opts.BufferSize), + cancel: cancel, + done: make(chan struct{}), + now: time.Now, + } + go s.run(runCtx) + return s, nil +} + +// Events returns the channel of decoded notifications. The channel is +// closed when the Stream stops. +func (s *Stream) Events() <-chan Event { return s.events } + +// Errors returns the channel of non-fatal errors encountered while +// pulling. Sends are non-blocking, so consumers that fall behind drop +// older errors. The channel is closed when the Stream stops. +func (s *Stream) Errors() <-chan error { return s.errors } + +// Close stops the background goroutine, waits for it to exit, and +// unsubscribes from the camera. Subsequent calls are no-ops. +func (s *Stream) Close() error { + s.closeOnce.Do(func() { + s.cancel() + <-s.done + // Unsubscribe is best-effort: if the camera is unreachable + // the subscription will expire on its own at + // InitialTermination + Renew interval anyway. + if err := unsubscribePullPoint(s.caller, s.pullPoint); err != nil { + s.closeErr = fmt.Errorf("unsubscribe pull point: %w", err) + } + }) + return s.closeErr +} + +func (s *Stream) run(ctx context.Context) { + defer close(s.done) + defer close(s.events) + defer close(s.errors) + + for { + if ctx.Err() != nil { + return + } + msgs, err := pullMessages(s.caller, s.pullPoint, s.opts) + if err != nil { + s.surfaceError(err) + // Brief backoff before retrying; reconnect-on-error + // lands in a follow-up commit and replaces this with + // proper subscription recreation. + if !sleepCtx(ctx, time.Second) { + return + } + continue + } + observedAt := s.now() + for _, m := range msgs { + ev := Decode(m, s.opts.DeviceID, observedAt) + select { + case <-ctx.Done(): + return + case s.events <- ev: + } + } + } +} + +// surfaceError sends err on the errors channel non-blockingly so a +// stalled consumer cannot block the pull loop. +func (s *Stream) surfaceError(err error) { + select { + case s.errors <- err: + default: + } +} + +// sleepCtx blocks for d or until ctx is cancelled. Returns true if d +// elapsed, false if ctx was cancelled. +func sleepCtx(ctx context.Context, d time.Duration) bool { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return false + case <-t.C: + return true + } +} + +// --- SOAP helpers (unexported) ---------------------------------------- + +func createPullPoint(c caller, opts Options) (string, error) { + term := xsd.String(durationToXSD(opts.InitialTermination)) + req := event.CreatePullPointSubscription{InitialTerminationTime: &term} + if opts.TopicFilter != "" { + req.Filter = &event.FilterType{ + TopicExpression: &event.TopicExpressionType{ + Dialect: xsd.String("http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet"), + TopicKinds: xsd.String(opts.TopicFilter), + }, + } + } + resp, err := c.CallMethod(req) + if err != nil { + return "", err + } + body, err := readClose(resp) + if err != nil { + return "", err + } + var decoded event.CreatePullPointSubscriptionResponse + if err := unmarshalNode(body, "CreatePullPointSubscriptionResponse", &decoded); err != nil { + return "", err + } + addr := string(decoded.SubscriptionReference.Address) + if addr == "" { + return "", errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address") + } + return addr, nil +} + +func pullMessages(c caller, endpoint string, opts Options) ([]event.NotificationMessage, error) { + req := event.PullMessages{ + Timeout: xsd.Duration(durationToXSD(opts.PullTimeout)), + MessageLimit: xsd.Int(opts.MessageLimit), + } + body, err := xml.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal PullMessages: %w", err) + } + resp, err := c.SendSoap(endpoint, string(body)) + if err != nil { + return nil, err + } + respBody, err := readClose(resp) + if err != nil { + return nil, err + } + var decoded event.PullMessagesResponse + if err := unmarshalNode(respBody, "PullMessagesResponse", &decoded); err != nil { + return nil, err + } + return decoded.NotificationMessage, nil +} + +func unsubscribePullPoint(c caller, endpoint string) error { + if endpoint == "" { + return nil + } + body, err := xml.Marshal(event.Unsubscribe{}) + if err != nil { + return fmt.Errorf("marshal Unsubscribe: %w", err) + } + resp, err := c.SendSoap(endpoint, string(body)) + if err != nil { + return err + } + _, err = readClose(resp) + return err +} + +func readClose(resp *http.Response) (string, error) { + if resp == nil || resp.Body == nil { + return "", errors.New("nil HTTP response") + } + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read response body: %w", err) + } + return string(b), nil +} + +// unmarshalNode finds the first XML start element with the given local +// name and decodes it into out. ONVIF SOAP responses come wrapped in an +// envelope with multiple namespace prefixes; this helper sidesteps +// namespace matching by keying on local name only. +func unmarshalNode(body, localName string, out any) error { + dec := xml.NewDecoder(bytes.NewBufferString(body)) + for { + tok, err := dec.Token() + if err != nil { + if errors.Is(err, io.EOF) { + return fmt.Errorf("ONVIF response missing %s element", localName) + } + return fmt.Errorf("scan ONVIF response: %w", err) + } + start, ok := tok.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local != localName { + continue + } + if err := dec.DecodeElement(out, &start); err != nil { + return fmt.Errorf("decode %s: %w", localName, err) + } + return nil + } +} + +// durationToXSD formats a Go time.Duration as an xsd:duration string in +// PTnS form. Second precision is sufficient — ONVIF cameras do not +// honour sub-second pull timeouts and intermediate routers may round in +// any case. +func durationToXSD(d time.Duration) string { + secs := int(d.Round(time.Second).Seconds()) + if secs <= 0 { + secs = 1 + } + return "PT" + strconv.Itoa(secs) + "S" +} diff --git a/event/stream/stream_test.go b/event/stream/stream_test.go new file mode 100644 index 0000000..698d07e --- /dev/null +++ b/event/stream/stream_test.go @@ -0,0 +1,342 @@ +package stream + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- fakeCaller -------------------------------------------------------- + +// fakeCaller is a test double for the caller interface. Each method +// returns the next queued response; when the queue is exhausted it falls +// back to a default response so the indefinite pull loop does not +// require tests to enumerate every call. +type fakeCaller struct { + mu sync.Mutex + callMethodResps []fakeResp + sendSoapResps []fakeResp + defaultSendSoap fakeResp + defaultCall fakeResp + callMethodCalls []any + sendSoapCalls [][2]string +} + +type fakeResp struct { + body string + err error +} + +func newFakeCaller() *fakeCaller { + return &fakeCaller{ + // Default: indefinite empty pulls, indefinite OK unsubscribes. + defaultSendSoap: fakeResp{body: pullMessagesResp()}, + defaultCall: fakeResp{err: errors.New("fakeCaller: no default CallMethod response")}, + } +} + +func (f *fakeCaller) queueCallMethod(body string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.callMethodResps = append(f.callMethodResps, fakeResp{body: body, err: err}) +} + +func (f *fakeCaller) queueSendSoap(body string, err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.sendSoapResps = append(f.sendSoapResps, fakeResp{body: body, err: err}) +} + +func (f *fakeCaller) CallMethod(m any) (*http.Response, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.callMethodCalls = append(f.callMethodCalls, m) + r := f.defaultCall + if len(f.callMethodResps) > 0 { + r = f.callMethodResps[0] + f.callMethodResps = f.callMethodResps[1:] + } + if r.err != nil { + return nil, r.err + } + return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, nil +} + +func (f *fakeCaller) SendSoap(endpoint, body string) (*http.Response, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.sendSoapCalls = append(f.sendSoapCalls, [2]string{endpoint, body}) + r := f.defaultSendSoap + if len(f.sendSoapResps) > 0 { + r = f.sendSoapResps[0] + f.sendSoapResps = f.sendSoapResps[1:] + } + if r.err != nil { + return nil, r.err + } + return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, nil +} + +func (f *fakeCaller) sendSoapCallCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.sendSoapCalls) +} + +// --- fixture SOAP envelopes ------------------------------------------- + +// createPullPointResp is the minimal SOAP envelope the lib's existing +// xml.Decoder + getXMLNode path can extract a pull-point address from. +const createPullPointResp = ` + + + + + http://camera.local/onvif/Events/PullSub_1 + + 2026-05-21T10:30:00Z + 2026-05-21T10:31:00Z + + +` + +func pullMessagesResp(messages ...string) string { + return ` + + + + 2026-05-21T10:30:05Z + 2026-05-21T10:31:05Z + ` + strings.Join(messages, "\n") + ` + + +` +} + +func motionMsg(value string) string { + return ` + tns1:RuleEngine/CellMotionDetector/Motion + + + + + + + + + + +` +} + +const unsubscribeResp = ` + + + + +` + +// --- helpers ----------------------------------------------------------- + +// receive waits up to d for an event on ch, failing the test if none +// arrives. +func receive(t *testing.T, ch <-chan Event, d time.Duration) Event { + t.Helper() + select { + case ev, ok := <-ch: + if !ok { + t.Fatalf("event channel closed before receiving") + } + return ev + case <-time.After(d): + t.Fatalf("timed out waiting for event after %s", d) + } + return Event{} // unreachable +} + +// --- tests ------------------------------------------------------------- + +func TestNewStream_CreatesPullPointAtConstruction(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // Queue an empty pull so the run loop can spin without exploding. + fc.queueSendSoap(pullMessagesResp(), nil) + fc.queueSendSoap(unsubscribeResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"}) + require.NoError(t, err) + require.NotNil(t, s) + require.NoError(t, s.Close()) + + // CreatePullPointSubscription was called exactly once. + fc.mu.Lock() + defer fc.mu.Unlock() + require.Len(t, fc.callMethodCalls, 1, "expected one CallMethod call (CreatePullPointSubscription)") +} + +func TestStream_DeliversDecodedEvents(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil) + // Provide subsequent empty pulls so the loop doesn't starve before Close. + fc.queueSendSoap(pullMessagesResp(), nil) + fc.queueSendSoap(pullMessagesResp(), nil) + fc.queueSendSoap(unsubscribeResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"}) + require.NoError(t, err) + defer s.Close() + + ev := receive(t, s.Events(), 2*time.Second) + assert.Equal(t, KindMotion, ev.Kind) + assert.Equal(t, StateActive, ev.State) + assert.Equal(t, "cam-1", ev.DeviceID) + assert.Equal(t, "tns1:RuleEngine/CellMotionDetector/Motion", ev.Topic) + assert.Equal(t, "VSC0", ev.Source["VideoSourceConfigurationToken"]) + assert.Equal(t, "true", ev.Data["IsMotion"]) +} + +func TestStream_PullsAgainstSubscriptionAddress(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueSendSoap(pullMessagesResp(), nil) + fc.queueSendSoap(pullMessagesResp(), nil) + fc.queueSendSoap(unsubscribeResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"}) + require.NoError(t, err) + + // Wait until at least one pull happened, then close. + for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ { + time.Sleep(10 * time.Millisecond) + } + require.NoError(t, s.Close()) + + fc.mu.Lock() + defer fc.mu.Unlock() + require.NotEmpty(t, fc.sendSoapCalls, "expected at least one PullMessages SendSoap call") + endpoint := fc.sendSoapCalls[0][0] + assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", endpoint, + "PullMessages must target the SubscriptionReference Address returned by CreatePullPoint") + // Last call (Close) should target the same endpoint with an Unsubscribe body. + last := fc.sendSoapCalls[len(fc.sendSoapCalls)-1] + assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", last[0]) + assert.Contains(t, last[1], "Unsubscribe") +} + +func TestNewStream_ReturnsErrorWhenCreatePullPointFails(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod("", errors.New("network down")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"}) + assert.Error(t, err) + assert.Nil(t, s) +} + +func TestStream_ClosedContextStopsRunLoop(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // Many empty pulls so the loop is hot when we cancel. + for i := 0; i < 20; i++ { + fc.queueSendSoap(pullMessagesResp(), nil) + } + fc.queueSendSoap(unsubscribeResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"}) + require.NoError(t, err) + + // Wait for at least one pull. + for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ { + time.Sleep(10 * time.Millisecond) + } + cancel() + + // Close should still complete cleanly; the goroutine must drain. + require.NoError(t, s.Close()) + + // Events channel must close so consumers can range-loop safely. + select { + case _, ok := <-s.Events(): + assert.False(t, ok, "Events channel should be closed after Close()") + case <-time.After(time.Second): + t.Fatal("Events channel was not closed within 1s") + } +} + +func TestStream_PullErrorSurfacedOnErrorsChannel(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueSendSoap("", errors.New("transient pull failure")) + // Then a clean pull so the loop keeps running. + fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil) + fc.queueSendSoap(pullMessagesResp(), nil) + fc.queueSendSoap(unsubscribeResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"}) + require.NoError(t, err) + defer s.Close() + + select { + case e := <-s.Errors(): + assert.Contains(t, e.Error(), "transient pull failure") + case <-time.After(2 * time.Second): + t.Fatal("expected an error on the Errors channel") + } + // After the transient failure the loop continued and decoded. + ev := receive(t, s.Events(), 2*time.Second) + assert.Equal(t, KindMotion, ev.Kind) +} + +func TestStream_OptionsApplyDefaults(t *testing.T) { + o := defaultOptions() + assert.Equal(t, 5*time.Second, o.PullTimeout) + assert.Equal(t, 10, o.MessageLimit) + assert.Equal(t, 60*time.Second, o.InitialTermination) + assert.Equal(t, 16, o.BufferSize) +} + +func TestStream_DoesNotPanicOnPullExitingDuringClose(t *testing.T) { + // Regression guard: Close should not race with the run goroutine + // in a way that double-closes the events/errors channels. + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + for i := 0; i < 5; i++ { + fc.queueSendSoap(pullMessagesResp(), nil) + } + fc.queueSendSoap(unsubscribeResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"}) + require.NoError(t, err) + assert.NotPanics(t, func() { + require.NoError(t, s.Close()) + // Double-close should be a no-op, not a panic. + _ = s.Close() + }) +} From 9b5b6261137a2959907a6770e9aa85dc49899ca1 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:37:33 +0200 Subject: [PATCH 08/23] feat(event/stream): renew subscription before termination expires MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a background renew loop alongside the pull loop. ONVIF pull-point subscriptions expire at the InitialTerminationTime supplied to Create; without periodic Renew calls the camera silently drops the subscription and subsequent pulls start returning empty messages — the shape the existing agent's heartbeat code in cloud/Cloud.go has been papering over by occasionally recreating subscriptions. Design ------ * New Options.RenewMargin (default 10s) — how far before InitialTermination expiry the renew fires. Smaller margins mean fewer SOAP round-trips; larger margins tolerate slow networks. With default 60s termination + 10s margin we renew every 50s, which is in line with what production NVRs (Milestone, Genetec) use. * The renew loop runs in a separate goroutine sharing ctx with the pull loop. WaitGroup synchronisation in run() ensures both have exited before close()-of-channels happens, so a renew in flight during Close() cannot send on a closed Errors channel. * Pathological config (RenewMargin >= InitialTermination) falls back to renewing at termination/2 rather than busy-looping or never renewing. * renewPullPoint sends a wsnt:Renew SOAP against the SubscriptionRef Address with the same InitialTermination duration; renew errors surface on Errors non-blockingly, identically to pull errors. Tests use very short termination/margin (80-100ms / 10ms) so a single test run observes multiple renews within ~500ms, and assert that renew calls target the SubscriptionReference endpoint (not the device endpoint). -race clean. --- event/stream/renew_test.go | 134 +++++++++++++++++++++++++++++++++++++ event/stream/stream.go | 70 +++++++++++++++++-- event/stream/topics.go | 2 +- 3 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 event/stream/renew_test.go diff --git a/event/stream/renew_test.go b/event/stream/renew_test.go new file mode 100644 index 0000000..20e2146 --- /dev/null +++ b/event/stream/renew_test.go @@ -0,0 +1,134 @@ +package stream + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// countSendSoapMatching counts how many recorded SendSoap calls have a +// body containing needle. Safe to call concurrently with the run loop. +func countSendSoapMatching(fc *fakeCaller, needle string) int { + fc.mu.Lock() + defer fc.mu.Unlock() + n := 0 + for _, c := range fc.sendSoapCalls { + if strings.Contains(c[1], needle) { + n++ + } + } + return n +} + +func TestStream_RenewsSubscriptionBeforeExpiry(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 100 ms termination with 10 ms margin -> renew every ~90 ms. + s, err := newStream(ctx, fc, Options{ + DeviceID: "cam-1", + InitialTermination: 100 * time.Millisecond, + RenewMargin: 10 * time.Millisecond, + }) + require.NoError(t, err) + defer s.Close() + + deadline := time.Now().Add(500 * time.Millisecond) + var renewCount int + for time.Now().Before(deadline) { + renewCount = countSendSoapMatching(fc, "Renew") + if renewCount >= 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + assert.GreaterOrEqual(t, renewCount, 1, "expected at least one Renew SendSoap call within 500ms") +} + +func TestStream_RenewSendsToSubscriptionEndpoint(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newStream(ctx, fc, Options{ + InitialTermination: 80 * time.Millisecond, + RenewMargin: 10 * time.Millisecond, + }) + require.NoError(t, err) + defer s.Close() + + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countSendSoapMatching(fc, "Renew") >= 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + + fc.mu.Lock() + defer fc.mu.Unlock() + var renewEndpoint string + for _, c := range fc.sendSoapCalls { + if strings.Contains(c[1], "Renew") { + renewEndpoint = c[0] + break + } + } + require.NotEmpty(t, renewEndpoint, "no Renew call found") + assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", renewEndpoint, + "Renew must target the SubscriptionReference Address") +} + +func TestStream_RenewMarginAppliesDefault(t *testing.T) { + o := defaultOptions() + assert.Equal(t, 10*time.Second, o.RenewMargin) +} + +func TestStream_RenewErrorSurfacedOnErrorsChannel(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // Defaults return empty pulls indefinitely so the pull loop is clean. + // Override defaultSendSoap on the fly to return a Renew error for + // any body that looks like a Renew. We do that by tagging the + // default response with an err, then resetting after capturing one. + // Simpler: just queue several explicit Renew-error responses; the + // fake's queue is consumed in FIFO and the pull body never matches + // 'Renew', so queued errors will land on the renew call only if + // queued before any pulls. To bias the order we drain via a custom + // default. + fc.mu.Lock() + fc.defaultSendSoap = fakeResp{err: errInjected{}} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, err := newStream(ctx, fc, Options{ + InitialTermination: 80 * time.Millisecond, + RenewMargin: 10 * time.Millisecond, + }) + require.NoError(t, err) + defer s.Close() + + select { + case e := <-s.Errors(): + assert.Contains(t, e.Error(), "injected") + case <-time.After(time.Second): + t.Fatal("expected an error on Errors channel from failing Renew/pull") + } +} + +// errInjected is a sentinel error type so the test message has a stable +// substring without depending on a wrapped string match. +type errInjected struct{} + +func (errInjected) Error() string { return "injected fake error" } diff --git a/event/stream/stream.go b/event/stream/stream.go index 8ed8a44..102f88e 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -38,9 +38,13 @@ type Options struct { // returned per PullMessages call. Default: 10. MessageLimit int // InitialTermination is the requested subscription lifetime passed - // to CreatePullPointSubscription. The renew loop (added in a later - // commit) will refresh well before this expires. Default: 60s. + // to CreatePullPointSubscription. The renew loop refreshes well + // before this expires. Default: 60s. InitialTermination time.Duration + // RenewMargin is how long before InitialTermination expiry the + // renew loop fires. Larger margins tolerate slower networks at the + // cost of more renew SOAP calls. Default: 10s. + RenewMargin time.Duration // BufferSize is the buffer size of the Events and Errors channels. // Larger buffers absorb consumer hiccups at the cost of memory. // Default: 16. @@ -52,6 +56,7 @@ func defaultOptions() Options { PullTimeout: 5 * time.Second, MessageLimit: 10, InitialTermination: 60 * time.Second, + RenewMargin: 10 * time.Second, BufferSize: 16, } } @@ -67,6 +72,9 @@ func (o Options) withDefaults() Options { if o.InitialTermination > 0 { d.InitialTermination = o.InitialTermination } + if o.RenewMargin > 0 { + d.RenewMargin = o.RenewMargin + } if o.BufferSize > 0 { d.BufferSize = o.BufferSize } @@ -179,6 +187,17 @@ func (s *Stream) run(ctx context.Context) { defer close(s.events) defer close(s.errors) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + s.renewLoop(ctx) + }() + s.pullLoop(ctx) + wg.Wait() +} + +func (s *Stream) pullLoop(ctx context.Context) { for { if ctx.Err() != nil { return @@ -186,9 +205,9 @@ func (s *Stream) run(ctx context.Context) { msgs, err := pullMessages(s.caller, s.pullPoint, s.opts) if err != nil { s.surfaceError(err) - // Brief backoff before retrying; reconnect-on-error - // lands in a follow-up commit and replaces this with - // proper subscription recreation. + // Brief backoff before retrying; automatic + // subscription recreation lands in the reconnect + // commit and replaces this fallback. if !sleepCtx(ctx, time.Second) { return } @@ -206,6 +225,33 @@ func (s *Stream) run(ctx context.Context) { } } +// renewLoop refreshes the subscription before InitialTermination expires. +// Exits when ctx is cancelled. +func (s *Stream) renewLoop(ctx context.Context) { + interval := s.opts.InitialTermination - s.opts.RenewMargin + if interval <= 0 { + // Pathological config (margin >= termination): fall back to + // renewing at half the termination so we still refresh, + // rather than busy-looping or never renewing. + interval = s.opts.InitialTermination / 2 + if interval <= 0 { + interval = time.Second + } + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := renewPullPoint(s.caller, s.pullPoint, s.opts); err != nil { + s.surfaceError(fmt.Errorf("renew pull point: %w", err)) + } + } + } +} + // surfaceError sends err on the errors channel non-blockingly so a // stalled consumer cannot block the pull loop. func (s *Stream) surfaceError(err error) { @@ -284,6 +330,20 @@ func pullMessages(c caller, endpoint string, opts Options) ([]event.Notification return decoded.NotificationMessage, nil } +func renewPullPoint(c caller, endpoint string, opts Options) error { + req := event.Renew{TerminationTime: xsd.String(durationToXSD(opts.InitialTermination))} + body, err := xml.Marshal(req) + if err != nil { + return fmt.Errorf("marshal Renew: %w", err) + } + resp, err := c.SendSoap(endpoint, string(body)) + if err != nil { + return err + } + _, err = readClose(resp) + return err +} + func unsubscribePullPoint(c caller, endpoint string) error { if endpoint == "" { return nil diff --git a/event/stream/topics.go b/event/stream/topics.go index 9aba5be..469027b 100644 --- a/event/stream/topics.go +++ b/event/stream/topics.go @@ -168,7 +168,7 @@ var topicRules = []struct { // tns1:RuleEngine/LineDetector/Crossed — line crossing (Hikvision, // Bosch IVA, others). Data: ObjectId (xsd:int); edge-triggered, no // State boolean. - // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4 + // https://www.onvif.org/specs/srv/analytics/ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4 {"LineDetector/Crossed", KindObjectDetected}, // tns1:RuleEngine/FieldDetector/ObjectsInside — intrusion / region From 82f98cb82479d0b995bdf43b16e2084bc418085d Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:40:42 +0200 Subject: [PATCH 09/23] feat(event/stream): recreate subscription after consecutive pull failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds automatic CreatePullPointSubscription recreation when the pull loop hits ReconnectAfterFailures (default 3) consecutive errors. Mirrors what production ONVIF clients (Home Assistant event_manager, Milestone integration) do because pull points die for many reasons none of which surface as a clean SOAP fault: camera reboot, NAT session timeout, subscription garbage-collected after a renew miss, firmware bug. Recreating is the only reliable recovery; Renew alone cannot save an already-dropped subscription. Two new options --------------- * ReconnectAfterFailures int (default 3) — how many consecutive pull failures trigger recreate. Conservative default; tunable for always-on cameras vs flaky NAT. * RetryBackoff time.Duration (default 1s) — base sleep between pull retries; recreate failures double this up to a 30s cap so a permanently broken camera does not hammer the network. Lifecycle changes ----------------- * Stream.pullPoint is now mutex-protected — the renew goroutine reads it concurrently with the pull loop installing a new address after recreate. getPullPoint/setPullPoint accessors keep the locking contained. * On successful recreate, failure count and backoff reset to defaults so the loop is back to its happy-path cadence. * On recreate failure, the loop continues retrying (until ctx cancel) with exponentially increasing sleep — never blocks Close. Tests cover: post-failure recreate hits a different SubscriptionRef Address and subsequent events come from the new endpoint; exponential backoff drives multiple recreate attempts when the camera stays down; defaults match production-sensible 3 failures / 1s backoff. -race clean. --- event/stream/reconnect_test.go | 120 +++++++++++++++++++++++++++++++++ event/stream/stream.go | 99 ++++++++++++++++++++++----- 2 files changed, 204 insertions(+), 15 deletions(-) create mode 100644 event/stream/reconnect_test.go diff --git a/event/stream/reconnect_test.go b/event/stream/reconnect_test.go new file mode 100644 index 0000000..fd229a5 --- /dev/null +++ b/event/stream/reconnect_test.go @@ -0,0 +1,120 @@ +package stream + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// createPullPointRespAlt mirrors the first fixture but returns a +// different SubscriptionReference Address so a test can prove that +// subsequent pulls hit the recreated endpoint. +const createPullPointRespAlt = ` + + + + + http://camera.local/onvif/Events/PullSub_2 + + 2026-05-21T10:30:10Z + 2026-05-21T10:31:10Z + + +` + +func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) { + fc := newFakeCaller() + // Initial subscription. + fc.queueCallMethod(createPullPointResp, nil) + // Recreated subscription returns a *different* endpoint. + fc.queueCallMethod(createPullPointRespAlt, nil) + + // First pull fails. With ReconnectAfterFailures=1 this triggers a + // recreate; subsequent pulls go to PullSub_2 which we'll observe. + fc.queueSendSoap("", errors.New("transient failure")) + fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + DeviceID: "cam-1", + PullTimeout: 50 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, // keep renew quiet + }) + require.NoError(t, err) + defer s.Close() + + ev := receive(t, s.Events(), 2*time.Second) + assert.Equal(t, KindMotion, ev.Kind) + + fc.mu.Lock() + defer fc.mu.Unlock() + require.Len(t, fc.callMethodCalls, 2, + "expected exactly 2 CallMethod calls (initial + recreate)") + // The PullMessages call that delivered the motion event must + // target the new endpoint. + var newEndpointPulls int + for _, c := range fc.sendSoapCalls { + if c[0] == "http://camera.local/onvif/Events/PullSub_2" { + newEndpointPulls++ + } + } + assert.GreaterOrEqual(t, newEndpointPulls, 1, + "expected pulls against the recreated subscription endpoint") +} + +func TestStream_BackoffWhenRecreateFails(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // After the initial successful create, every CallMethod (recreate) + // and SendSoap (pull) fails. The loop should keep retrying with + // exponential backoff rather than blocking forever or spinning. + fc.mu.Lock() + fc.defaultCall = fakeResp{err: errors.New("recreate fail")} + fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 10 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + deadline := time.Now().Add(2 * time.Second) + var calls atomic.Int32 + for time.Now().Before(deadline) { + fc.mu.Lock() + calls.Store(int32(len(fc.callMethodCalls))) + fc.mu.Unlock() + if calls.Load() >= 4 { + break + } + time.Sleep(20 * time.Millisecond) + } + assert.GreaterOrEqual(t, calls.Load(), int32(4), + "expected stream to retry recreate (>=3 retries on top of the initial create)") +} + +func TestStream_ReconnectAfterFailuresDefault(t *testing.T) { + o := defaultOptions() + assert.Equal(t, 3, o.ReconnectAfterFailures) +} + +func TestStream_RetryBackoffDefault(t *testing.T) { + o := defaultOptions() + assert.Equal(t, time.Second, o.RetryBackoff) +} diff --git a/event/stream/stream.go b/event/stream/stream.go index 102f88e..a7cc06b 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -45,6 +45,17 @@ type Options struct { // renew loop fires. Larger margins tolerate slower networks at the // cost of more renew SOAP calls. Default: 10s. RenewMargin time.Duration + // ReconnectAfterFailures is the consecutive PullMessages failure + // count that triggers a CreatePullPointSubscription recreate. The + // camera or pull-point can die for many reasons (camera reboot, + // subscription garbage-collected after a renew miss, intermediate + // NAT timeout); rebuilding the subscription is the only reliable + // recovery. Default: 3. + ReconnectAfterFailures int + // RetryBackoff is the initial sleep between a pull/recreate failure + // and the next attempt. Recreate failures double this up to a 30s + // ceiling. Default: 1s. + RetryBackoff time.Duration // BufferSize is the buffer size of the Events and Errors channels. // Larger buffers absorb consumer hiccups at the cost of memory. // Default: 16. @@ -53,11 +64,13 @@ type Options struct { func defaultOptions() Options { return Options{ - PullTimeout: 5 * time.Second, - MessageLimit: 10, - InitialTermination: 60 * time.Second, - RenewMargin: 10 * time.Second, - BufferSize: 16, + PullTimeout: 5 * time.Second, + MessageLimit: 10, + InitialTermination: 60 * time.Second, + RenewMargin: 10 * time.Second, + ReconnectAfterFailures: 3, + RetryBackoff: time.Second, + BufferSize: 16, } } @@ -75,6 +88,12 @@ func (o Options) withDefaults() Options { if o.RenewMargin > 0 { d.RenewMargin = o.RenewMargin } + if o.ReconnectAfterFailures > 0 { + d.ReconnectAfterFailures = o.ReconnectAfterFailures + } + if o.RetryBackoff > 0 { + d.RetryBackoff = o.RetryBackoff + } if o.BufferSize > 0 { d.BufferSize = o.BufferSize } @@ -83,6 +102,9 @@ func (o Options) withDefaults() Options { return d } +// maxRecreateBackoff caps exponential backoff between recreate attempts. +const maxRecreateBackoff = 30 * time.Second + // caller is the subset of *onvif.Device the Stream depends on. Tests // substitute a fake; production code uses the device adapter. type caller interface { @@ -107,9 +129,11 @@ func (d deviceCaller) SendSoap(endpoint, body string) (*http.Response, error) { // A Stream is safe for concurrent use by Close from any goroutine while // readers consume Events / Errors; Close is idempotent. type Stream struct { - caller caller - opts Options - pullPoint string + caller caller + opts Options + + pullPointMu sync.Mutex + pullPoint string events chan Event errors chan error @@ -124,6 +148,18 @@ type Stream struct { now func() time.Time } +func (s *Stream) getPullPoint() string { + s.pullPointMu.Lock() + defer s.pullPointMu.Unlock() + return s.pullPoint +} + +func (s *Stream) setPullPoint(addr string) { + s.pullPointMu.Lock() + defer s.pullPointMu.Unlock() + s.pullPoint = addr +} + // NewStream creates a Stream against an ONVIF device. It performs the // CreatePullPointSubscription call synchronously so connectivity and // authentication problems surface immediately as an error rather than @@ -175,7 +211,7 @@ func (s *Stream) Close() error { // Unsubscribe is best-effort: if the camera is unreachable // the subscription will expire on its own at // InitialTermination + Renew interval anyway. - if err := unsubscribePullPoint(s.caller, s.pullPoint); err != nil { + if err := unsubscribePullPoint(s.caller, s.getPullPoint()); err != nil { s.closeErr = fmt.Errorf("unsubscribe pull point: %w", err) } }) @@ -198,21 +234,31 @@ func (s *Stream) run(ctx context.Context) { } func (s *Stream) pullLoop(ctx context.Context) { + var failures int + recreateBackoff := s.opts.RetryBackoff + for { if ctx.Err() != nil { return } - msgs, err := pullMessages(s.caller, s.pullPoint, s.opts) + msgs, err := pullMessages(s.caller, s.getPullPoint(), s.opts) if err != nil { s.surfaceError(err) - // Brief backoff before retrying; automatic - // subscription recreation lands in the reconnect - // commit and replaces this fallback. - if !sleepCtx(ctx, time.Second) { + failures++ + if failures >= s.opts.ReconnectAfterFailures { + if !s.attemptRecreate(ctx, &failures, &recreateBackoff) { + return + } + continue + } + if !sleepCtx(ctx, s.opts.RetryBackoff) { return } continue } + // Successful pull resets failure tracking. + failures = 0 + recreateBackoff = s.opts.RetryBackoff observedAt := s.now() for _, m := range msgs { ev := Decode(m, s.opts.DeviceID, observedAt) @@ -225,6 +271,29 @@ func (s *Stream) pullLoop(ctx context.Context) { } } +// attemptRecreate calls CreatePullPointSubscription and on success +// installs the new endpoint atomically. Returns false if ctx was +// cancelled while waiting for backoff (caller should exit the run +// loop). +func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) bool { + addr, err := createPullPoint(s.caller, s.opts) + if err != nil { + s.surfaceError(fmt.Errorf("recreate pull point: %w", err)) + if !sleepCtx(ctx, *backoff) { + return false + } + *backoff *= 2 + if *backoff > maxRecreateBackoff { + *backoff = maxRecreateBackoff + } + return true + } + s.setPullPoint(addr) + *failures = 0 + *backoff = s.opts.RetryBackoff + return true +} + // renewLoop refreshes the subscription before InitialTermination expires. // Exits when ctx is cancelled. func (s *Stream) renewLoop(ctx context.Context) { @@ -245,7 +314,7 @@ func (s *Stream) renewLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if err := renewPullPoint(s.caller, s.pullPoint, s.opts); err != nil { + if err := renewPullPoint(s.caller, s.getPullPoint(), s.opts); err != nil { s.surfaceError(fmt.Errorf("renew pull point: %w", err)) } } From 6465564f2aa7f60501e266c832fc4db7b4d941dc Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:40:57 +0200 Subject: [PATCH 10/23] style(event/stream): align reconnect_test Options struct literal gofmt -w pass on reconnect_test.go. The Options struct field names had mismatched alignment; reformatted to match gofmt canonical layout. No behaviour change. --- event/stream/reconnect_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/event/stream/reconnect_test.go b/event/stream/reconnect_test.go index fd229a5..9162768 100644 --- a/event/stream/reconnect_test.go +++ b/event/stream/reconnect_test.go @@ -44,11 +44,11 @@ func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() s, err := newStream(ctx, fc, Options{ - DeviceID: "cam-1", - PullTimeout: 50 * time.Millisecond, - ReconnectAfterFailures: 1, - RetryBackoff: 10 * time.Millisecond, - InitialTermination: 30 * time.Second, // keep renew quiet + DeviceID: "cam-1", + PullTimeout: 50 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, // keep renew quiet }) require.NoError(t, err) defer s.Close() From 176e0d8f3c5b4157f7f0f1a46e4d5d3e15dbceca Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:42:02 +0200 Subject: [PATCH 11/23] feat(examples): add event/stream CLI for live camera verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a small command at examples/event/stream that opens a real ONVIF event stream against a camera and prints decoded events one per line. Intended for verifying the classifier against actual hardware (AXIS in particular) and as runnable documentation for new consumers of the package — point it at a configured camera, trigger motion, watch the events arrive. Behaviour --------- * Required flags: -xaddr, -username, -password (matches existing examples/event/* commands so anyone running the older subscribe / pullmessage demos already knows the shape). * Optional -filter passes through to Options.TopicFilter; default empty so AXIS works out of the box. * -duration N stops after N (default 0 = run until Ctrl-C). * Prints kind/state/op/topic on each event plus source and data maps when present, so multi-item ONVIF payloads (AXIS AOA active+classType+confidence, DigitalInput InputToken+LogicalState) are visible without re-reading PullMessages SOAP. * Errors channel surfaced to stderr via log; the stream auto-recovers per the reconnect logic in stream.go so transient errors do not terminate the demo. Not part of any CI; not a production tool — this is a verification harness. --- examples/event/stream/main.go | 107 ++++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 examples/event/stream/main.go diff --git a/examples/event/stream/main.go b/examples/event/stream/main.go new file mode 100644 index 0000000..a795ed4 --- /dev/null +++ b/examples/event/stream/main.go @@ -0,0 +1,107 @@ +// Command streamtest opens an event stream against an ONVIF camera and +// prints decoded events as they arrive. Useful for verifying the +// classifier against real-camera topics; not intended as a production +// tool. +// +// Example: +// +// go run ./examples/event/stream \ +// -xaddr 192.168.1.10 \ +// -username root -password admin \ +// -duration 60s +// +// The xaddr is the camera's host or host:port (the library appends +// /onvif/device_service); pass with no protocol prefix. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/kerberos-io/onvif" + "github.com/kerberos-io/onvif/event/stream" +) + +func main() { + xaddr := flag.String("xaddr", "", "camera host or host:port (required)") + username := flag.String("username", "", "ONVIF user (required)") + password := flag.String("password", "", "ONVIF password (required)") + deviceID := flag.String("device-id", "", "logical name printed with each event (default: xaddr)") + filter := flag.String("filter", "", "raw ONVIF ConcreteSet topic filter (empty = all topics, works on AXIS)") + pullTimeout := flag.Duration("pull-timeout", 5*time.Second, "server-side wait per PullMessages call") + duration := flag.Duration("duration", 0, "stop after this long (0 = run until Ctrl-C)") + flag.Parse() + + if *xaddr == "" || *username == "" || *password == "" { + flag.Usage() + os.Exit(2) + } + if *deviceID == "" { + *deviceID = *xaddr + } + + dev, err := onvif.NewDevice(onvif.DeviceParams{ + Xaddr: *xaddr, + Username: *username, + Password: *password, + AuthMode: onvif.UsernameTokenAuth, + }) + if err != nil { + log.Fatalf("connect: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if *duration > 0 { + var done context.CancelFunc + ctx, done = context.WithTimeout(ctx, *duration) + defer done() + } + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigs + cancel() + }() + + s, err := stream.NewStream(ctx, dev, stream.Options{ + DeviceID: *deviceID, + TopicFilter: *filter, + PullTimeout: *pullTimeout, + }) + if err != nil { + log.Fatalf("open stream: %v", err) + } + defer s.Close() + + log.Printf("streaming from %s (device-id=%s, filter=%q)", *xaddr, *deviceID, *filter) + for { + select { + case <-ctx.Done(): + log.Printf("done (%v)", ctx.Err()) + return + case ev, ok := <-s.Events(): + if !ok { + return + } + fmt.Printf("%s kind=%-15s state=%-9s op=%-12s topic=%s", + ev.Timestamp.Format(time.RFC3339), ev.Kind, ev.State, ev.Operation, ev.Topic) + if len(ev.Source) > 0 { + fmt.Printf(" source=%v", ev.Source) + } + if len(ev.Data) > 0 { + fmt.Printf(" data=%v", ev.Data) + } + fmt.Println() + case e := <-s.Errors(): + log.Printf("stream error: %v", e) + } + } +} From 93620f04a357fbeaee7ec302872b3841f4a9b5a0 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:54:05 +0200 Subject: [PATCH 12/23] fix(event/stream): production-grade SOAP and lifecycle hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the five ship-blocker findings from the second review: 1. Bounded body read (review F1 / R-HIGH) readClose now wraps resp.Body with io.LimitReader(10 MiB). A hostile or buggy camera streaming an unbounded body cannot OOM the agent. Legitimate PullMessages payloads are <200KB even with dense analytics. 2. SOAP Fault detection (review F3) unmarshalNode now scans for SOAP 1.1 faultstring and SOAP 1.2 Reason/Text BEFORE the missing-element error path. Auth failures ('not authorized'), InvalidFilterFault and expired-subscription faults now surface their reason text instead of collapsing to the unhelpful 'response missing PullMessagesResponse element'. This is the difference between a debuggable error and a hidden one when a customer's credentials change. 3. Absolute Renew TerminationTime (review F1 wire-correctness) renewPullPoint now sends an RFC3339 UTC datetime ('2026-05-21T10:30:00Z') instead of a relative xsd:duration ('PT60S'). WS-BaseNotification §6.1.1 accepts both, but older Hikvision, some Dahua and Bosch firmwares only accept the absolute form — the library's own type comment even flags this ('BUG(r) Bad AbsoluteOrRelativeTimeType type'). 4. Bounded Close (review P0) Close now wraps Unsubscribe in a 5s timeout. Previously a TCP-accepted-but-never-replying camera would wedge Close indefinitely; now Close returns with a timeout error and the subscription expires on its own at InitialTermination. 5. Explicit channel-close ordering after wg.Wait The run goroutine previously relied on defer-LIFO to guarantee renew exits before close(errors). Future maintainers extending run() could invert that order silently. Closes are now explicit sequential statements after wg.Wait() so the invariant is local, not order-of-defers magic. Also expands wsnt:UtcTime parsing in decode.go to cover the four formats observed across vendor firmwares: RFC3339 with sub-seconds, compact offsets ('+0200', Geovision/Dahua), and naked timestamps without timezone (older Hikvision; per spec UTC is implied). Caller interface gains a doc comment noting it must be safe for concurrent use, documenting the contract Stream depends on (*onvif. Device satisfies it via http.Client). Tests added: SOAP 1.1 and 1.2 fault extraction, fault surfacing through unmarshalNode, Renew absolute-datetime assertion, Close-with-blocked-Unsubscribe returning within the timeout. -race clean. --- event/stream/decode.go | 22 ++++- event/stream/soap_test.go | 155 ++++++++++++++++++++++++++++++++++++ event/stream/stream.go | 94 +++++++++++++++++++--- event/stream/stream_test.go | 27 +++++-- 4 files changed, 275 insertions(+), 23 deletions(-) create mode 100644 event/stream/soap_test.go diff --git a/event/stream/decode.go b/event/stream/decode.go index 1d96941..f0bc7df 100644 --- a/event/stream/decode.go +++ b/event/stream/decode.go @@ -88,17 +88,31 @@ func parsePropertyOperation(s string) PropertyOperation { // time when the attribute is absent or unparseable. The result is // normalised to UTC so equality comparisons across timezones work. // -// xsd:dateTime in ONVIF messages is RFC 3339 in practice; we try -// time.RFC3339Nano first (covers sub-second precision) and fall back to -// time.RFC3339 for cameras that drop the fractional part. +// xsd:dateTime in ONVIF messages is RFC 3339 in practice but real +// cameras emit several flavours: with/without sub-seconds, with colon +// or compact ("+0200") timezone offsets, and some older Hikvision +// firmwares omit the timezone entirely (treated as UTC per +// WS-BaseNotification which mandates UTC for UtcTime). func parseDeviceTime(s string) time.Time { if s == "" { return time.Time{} } - for _, layout := range []string{time.RFC3339Nano, time.RFC3339} { + for _, layout := range deviceTimeLayouts { if t, err := time.Parse(layout, s); err == nil { return t.UTC() } } return time.Time{} } + +// deviceTimeLayouts lists the wsnt:UtcTime forms observed across vendor +// firmwares. Ordered from most-precise / most-common first so the +// happy path hits early. +var deviceTimeLayouts = []string{ + time.RFC3339Nano, // 2026-05-21T10:30:00.500Z, ...+02:00 + time.RFC3339, // 2026-05-21T10:30:00Z, ...+02:00 + "2006-01-02T15:04:05.999-0700", // sub-second + compact offset (Geovision) + "2006-01-02T15:04:05-0700", // compact offset (some Dahua) + "2006-01-02T15:04:05.999", // no timezone, sub-second (rare) + "2006-01-02T15:04:05", // naked, no TZ (older Hikvision) +} diff --git a/event/stream/soap_test.go b/event/stream/soap_test.go new file mode 100644 index 0000000..e8c48f5 --- /dev/null +++ b/event/stream/soap_test.go @@ -0,0 +1,155 @@ +package stream + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- SOAP fault detection --------------------------------------------- + +func TestExtractSOAPFault_SOAP11(t *testing.T) { + body := ` + + + + env:Client + The action requested requires authorization and the sender is not authorized + + +` + got := extractSOAPFault(body) + assert.Contains(t, got, "not authorized") +} + +func TestExtractSOAPFault_SOAP12(t *testing.T) { + body := ` + + + + env:Sender + Subscription has expired + + +` + got := extractSOAPFault(body) + assert.Contains(t, got, "Subscription has expired") +} + +func TestExtractSOAPFault_NotAFault(t *testing.T) { + assert.Empty(t, extractSOAPFault(createPullPointResp)) +} + +func TestExtractSOAPFault_EmptyBody(t *testing.T) { + assert.Empty(t, extractSOAPFault("")) +} + +func TestUnmarshalNode_ReturnsFaultReasonInsteadOfMissingElement(t *testing.T) { + body := ` + not authorized +` + var out struct{} + err := unmarshalNode(body, "PullMessagesResponse", &out) + require.Error(t, err) + assert.Contains(t, err.Error(), "not authorized") + assert.NotContains(t, err.Error(), "missing PullMessagesResponse") +} + +// --- Renew sends absolute datetime ----------------------------------- + +func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + InitialTermination: 30 * time.Millisecond, + RenewMargin: 5 * time.Millisecond, + }) + require.NoError(t, err) + defer s.Close() + + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countSendSoapMatching(fc, "Renew") >= 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + + fc.mu.Lock() + defer fc.mu.Unlock() + var renewBody string + for _, c := range fc.sendSoapCalls { + if strings.Contains(c[1], "Renew") { + renewBody = c[1] + break + } + } + require.NotEmpty(t, renewBody, "no Renew call observed") + // Absolute form is "YYYY-MM-DDTHH:MM:SSZ" not "PTnS". + assert.NotContains(t, renewBody, "PT", "Renew should not send relative duration; some firmwares reject it") + assert.Regexp(t, `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, renewBody, "Renew should send absolute RFC3339 UTC") +} + +// --- Bounded body read ----------------------------------------------- + +func TestReadClose_LimitsBodySize(t *testing.T) { + // Build a response with a body just over the limit. readClose must + // not return more than the limit even if the camera pretends to + // send more. + if maxResponseBytes < 1024 { + t.Skip("limit too small for this test") + } + big := strings.Repeat("A", maxResponseBytes+1024) + // Wrap in a minimal SOAP envelope so the body is at least + // well-formed shape-wise. + body := "" + big + "" + fc := newFakeCaller() + fc.queueCallMethod(body, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + // Construction will fail because the truncated body has no + // CreatePullPointSubscriptionResponse — that's fine; what matters + // is the read completes without OOM. + _, err := newStream(ctx, fc, Options{}) + assert.Error(t, err) +} + +// --- Close timeout --------------------------------------------------- + +func TestClose_BoundedByTimeoutOnHungUnsubscribe(t *testing.T) { + // Patch closeUnsubscribeTimeout for the duration of the test so the + // assertion completes promptly. We can't change the const at runtime + // so we use a short InitialTermination and verify Close still + // returns within closeUnsubscribeTimeout + slack rather than + // blocking forever. + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + block := make(chan struct{}) + defer close(block) // release the hung Unsubscribe so the fake's goroutine exits + fc.mu.Lock() + fc.blockUnsubscribe = block + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) + require.NoError(t, err) + + start := time.Now() + err = s.Close() + elapsed := time.Since(start) + // Unsubscribe is hung, so Close must surface a timeout error from + // the bounded wait rather than block forever. closeUnsubscribeTimeout + // is 5s; allow 1s slack for scheduling. + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") + assert.Less(t, elapsed, closeUnsubscribeTimeout+time.Second, + "Close exceeded bound (%s); expected ~%s", elapsed, closeUnsubscribeTimeout) +} diff --git a/event/stream/stream.go b/event/stream/stream.go index a7cc06b..9e4fd7a 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -8,7 +8,9 @@ import ( "fmt" "io" "net/http" + "regexp" "strconv" + "strings" "sync" "time" @@ -17,6 +19,18 @@ import ( "github.com/kerberos-io/onvif/xsd" ) +// maxResponseBytes caps the size of a SOAP response we will buffer in +// memory. ONVIF PullMessages bodies are normally <100KB even with dense +// analytics payloads; 10 MiB is comfortably above legitimate traffic +// while keeping a hostile or buggy camera from OOMing the process. +const maxResponseBytes = 10 << 20 + +// closeUnsubscribeTimeout bounds the SOAP Unsubscribe call issued by +// Close so a hung camera connection cannot wedge the caller. The +// subscription expires at the camera anyway once InitialTermination +// elapses, so a missed unsubscribe is at worst cosmetic. +const closeUnsubscribeTimeout = 5 * time.Second + // Options configures a Stream. The zero value is usable; defaultOptions // fills in production-sensible defaults for any unset field. type Options struct { @@ -107,6 +121,11 @@ const maxRecreateBackoff = 30 * time.Second // caller is the subset of *onvif.Device the Stream depends on. Tests // substitute a fake; production code uses the device adapter. +// +// Implementations must be safe for concurrent use: the pull loop and +// renew loop call into caller from separate goroutines. *onvif.Device +// satisfies this because its HTTP client is the goroutine-safe +// http.Client. type caller interface { CallMethod(method any) (*http.Response, error) SendSoap(endpoint, body string) (*http.Response, error) @@ -204,25 +223,33 @@ func (s *Stream) Errors() <-chan error { return s.errors } // Close stops the background goroutine, waits for it to exit, and // unsubscribes from the camera. Subsequent calls are no-ops. +// +// Unsubscribe is bounded by closeUnsubscribeTimeout so a hung camera +// connection cannot wedge the caller. On timeout Close still returns +// promptly; the subscription will expire at the camera once +// InitialTermination + RenewMargin elapses without a renew. func (s *Stream) Close() error { s.closeOnce.Do(func() { s.cancel() <-s.done - // Unsubscribe is best-effort: if the camera is unreachable - // the subscription will expire on its own at - // InitialTermination + Renew interval anyway. - if err := unsubscribePullPoint(s.caller, s.getPullPoint()); err != nil { - s.closeErr = fmt.Errorf("unsubscribe pull point: %w", err) + + errCh := make(chan error, 1) + go func() { + errCh <- unsubscribePullPoint(s.caller, s.getPullPoint()) + }() + select { + case err := <-errCh: + if err != nil { + s.closeErr = fmt.Errorf("unsubscribe pull point: %w", err) + } + case <-time.After(closeUnsubscribeTimeout): + s.closeErr = fmt.Errorf("unsubscribe pull point: timeout after %s", closeUnsubscribeTimeout) } }) return s.closeErr } func (s *Stream) run(ctx context.Context) { - defer close(s.done) - defer close(s.events) - defer close(s.errors) - var wg sync.WaitGroup wg.Add(1) go func() { @@ -231,6 +258,13 @@ func (s *Stream) run(ctx context.Context) { }() s.pullLoop(ctx) wg.Wait() + + // Explicit close order after both goroutines have exited so a + // future maintainer extending this function does not accidentally + // rely on defer-ordering for channel-close safety. + close(s.errors) + close(s.events) + close(s.done) } func (s *Stream) pullLoop(ctx context.Context) { @@ -400,7 +434,12 @@ func pullMessages(c caller, endpoint string, opts Options) ([]event.Notification } func renewPullPoint(c caller, endpoint string, opts Options) error { - req := event.Renew{TerminationTime: xsd.String(durationToXSD(opts.InitialTermination))} + // WS-BaseNotification §6.1.1 declares TerminationTime as + // xsd:dateTime OR xsd:duration, but older Hikvision, some Dahua + // and some Bosch firmwares reject the relative-duration form. Send + // an absolute UTC datetime to match what production NVRs do. + absoluteEnd := time.Now().UTC().Add(opts.InitialTermination).Format("2006-01-02T15:04:05Z") + req := event.Renew{TerminationTime: xsd.String(absoluteEnd)} body, err := xml.Marshal(req) if err != nil { return fmt.Errorf("marshal Renew: %w", err) @@ -434,7 +473,9 @@ func readClose(resp *http.Response) (string, error) { return "", errors.New("nil HTTP response") } defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) + // LimitReader prevents a hostile or buggy camera from OOMing the + // agent by streaming an unbounded response body. + b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) if err != nil { return "", fmt.Errorf("read response body: %w", err) } @@ -445,7 +486,15 @@ func readClose(resp *http.Response) (string, error) { // name and decodes it into out. ONVIF SOAP responses come wrapped in an // envelope with multiple namespace prefixes; this helper sidesteps // namespace matching by keying on local name only. +// +// When the camera returns a SOAP Fault instead of the expected +// response, the fault reason is surfaced as the error so callers can +// distinguish "auth failed" / "subscription expired" from "unparseable +// response". func unmarshalNode(body, localName string, out any) error { + if reason := extractSOAPFault(body); reason != "" { + return fmt.Errorf("ONVIF SOAP fault: %s", reason) + } dec := xml.NewDecoder(bytes.NewBufferString(body)) for { tok, err := dec.Token() @@ -469,6 +518,29 @@ func unmarshalNode(body, localName string, out any) error { } } +var ( + // SOAP 1.1: reason + soap11FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?faultstring[^>]*>(.*?)\s]+:)?faultstring>`) + // SOAP 1.2: ...reason... + soap12FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Reason\b[^>]*>.*?<(?:[^:>\s]+:)?Text[^>]*>(.*?)\s]+:)?Text>`) +) + +// extractSOAPFault returns the human-readable reason text from a SOAP +// fault, or empty string when the body is not a fault. Handles both +// SOAP 1.1 (faultstring) and SOAP 1.2 (Reason/Text) shapes. +func extractSOAPFault(body string) string { + if !strings.Contains(body, "Fault") { + return "" + } + if m := soap11FaultRE.FindStringSubmatch(body); len(m) > 1 { + return strings.TrimSpace(m[1]) + } + if m := soap12FaultRE.FindStringSubmatch(body); len(m) > 1 { + return strings.TrimSpace(m[1]) + } + return "" +} + // durationToXSD formats a Go time.Duration as an xsd:duration string in // PTnS form. Second precision is sufficient — ONVIF cameras do not // honour sub-second pull timeouts and intermediate routers may round in diff --git a/event/stream/stream_test.go b/event/stream/stream_test.go index 698d07e..010f10a 100644 --- a/event/stream/stream_test.go +++ b/event/stream/stream_test.go @@ -20,14 +20,19 @@ import ( // returns the next queued response; when the queue is exhausted it falls // back to a default response so the indefinite pull loop does not // require tests to enumerate every call. +// +// blockUnsubscribe, when non-nil, causes SendSoap calls whose body +// contains "Unsubscribe" to block until the channel is closed. Used to +// verify Close's timeout path. type fakeCaller struct { - mu sync.Mutex - callMethodResps []fakeResp - sendSoapResps []fakeResp - defaultSendSoap fakeResp - defaultCall fakeResp - callMethodCalls []any - sendSoapCalls [][2]string + mu sync.Mutex + callMethodResps []fakeResp + sendSoapResps []fakeResp + defaultSendSoap fakeResp + defaultCall fakeResp + callMethodCalls []any + sendSoapCalls [][2]string + blockUnsubscribe chan struct{} } type fakeResp struct { @@ -72,13 +77,19 @@ func (f *fakeCaller) CallMethod(m any) (*http.Response, error) { func (f *fakeCaller) SendSoap(endpoint, body string) (*http.Response, error) { f.mu.Lock() - defer f.mu.Unlock() f.sendSoapCalls = append(f.sendSoapCalls, [2]string{endpoint, body}) r := f.defaultSendSoap if len(f.sendSoapResps) > 0 { r = f.sendSoapResps[0] f.sendSoapResps = f.sendSoapResps[1:] } + block := f.blockUnsubscribe + f.mu.Unlock() + + if block != nil && strings.Contains(body, "Unsubscribe") { + <-block + } + if r.err != nil { return nil, r.err } From d718145bd3d956e4ed5da55baab2357c9a622755 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:54:25 +0200 Subject: [PATCH 13/23] style(event/stream): gofmt decode.go layout table alignment --- event/stream/decode.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/event/stream/decode.go b/event/stream/decode.go index f0bc7df..2559a8b 100644 --- a/event/stream/decode.go +++ b/event/stream/decode.go @@ -109,8 +109,8 @@ func parseDeviceTime(s string) time.Time { // firmwares. Ordered from most-precise / most-common first so the // happy path hits early. var deviceTimeLayouts = []string{ - time.RFC3339Nano, // 2026-05-21T10:30:00.500Z, ...+02:00 - time.RFC3339, // 2026-05-21T10:30:00Z, ...+02:00 + time.RFC3339Nano, // 2026-05-21T10:30:00.500Z, ...+02:00 + time.RFC3339, // 2026-05-21T10:30:00Z, ...+02:00 "2006-01-02T15:04:05.999-0700", // sub-second + compact offset (Geovision) "2006-01-02T15:04:05-0700", // compact offset (some Dahua) "2006-01-02T15:04:05.999", // no timezone, sub-second (rare) From 6fc9b23e9fa4b87c110eee3c037590aa31505532 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:55:58 +0200 Subject: [PATCH 14/23] feat(event/stream): typed errors and AfterReconnect observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare fmt.Errorf wrappers on the Errors channel with three typed errors and adds an Event.AfterReconnect flag so consumers can distinguish post-recreate replay events from live ones. Typed errors ------------ ErrPullFailed, ErrRenewFailed, ErrRecreateFailed all implement Unwrap() and Op() Op. Consumers can branch with errors.As without parsing strings: var pull ErrPullFailed if errors.As(e, &pull) { /* transient; logged */ } var recreate ErrRecreateFailed if errors.As(e, &recreate) { /* alert: camera may be offline */ } Op() returns OpPull / OpRenew / OpRecreate for cases where the caller wants to log the operation name without unwrapping. Both addressed the review's 'highest-leverage v1 change' concern about bare error on the Errors channel. AfterReconnect observability ---------------------------- ONVIF cameras replay each property's current value with PropertyInitialized whenever a new pull-point subscription is established (per the Event Service spec). A consumer doing edge detection on motion = StateActive would otherwise see a phantom 'motion started' for every active property after every reconnect. The pull loop now tracks an afterReconnect flag local to the goroutine: set to true when attemptRecreate returns justRecreated, applied to every emitted event, cleared on the first non-Initialized event we see. This bounds the replay window naturally — once the camera has finished sending current state, the next event tells us we're live. attemptRecreate now returns (justRecreated, cont) so the pull loop knows whether the just-completed recreate succeeded vs. the call returning due to ctx-cancel during backoff. Test coverage ------------- * errors_test.go: typed-error Unwrap/Op assertions plus Stream-level proof that pull and recreate failures arrive on the Errors channel wearing the right type. * AfterReconnect flag: drives the stream through a failure, observes the next event carries the flag and the one after does not. --- event/stream/errors.go | 42 ++++++++++++ event/stream/errors_test.go | 133 ++++++++++++++++++++++++++++++++++++ event/stream/stream.go | 39 ++++++++--- event/stream/types.go | 9 +++ 4 files changed, 212 insertions(+), 11 deletions(-) create mode 100644 event/stream/errors.go create mode 100644 event/stream/errors_test.go diff --git a/event/stream/errors.go b/event/stream/errors.go new file mode 100644 index 0000000..bd80674 --- /dev/null +++ b/event/stream/errors.go @@ -0,0 +1,42 @@ +package stream + +import "fmt" + +// Op identifies which Stream operation failed. Used by ErrPullFailed, +// ErrRenewFailed and ErrRecreateFailed so consumers can branch with +// errors.As without parsing the wrapped message. +type Op string + +const ( + OpPull Op = "pull" + OpRenew Op = "renew" + OpRecreate Op = "recreate" +) + +// ErrPullFailed wraps a transient PullMessages failure. The pull loop +// surfaces it on the Errors channel and continues. Consumers can match +// with errors.As(err, &stream.ErrPullFailed{}) — see +// TestErrors_TypedAssertion. +type ErrPullFailed struct{ Err error } + +func (e ErrPullFailed) Error() string { return fmt.Sprintf("pull messages: %v", e.Err) } +func (e ErrPullFailed) Unwrap() error { return e.Err } +func (ErrPullFailed) Op() Op { return OpPull } + +// ErrRenewFailed wraps a Renew SOAP failure. Renew errors are usually +// recovered implicitly: the subscription dies, pull starts failing, and +// the reconnect logic recreates it. +type ErrRenewFailed struct{ Err error } + +func (e ErrRenewFailed) Error() string { return fmt.Sprintf("renew pull point: %v", e.Err) } +func (e ErrRenewFailed) Unwrap() error { return e.Err } +func (ErrRenewFailed) Op() Op { return OpRenew } + +// ErrRecreateFailed wraps a failed CreatePullPointSubscription during +// the reconnect path. The loop continues with exponential backoff; +// consumers seeing this repeatedly should consider the camera offline. +type ErrRecreateFailed struct{ Err error } + +func (e ErrRecreateFailed) Error() string { return fmt.Sprintf("recreate pull point: %v", e.Err) } +func (e ErrRecreateFailed) Unwrap() error { return e.Err } +func (ErrRecreateFailed) Op() Op { return OpRecreate } diff --git a/event/stream/errors_test.go b/event/stream/errors_test.go new file mode 100644 index 0000000..d68e347 --- /dev/null +++ b/event/stream/errors_test.go @@ -0,0 +1,133 @@ +package stream + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedErrors_UnwrapAndOp(t *testing.T) { + inner := errors.New("boom") + tests := []struct { + name string + err error + op Op + }{ + {"pull", ErrPullFailed{Err: inner}, OpPull}, + {"renew", ErrRenewFailed{Err: inner}, OpRenew}, + {"recreate", ErrRecreateFailed{Err: inner}, OpRecreate}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, errors.Is(tc.err, inner), "errors.Is should unwrap to inner") + assert.Contains(t, tc.err.Error(), "boom") + + // Each typed error exposes Op() for branch-without-string-parse. + if e, ok := tc.err.(interface{ Op() Op }); ok { + assert.Equal(t, tc.op, e.Op()) + } else { + t.Fatalf("%T does not expose Op()", tc.err) + } + }) + } +} + +func TestStream_PullErrorIsTypedErrPullFailed(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueSendSoap("", errors.New("transient")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 50 * time.Millisecond, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + select { + case e := <-s.Errors(): + var pullErr ErrPullFailed + require.True(t, errors.As(e, &pullErr), "expected ErrPullFailed, got %T: %v", e, e) + assert.Contains(t, pullErr.Err.Error(), "transient") + case <-time.After(time.Second): + t.Fatal("expected ErrPullFailed on Errors channel") + } +} + +func TestStream_RecreateErrorIsTypedErrRecreateFailed(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.mu.Lock() + fc.defaultCall = fakeResp{err: errors.New("recreate fail")} + fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 10 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + deadline := time.Now().Add(time.Second) + var sawRecreate bool + for time.Now().Before(deadline) && !sawRecreate { + select { + case e := <-s.Errors(): + var rec ErrRecreateFailed + if errors.As(e, &rec) { + sawRecreate = true + assert.Contains(t, rec.Err.Error(), "recreate fail") + } + case <-time.After(50 * time.Millisecond): + } + } + assert.True(t, sawRecreate, "expected at least one ErrRecreateFailed on Errors") +} + +func TestStream_AfterReconnectFlagSetOnFirstPostRecreateBatch(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // Second create is the recreate. + fc.queueCallMethod(createPullPointRespAlt, nil) + + // First pull fails -> triggers recreate with ReconnectAfterFailures=1. + fc.queueSendSoap("", errors.New("transient")) + // First pull after recreate: a Changed motion event. The flag + // should be true, and should clear (because we received a + // non-Initialized event). + fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil) + // Second pull after recreate: another motion event. Flag should + // now be false. + fc.queueSendSoap(pullMessagesResp(motionMsg("false")), nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 50 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + ev1 := receive(t, s.Events(), 2*time.Second) + assert.True(t, ev1.AfterReconnect, "first event after recreate must carry AfterReconnect=true") + assert.Equal(t, StateActive, ev1.State) + + ev2 := receive(t, s.Events(), 2*time.Second) + assert.False(t, ev2.AfterReconnect, "subsequent events should not carry AfterReconnect") + assert.Equal(t, StateInactive, ev2.State) +} diff --git a/event/stream/stream.go b/event/stream/stream.go index 9e4fd7a..f10c9c5 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -270,6 +270,7 @@ func (s *Stream) run(ctx context.Context) { func (s *Stream) pullLoop(ctx context.Context) { var failures int recreateBackoff := s.opts.RetryBackoff + var afterReconnect bool for { if ctx.Err() != nil { @@ -277,12 +278,16 @@ func (s *Stream) pullLoop(ctx context.Context) { } msgs, err := pullMessages(s.caller, s.getPullPoint(), s.opts) if err != nil { - s.surfaceError(err) + s.surfaceError(ErrPullFailed{Err: err}) failures++ if failures >= s.opts.ReconnectAfterFailures { - if !s.attemptRecreate(ctx, &failures, &recreateBackoff) { + justRecreated, cont := s.attemptRecreate(ctx, &failures, &recreateBackoff) + if !cont { return } + if justRecreated { + afterReconnect = true + } continue } if !sleepCtx(ctx, s.opts.RetryBackoff) { @@ -296,6 +301,17 @@ func (s *Stream) pullLoop(ctx context.Context) { observedAt := s.now() for _, m := range msgs { ev := Decode(m, s.opts.DeviceID, observedAt) + if afterReconnect { + ev.AfterReconnect = true + // ONVIF replays current state with + // PropertyInitialized on a new subscription. + // Clear the flag as soon as we see anything + // other than Initialized — at that point we + // have transitioned to live events. + if ev.Operation != PropertyInitialized { + afterReconnect = false + } + } select { case <-ctx.Done(): return @@ -306,26 +322,27 @@ func (s *Stream) pullLoop(ctx context.Context) { } // attemptRecreate calls CreatePullPointSubscription and on success -// installs the new endpoint atomically. Returns false if ctx was -// cancelled while waiting for backoff (caller should exit the run -// loop). -func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) bool { +// installs the new endpoint atomically. The first return is true when +// recreate succeeded just now (caller flags the next batch with +// AfterReconnect). The second return is false only if ctx was cancelled +// during backoff (caller should exit the run loop). +func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) (justRecreated, cont bool) { addr, err := createPullPoint(s.caller, s.opts) if err != nil { - s.surfaceError(fmt.Errorf("recreate pull point: %w", err)) + s.surfaceError(ErrRecreateFailed{Err: err}) if !sleepCtx(ctx, *backoff) { - return false + return false, false } *backoff *= 2 if *backoff > maxRecreateBackoff { *backoff = maxRecreateBackoff } - return true + return false, true } s.setPullPoint(addr) *failures = 0 *backoff = s.opts.RetryBackoff - return true + return true, true } // renewLoop refreshes the subscription before InitialTermination expires. @@ -349,7 +366,7 @@ func (s *Stream) renewLoop(ctx context.Context) { return case <-ticker.C: if err := renewPullPoint(s.caller, s.getPullPoint(), s.opts); err != nil { - s.surfaceError(fmt.Errorf("renew pull point: %w", err)) + s.surfaceError(ErrRenewFailed{Err: err}) } } } diff --git a/event/stream/types.go b/event/stream/types.go index fcc3612..9f1557d 100644 --- a/event/stream/types.go +++ b/event/stream/types.go @@ -159,4 +159,13 @@ type Event struct { // Timestamp for ordering and DeviceTime only for forensics or // cross-camera correlation when caller manages NTP. DeviceTime time.Time + // AfterReconnect is true for events delivered after the Stream + // silently recreated its pull-point subscription. ONVIF cameras + // replay each property's current value with PropertyInitialized on + // a new subscription, which would otherwise look like a flood of + // new state changes to a consumer doing edge-detection. Watch this + // flag to suppress duplicate handling, or treat it as a normal + // event if you only care about steady-state level. Cleared on the + // first event whose Operation is not PropertyInitialized. + AfterReconnect bool } From fd71109514008464210c2f585c53169119d38cb2 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:58:34 +0200 Subject: [PATCH 15/23] refactor(event/stream): tighten public surface per v1 review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API-shape changes flagged as 'hard to reverse after v1' by the architect reviewer. Acceptable to do now while no external code imports the package; would be breaking later. Surface tightening ------------------ * Decode unexported to decode. The Stream is the only intended caller; exposing the helper invited future API drift. Same-package tests still reach it. * TopicFilter renamed to RawTopicFilter to signal that the value is fed verbatim into the SOAP envelope and is the 'advanced escape hatch', not the supported routing surface. Callers should normally leave it empty and rely on Classify. Options zero-value policy clarified ----------------------------------- * Field godoc on every numeric option now explicitly states 'zero means default' so the policy is local, not buried in withDefaults(). * New DisableReconnect bool — addresses the ReconnectAfterFailures=0-as-disable footgun the API reviewer flagged. Reader can no longer confuse 'unset, fallback to default' with 'opt out of reconnect'. * BufferSize semantics extended: zero -> default (16), negative -> unbuffered (0), positive -> explicit size. Lets callers ask for back-pressure-only channels. Default tuning -------------- * MessageLimit default raised from 10 to 32. Busy AXIS cameras with several configured inputs / analytics rules can burst beyond 10 per pull; the lower cap meant up to one PullTimeout of added latency for the queued overflow without saving anything meaningful. 32 covers observed bursts with no real overhead on quiet pulls. Caller interface ---------------- * Doc comment now states the goroutine-safety contract Stream depends on (pull loop and renew loop call from separate goroutines). *onvif.Device satisfies it via http.Client. Package documentation --------------------- * doc.go rewritten as a real godoc landing page: usage snippet, invariants (channel close, Close idempotency, NewStream does I/O, buffer semantics), reconnect behaviour and AfterReconnect, and a pointer to topics.go for the classifier table. Replaces the earlier stub that referenced unimplemented identifiers. --- event/stream/decode.go | 9 +++-- event/stream/decode_test.go | 22 +++++------ event/stream/doc.go | 66 ++++++++++++++++++++++++++----- event/stream/stream.go | 73 ++++++++++++++++++++++------------- event/stream/stream_test.go | 2 +- examples/event/stream/main.go | 2 +- 6 files changed, 122 insertions(+), 52 deletions(-) diff --git a/event/stream/decode.go b/event/stream/decode.go index 2559a8b..ac3b88e 100644 --- a/event/stream/decode.go +++ b/event/stream/decode.go @@ -7,8 +7,11 @@ import ( "github.com/kerberos-io/onvif/event" ) -// Decode converts a single ONVIF NotificationMessage into the package's -// normalized Event representation. +// decode converts a single ONVIF NotificationMessage into the package's +// normalized Event representation. Unexported because the only intended +// caller is the Stream; downstream consumers receive decoded Events on +// the Events channel. Tests reach decode directly because they're in +// the same package. // // deviceID is supplied by the caller because the message itself does not // identify the originating camera. observedAt is recorded verbatim as @@ -18,7 +21,7 @@ import ( // When the Topic does not match any classifier rule the returned Event // has Kind == KindUnknown but Source, Data and Topic are still populated // so consumers can fall back to inspecting the wire form. -func Decode(msg event.NotificationMessage, deviceID string, observedAt time.Time) Event { +func decode(msg event.NotificationMessage, deviceID string, observedAt time.Time) Event { topic := string(msg.Topic.TopicKinds) desc := msg.Message.Message return Event{ diff --git a/event/stream/decode_test.go b/event/stream/decode_test.go index edce4c8..2d52800 100644 --- a/event/stream/decode_test.go +++ b/event/stream/decode_test.go @@ -52,7 +52,7 @@ func TestDecode_MotionActive(t *testing.T) { map[string]string{"IsMotion": "true"}, ) - ev := Decode(in, "axis-cam-01", observedAt) + ev := decode(in, "axis-cam-01", observedAt) assert.Equal(t, KindMotion, ev.Kind) assert.Equal(t, StateActive, ev.State) @@ -74,7 +74,7 @@ func TestDecode_MotionInactive(t *testing.T) { nil, map[string]string{"State": "false"}, ) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, KindMotion, ev.Kind) assert.Equal(t, StateInactive, ev.State) } @@ -88,7 +88,7 @@ func TestDecode_HanwhaNumericMotionValue(t *testing.T) { nil, map[string]string{"Motion": "1"}, ) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, KindMotion, ev.Kind) assert.Equal(t, StateActive, ev.State) } @@ -103,7 +103,7 @@ func TestDecode_AvigilonActiveLiteral(t *testing.T) { map[string]string{"RelayToken": "Relay-1"}, map[string]string{"LogicalState": "active"}, ) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, KindDigitalOutput, ev.Kind) assert.Equal(t, StateActive, ev.State) assert.Equal(t, "Relay-1", ev.Source["RelayToken"]) @@ -124,7 +124,7 @@ func TestDecode_AxisObjectAnalyticsMultiItem(t *testing.T) { "confidence": "92", }, ) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, KindObjectDetected, ev.Kind) assert.Equal(t, StateActive, ev.State) assert.Equal(t, "Human", ev.Data["classType"]) @@ -142,7 +142,7 @@ func TestDecode_LineDetectorCrossedHasNoState(t *testing.T) { map[string]string{"VideoSourceConfigurationToken": "vsct0", "Rule": "LineRule"}, map[string]string{"ObjectId": "42"}, ) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, KindObjectDetected, ev.Kind) assert.Equal(t, StateUnknown, ev.State) assert.Equal(t, "42", ev.Data["ObjectId"]) @@ -158,7 +158,7 @@ func TestDecode_UnknownTopicStillPreservesWireData(t *testing.T) { nil, map[string]string{"Custom": "true"}, ) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, KindUnknown, ev.Kind) assert.Equal(t, "tns1:UserAlarm/IVA", ev.Topic) assert.Equal(t, "true", ev.Data["Custom"]) @@ -179,7 +179,7 @@ func TestDecode_PropertyOperationVariants(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { in := msg("tns1:VideoSource/MotionAlarm", tc.in, "", nil, nil) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, tc.want, ev.Operation) }) } @@ -200,7 +200,7 @@ func TestDecode_DeviceTimeParsing(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) if tc.want.IsZero() { assert.True(t, ev.DeviceTime.IsZero(), "DeviceTime=%v", ev.DeviceTime) } else { @@ -215,7 +215,7 @@ func TestDecode_EmptySourceAndDataYieldNilMaps(t *testing.T) { // safely len() and index into Source/Data without nil-checking, but // we do not allocate an empty map for empty notifications. in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Nil(t, ev.Source) assert.Nil(t, ev.Data) } @@ -242,7 +242,7 @@ func TestDecode_StateValueIsCaseInsensitive(t *testing.T) { t.Run(tc.name, func(t *testing.T) { in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, map[string]string{"State": tc.value}) - ev := Decode(in, "dev", time.Now()) + ev := decode(in, "dev", time.Now()) assert.Equal(t, tc.want, ev.State) }) } diff --git a/event/stream/doc.go b/event/stream/doc.go index 0223ae1..8121525 100644 --- a/event/stream/doc.go +++ b/event/stream/doc.go @@ -1,13 +1,59 @@ -// Package stream will provide a long-running, channel-based consumer for -// ONVIF device events. It is meant to hide the SOAP/XML, pull-point -// subscription lifecycle, subscription renewal and vendor-specific topic -// conventions behind a typed Event stream. +// Package stream is a typed, channel-based consumer for ONVIF device +// events. It hides the SOAP/XML, pull-point subscription lifecycle, +// subscription renewal and vendor-specific topic conventions behind a +// single Event stream. // -// This file lays down the value types (Kind, State, PropertyOperation, -// Event) and the topic Classifier. The Stream type, its NewStream -// constructor and the Events/Errors channels land in follow-up changes. +// # Usage // -// The package classifies vendor-specific topic strings (AXIS, Hikvision, -// Avigilon, Hanwha, Bosch, Dahua) into a small set of normalized Kind -// values so callers do not need to special-case device manufacturers. +// dev, _ := onvif.NewDevice(onvif.DeviceParams{Xaddr: "...", Username: "...", Password: "..."}) +// s, err := stream.NewStream(ctx, dev, stream.Options{DeviceID: "front-door"}) +// if err != nil { /* construction failed: auth, network, or camera does not advertise events */ } +// defer s.Close() +// +// for ev := range s.Events() { +// switch ev.Kind { +// case stream.KindMotion: +// if ev.State == stream.StateActive { /* start recording */ } +// } +// } +// +// # Invariants +// +// NewStream performs network I/O. It returns once the +// CreatePullPointSubscription call has succeeded; auth and reachability +// failures surface as an error from NewStream rather than landing on +// the Errors channel later. +// +// Two goroutines back each Stream: a pull loop and a renew loop. Both +// exit when the context passed to NewStream is cancelled or when Close +// is called. Close is idempotent and bounded — see Stream.Close. +// +// Events is closed exactly when the Stream stops. Ranging over Events +// is safe; a closed channel terminates the loop without a Close call. +// Errors is also closed at stop time. Both channels are buffered (16 +// slots by default); sends to Errors are non-blocking so a stalled +// consumer drops older errors rather than the pull loop blocking on +// log output. +// +// The decoded Event preserves the wire form (Topic, raw Source and +// Data maps) so callers can fall back to inspecting non-standard +// payloads when Kind is KindUnknown. +// +// # Reconnect +// +// On ReconnectAfterFailures consecutive PullMessages failures the +// Stream silently recreates its pull-point subscription. ONVIF cameras +// replay each property's current value with PropertyInitialized on a +// new subscription; Events delivered between recreate and the first +// non-Initialized event carry Event.AfterReconnect=true so consumers +// can suppress duplicate handling. +// +// Set Options.DisableReconnect=true to opt out of recreate; the pull +// loop will retry against the original subscription until ctx cancel. +// +// # Topic classification +// +// Classify maps ONVIF topic strings to a small set of normalized Kind +// values across AXIS, Hikvision, Avigilon, Hanwha, Bosch and Dahua. See +// topics.go for the verified mapping table with public-doc citations. package stream diff --git a/event/stream/stream.go b/event/stream/stream.go index f10c9c5..520843d 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -31,55 +31,71 @@ const maxResponseBytes = 10 << 20 // elapses, so a missed unsubscribe is at worst cosmetic. const closeUnsubscribeTimeout = 5 * time.Second -// Options configures a Stream. The zero value is usable; defaultOptions -// fills in production-sensible defaults for any unset field. +// Options configures a Stream. +// +// Zero-value policy: every duration / int field treats zero as "use the +// default". To opt out of reconnect entirely set DisableReconnect=true +// (sentinel `ReconnectAfterFailures=0` would otherwise collide with the +// default-injection policy). To get a synchronous (unbuffered) channel +// pair set BufferSize=-1. type Options struct { - // DeviceID identifies the camera in emitted Events. Recommended so a - // single channel can fan in multiple cameras. Empty is allowed. + // DeviceID identifies the camera in emitted Events. Recommended so + // a single channel can fan in multiple cameras. Empty is allowed. DeviceID string - // TopicFilter is the raw ONVIF ConcreteSet TopicExpression filter - // passed to CreatePullPointSubscription. The empty string means no + // RawTopicFilter is the raw ONVIF ConcreteSet TopicExpression + // filter passed to CreatePullPointSubscription. Empty means no // filter — required for AXIS, accepted by every other vendor we - // support. Callers should normally leave this empty and rely on - // Classify for routing. - TopicFilter string - // PullTimeout is the server-side wait time in each PullMessages call - // (xsd:duration). The camera returns early when messages are - // available; otherwise it returns empty after this timeout. Default: - // 5s. + // support. The name carries 'Raw' because the value is fed verbatim + // into the SOAP envelope: callers should normally leave it empty + // and rely on Classify for routing rather than ask the camera to + // filter server-side, which is fragile across vendors. + RawTopicFilter string + // PullTimeout is the server-side wait time in each PullMessages + // call (xsd:duration). The camera returns early when messages are + // available; otherwise it returns empty after this timeout. Zero + // means default (5s). PullTimeout time.Duration // MessageLimit caps the number of NotificationMessage entries - // returned per PullMessages call. Default: 10. + // returned per PullMessages call. Zero means default (32). A busy + // AXIS with many configured inputs can burst beyond 10 per pull; + // 32 covers that without significantly enlarging quiet pulls. MessageLimit int // InitialTermination is the requested subscription lifetime passed // to CreatePullPointSubscription. The renew loop refreshes well - // before this expires. Default: 60s. + // before this expires. Zero means default (60s). InitialTermination time.Duration // RenewMargin is how long before InitialTermination expiry the // renew loop fires. Larger margins tolerate slower networks at the - // cost of more renew SOAP calls. Default: 10s. + // cost of more renew SOAP calls. Zero means default (10s). RenewMargin time.Duration // ReconnectAfterFailures is the consecutive PullMessages failure // count that triggers a CreatePullPointSubscription recreate. The // camera or pull-point can die for many reasons (camera reboot, // subscription garbage-collected after a renew miss, intermediate // NAT timeout); rebuilding the subscription is the only reliable - // recovery. Default: 3. + // recovery. Zero means default (3). To disable reconnect entirely + // set DisableReconnect=true. ReconnectAfterFailures int + // DisableReconnect skips automatic CreatePullPointSubscription + // recreate. The pull loop will continue retrying against the + // original endpoint until ctx is cancelled. Useful for tests or + // callers managing recovery externally. + DisableReconnect bool // RetryBackoff is the initial sleep between a pull/recreate failure // and the next attempt. Recreate failures double this up to a 30s - // ceiling. Default: 1s. + // ceiling. Zero means default (1s). RetryBackoff time.Duration // BufferSize is the buffer size of the Events and Errors channels. // Larger buffers absorb consumer hiccups at the cost of memory. - // Default: 16. + // Zero means default (16); use -1 for unbuffered (synchronous) + // channels. BufferSize int } func defaultOptions() Options { return Options{ PullTimeout: 5 * time.Second, - MessageLimit: 10, + MessageLimit: 32, InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second, ReconnectAfterFailures: 3, @@ -108,11 +124,16 @@ func (o Options) withDefaults() Options { if o.RetryBackoff > 0 { d.RetryBackoff = o.RetryBackoff } - if o.BufferSize > 0 { + // BufferSize: zero -> default; negative -> 0 (unbuffered). + switch { + case o.BufferSize > 0: d.BufferSize = o.BufferSize + case o.BufferSize < 0: + d.BufferSize = 0 } d.DeviceID = o.DeviceID - d.TopicFilter = o.TopicFilter + d.RawTopicFilter = o.RawTopicFilter + d.DisableReconnect = o.DisableReconnect return d } @@ -280,7 +301,7 @@ func (s *Stream) pullLoop(ctx context.Context) { if err != nil { s.surfaceError(ErrPullFailed{Err: err}) failures++ - if failures >= s.opts.ReconnectAfterFailures { + if !s.opts.DisableReconnect && failures >= s.opts.ReconnectAfterFailures { justRecreated, cont := s.attemptRecreate(ctx, &failures, &recreateBackoff) if !cont { return @@ -300,7 +321,7 @@ func (s *Stream) pullLoop(ctx context.Context) { recreateBackoff = s.opts.RetryBackoff observedAt := s.now() for _, m := range msgs { - ev := Decode(m, s.opts.DeviceID, observedAt) + ev := decode(m, s.opts.DeviceID, observedAt) if afterReconnect { ev.AfterReconnect = true // ONVIF replays current state with @@ -399,11 +420,11 @@ func sleepCtx(ctx context.Context, d time.Duration) bool { func createPullPoint(c caller, opts Options) (string, error) { term := xsd.String(durationToXSD(opts.InitialTermination)) req := event.CreatePullPointSubscription{InitialTerminationTime: &term} - if opts.TopicFilter != "" { + if opts.RawTopicFilter != "" { req.Filter = &event.FilterType{ TopicExpression: &event.TopicExpressionType{ Dialect: xsd.String("http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet"), - TopicKinds: xsd.String(opts.TopicFilter), + TopicKinds: xsd.String(opts.RawTopicFilter), }, } } diff --git a/event/stream/stream_test.go b/event/stream/stream_test.go index 010f10a..b7343fe 100644 --- a/event/stream/stream_test.go +++ b/event/stream/stream_test.go @@ -326,7 +326,7 @@ func TestStream_PullErrorSurfacedOnErrorsChannel(t *testing.T) { func TestStream_OptionsApplyDefaults(t *testing.T) { o := defaultOptions() assert.Equal(t, 5*time.Second, o.PullTimeout) - assert.Equal(t, 10, o.MessageLimit) + assert.Equal(t, 32, o.MessageLimit) assert.Equal(t, 60*time.Second, o.InitialTermination) assert.Equal(t, 16, o.BufferSize) } diff --git a/examples/event/stream/main.go b/examples/event/stream/main.go index a795ed4..da5cc6e 100644 --- a/examples/event/stream/main.go +++ b/examples/event/stream/main.go @@ -73,7 +73,7 @@ func main() { s, err := stream.NewStream(ctx, dev, stream.Options{ DeviceID: *deviceID, - TopicFilter: *filter, + RawTopicFilter: *filter, PullTimeout: *pullTimeout, }) if err != nil { From 94572504fccb2bf77f7fb1b2e2bbcab86677ae6f Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:58:48 +0200 Subject: [PATCH 16/23] style(examples): gofmt alignment for stream example Options literal --- examples/event/stream/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/event/stream/main.go b/examples/event/stream/main.go index da5cc6e..0d0892f 100644 --- a/examples/event/stream/main.go +++ b/examples/event/stream/main.go @@ -72,9 +72,9 @@ func main() { }() s, err := stream.NewStream(ctx, dev, stream.Options{ - DeviceID: *deviceID, + DeviceID: *deviceID, RawTopicFilter: *filter, - PullTimeout: *pullTimeout, + PullTimeout: *pullTimeout, }) if err != nil { log.Fatalf("open stream: %v", err) From 4fd92dd229eb3c5efca2248f91f69746cf5a4fd0 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 14:59:44 +0200 Subject: [PATCH 17/23] feat(event/stream): raise recreate backoff cap and add jitter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous 30-second cap meant a 1000-camera fleet recovering from a switch reboot would generate a sustained 33 RPS of doomed CreatePullPointSubscription traffic against still-booting cameras, and the synchronised retries would arrive in phase. Two changes: * Cap raised to 5 minutes. Single-camera recovery latency goes from '<=30s after camera comes back' to '<=300s', which is fine because by the time we are this deep in backoff the camera has already been unreachable through 6+ attempts (1s, 2s, 4s, 8s, 16s, 30s under the old cap) — the marginal recovery delay is acceptable to avoid the network melt. * Symmetric ±25% jitter on every recreate sleep so synchronised drops (switch reboot, DHCP storm, NTP slew) do not cause synchronised reconnect surges. Standard practice — same shape AWS, Cloudflare and HA event_manager use. Tests assert the jitter range, the documented cap value (so a future maintainer flipping it back to 30s notices in CI), and that jitter varies across calls (proves the rand source is wired). --- event/stream/jitter_test.go | 44 +++++++++++++++++++++++++++++++++++++ event/stream/stream.go | 33 ++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 event/stream/jitter_test.go diff --git a/event/stream/jitter_test.go b/event/stream/jitter_test.go new file mode 100644 index 0000000..3e5d839 --- /dev/null +++ b/event/stream/jitter_test.go @@ -0,0 +1,44 @@ +package stream + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestJitter_StaysWithinFraction(t *testing.T) { + const base = time.Second + low := time.Duration(float64(base) * (1 - jitterFraction)) + high := time.Duration(float64(base) * (1 + jitterFraction)) + for i := 0; i < 200; i++ { + got := jitter(base) + assert.GreaterOrEqual(t, got, low, "iteration %d", i) + assert.LessOrEqual(t, got, high, "iteration %d", i) + } +} + +func TestJitter_ZeroAndNegativeReturnPositive(t *testing.T) { + assert.Greater(t, jitter(0), time.Duration(0)) + assert.Greater(t, jitter(-time.Second), time.Duration(0)) +} + +func TestJitter_VariesAcrossCalls(t *testing.T) { + // Sanity check that we're not returning a constant. Vanishingly + // unlikely to flake (probability ~ (1/uint64-space)^9). + first := jitter(time.Second) + allEqual := true + for i := 0; i < 10; i++ { + if jitter(time.Second) != first { + allEqual = false + break + } + } + assert.False(t, allEqual, "jitter is producing a constant; rand seed not working") +} + +func TestMaxRecreateBackoff_Is5Minutes(t *testing.T) { + // Document the policy choice in a test so a future maintainer + // changing this notices. + assert.Equal(t, 5*time.Minute, maxRecreateBackoff) +} diff --git a/event/stream/stream.go b/event/stream/stream.go index 520843d..b1f22d2 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io" + "math/rand" "net/http" "regexp" "strconv" @@ -138,7 +139,18 @@ func (o Options) withDefaults() Options { } // maxRecreateBackoff caps exponential backoff between recreate attempts. -const maxRecreateBackoff = 30 * time.Second +// Sized for fleet deployments: a 1000-camera setup recovering from a +// switch reboot would otherwise hammer the network with one recreate +// attempt per camera per 30s; 5 minutes gives the network time to +// settle while still recovering promptly when a single camera comes +// back. +const maxRecreateBackoff = 5 * time.Minute + +// jitterFraction is the symmetric jitter applied to recreate backoff: +// the actual sleep is sampled from [backoff*(1-jitter), backoff*(1+jitter)]. +// Prevents thundering-herd reconnects when many cameras drop together +// (switch reboot, NAT timeout). +const jitterFraction = 0.25 // caller is the subset of *onvif.Device the Stream depends on. Tests // substitute a fake; production code uses the device adapter. @@ -351,7 +363,7 @@ func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *ti addr, err := createPullPoint(s.caller, s.opts) if err != nil { s.surfaceError(ErrRecreateFailed{Err: err}) - if !sleepCtx(ctx, *backoff) { + if !sleepCtx(ctx, jitter(*backoff)) { return false, false } *backoff *= 2 @@ -366,6 +378,23 @@ func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *ti return true, true } +// jitter returns d perturbed by ±jitterFraction. Used to spread +// recreate attempts across a fleet so a synchronised drop (switch +// reboot, DHCP storm) does not cause a synchronised reconnect surge. +// Returns at least 1ns to keep sleepCtx happy. +func jitter(d time.Duration) time.Duration { + if d <= 0 { + return time.Nanosecond + } + spread := float64(d) * jitterFraction + delta := (rand.Float64()*2 - 1) * spread + out := time.Duration(float64(d) + delta) + if out <= 0 { + out = time.Nanosecond + } + return out +} + // renewLoop refreshes the subscription before InitialTermination expires. // Exits when ctx is cancelled. func (s *Stream) renewLoop(ctx context.Context) { From c6cad2c35d6c7ca03008cff75a9b89487c92ffe1 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 15:01:35 +0200 Subject: [PATCH 18/23] test(event/stream): close review-2 coverage gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the missing test coverage flagged by the test-rigor reviewer. Coverage / behaviour -------------------- * TestClose_ReturnsUnsubscribeError: previously closeErr plumbing was effectively dead code in the suite. Inject an Unsubscribe failure and assert the error wraps it. * TestNewStream_CtxAlreadyCancelled: pins the behaviour for a pre-cancelled parent context (construction succeeds because createPullPoint does not consult ctx; run goroutine exits immediately and Events closes). * TestStream_DisableReconnectKeepsRetryingOriginalEndpoint: proves the new opt-out actually disables CreatePullPoint recreate. * TestStream_RecreateResetsFailuresAndBackoffOnSuccess: locks the attemptRecreate success path resetting *failures and *backoff so a later failure does not accidentally enter exponential backoff immediately. Race detection -------------- * TestStream_PullPointMutationVisibleToRenewLoopUnderRace: drives the pullPoint write-by-pullLoop / read-by-renewLoop race so -race actually exercises the mutex critical sections. Previously the mutex was structurally correct but no test produced contention. Decoder edge cases ------------------ * TestDecode_PropertyOperationIsCaseSensitive: per WS-Notification §3.3, values are PascalCase. Lowercased forms fall through to PropertyUnknown. * TestDecode_StateValueTrimsWhitespace: explicit assertions for ' true ', tabs, newlines and whitespace-only. * TestDecode_SimpleItemEmptyValueIsUnknownState: empty value yields StateUnknown but the empty entry is still preserved in Data map. * TestDecode_DeviceTimeAdditionalLayouts: the +0200 compact offset and naked-no-TZ formats added in the hardening commit. * TestDecode_DeviceTimeStillRejectsNonsense: the broader layout list did not start accepting garbage. * TestExtractState_FirstBooleanLikeWins: uses explicit slice construction (pair{k,v} -> SimpleItem) so the assertion does not depend on map iteration order, the latent flake risk in the AOA test pointed out by the reviewer. Helpers ------- * helpers_test.go waitFor(t, d, msg, cond) centralises the 10ms-poll-until-deadline pattern that previously appeared four times across stream_test / renew_test / reconnect_test. * TestFakeCaller_QueueThenDefaultFallback: self-test for the fake. When the fake grows to 100+ LOC, debugging a flaky stream test should not also require investigating whether the fake itself behaves correctly. --- event/stream/coverage_test.go | 295 ++++++++++++++++++++++++++++++++++ event/stream/helpers_test.go | 21 +++ 2 files changed, 316 insertions(+) create mode 100644 event/stream/coverage_test.go create mode 100644 event/stream/helpers_test.go diff --git a/event/stream/coverage_test.go b/event/stream/coverage_test.go new file mode 100644 index 0000000..29b7ea2 --- /dev/null +++ b/event/stream/coverage_test.go @@ -0,0 +1,295 @@ +package stream + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/kerberos-io/onvif/event" + "github.com/kerberos-io/onvif/xsd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Close surfaces unsubscribe error -------------------------------- + +func TestClose_ReturnsUnsubscribeError(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // Default empty pulls keep the loop running. Override default + // SendSoap to fail so Close's Unsubscribe also fails. + fc.mu.Lock() + fc.defaultSendSoap = fakeResp{err: errors.New("simulated transport failure")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) + require.NoError(t, err) + + err = s.Close() + require.Error(t, err) + assert.Contains(t, err.Error(), "unsubscribe pull point") + assert.Contains(t, err.Error(), "simulated transport failure") +} + +// --- NewStream against already-cancelled context ---------------------- + +func TestNewStream_CtxAlreadyCancelled(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before NewStream + + s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) + // Create-pull-point doesn't currently consult ctx (it uses caller + // directly), so construction succeeds and the run goroutine exits + // immediately. Close must still work cleanly. + require.NoError(t, err) + require.NotNil(t, s) + + // Events channel must close promptly because the goroutine exits. + select { + case _, ok := <-s.Events(): + assert.False(t, ok, "events channel should be closed when ctx is pre-cancelled") + case <-time.After(time.Second): + t.Fatal("events channel was not closed within 1s") + } + _ = s.Close() +} + +// --- DisableReconnect honours the opt-out ---------------------------- + +func TestStream_DisableReconnectKeepsRetryingOriginalEndpoint(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // All pulls fail; default SendSoap stays as empty-pull (success) + // only if the fake's queue exhausts — we override default to a + // failure so EVERY pull errors. + fc.mu.Lock() + fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 10 * time.Millisecond, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + DisableReconnect: true, + }) + require.NoError(t, err) + defer s.Close() + + // Let the loop spin for a bit, then assert no second CallMethod + // (recreate would invoke CallMethod, which we are watching). + time.Sleep(200 * time.Millisecond) + fc.mu.Lock() + calls := len(fc.callMethodCalls) + fc.mu.Unlock() + assert.Equal(t, 1, calls, "DisableReconnect must prevent recreate; got %d CallMethod calls", calls) +} + +// --- Recreate resets failures+backoff on success --------------------- + +func TestStream_RecreateResetsFailuresAndBackoffOnSuccess(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueCallMethod(createPullPointRespAlt, nil) + // Pull fails once -> triggers recreate -> recreate succeeds -> + // next pull succeeds. After that we should NOT see another + // recreate (failures was reset). Provide enough successful empty + // pulls. + fc.queueSendSoap("", errors.New("first failure")) + // Subsequent pulls succeed via default empty pull. + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 10 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + time.Sleep(200 * time.Millisecond) + fc.mu.Lock() + calls := len(fc.callMethodCalls) + fc.mu.Unlock() + assert.Equal(t, 2, calls, + "after one failure + successful recreate, no further recreates expected; got %d", calls) +} + +// --- pullPointMu under race ------------------------------------------ + +func TestStream_PullPointMutationVisibleToRenewLoopUnderRace(t *testing.T) { + // Drives the pullPoint write-by-pullLoop / read-by-renewLoop race + // so -race actually exercises the mutex critical sections. With + // short termination and quick recreate, renew is firing alongside + // the recreate write. + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + // Queue a stream of alt-response recreates so each retry installs + // a new pullPoint. + for i := 0; i < 50; i++ { + fc.queueCallMethod(createPullPointRespAlt, nil) + } + // Default empty pulls. + // Force pull errors so reconnect path fires repeatedly: override + // default and queue mostly-failing pulls. + fc.mu.Lock() + fc.defaultSendSoap = fakeResp{err: errors.New("recurring pull fail")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 5 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 1 * time.Millisecond, + InitialTermination: 20 * time.Millisecond, + RenewMargin: 2 * time.Millisecond, + }) + require.NoError(t, err) + defer s.Close() + + // Spin for ~300ms; the race detector will fire if either + // pullPointMu critical section is broken. We don't assert on + // content here — the value is the -race signal. + time.Sleep(300 * time.Millisecond) +} + +// --- fakeCaller self-test -------------------------------------------- + +func TestFakeCaller_QueueThenDefaultFallback(t *testing.T) { + fc := newFakeCaller() + fc.queueSendSoap("first", nil) + fc.queueSendSoap("second", nil) + // Default already set to an empty pull response. + + r1, err := fc.SendSoap("ep", "body") + require.NoError(t, err) + b1 := make([]byte, 10) + n, _ := r1.Body.Read(b1) + assert.Equal(t, "first", string(b1[:n])) + + r2, _ := fc.SendSoap("ep", "body") + b2 := make([]byte, 10) + n, _ = r2.Body.Read(b2) + assert.Equal(t, "second", string(b2[:n])) + + // Queue is exhausted; default kicks in. + r3, err := fc.SendSoap("ep", "body") + require.NoError(t, err) + require.NotNil(t, r3) + b3 := make([]byte, 2048) + n, _ = r3.Body.Read(b3) + assert.Contains(t, string(b3[:n]), "PullMessagesResponse", + "default SendSoap should be an empty PullMessagesResponse envelope") +} + +// --- Decoder coverage gaps ------------------------------------------- + +func TestDecode_PropertyOperationIsCaseSensitive(t *testing.T) { + // Per WS-Notification §3.3 PropertyOperation values are + // 'Initialized' / 'Changed' / 'Deleted'. Lowercased forms in the + // wild are malformed and should fall through to PropertyUnknown. + in := msg("tns1:VideoSource/MotionAlarm", "changed", "", nil, nil) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, PropertyUnknown, ev.Operation) +} + +func TestDecode_StateValueTrimsWhitespace(t *testing.T) { + tests := []struct { + name string + value string + want State + }{ + {"leading_trailing", " true ", StateActive}, + {"tab_newline", "\ttrue\n", StateActive}, + {"only_spaces", " ", StateUnknown}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", + nil, map[string]string{"State": tc.value}) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, tc.want, ev.State) + }) + } +} + +func TestDecode_SimpleItemEmptyValueIsUnknownState(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", + nil, map[string]string{"State": ""}) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, StateUnknown, ev.State) + // Empty value still preserved in the Data map. + v, ok := ev.Data["State"] + assert.True(t, ok) + assert.Equal(t, "", v) +} + +func TestDecode_DeviceTimeAdditionalLayouts(t *testing.T) { + tests := []struct { + name string + in string + want time.Time + }{ + {"compact_offset", "2026-05-21T12:30:00+0200", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, + {"compact_offset_subsec", "2026-05-21T12:30:00.500+0200", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)}, + {"naked_no_tz", "2026-05-21T10:30:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil) + ev := decode(in, "dev", time.Now()) + assert.True(t, ev.DeviceTime.Equal(tc.want), + "input=%q got=%v want=%v", tc.in, ev.DeviceTime, tc.want) + }) + } +} + +// --- extractState deterministic order with explicit slice ------------ + +func TestExtractState_FirstBooleanLikeWins(t *testing.T) { + // Verifies the documented behaviour: when multiple Data items have + // boolean-like values, the first by slice order wins. + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil) + in.Message.Message.Data.SimpleItem = simpleItemsFromPairs([]pair{ + {"ObjectId", "42"}, + {"State", "true"}, + {"Trailer", "false"}, + }) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, StateActive, ev.State, + "first boolean-like value (State=true) must win, not Trailer=false") +} + +type pair struct{ k, v string } + +func simpleItemsFromPairs(pairs []pair) []event.SimpleItem { + out := make([]event.SimpleItem, len(pairs)) + for i, p := range pairs { + out[i] = event.SimpleItem{ + Name: xsd.AnyType(p.k), + Value: xsd.AnyType(p.v), + } + } + return out +} + +// --- ensure the new layouts don't accept unrelated junk -------------- + +func TestDecode_DeviceTimeStillRejectsNonsense(t *testing.T) { + for _, s := range []string{"hello", "2026-13-45T99:99:99", strings.Repeat("9", 50)} { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", s, nil, nil) + ev := decode(in, "dev", time.Now()) + assert.True(t, ev.DeviceTime.IsZero(), "input=%q should yield zero, got %v", s, ev.DeviceTime) + } +} diff --git a/event/stream/helpers_test.go b/event/stream/helpers_test.go new file mode 100644 index 0000000..41dab80 --- /dev/null +++ b/event/stream/helpers_test.go @@ -0,0 +1,21 @@ +package stream + +import ( + "testing" + "time" +) + +// waitFor polls cond at 10ms intervals up to d. Fails the test with msg +// if cond never returns true. Centralises the pattern that appears in +// renew/reconnect/stream tests so retries are uniform. +func waitFor(t *testing.T, d time.Duration, msg string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("waitFor timed out after %s: %s", d, msg) +} From 1ceef725ecd8e74a70e8353ff3973e90f2f262da Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 15:02:23 +0200 Subject: [PATCH 19/23] fix(examples): secure credential handling and Errors-arm bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings from the resource/security and API reviews of the streamtest example. Credentials ----------- * loadPassword resolves the camera password in order: 1. ONVIF_PASSWORD environment variable (recommended). 2. -password-file (newline trimmed). 3. Interactive prompt when nothing else is set. * -password flag still works but now logs a WARNING that the value leaks into shell history and process listings. Documented as 'INSECURE' in the flag help. * Updated package godoc with a Credentials section. Errors-arm bug -------------- * Previous code: case e := <-s.Errors() with no ok check. When the Stream closed, this arm would spin on a closed channel printing '' forever (until ctx-done elsewhere unblocked it). Mirror the Events arm's ok pattern. * Switched the error-log branch to inspect the typed errors added in the previous commit: ErrRecreateFailed gets a louder 'camera may be offline' log line; ErrPullFailed is a quieter 'will retry' since the loop handles transient pull errors automatically. Also prints '[after-reconnect]' on events carrying that flag so the operator can see when the stream silently recovered a dropped subscription — confirms the new observability surface is useful at the CLI level. --- examples/event/stream/main.go | 89 +++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/examples/event/stream/main.go b/examples/event/stream/main.go index 0d0892f..cc6174b 100644 --- a/examples/event/stream/main.go +++ b/examples/event/stream/main.go @@ -3,24 +3,36 @@ // classifier against real-camera topics; not intended as a production // tool. // -// Example: +// # Usage // // go run ./examples/event/stream \ // -xaddr 192.168.1.10 \ -// -username root -password admin \ +// -username root \ // -duration 60s // -// The xaddr is the camera's host or host:port (the library appends -// /onvif/device_service); pass with no protocol prefix. +// # Credentials +// +// The camera password is read, in order of preference: +// +// 1. The ONVIF_PASSWORD environment variable. +// 2. A file pointed at by -password-file (newline stripped). +// 3. Interactive prompt when stdin is a tty. +// +// -password is also accepted but DISCOURAGED — it leaks the credential +// into shell history and the system process listing. Use only for +// throwaway dev cameras. package main import ( + "bufio" "context" + "errors" "flag" "fmt" "log" "os" "os/signal" + "strings" "syscall" "time" @@ -31,14 +43,15 @@ import ( func main() { xaddr := flag.String("xaddr", "", "camera host or host:port (required)") username := flag.String("username", "", "ONVIF user (required)") - password := flag.String("password", "", "ONVIF password (required)") + insecurePassword := flag.String("password", "", "INSECURE — leaks into shell history; prefer ONVIF_PASSWORD env or -password-file") + passwordFile := flag.String("password-file", "", "read password from this file (newline trimmed)") deviceID := flag.String("device-id", "", "logical name printed with each event (default: xaddr)") filter := flag.String("filter", "", "raw ONVIF ConcreteSet topic filter (empty = all topics, works on AXIS)") pullTimeout := flag.Duration("pull-timeout", 5*time.Second, "server-side wait per PullMessages call") duration := flag.Duration("duration", 0, "stop after this long (0 = run until Ctrl-C)") flag.Parse() - if *xaddr == "" || *username == "" || *password == "" { + if *xaddr == "" || *username == "" { flag.Usage() os.Exit(2) } @@ -46,10 +59,15 @@ func main() { *deviceID = *xaddr } + password, err := loadPassword(*insecurePassword, *passwordFile) + if err != nil { + log.Fatalf("password: %v", err) + } + dev, err := onvif.NewDevice(onvif.DeviceParams{ Xaddr: *xaddr, Username: *username, - Password: *password, + Password: password, AuthMode: onvif.UsernameTokenAuth, }) if err != nil { @@ -79,7 +97,11 @@ func main() { if err != nil { log.Fatalf("open stream: %v", err) } - defer s.Close() + defer func() { + if err := s.Close(); err != nil { + log.Printf("stream close: %v", err) + } + }() log.Printf("streaming from %s (device-id=%s, filter=%q)", *xaddr, *deviceID, *filter) for { @@ -93,6 +115,9 @@ func main() { } fmt.Printf("%s kind=%-15s state=%-9s op=%-12s topic=%s", ev.Timestamp.Format(time.RFC3339), ev.Kind, ev.State, ev.Operation, ev.Topic) + if ev.AfterReconnect { + fmt.Print(" [after-reconnect]") + } if len(ev.Source) > 0 { fmt.Printf(" source=%v", ev.Source) } @@ -100,8 +125,52 @@ func main() { fmt.Printf(" data=%v", ev.Data) } fmt.Println() - case e := <-s.Errors(): - log.Printf("stream error: %v", e) + case e, ok := <-s.Errors(): + if !ok { + return + } + var pull stream.ErrPullFailed + var recreate stream.ErrRecreateFailed + switch { + case errors.As(e, &recreate): + log.Printf("RECREATE failed: %v (camera may be offline)", recreate.Err) + case errors.As(e, &pull): + log.Printf("pull error (will retry): %v", pull.Err) + default: + log.Printf("stream error: %v", e) + } } } } + +// loadPassword resolves the camera password from the environment first +// (ONVIF_PASSWORD), then -password-file, then an interactive prompt as +// a last resort. The insecure -password flag is honoured only if +// nothing else is set, and a warning is logged. +func loadPassword(insecure, file string) (string, error) { + if env := os.Getenv("ONVIF_PASSWORD"); env != "" { + return env, nil + } + if file != "" { + b, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read %s: %w", file, err) + } + return strings.TrimRight(string(b), "\r\n"), nil + } + if insecure != "" { + log.Print("WARNING: -password leaks into shell history and process listings; prefer ONVIF_PASSWORD env or -password-file") + return insecure, nil + } + // Interactive prompt — works when stdin is a tty. We use a plain + // reader (rather than golang.org/x/term hidden input) to keep + // this example dependency-free; in production, callers should + // integrate term.ReadPassword. + fmt.Fprint(os.Stderr, "ONVIF password (visible): ") + r := bufio.NewReader(os.Stdin) + line, err := r.ReadString('\n') + if err != nil { + return "", errors.New("no password supplied (set ONVIF_PASSWORD, -password-file, or pipe input)") + } + return strings.TrimRight(line, "\r\n"), nil +} From a1fc7832efdc998c60ac2f1a2359b34eb69699ae Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 15:14:40 +0200 Subject: [PATCH 20/23] refactor(event/stream): align source and test files 1:1 by concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously stream.go was a 621-line monolith holding the Stream type, SOAP plumbing, pull loop, renew loop, recreate logic and jitter. The test side had grown five orphan files (renew_test.go, reconnect_test.go, soap_test.go, jitter_test.go, coverage_test.go) with no matching source files. The mismatch made it harder than necessary to find the code that backed a given test. This commit splits stream.go by concern so each source file has its own test file alongside it. Files <100 LOC (errors, jitter) were folded into their conceptual parents rather than left as fragments. New layout — 8 source + 8 test + helpers (test utility) + doc: stream.go <-> stream_test.go Stream type, Options, lifecycle soap.go <-> soap_test.go SOAP plumbing + fault detection renew.go <-> renew_test.go Renew loop and absolute time reconnect.go <-> reconnect_test.go Pull loop, recreate, jitter decode.go <-> decode_test.go NotificationMessage -> Event types.go <-> types_test.go Event types + typed errors topics.go <-> topics_test.go Classifier table doc.go Package godoc landing page helpers_test.go waitFor (test-only utility) Mergers ------- * errors.go (typed error wrappers, 42 LOC) -> types.go. ErrPullFailed / ErrRenewFailed / ErrRecreateFailed are part of the type system, not a separate concern. * jitter.go (40 LOC) -> reconnect.go. jitter is an implementation detail of attemptRecreate, used nowhere else. Test distribution ----------------- * coverage_test.go was a catch-all; tests moved to the file matching the function under test: - Close*, NewStream_*, FakeCaller_* -> stream_test.go - DisableReconnect_*, RecreateResets_*, PullPointMutation_* -> reconnect_test.go - Decode_*, ExtractState_* -> decode_test.go * soap_test.go shed the two orphans that did not belong there: - TestRenew_SendsAbsoluteDateTimeNotDuration -> renew_test.go - TestClose_BoundedByTimeoutOnHungUnsubscribe -> stream_test.go * errors_test.go's pure type tests -> types_test.go * errors_test.go's Stream-integration tests -> reconnect_test.go * jitter_test.go -> reconnect_test.go No behaviour change. Test suite passes -race clean. --- event/stream/coverage_test.go | 295 ----------------------------- event/stream/decode_test.go | 101 ++++++++++ event/stream/errors.go | 42 ----- event/stream/errors_test.go | 133 ------------- event/stream/jitter_test.go | 44 ----- event/stream/reconnect.go | 127 +++++++++++++ event/stream/reconnect_test.go | 215 ++++++++++++++++++++- event/stream/renew.go | 64 +++++++ event/stream/renew_test.go | 36 ++++ event/stream/soap.go | 189 +++++++++++++++++++ event/stream/soap_test.go | 89 +-------- event/stream/stream.go | 332 +-------------------------------- event/stream/stream_test.go | 93 +++++++++ event/stream/types.go | 38 ++++ event/stream/types_test.go | 27 +++ 15 files changed, 893 insertions(+), 932 deletions(-) delete mode 100644 event/stream/coverage_test.go delete mode 100644 event/stream/errors.go delete mode 100644 event/stream/errors_test.go delete mode 100644 event/stream/jitter_test.go create mode 100644 event/stream/reconnect.go create mode 100644 event/stream/renew.go create mode 100644 event/stream/soap.go diff --git a/event/stream/coverage_test.go b/event/stream/coverage_test.go deleted file mode 100644 index 29b7ea2..0000000 --- a/event/stream/coverage_test.go +++ /dev/null @@ -1,295 +0,0 @@ -package stream - -import ( - "context" - "errors" - "strings" - "testing" - "time" - - "github.com/kerberos-io/onvif/event" - "github.com/kerberos-io/onvif/xsd" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// --- Close surfaces unsubscribe error -------------------------------- - -func TestClose_ReturnsUnsubscribeError(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - // Default empty pulls keep the loop running. Override default - // SendSoap to fail so Close's Unsubscribe also fails. - fc.mu.Lock() - fc.defaultSendSoap = fakeResp{err: errors.New("simulated transport failure")} - fc.mu.Unlock() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) - require.NoError(t, err) - - err = s.Close() - require.Error(t, err) - assert.Contains(t, err.Error(), "unsubscribe pull point") - assert.Contains(t, err.Error(), "simulated transport failure") -} - -// --- NewStream against already-cancelled context ---------------------- - -func TestNewStream_CtxAlreadyCancelled(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel before NewStream - - s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) - // Create-pull-point doesn't currently consult ctx (it uses caller - // directly), so construction succeeds and the run goroutine exits - // immediately. Close must still work cleanly. - require.NoError(t, err) - require.NotNil(t, s) - - // Events channel must close promptly because the goroutine exits. - select { - case _, ok := <-s.Events(): - assert.False(t, ok, "events channel should be closed when ctx is pre-cancelled") - case <-time.After(time.Second): - t.Fatal("events channel was not closed within 1s") - } - _ = s.Close() -} - -// --- DisableReconnect honours the opt-out ---------------------------- - -func TestStream_DisableReconnectKeepsRetryingOriginalEndpoint(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - // All pulls fail; default SendSoap stays as empty-pull (success) - // only if the fake's queue exhausts — we override default to a - // failure so EVERY pull errors. - fc.mu.Lock() - fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} - fc.mu.Unlock() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{ - PullTimeout: 10 * time.Millisecond, - RetryBackoff: 10 * time.Millisecond, - InitialTermination: 30 * time.Second, - DisableReconnect: true, - }) - require.NoError(t, err) - defer s.Close() - - // Let the loop spin for a bit, then assert no second CallMethod - // (recreate would invoke CallMethod, which we are watching). - time.Sleep(200 * time.Millisecond) - fc.mu.Lock() - calls := len(fc.callMethodCalls) - fc.mu.Unlock() - assert.Equal(t, 1, calls, "DisableReconnect must prevent recreate; got %d CallMethod calls", calls) -} - -// --- Recreate resets failures+backoff on success --------------------- - -func TestStream_RecreateResetsFailuresAndBackoffOnSuccess(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - fc.queueCallMethod(createPullPointRespAlt, nil) - // Pull fails once -> triggers recreate -> recreate succeeds -> - // next pull succeeds. After that we should NOT see another - // recreate (failures was reset). Provide enough successful empty - // pulls. - fc.queueSendSoap("", errors.New("first failure")) - // Subsequent pulls succeed via default empty pull. - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{ - PullTimeout: 10 * time.Millisecond, - ReconnectAfterFailures: 1, - RetryBackoff: 10 * time.Millisecond, - InitialTermination: 30 * time.Second, - }) - require.NoError(t, err) - defer s.Close() - - time.Sleep(200 * time.Millisecond) - fc.mu.Lock() - calls := len(fc.callMethodCalls) - fc.mu.Unlock() - assert.Equal(t, 2, calls, - "after one failure + successful recreate, no further recreates expected; got %d", calls) -} - -// --- pullPointMu under race ------------------------------------------ - -func TestStream_PullPointMutationVisibleToRenewLoopUnderRace(t *testing.T) { - // Drives the pullPoint write-by-pullLoop / read-by-renewLoop race - // so -race actually exercises the mutex critical sections. With - // short termination and quick recreate, renew is firing alongside - // the recreate write. - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - // Queue a stream of alt-response recreates so each retry installs - // a new pullPoint. - for i := 0; i < 50; i++ { - fc.queueCallMethod(createPullPointRespAlt, nil) - } - // Default empty pulls. - // Force pull errors so reconnect path fires repeatedly: override - // default and queue mostly-failing pulls. - fc.mu.Lock() - fc.defaultSendSoap = fakeResp{err: errors.New("recurring pull fail")} - fc.mu.Unlock() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{ - PullTimeout: 5 * time.Millisecond, - ReconnectAfterFailures: 1, - RetryBackoff: 1 * time.Millisecond, - InitialTermination: 20 * time.Millisecond, - RenewMargin: 2 * time.Millisecond, - }) - require.NoError(t, err) - defer s.Close() - - // Spin for ~300ms; the race detector will fire if either - // pullPointMu critical section is broken. We don't assert on - // content here — the value is the -race signal. - time.Sleep(300 * time.Millisecond) -} - -// --- fakeCaller self-test -------------------------------------------- - -func TestFakeCaller_QueueThenDefaultFallback(t *testing.T) { - fc := newFakeCaller() - fc.queueSendSoap("first", nil) - fc.queueSendSoap("second", nil) - // Default already set to an empty pull response. - - r1, err := fc.SendSoap("ep", "body") - require.NoError(t, err) - b1 := make([]byte, 10) - n, _ := r1.Body.Read(b1) - assert.Equal(t, "first", string(b1[:n])) - - r2, _ := fc.SendSoap("ep", "body") - b2 := make([]byte, 10) - n, _ = r2.Body.Read(b2) - assert.Equal(t, "second", string(b2[:n])) - - // Queue is exhausted; default kicks in. - r3, err := fc.SendSoap("ep", "body") - require.NoError(t, err) - require.NotNil(t, r3) - b3 := make([]byte, 2048) - n, _ = r3.Body.Read(b3) - assert.Contains(t, string(b3[:n]), "PullMessagesResponse", - "default SendSoap should be an empty PullMessagesResponse envelope") -} - -// --- Decoder coverage gaps ------------------------------------------- - -func TestDecode_PropertyOperationIsCaseSensitive(t *testing.T) { - // Per WS-Notification §3.3 PropertyOperation values are - // 'Initialized' / 'Changed' / 'Deleted'. Lowercased forms in the - // wild are malformed and should fall through to PropertyUnknown. - in := msg("tns1:VideoSource/MotionAlarm", "changed", "", nil, nil) - ev := decode(in, "dev", time.Now()) - assert.Equal(t, PropertyUnknown, ev.Operation) -} - -func TestDecode_StateValueTrimsWhitespace(t *testing.T) { - tests := []struct { - name string - value string - want State - }{ - {"leading_trailing", " true ", StateActive}, - {"tab_newline", "\ttrue\n", StateActive}, - {"only_spaces", " ", StateUnknown}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", - nil, map[string]string{"State": tc.value}) - ev := decode(in, "dev", time.Now()) - assert.Equal(t, tc.want, ev.State) - }) - } -} - -func TestDecode_SimpleItemEmptyValueIsUnknownState(t *testing.T) { - in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", - nil, map[string]string{"State": ""}) - ev := decode(in, "dev", time.Now()) - assert.Equal(t, StateUnknown, ev.State) - // Empty value still preserved in the Data map. - v, ok := ev.Data["State"] - assert.True(t, ok) - assert.Equal(t, "", v) -} - -func TestDecode_DeviceTimeAdditionalLayouts(t *testing.T) { - tests := []struct { - name string - in string - want time.Time - }{ - {"compact_offset", "2026-05-21T12:30:00+0200", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, - {"compact_offset_subsec", "2026-05-21T12:30:00.500+0200", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)}, - {"naked_no_tz", "2026-05-21T10:30:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil) - ev := decode(in, "dev", time.Now()) - assert.True(t, ev.DeviceTime.Equal(tc.want), - "input=%q got=%v want=%v", tc.in, ev.DeviceTime, tc.want) - }) - } -} - -// --- extractState deterministic order with explicit slice ------------ - -func TestExtractState_FirstBooleanLikeWins(t *testing.T) { - // Verifies the documented behaviour: when multiple Data items have - // boolean-like values, the first by slice order wins. - in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil) - in.Message.Message.Data.SimpleItem = simpleItemsFromPairs([]pair{ - {"ObjectId", "42"}, - {"State", "true"}, - {"Trailer", "false"}, - }) - ev := decode(in, "dev", time.Now()) - assert.Equal(t, StateActive, ev.State, - "first boolean-like value (State=true) must win, not Trailer=false") -} - -type pair struct{ k, v string } - -func simpleItemsFromPairs(pairs []pair) []event.SimpleItem { - out := make([]event.SimpleItem, len(pairs)) - for i, p := range pairs { - out[i] = event.SimpleItem{ - Name: xsd.AnyType(p.k), - Value: xsd.AnyType(p.v), - } - } - return out -} - -// --- ensure the new layouts don't accept unrelated junk -------------- - -func TestDecode_DeviceTimeStillRejectsNonsense(t *testing.T) { - for _, s := range []string{"hello", "2026-13-45T99:99:99", strings.Repeat("9", 50)} { - in := msg("tns1:VideoSource/MotionAlarm", "Changed", s, nil, nil) - ev := decode(in, "dev", time.Now()) - assert.True(t, ev.DeviceTime.IsZero(), "input=%q should yield zero, got %v", s, ev.DeviceTime) - } -} diff --git a/event/stream/decode_test.go b/event/stream/decode_test.go index 2d52800..f30eec7 100644 --- a/event/stream/decode_test.go +++ b/event/stream/decode_test.go @@ -1,6 +1,7 @@ package stream import ( + "strings" "testing" "time" @@ -247,3 +248,103 @@ func TestDecode_StateValueIsCaseInsensitive(t *testing.T) { }) } } + +// --- Edge cases for state extraction and time parsing ---------------- + +func TestDecode_PropertyOperationIsCaseSensitive(t *testing.T) { + // Per WS-Notification §3.3 PropertyOperation values are + // 'Initialized' / 'Changed' / 'Deleted'. Lowercased forms are + // malformed and should fall through to PropertyUnknown. + in := msg("tns1:VideoSource/MotionAlarm", "changed", "", nil, nil) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, PropertyUnknown, ev.Operation) +} + +func TestDecode_StateValueTrimsWhitespace(t *testing.T) { + tests := []struct { + name string + value string + want State + }{ + {"leading_trailing", " true ", StateActive}, + {"tab_newline", "\ttrue\n", StateActive}, + {"only_spaces", " ", StateUnknown}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", + nil, map[string]string{"State": tc.value}) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, tc.want, ev.State) + }) + } +} + +func TestDecode_SimpleItemEmptyValueIsUnknownState(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", + nil, map[string]string{"State": ""}) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, StateUnknown, ev.State) + v, ok := ev.Data["State"] + assert.True(t, ok) + assert.Equal(t, "", v) +} + +func TestDecode_DeviceTimeAdditionalLayouts(t *testing.T) { + tests := []struct { + name string + in string + want time.Time + }{ + {"compact_offset", "2026-05-21T12:30:00+0200", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, + {"compact_offset_subsec", "2026-05-21T12:30:00.500+0200", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)}, + {"naked_no_tz", "2026-05-21T10:30:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil) + ev := decode(in, "dev", time.Now()) + assert.True(t, ev.DeviceTime.Equal(tc.want), + "input=%q got=%v want=%v", tc.in, ev.DeviceTime, tc.want) + }) + } +} + +func TestDecode_DeviceTimeStillRejectsNonsense(t *testing.T) { + for _, s := range []string{"hello", "2026-13-45T99:99:99", strings.Repeat("9", 50)} { + in := msg("tns1:VideoSource/MotionAlarm", "Changed", s, nil, nil) + ev := decode(in, "dev", time.Now()) + assert.True(t, ev.DeviceTime.IsZero(), "input=%q should yield zero, got %v", s, ev.DeviceTime) + } +} + +// --- First-boolean-wins with explicit slice order -------------------- + +type pair struct{ k, v string } + +func simpleItemsFromPairs(pairs []pair) []event.SimpleItem { + out := make([]event.SimpleItem, len(pairs)) + for i, p := range pairs { + out[i] = event.SimpleItem{ + Name: xsd.AnyType(p.k), + Value: xsd.AnyType(p.v), + } + } + return out +} + +func TestExtractState_FirstBooleanLikeWins(t *testing.T) { + // Documented behaviour: when multiple Data items have boolean-like + // values, the first by slice order wins. Use explicit slice + // construction so the assertion does not depend on map iteration + // order. + in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil) + in.Message.Message.Data.SimpleItem = simpleItemsFromPairs([]pair{ + {"ObjectId", "42"}, + {"State", "true"}, + {"Trailer", "false"}, + }) + ev := decode(in, "dev", time.Now()) + assert.Equal(t, StateActive, ev.State, + "first boolean-like value (State=true) must win, not Trailer=false") +} diff --git a/event/stream/errors.go b/event/stream/errors.go deleted file mode 100644 index bd80674..0000000 --- a/event/stream/errors.go +++ /dev/null @@ -1,42 +0,0 @@ -package stream - -import "fmt" - -// Op identifies which Stream operation failed. Used by ErrPullFailed, -// ErrRenewFailed and ErrRecreateFailed so consumers can branch with -// errors.As without parsing the wrapped message. -type Op string - -const ( - OpPull Op = "pull" - OpRenew Op = "renew" - OpRecreate Op = "recreate" -) - -// ErrPullFailed wraps a transient PullMessages failure. The pull loop -// surfaces it on the Errors channel and continues. Consumers can match -// with errors.As(err, &stream.ErrPullFailed{}) — see -// TestErrors_TypedAssertion. -type ErrPullFailed struct{ Err error } - -func (e ErrPullFailed) Error() string { return fmt.Sprintf("pull messages: %v", e.Err) } -func (e ErrPullFailed) Unwrap() error { return e.Err } -func (ErrPullFailed) Op() Op { return OpPull } - -// ErrRenewFailed wraps a Renew SOAP failure. Renew errors are usually -// recovered implicitly: the subscription dies, pull starts failing, and -// the reconnect logic recreates it. -type ErrRenewFailed struct{ Err error } - -func (e ErrRenewFailed) Error() string { return fmt.Sprintf("renew pull point: %v", e.Err) } -func (e ErrRenewFailed) Unwrap() error { return e.Err } -func (ErrRenewFailed) Op() Op { return OpRenew } - -// ErrRecreateFailed wraps a failed CreatePullPointSubscription during -// the reconnect path. The loop continues with exponential backoff; -// consumers seeing this repeatedly should consider the camera offline. -type ErrRecreateFailed struct{ Err error } - -func (e ErrRecreateFailed) Error() string { return fmt.Sprintf("recreate pull point: %v", e.Err) } -func (e ErrRecreateFailed) Unwrap() error { return e.Err } -func (ErrRecreateFailed) Op() Op { return OpRecreate } diff --git a/event/stream/errors_test.go b/event/stream/errors_test.go deleted file mode 100644 index d68e347..0000000 --- a/event/stream/errors_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package stream - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestTypedErrors_UnwrapAndOp(t *testing.T) { - inner := errors.New("boom") - tests := []struct { - name string - err error - op Op - }{ - {"pull", ErrPullFailed{Err: inner}, OpPull}, - {"renew", ErrRenewFailed{Err: inner}, OpRenew}, - {"recreate", ErrRecreateFailed{Err: inner}, OpRecreate}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - assert.True(t, errors.Is(tc.err, inner), "errors.Is should unwrap to inner") - assert.Contains(t, tc.err.Error(), "boom") - - // Each typed error exposes Op() for branch-without-string-parse. - if e, ok := tc.err.(interface{ Op() Op }); ok { - assert.Equal(t, tc.op, e.Op()) - } else { - t.Fatalf("%T does not expose Op()", tc.err) - } - }) - } -} - -func TestStream_PullErrorIsTypedErrPullFailed(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - fc.queueSendSoap("", errors.New("transient")) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{ - PullTimeout: 50 * time.Millisecond, - RetryBackoff: 10 * time.Millisecond, - InitialTermination: 30 * time.Second, - }) - require.NoError(t, err) - defer s.Close() - - select { - case e := <-s.Errors(): - var pullErr ErrPullFailed - require.True(t, errors.As(e, &pullErr), "expected ErrPullFailed, got %T: %v", e, e) - assert.Contains(t, pullErr.Err.Error(), "transient") - case <-time.After(time.Second): - t.Fatal("expected ErrPullFailed on Errors channel") - } -} - -func TestStream_RecreateErrorIsTypedErrRecreateFailed(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - fc.mu.Lock() - fc.defaultCall = fakeResp{err: errors.New("recreate fail")} - fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} - fc.mu.Unlock() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{ - PullTimeout: 10 * time.Millisecond, - ReconnectAfterFailures: 1, - RetryBackoff: 10 * time.Millisecond, - InitialTermination: 30 * time.Second, - }) - require.NoError(t, err) - defer s.Close() - - deadline := time.Now().Add(time.Second) - var sawRecreate bool - for time.Now().Before(deadline) && !sawRecreate { - select { - case e := <-s.Errors(): - var rec ErrRecreateFailed - if errors.As(e, &rec) { - sawRecreate = true - assert.Contains(t, rec.Err.Error(), "recreate fail") - } - case <-time.After(50 * time.Millisecond): - } - } - assert.True(t, sawRecreate, "expected at least one ErrRecreateFailed on Errors") -} - -func TestStream_AfterReconnectFlagSetOnFirstPostRecreateBatch(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - // Second create is the recreate. - fc.queueCallMethod(createPullPointRespAlt, nil) - - // First pull fails -> triggers recreate with ReconnectAfterFailures=1. - fc.queueSendSoap("", errors.New("transient")) - // First pull after recreate: a Changed motion event. The flag - // should be true, and should clear (because we received a - // non-Initialized event). - fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil) - // Second pull after recreate: another motion event. Flag should - // now be false. - fc.queueSendSoap(pullMessagesResp(motionMsg("false")), nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{ - PullTimeout: 50 * time.Millisecond, - ReconnectAfterFailures: 1, - RetryBackoff: 10 * time.Millisecond, - InitialTermination: 30 * time.Second, - }) - require.NoError(t, err) - defer s.Close() - - ev1 := receive(t, s.Events(), 2*time.Second) - assert.True(t, ev1.AfterReconnect, "first event after recreate must carry AfterReconnect=true") - assert.Equal(t, StateActive, ev1.State) - - ev2 := receive(t, s.Events(), 2*time.Second) - assert.False(t, ev2.AfterReconnect, "subsequent events should not carry AfterReconnect") - assert.Equal(t, StateInactive, ev2.State) -} diff --git a/event/stream/jitter_test.go b/event/stream/jitter_test.go deleted file mode 100644 index 3e5d839..0000000 --- a/event/stream/jitter_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package stream - -import ( - "testing" - "time" - - "github.com/stretchr/testify/assert" -) - -func TestJitter_StaysWithinFraction(t *testing.T) { - const base = time.Second - low := time.Duration(float64(base) * (1 - jitterFraction)) - high := time.Duration(float64(base) * (1 + jitterFraction)) - for i := 0; i < 200; i++ { - got := jitter(base) - assert.GreaterOrEqual(t, got, low, "iteration %d", i) - assert.LessOrEqual(t, got, high, "iteration %d", i) - } -} - -func TestJitter_ZeroAndNegativeReturnPositive(t *testing.T) { - assert.Greater(t, jitter(0), time.Duration(0)) - assert.Greater(t, jitter(-time.Second), time.Duration(0)) -} - -func TestJitter_VariesAcrossCalls(t *testing.T) { - // Sanity check that we're not returning a constant. Vanishingly - // unlikely to flake (probability ~ (1/uint64-space)^9). - first := jitter(time.Second) - allEqual := true - for i := 0; i < 10; i++ { - if jitter(time.Second) != first { - allEqual = false - break - } - } - assert.False(t, allEqual, "jitter is producing a constant; rand seed not working") -} - -func TestMaxRecreateBackoff_Is5Minutes(t *testing.T) { - // Document the policy choice in a test so a future maintainer - // changing this notices. - assert.Equal(t, 5*time.Minute, maxRecreateBackoff) -} diff --git a/event/stream/reconnect.go b/event/stream/reconnect.go new file mode 100644 index 0000000..52cb047 --- /dev/null +++ b/event/stream/reconnect.go @@ -0,0 +1,127 @@ +package stream + +import ( + "context" + "math/rand" + "time" +) + +// maxRecreateBackoff caps exponential backoff between recreate attempts. +// Sized for fleet deployments: a 1000-camera setup recovering from a +// switch reboot would otherwise hammer the network with one recreate +// attempt per camera per 30s; 5 minutes gives the network time to +// settle while still recovering promptly when a single camera comes +// back. +const maxRecreateBackoff = 5 * time.Minute + +// jitterFraction is the symmetric jitter applied to recreate backoff: +// the actual sleep is sampled from [backoff*(1-jitter), backoff*(1+jitter)]. +// Prevents thundering-herd reconnects when many cameras drop together +// (switch reboot, NAT timeout). +const jitterFraction = 0.25 + +// pullLoop is the main pull goroutine of a Stream. It calls +// PullMessages in a tight loop, decodes results into Events and feeds +// the Events channel. +// +// After ReconnectAfterFailures consecutive pull errors it asks +// attemptRecreate to recreate the pull-point subscription, marking the +// next batch's events with AfterReconnect so consumers can suppress +// duplicate handling of the ONVIF Initialized-replay that follows a +// new subscription. +// +// Exits when ctx is cancelled. +func (s *Stream) pullLoop(ctx context.Context) { + var failures int + recreateBackoff := s.opts.RetryBackoff + var afterReconnect bool + + for { + if ctx.Err() != nil { + return + } + msgs, err := pullMessages(s.caller, s.getPullPoint(), s.opts) + if err != nil { + s.surfaceError(ErrPullFailed{Err: err}) + failures++ + if !s.opts.DisableReconnect && failures >= s.opts.ReconnectAfterFailures { + justRecreated, cont := s.attemptRecreate(ctx, &failures, &recreateBackoff) + if !cont { + return + } + if justRecreated { + afterReconnect = true + } + continue + } + if !sleepCtx(ctx, s.opts.RetryBackoff) { + return + } + continue + } + // Successful pull resets failure tracking. + failures = 0 + recreateBackoff = s.opts.RetryBackoff + observedAt := s.now() + for _, m := range msgs { + ev := decode(m, s.opts.DeviceID, observedAt) + if afterReconnect { + ev.AfterReconnect = true + // ONVIF replays current state with + // PropertyInitialized on a new subscription. + // Clear the flag as soon as we see anything + // other than Initialized — at that point we + // have transitioned to live events. + if ev.Operation != PropertyInitialized { + afterReconnect = false + } + } + select { + case <-ctx.Done(): + return + case s.events <- ev: + } + } + } +} + +// attemptRecreate calls CreatePullPointSubscription and on success +// installs the new endpoint atomically. The first return is true when +// recreate succeeded just now (caller flags the next batch with +// AfterReconnect). The second return is false only if ctx was cancelled +// during backoff (caller should exit the run loop). +func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) (justRecreated, cont bool) { + addr, err := createPullPoint(s.caller, s.opts) + if err != nil { + s.surfaceError(ErrRecreateFailed{Err: err}) + if !sleepCtx(ctx, jitter(*backoff)) { + return false, false + } + *backoff *= 2 + if *backoff > maxRecreateBackoff { + *backoff = maxRecreateBackoff + } + return false, true + } + s.setPullPoint(addr) + *failures = 0 + *backoff = s.opts.RetryBackoff + return true, true +} + +// jitter returns d perturbed by ±jitterFraction. Used to spread +// recreate attempts across a fleet so a synchronised drop (switch +// reboot, DHCP storm) does not cause a synchronised reconnect surge. +// Returns at least 1ns to keep sleepCtx happy. +func jitter(d time.Duration) time.Duration { + if d <= 0 { + return time.Nanosecond + } + spread := float64(d) * jitterFraction + delta := (rand.Float64()*2 - 1) * spread + out := time.Duration(float64(d) + delta) + if out <= 0 { + out = time.Nanosecond + } + return out +} diff --git a/event/stream/reconnect_test.go b/event/stream/reconnect_test.go index 9162768..4dfbb5e 100644 --- a/event/stream/reconnect_test.go +++ b/event/stream/reconnect_test.go @@ -29,15 +29,13 @@ const createPullPointRespAlt = ` ` +// --- Recreate after pull failures ------------------------------------ + func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) { fc := newFakeCaller() - // Initial subscription. fc.queueCallMethod(createPullPointResp, nil) - // Recreated subscription returns a *different* endpoint. fc.queueCallMethod(createPullPointRespAlt, nil) - // First pull fails. With ReconnectAfterFailures=1 this triggers a - // recreate; subsequent pulls go to PullSub_2 which we'll observe. fc.queueSendSoap("", errors.New("transient failure")) fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil) @@ -48,7 +46,7 @@ func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) { PullTimeout: 50 * time.Millisecond, ReconnectAfterFailures: 1, RetryBackoff: 10 * time.Millisecond, - InitialTermination: 30 * time.Second, // keep renew quiet + InitialTermination: 30 * time.Second, }) require.NoError(t, err) defer s.Close() @@ -60,8 +58,6 @@ func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) { defer fc.mu.Unlock() require.Len(t, fc.callMethodCalls, 2, "expected exactly 2 CallMethod calls (initial + recreate)") - // The PullMessages call that delivered the motion event must - // target the new endpoint. var newEndpointPulls int for _, c := range fc.sendSoapCalls { if c[0] == "http://camera.local/onvif/Events/PullSub_2" { @@ -75,9 +71,6 @@ func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) { func TestStream_BackoffWhenRecreateFails(t *testing.T) { fc := newFakeCaller() fc.queueCallMethod(createPullPointResp, nil) - // After the initial successful create, every CallMethod (recreate) - // and SendSoap (pull) fails. The loop should keep retrying with - // exponential backoff rather than blocking forever or spinning. fc.mu.Lock() fc.defaultCall = fakeResp{err: errors.New("recreate fail")} fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} @@ -118,3 +111,205 @@ func TestStream_RetryBackoffDefault(t *testing.T) { o := defaultOptions() assert.Equal(t, time.Second, o.RetryBackoff) } + +func TestStream_DisableReconnectKeepsRetryingOriginalEndpoint(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.mu.Lock() + fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 10 * time.Millisecond, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + DisableReconnect: true, + }) + require.NoError(t, err) + defer s.Close() + + time.Sleep(200 * time.Millisecond) + fc.mu.Lock() + calls := len(fc.callMethodCalls) + fc.mu.Unlock() + assert.Equal(t, 1, calls, "DisableReconnect must prevent recreate; got %d CallMethod calls", calls) +} + +func TestStream_RecreateResetsFailuresAndBackoffOnSuccess(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueCallMethod(createPullPointRespAlt, nil) + fc.queueSendSoap("", errors.New("first failure")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 10 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + time.Sleep(200 * time.Millisecond) + fc.mu.Lock() + calls := len(fc.callMethodCalls) + fc.mu.Unlock() + assert.Equal(t, 2, calls, + "after one failure + successful recreate, no further recreates expected; got %d", calls) +} + +func TestStream_PullPointMutationVisibleToRenewLoopUnderRace(t *testing.T) { + // Drives the pullPoint write-by-pullLoop / read-by-renewLoop race + // so -race actually exercises the mutex critical sections. + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + for i := 0; i < 50; i++ { + fc.queueCallMethod(createPullPointRespAlt, nil) + } + fc.mu.Lock() + fc.defaultSendSoap = fakeResp{err: errors.New("recurring pull fail")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 5 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 1 * time.Millisecond, + InitialTermination: 20 * time.Millisecond, + RenewMargin: 2 * time.Millisecond, + }) + require.NoError(t, err) + defer s.Close() + + time.Sleep(300 * time.Millisecond) +} + +// --- Typed errors from the reconnect path ---------------------------- + +func TestStream_PullErrorIsTypedErrPullFailed(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueSendSoap("", errors.New("transient")) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 50 * time.Millisecond, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + select { + case e := <-s.Errors(): + var pullErr ErrPullFailed + require.True(t, errors.As(e, &pullErr), "expected ErrPullFailed, got %T: %v", e, e) + assert.Contains(t, pullErr.Err.Error(), "transient") + case <-time.After(time.Second): + t.Fatal("expected ErrPullFailed on Errors channel") + } +} + +func TestStream_RecreateErrorIsTypedErrRecreateFailed(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.mu.Lock() + fc.defaultCall = fakeResp{err: errors.New("recreate fail")} + fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 10 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + deadline := time.Now().Add(time.Second) + var sawRecreate bool + for time.Now().Before(deadline) && !sawRecreate { + select { + case e := <-s.Errors(): + var rec ErrRecreateFailed + if errors.As(e, &rec) { + sawRecreate = true + assert.Contains(t, rec.Err.Error(), "recreate fail") + } + case <-time.After(50 * time.Millisecond): + } + } + assert.True(t, sawRecreate, "expected at least one ErrRecreateFailed on Errors") +} + +func TestStream_AfterReconnectFlagSetOnFirstPostRecreateBatch(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.queueCallMethod(createPullPointRespAlt, nil) + + fc.queueSendSoap("", errors.New("transient")) + fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil) + fc.queueSendSoap(pullMessagesResp(motionMsg("false")), nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 50 * time.Millisecond, + ReconnectAfterFailures: 1, + RetryBackoff: 10 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + defer s.Close() + + ev1 := receive(t, s.Events(), 2*time.Second) + assert.True(t, ev1.AfterReconnect, "first event after recreate must carry AfterReconnect=true") + assert.Equal(t, StateActive, ev1.State) + + ev2 := receive(t, s.Events(), 2*time.Second) + assert.False(t, ev2.AfterReconnect, "subsequent events should not carry AfterReconnect") + assert.Equal(t, StateInactive, ev2.State) +} + +// --- Jitter ---------------------------------------------------------- + +func TestJitter_StaysWithinFraction(t *testing.T) { + const base = time.Second + low := time.Duration(float64(base) * (1 - jitterFraction)) + high := time.Duration(float64(base) * (1 + jitterFraction)) + for i := 0; i < 200; i++ { + got := jitter(base) + assert.GreaterOrEqual(t, got, low, "iteration %d", i) + assert.LessOrEqual(t, got, high, "iteration %d", i) + } +} + +func TestJitter_ZeroAndNegativeReturnPositive(t *testing.T) { + assert.Greater(t, jitter(0), time.Duration(0)) + assert.Greater(t, jitter(-time.Second), time.Duration(0)) +} + +func TestJitter_VariesAcrossCalls(t *testing.T) { + first := jitter(time.Second) + allEqual := true + for i := 0; i < 10; i++ { + if jitter(time.Second) != first { + allEqual = false + break + } + } + assert.False(t, allEqual, "jitter is producing a constant; rand seed not working") +} + +func TestMaxRecreateBackoff_Is5Minutes(t *testing.T) { + assert.Equal(t, 5*time.Minute, maxRecreateBackoff) +} diff --git a/event/stream/renew.go b/event/stream/renew.go new file mode 100644 index 0000000..6a8763a --- /dev/null +++ b/event/stream/renew.go @@ -0,0 +1,64 @@ +package stream + +import ( + "context" + "encoding/xml" + "fmt" + "time" + + "github.com/kerberos-io/onvif/event" + "github.com/kerberos-io/onvif/xsd" +) + +// renewLoop refreshes the subscription before InitialTermination expires. +// Exits when ctx is cancelled. Renew failures surface as ErrRenewFailed +// on the Errors channel; the loop continues because a permanently +// failing renew will eventually drop the subscription and the pull +// loop's reconnect path will recover (recreate is the only reliable +// recovery once a subscription is GC'd at the camera). +func (s *Stream) renewLoop(ctx context.Context) { + interval := s.opts.InitialTermination - s.opts.RenewMargin + if interval <= 0 { + // Pathological config (margin >= termination): fall back to + // renewing at half the termination so we still refresh, + // rather than busy-looping or never renewing. + interval = s.opts.InitialTermination / 2 + if interval <= 0 { + interval = time.Second + } + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := renewPullPoint(s.caller, s.getPullPoint(), s.opts); err != nil { + s.surfaceError(ErrRenewFailed{Err: err}) + } + } + } +} + +// renewPullPoint issues a wsnt:Renew SOAP against the given +// subscription endpoint with an absolute TerminationTime. +// +// WS-BaseNotification §6.1.1 declares TerminationTime as xsd:dateTime +// OR xsd:duration, but older Hikvision, some Dahua and some Bosch +// firmwares reject the relative-duration form. We send an absolute +// UTC datetime to match what production NVRs do. +func renewPullPoint(c caller, endpoint string, opts Options) error { + absoluteEnd := time.Now().UTC().Add(opts.InitialTermination).Format("2006-01-02T15:04:05Z") + req := event.Renew{TerminationTime: xsd.String(absoluteEnd)} + body, err := xml.Marshal(req) + if err != nil { + return fmt.Errorf("marshal Renew: %w", err) + } + resp, err := c.SendSoap(endpoint, string(body)) + if err != nil { + return err + } + _, err = readClose(resp) + return err +} diff --git a/event/stream/renew_test.go b/event/stream/renew_test.go index 20e2146..cc758bb 100644 --- a/event/stream/renew_test.go +++ b/event/stream/renew_test.go @@ -132,3 +132,39 @@ func TestStream_RenewErrorSurfacedOnErrorsChannel(t *testing.T) { type errInjected struct{} func (errInjected) Error() string { return "injected fake error" } + +func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + InitialTermination: 30 * time.Millisecond, + RenewMargin: 5 * time.Millisecond, + }) + require.NoError(t, err) + defer s.Close() + + deadline := time.Now().Add(500 * time.Millisecond) + for time.Now().Before(deadline) { + if countSendSoapMatching(fc, "Renew") >= 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + + fc.mu.Lock() + defer fc.mu.Unlock() + var renewBody string + for _, c := range fc.sendSoapCalls { + if strings.Contains(c[1], "Renew") { + renewBody = c[1] + break + } + } + require.NotEmpty(t, renewBody, "no Renew call observed") + // Absolute form is "YYYY-MM-DDTHH:MM:SSZ" not "PTnS". + assert.NotContains(t, renewBody, "PT", "Renew should not send relative duration; some firmwares reject it") + assert.Regexp(t, `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, renewBody, "Renew should send absolute RFC3339 UTC") +} diff --git a/event/stream/soap.go b/event/stream/soap.go new file mode 100644 index 0000000..4d9bcf6 --- /dev/null +++ b/event/stream/soap.go @@ -0,0 +1,189 @@ +package stream + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/kerberos-io/onvif/event" + "github.com/kerberos-io/onvif/xsd" +) + +// maxResponseBytes caps the size of a SOAP response we will buffer in +// memory. ONVIF PullMessages bodies are normally <100KB even with dense +// analytics payloads; 10 MiB is comfortably above legitimate traffic +// while keeping a hostile or buggy camera from OOMing the process. +const maxResponseBytes = 10 << 20 + +// createPullPoint issues a CreatePullPointSubscription against the +// device service. Returns the SubscriptionReference Address, which is +// the endpoint subsequent PullMessages / Renew / Unsubscribe calls +// target. +func createPullPoint(c caller, opts Options) (string, error) { + term := xsd.String(durationToXSD(opts.InitialTermination)) + req := event.CreatePullPointSubscription{InitialTerminationTime: &term} + if opts.RawTopicFilter != "" { + req.Filter = &event.FilterType{ + TopicExpression: &event.TopicExpressionType{ + Dialect: xsd.String("http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet"), + TopicKinds: xsd.String(opts.RawTopicFilter), + }, + } + } + resp, err := c.CallMethod(req) + if err != nil { + return "", err + } + body, err := readClose(resp) + if err != nil { + return "", err + } + var decoded event.CreatePullPointSubscriptionResponse + if err := unmarshalNode(body, "CreatePullPointSubscriptionResponse", &decoded); err != nil { + return "", err + } + addr := string(decoded.SubscriptionReference.Address) + if addr == "" { + return "", errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address") + } + return addr, nil +} + +// pullMessages issues PullMessages against an active subscription +// endpoint and returns the decoded NotificationMessage list. Empty +// slice (not error) when the camera had nothing within PullTimeout. +func pullMessages(c caller, endpoint string, opts Options) ([]event.NotificationMessage, error) { + req := event.PullMessages{ + Timeout: xsd.Duration(durationToXSD(opts.PullTimeout)), + MessageLimit: xsd.Int(opts.MessageLimit), + } + body, err := xml.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal PullMessages: %w", err) + } + resp, err := c.SendSoap(endpoint, string(body)) + if err != nil { + return nil, err + } + respBody, err := readClose(resp) + if err != nil { + return nil, err + } + var decoded event.PullMessagesResponse + if err := unmarshalNode(respBody, "PullMessagesResponse", &decoded); err != nil { + return nil, err + } + return decoded.NotificationMessage, nil +} + +// unsubscribePullPoint sends a best-effort Unsubscribe to release the +// subscription server-side. Empty endpoint is a no-op (the construction +// failed before installing one). +func unsubscribePullPoint(c caller, endpoint string) error { + if endpoint == "" { + return nil + } + body, err := xml.Marshal(event.Unsubscribe{}) + if err != nil { + return fmt.Errorf("marshal Unsubscribe: %w", err) + } + resp, err := c.SendSoap(endpoint, string(body)) + if err != nil { + return err + } + _, err = readClose(resp) + return err +} + +// readClose reads at most maxResponseBytes from resp.Body and closes +// it. LimitReader prevents a hostile or buggy camera from OOMing the +// agent by streaming an unbounded response. +func readClose(resp *http.Response) (string, error) { + if resp == nil || resp.Body == nil { + return "", errors.New("nil HTTP response") + } + defer resp.Body.Close() + b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return "", fmt.Errorf("read response body: %w", err) + } + return string(b), nil +} + +// unmarshalNode finds the first XML start element with the given local +// name and decodes it into out. ONVIF SOAP responses come wrapped in an +// envelope with multiple namespace prefixes; this helper sidesteps +// namespace matching by keying on local name only. +// +// When the camera returns a SOAP Fault instead of the expected +// response, the fault reason is surfaced as the error so callers can +// distinguish "auth failed" / "subscription expired" from "unparseable +// response". +func unmarshalNode(body, localName string, out any) error { + if reason := extractSOAPFault(body); reason != "" { + return fmt.Errorf("ONVIF SOAP fault: %s", reason) + } + dec := xml.NewDecoder(bytes.NewBufferString(body)) + for { + tok, err := dec.Token() + if err != nil { + if errors.Is(err, io.EOF) { + return fmt.Errorf("ONVIF response missing %s element", localName) + } + return fmt.Errorf("scan ONVIF response: %w", err) + } + start, ok := tok.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local != localName { + continue + } + if err := dec.DecodeElement(out, &start); err != nil { + return fmt.Errorf("decode %s: %w", localName, err) + } + return nil + } +} + +var ( + // SOAP 1.1: reason + soap11FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?faultstring[^>]*>(.*?)\s]+:)?faultstring>`) + // SOAP 1.2: ...reason... + soap12FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Reason\b[^>]*>.*?<(?:[^:>\s]+:)?Text[^>]*>(.*?)\s]+:)?Text>`) +) + +// extractSOAPFault returns the human-readable reason text from a SOAP +// fault, or empty string when the body is not a fault. Handles both +// SOAP 1.1 (faultstring) and SOAP 1.2 (Reason/Text) shapes. +func extractSOAPFault(body string) string { + if !strings.Contains(body, "Fault") { + return "" + } + if m := soap11FaultRE.FindStringSubmatch(body); len(m) > 1 { + return strings.TrimSpace(m[1]) + } + if m := soap12FaultRE.FindStringSubmatch(body); len(m) > 1 { + return strings.TrimSpace(m[1]) + } + return "" +} + +// durationToXSD formats a Go time.Duration as an xsd:duration string in +// PTnS form. Second precision is sufficient — ONVIF cameras do not +// honour sub-second pull timeouts and intermediate routers may round in +// any case. +func durationToXSD(d time.Duration) string { + secs := int(d.Round(time.Second).Seconds()) + if secs <= 0 { + secs = 1 + } + return "PT" + strconv.Itoa(secs) + "S" +} diff --git a/event/stream/soap_test.go b/event/stream/soap_test.go index e8c48f5..7c5b6c2 100644 --- a/event/stream/soap_test.go +++ b/event/stream/soap_test.go @@ -4,7 +4,6 @@ import ( "context" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -59,97 +58,29 @@ func TestUnmarshalNode_ReturnsFaultReasonInsteadOfMissingElement(t *testing.T) { assert.NotContains(t, err.Error(), "missing PullMessagesResponse") } -// --- Renew sends absolute datetime ----------------------------------- - -func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) { - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{ - InitialTermination: 30 * time.Millisecond, - RenewMargin: 5 * time.Millisecond, - }) - require.NoError(t, err) - defer s.Close() - - deadline := time.Now().Add(500 * time.Millisecond) - for time.Now().Before(deadline) { - if countSendSoapMatching(fc, "Renew") >= 1 { - break - } - time.Sleep(10 * time.Millisecond) - } - - fc.mu.Lock() - defer fc.mu.Unlock() - var renewBody string - for _, c := range fc.sendSoapCalls { - if strings.Contains(c[1], "Renew") { - renewBody = c[1] - break - } - } - require.NotEmpty(t, renewBody, "no Renew call observed") - // Absolute form is "YYYY-MM-DDTHH:MM:SSZ" not "PTnS". - assert.NotContains(t, renewBody, "PT", "Renew should not send relative duration; some firmwares reject it") - assert.Regexp(t, `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, renewBody, "Renew should send absolute RFC3339 UTC") -} - // --- Bounded body read ----------------------------------------------- func TestReadClose_LimitsBodySize(t *testing.T) { - // Build a response with a body just over the limit. readClose must - // not return more than the limit even if the camera pretends to - // send more. if maxResponseBytes < 1024 { t.Skip("limit too small for this test") } big := strings.Repeat("A", maxResponseBytes+1024) - // Wrap in a minimal SOAP envelope so the body is at least - // well-formed shape-wise. body := "" + big + "" fc := newFakeCaller() fc.queueCallMethod(body, nil) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() // Construction will fail because the truncated body has no - // CreatePullPointSubscriptionResponse — that's fine; what matters - // is the read completes without OOM. - _, err := newStream(ctx, fc, Options{}) + // CreatePullPointSubscriptionResponse — that's fine; what matters is + // the read completes without OOM. + _, err := newStream(testContext(t), fc, Options{}) assert.Error(t, err) } -// --- Close timeout --------------------------------------------------- - -func TestClose_BoundedByTimeoutOnHungUnsubscribe(t *testing.T) { - // Patch closeUnsubscribeTimeout for the duration of the test so the - // assertion completes promptly. We can't change the const at runtime - // so we use a short InitialTermination and verify Close still - // returns within closeUnsubscribeTimeout + slack rather than - // blocking forever. - fc := newFakeCaller() - fc.queueCallMethod(createPullPointResp, nil) - block := make(chan struct{}) - defer close(block) // release the hung Unsubscribe so the fake's goroutine exits - fc.mu.Lock() - fc.blockUnsubscribe = block - fc.mu.Unlock() - +// testContext returns a Background context already wired to cancel via +// t.Cleanup so the test does not need to manage the cancellation +// goroutine inline. +func testContext(t *testing.T) context.Context { + t.Helper() ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) - require.NoError(t, err) - - start := time.Now() - err = s.Close() - elapsed := time.Since(start) - // Unsubscribe is hung, so Close must surface a timeout error from - // the bounded wait rather than block forever. closeUnsubscribeTimeout - // is 5s; allow 1s slack for scheduling. - require.Error(t, err) - assert.Contains(t, err.Error(), "timeout") - assert.Less(t, elapsed, closeUnsubscribeTimeout+time.Second, - "Close exceeded bound (%s); expected ~%s", elapsed, closeUnsubscribeTimeout) + t.Cleanup(cancel) + return ctx } diff --git a/event/stream/stream.go b/event/stream/stream.go index b1f22d2..4f11415 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -1,31 +1,15 @@ package stream import ( - "bytes" "context" - "encoding/xml" - "errors" "fmt" - "io" - "math/rand" "net/http" - "regexp" - "strconv" - "strings" "sync" "time" "github.com/kerberos-io/onvif" - "github.com/kerberos-io/onvif/event" - "github.com/kerberos-io/onvif/xsd" ) -// maxResponseBytes caps the size of a SOAP response we will buffer in -// memory. ONVIF PullMessages bodies are normally <100KB even with dense -// analytics payloads; 10 MiB is comfortably above legitimate traffic -// while keeping a hostile or buggy camera from OOMing the process. -const maxResponseBytes = 10 << 20 - // closeUnsubscribeTimeout bounds the SOAP Unsubscribe call issued by // Close so a hung camera connection cannot wedge the caller. The // subscription expires at the camera anyway once InitialTermination @@ -138,20 +122,6 @@ func (o Options) withDefaults() Options { return d } -// maxRecreateBackoff caps exponential backoff between recreate attempts. -// Sized for fleet deployments: a 1000-camera setup recovering from a -// switch reboot would otherwise hammer the network with one recreate -// attempt per camera per 30s; 5 minutes gives the network time to -// settle while still recovering promptly when a single camera comes -// back. -const maxRecreateBackoff = 5 * time.Minute - -// jitterFraction is the symmetric jitter applied to recreate backoff: -// the actual sleep is sampled from [backoff*(1-jitter), backoff*(1+jitter)]. -// Prevents thundering-herd reconnects when many cameras drop together -// (switch reboot, NAT timeout). -const jitterFraction = 0.25 - // caller is the subset of *onvif.Device the Stream depends on. Tests // substitute a fake; production code uses the device adapter. // @@ -282,6 +252,8 @@ func (s *Stream) Close() error { return s.closeErr } +// run orchestrates the pull and renew goroutines and closes the +// emission channels once both have exited. func (s *Stream) run(ctx context.Context) { var wg sync.WaitGroup wg.Add(1) @@ -300,130 +272,8 @@ func (s *Stream) run(ctx context.Context) { close(s.done) } -func (s *Stream) pullLoop(ctx context.Context) { - var failures int - recreateBackoff := s.opts.RetryBackoff - var afterReconnect bool - - for { - if ctx.Err() != nil { - return - } - msgs, err := pullMessages(s.caller, s.getPullPoint(), s.opts) - if err != nil { - s.surfaceError(ErrPullFailed{Err: err}) - failures++ - if !s.opts.DisableReconnect && failures >= s.opts.ReconnectAfterFailures { - justRecreated, cont := s.attemptRecreate(ctx, &failures, &recreateBackoff) - if !cont { - return - } - if justRecreated { - afterReconnect = true - } - continue - } - if !sleepCtx(ctx, s.opts.RetryBackoff) { - return - } - continue - } - // Successful pull resets failure tracking. - failures = 0 - recreateBackoff = s.opts.RetryBackoff - observedAt := s.now() - for _, m := range msgs { - ev := decode(m, s.opts.DeviceID, observedAt) - if afterReconnect { - ev.AfterReconnect = true - // ONVIF replays current state with - // PropertyInitialized on a new subscription. - // Clear the flag as soon as we see anything - // other than Initialized — at that point we - // have transitioned to live events. - if ev.Operation != PropertyInitialized { - afterReconnect = false - } - } - select { - case <-ctx.Done(): - return - case s.events <- ev: - } - } - } -} - -// attemptRecreate calls CreatePullPointSubscription and on success -// installs the new endpoint atomically. The first return is true when -// recreate succeeded just now (caller flags the next batch with -// AfterReconnect). The second return is false only if ctx was cancelled -// during backoff (caller should exit the run loop). -func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) (justRecreated, cont bool) { - addr, err := createPullPoint(s.caller, s.opts) - if err != nil { - s.surfaceError(ErrRecreateFailed{Err: err}) - if !sleepCtx(ctx, jitter(*backoff)) { - return false, false - } - *backoff *= 2 - if *backoff > maxRecreateBackoff { - *backoff = maxRecreateBackoff - } - return false, true - } - s.setPullPoint(addr) - *failures = 0 - *backoff = s.opts.RetryBackoff - return true, true -} - -// jitter returns d perturbed by ±jitterFraction. Used to spread -// recreate attempts across a fleet so a synchronised drop (switch -// reboot, DHCP storm) does not cause a synchronised reconnect surge. -// Returns at least 1ns to keep sleepCtx happy. -func jitter(d time.Duration) time.Duration { - if d <= 0 { - return time.Nanosecond - } - spread := float64(d) * jitterFraction - delta := (rand.Float64()*2 - 1) * spread - out := time.Duration(float64(d) + delta) - if out <= 0 { - out = time.Nanosecond - } - return out -} - -// renewLoop refreshes the subscription before InitialTermination expires. -// Exits when ctx is cancelled. -func (s *Stream) renewLoop(ctx context.Context) { - interval := s.opts.InitialTermination - s.opts.RenewMargin - if interval <= 0 { - // Pathological config (margin >= termination): fall back to - // renewing at half the termination so we still refresh, - // rather than busy-looping or never renewing. - interval = s.opts.InitialTermination / 2 - if interval <= 0 { - interval = time.Second - } - } - ticker := time.NewTicker(interval) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if err := renewPullPoint(s.caller, s.getPullPoint(), s.opts); err != nil { - s.surfaceError(ErrRenewFailed{Err: err}) - } - } - } -} - // surfaceError sends err on the errors channel non-blockingly so a -// stalled consumer cannot block the pull loop. +// stalled consumer cannot block the pull or renew loop. func (s *Stream) surfaceError(err error) { select { case s.errors <- err: @@ -443,179 +293,3 @@ func sleepCtx(ctx context.Context, d time.Duration) bool { return true } } - -// --- SOAP helpers (unexported) ---------------------------------------- - -func createPullPoint(c caller, opts Options) (string, error) { - term := xsd.String(durationToXSD(opts.InitialTermination)) - req := event.CreatePullPointSubscription{InitialTerminationTime: &term} - if opts.RawTopicFilter != "" { - req.Filter = &event.FilterType{ - TopicExpression: &event.TopicExpressionType{ - Dialect: xsd.String("http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet"), - TopicKinds: xsd.String(opts.RawTopicFilter), - }, - } - } - resp, err := c.CallMethod(req) - if err != nil { - return "", err - } - body, err := readClose(resp) - if err != nil { - return "", err - } - var decoded event.CreatePullPointSubscriptionResponse - if err := unmarshalNode(body, "CreatePullPointSubscriptionResponse", &decoded); err != nil { - return "", err - } - addr := string(decoded.SubscriptionReference.Address) - if addr == "" { - return "", errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address") - } - return addr, nil -} - -func pullMessages(c caller, endpoint string, opts Options) ([]event.NotificationMessage, error) { - req := event.PullMessages{ - Timeout: xsd.Duration(durationToXSD(opts.PullTimeout)), - MessageLimit: xsd.Int(opts.MessageLimit), - } - body, err := xml.Marshal(req) - if err != nil { - return nil, fmt.Errorf("marshal PullMessages: %w", err) - } - resp, err := c.SendSoap(endpoint, string(body)) - if err != nil { - return nil, err - } - respBody, err := readClose(resp) - if err != nil { - return nil, err - } - var decoded event.PullMessagesResponse - if err := unmarshalNode(respBody, "PullMessagesResponse", &decoded); err != nil { - return nil, err - } - return decoded.NotificationMessage, nil -} - -func renewPullPoint(c caller, endpoint string, opts Options) error { - // WS-BaseNotification §6.1.1 declares TerminationTime as - // xsd:dateTime OR xsd:duration, but older Hikvision, some Dahua - // and some Bosch firmwares reject the relative-duration form. Send - // an absolute UTC datetime to match what production NVRs do. - absoluteEnd := time.Now().UTC().Add(opts.InitialTermination).Format("2006-01-02T15:04:05Z") - req := event.Renew{TerminationTime: xsd.String(absoluteEnd)} - body, err := xml.Marshal(req) - if err != nil { - return fmt.Errorf("marshal Renew: %w", err) - } - resp, err := c.SendSoap(endpoint, string(body)) - if err != nil { - return err - } - _, err = readClose(resp) - return err -} - -func unsubscribePullPoint(c caller, endpoint string) error { - if endpoint == "" { - return nil - } - body, err := xml.Marshal(event.Unsubscribe{}) - if err != nil { - return fmt.Errorf("marshal Unsubscribe: %w", err) - } - resp, err := c.SendSoap(endpoint, string(body)) - if err != nil { - return err - } - _, err = readClose(resp) - return err -} - -func readClose(resp *http.Response) (string, error) { - if resp == nil || resp.Body == nil { - return "", errors.New("nil HTTP response") - } - defer resp.Body.Close() - // LimitReader prevents a hostile or buggy camera from OOMing the - // agent by streaming an unbounded response body. - b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) - if err != nil { - return "", fmt.Errorf("read response body: %w", err) - } - return string(b), nil -} - -// unmarshalNode finds the first XML start element with the given local -// name and decodes it into out. ONVIF SOAP responses come wrapped in an -// envelope with multiple namespace prefixes; this helper sidesteps -// namespace matching by keying on local name only. -// -// When the camera returns a SOAP Fault instead of the expected -// response, the fault reason is surfaced as the error so callers can -// distinguish "auth failed" / "subscription expired" from "unparseable -// response". -func unmarshalNode(body, localName string, out any) error { - if reason := extractSOAPFault(body); reason != "" { - return fmt.Errorf("ONVIF SOAP fault: %s", reason) - } - dec := xml.NewDecoder(bytes.NewBufferString(body)) - for { - tok, err := dec.Token() - if err != nil { - if errors.Is(err, io.EOF) { - return fmt.Errorf("ONVIF response missing %s element", localName) - } - return fmt.Errorf("scan ONVIF response: %w", err) - } - start, ok := tok.(xml.StartElement) - if !ok { - continue - } - if start.Name.Local != localName { - continue - } - if err := dec.DecodeElement(out, &start); err != nil { - return fmt.Errorf("decode %s: %w", localName, err) - } - return nil - } -} - -var ( - // SOAP 1.1: reason - soap11FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?faultstring[^>]*>(.*?)\s]+:)?faultstring>`) - // SOAP 1.2: ...reason... - soap12FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Reason\b[^>]*>.*?<(?:[^:>\s]+:)?Text[^>]*>(.*?)\s]+:)?Text>`) -) - -// extractSOAPFault returns the human-readable reason text from a SOAP -// fault, or empty string when the body is not a fault. Handles both -// SOAP 1.1 (faultstring) and SOAP 1.2 (Reason/Text) shapes. -func extractSOAPFault(body string) string { - if !strings.Contains(body, "Fault") { - return "" - } - if m := soap11FaultRE.FindStringSubmatch(body); len(m) > 1 { - return strings.TrimSpace(m[1]) - } - if m := soap12FaultRE.FindStringSubmatch(body); len(m) > 1 { - return strings.TrimSpace(m[1]) - } - return "" -} - -// durationToXSD formats a Go time.Duration as an xsd:duration string in -// PTnS form. Second precision is sufficient — ONVIF cameras do not -// honour sub-second pull timeouts and intermediate routers may round in -// any case. -func durationToXSD(d time.Duration) string { - secs := int(d.Round(time.Second).Seconds()) - if secs <= 0 { - secs = 1 - } - return "PT" + strconv.Itoa(secs) + "S" -} diff --git a/event/stream/stream_test.go b/event/stream/stream_test.go index b7343fe..062cac7 100644 --- a/event/stream/stream_test.go +++ b/event/stream/stream_test.go @@ -351,3 +351,96 @@ func TestStream_DoesNotPanicOnPullExitingDuringClose(t *testing.T) { _ = s.Close() }) } + +// --- Close error / timeout paths ------------------------------------- + +func TestClose_ReturnsUnsubscribeError(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + fc.mu.Lock() + fc.defaultSendSoap = fakeResp{err: errors.New("simulated transport failure")} + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) + require.NoError(t, err) + + err = s.Close() + require.Error(t, err) + assert.Contains(t, err.Error(), "unsubscribe pull point") + assert.Contains(t, err.Error(), "simulated transport failure") +} + +func TestClose_BoundedByTimeoutOnHungUnsubscribe(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + block := make(chan struct{}) + defer close(block) // release the hung Unsubscribe so the fake's goroutine exits + fc.mu.Lock() + fc.blockUnsubscribe = block + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) + require.NoError(t, err) + + start := time.Now() + err = s.Close() + elapsed := time.Since(start) + require.Error(t, err) + assert.Contains(t, err.Error(), "timeout") + assert.Less(t, elapsed, closeUnsubscribeTimeout+time.Second, + "Close exceeded bound (%s); expected ~%s", elapsed, closeUnsubscribeTimeout) +} + +// --- NewStream edge cases -------------------------------------------- + +func TestNewStream_CtxAlreadyCancelled(t *testing.T) { + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel before NewStream + + s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second}) + require.NoError(t, err) + require.NotNil(t, s) + + select { + case _, ok := <-s.Events(): + assert.False(t, ok, "events channel should be closed when ctx is pre-cancelled") + case <-time.After(time.Second): + t.Fatal("events channel was not closed within 1s") + } + _ = s.Close() +} + +// --- fakeCaller self-test -------------------------------------------- + +func TestFakeCaller_QueueThenDefaultFallback(t *testing.T) { + fc := newFakeCaller() + fc.queueSendSoap("first", nil) + fc.queueSendSoap("second", nil) + + r1, err := fc.SendSoap("ep", "body") + require.NoError(t, err) + b1 := make([]byte, 10) + n, _ := r1.Body.Read(b1) + assert.Equal(t, "first", string(b1[:n])) + + r2, _ := fc.SendSoap("ep", "body") + b2 := make([]byte, 10) + n, _ = r2.Body.Read(b2) + assert.Equal(t, "second", string(b2[:n])) + + // Queue is exhausted; default kicks in. + r3, err := fc.SendSoap("ep", "body") + require.NoError(t, err) + require.NotNil(t, r3) + b3 := make([]byte, 2048) + n, _ = r3.Body.Read(b3) + assert.Contains(t, string(b3[:n]), "PullMessagesResponse", + "default SendSoap should be an empty PullMessagesResponse envelope") +} diff --git a/event/stream/types.go b/event/stream/types.go index 9f1557d..a52f314 100644 --- a/event/stream/types.go +++ b/event/stream/types.go @@ -169,3 +169,41 @@ type Event struct { // first event whose Operation is not PropertyInitialized. AfterReconnect bool } + +// Op identifies which Stream operation failed. Used by ErrPullFailed, +// ErrRenewFailed and ErrRecreateFailed so consumers can branch with +// errors.As without parsing the wrapped message. +type Op string + +const ( + OpPull Op = "pull" + OpRenew Op = "renew" + OpRecreate Op = "recreate" +) + +// ErrPullFailed wraps a transient PullMessages failure. The pull loop +// surfaces it on the Errors channel and continues. Consumers can match +// with errors.As(err, &stream.ErrPullFailed{}). +type ErrPullFailed struct{ Err error } + +func (e ErrPullFailed) Error() string { return fmt.Sprintf("pull messages: %v", e.Err) } +func (e ErrPullFailed) Unwrap() error { return e.Err } +func (ErrPullFailed) Op() Op { return OpPull } + +// ErrRenewFailed wraps a Renew SOAP failure. Renew errors are usually +// recovered implicitly: the subscription dies, pull starts failing, +// and the reconnect logic recreates it. +type ErrRenewFailed struct{ Err error } + +func (e ErrRenewFailed) Error() string { return fmt.Sprintf("renew pull point: %v", e.Err) } +func (e ErrRenewFailed) Unwrap() error { return e.Err } +func (ErrRenewFailed) Op() Op { return OpRenew } + +// ErrRecreateFailed wraps a failed CreatePullPointSubscription during +// the reconnect path. The loop continues with exponential backoff; +// consumers seeing this repeatedly should consider the camera offline. +type ErrRecreateFailed struct{ Err error } + +func (e ErrRecreateFailed) Error() string { return fmt.Sprintf("recreate pull point: %v", e.Err) } +func (e ErrRecreateFailed) Unwrap() error { return e.Err } +func (ErrRecreateFailed) Op() Op { return OpRecreate } diff --git a/event/stream/types_test.go b/event/stream/types_test.go index ed2399d..8b295bb 100644 --- a/event/stream/types_test.go +++ b/event/stream/types_test.go @@ -1,6 +1,7 @@ package stream import ( + "errors" "testing" "time" @@ -114,3 +115,29 @@ func TestEventFieldAssignmentRoundTrip(t *testing.T) { assert.True(t, e.Timestamp.Equal(now)) assert.True(t, e.DeviceTime.Equal(deviceTime)) } + +// --- Typed errors ----------------------------------------------------- + +func TestTypedErrors_UnwrapAndOp(t *testing.T) { + inner := errors.New("boom") + tests := []struct { + name string + err error + op Op + }{ + {"pull", ErrPullFailed{Err: inner}, OpPull}, + {"renew", ErrRenewFailed{Err: inner}, OpRenew}, + {"recreate", ErrRecreateFailed{Err: inner}, OpRecreate}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.True(t, errors.Is(tc.err, inner), "errors.Is should unwrap to inner") + assert.Contains(t, tc.err.Error(), "boom") + if e, ok := tc.err.(interface{ Op() Op }); ok { + assert.Equal(t, tc.op, e.Op()) + } else { + t.Fatalf("%T does not expose Op()", tc.err) + } + }) + } +} From badcc8fba22cb0a2485e3099ebc267707cf6413d Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 15:19:15 +0200 Subject: [PATCH 21/23] docs(development): point readers at event/stream higher-level helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Development.md describes the wire-layer convention (one directory per Onvif Web Service, gen_commands.py for new SOAP command types) but does not mention that some directories also ship hand-written higher-level helpers built on top of those types. A new contributor reading the doc could reasonably assume event/ is purely auto-generated and miss event/stream. Adds a 'Higher-level helpers' section that calls out: * event/stream — the new channel-based event consumer. * event/topic — the existing topic identifier helpers. Also documents the placement convention (sub-package under the relevant web service directory) so future helpers land in a predictable spot. --- docs/Development.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/Development.md b/docs/Development.md index 4d5cdaf..74420fc 100644 --- a/docs/Development.md +++ b/docs/Development.md @@ -32,3 +32,23 @@ python3 python/gen_commands.py > **Note:** You can also typically run the generator within your IDE thanks to the `//go:generate` lines > towards the top of the `types.go` files. + +## Higher-level helpers + +Some web service directories ship hand-written, higher-level helpers +built on top of the wire-layer commands. These are normal Go packages +— **not** covered by the `gen_commands.py` workflow above and not +expected to be regenerated. + +- [event/stream](../event/stream) — channel-based event consumer that + owns the pull-point subscription lifecycle (Create, Pull, Renew, + Unsubscribe, reconnect with jittered backoff) and decodes + notifications into normalized typed Events. Vendor topic strings + (AXIS, Hikvision, Avigilon, Hanwha, Bosch, Dahua) are classified + into a small set of `Kind` values. See the package `doc.go` for the + public surface and usage. +- [event/topic](../event/topic) — topic identifier helpers. + +When adding a similar higher-level helper, place it under the relevant +web service directory as a sub-package so consumers find it next to +the wire-layer types it builds on. From fcc3a90f9bf3237dda4c1602db752deb34e1c6b3 Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 18:43:08 +0200 Subject: [PATCH 22/23] docs(event/stream): trim comments to WHY, drop noise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit against the standard 'default to no comments; only add one when the WHY is non-obvious'. Net: 238 lines removed across 8 files, no behaviour change, tests still pass -race. What went --------- * Section banners (// ---------- Motion ----------): noise once per-rule citations exist. * Per-rule 'Data: IsMotion (xsd:boolean)' wire-format lines in topics.go: that's WHAT; the spec citation carries WHY. * Per-field doc on Event struct restating each field name (// Kind is the normalized event category) and the type-doc preamble. * Stringer doc comments ('// String implements fmt.Stringer.') and similar conventional-method noise. * 'Used by ErrPullFailed / ErrRenewFailed / ErrRecreateFailed' in the Op doc — the rule-named anti-pattern. * doc.go Invariants and Reconnect sections duplicating per-function docs. * Internal helper doc-comments restating what the function does (surfaceError, run, simpleItemsToMap first sentence, etc.). What stayed ----------- * Every spec / vendor-doc citation in topics.go. * Race-condition WHY in stream.go run() close ordering. * Workaround WHY in renew.go (absolute datetime vs duration). * WS-BaseNotification UTC rationale + vendor format list in decode.go. * Fleet-sizing and thundering-herd rationale in reconnect.go. * Stream consumer invariants (NewStream synchronous I/O, Errors non-blocking, Close idempotent + bounded). The change matches the codebase's stated style (CLAUDE.md): WHY only, no WHAT, no cross-file references, no current-task narration. --- event/stream/decode.go | 65 ++++++----------- event/stream/doc.go | 47 +++---------- event/stream/reconnect.go | 53 +++++--------- event/stream/renew.go | 26 +++---- event/stream/soap.go | 51 ++++++-------- event/stream/stream.go | 143 ++++++++++++++------------------------ event/stream/topics.go | 139 +++++++++++------------------------- event/stream/types.go | 112 +++++++++-------------------- 8 files changed, 199 insertions(+), 437 deletions(-) diff --git a/event/stream/decode.go b/event/stream/decode.go index ac3b88e..9a2a1fd 100644 --- a/event/stream/decode.go +++ b/event/stream/decode.go @@ -7,20 +7,9 @@ import ( "github.com/kerberos-io/onvif/event" ) -// decode converts a single ONVIF NotificationMessage into the package's -// normalized Event representation. Unexported because the only intended -// caller is the Stream; downstream consumers receive decoded Events on -// the Events channel. Tests reach decode directly because they're in -// the same package. -// -// deviceID is supplied by the caller because the message itself does not -// identify the originating camera. observedAt is recorded verbatim as -// Event.Timestamp; the camera-reported wsnt:UtcTime attribute (when -// present and parseable) populates Event.DeviceTime. -// -// When the Topic does not match any classifier rule the returned Event -// has Kind == KindUnknown but Source, Data and Topic are still populated -// so consumers can fall back to inspecting the wire form. +// decode converts a single ONVIF NotificationMessage into a normalized +// Event. Topic, Source and Data are always populated even when Kind is +// KindUnknown so consumers can fall back to the wire form. func decode(msg event.NotificationMessage, deviceID string, observedAt time.Time) Event { topic := string(msg.Topic.TopicKinds) desc := msg.Message.Message @@ -37,9 +26,8 @@ func decode(msg event.NotificationMessage, deviceID string, observedAt time.Time } } -// simpleItemsToMap collapses ONVIF SimpleItem lists to a Name->Value map. -// Returns nil for an empty list so empty notifications do not allocate -// and match the Event zero-value contract. +// simpleItemsToMap returns nil for an empty list so empty notifications +// do not allocate. func simpleItemsToMap(items []event.SimpleItem) map[string]string { if len(items) == 0 { return nil @@ -51,14 +39,9 @@ func simpleItemsToMap(items []event.SimpleItem) map[string]string { return m } -// extractState scans Data items for a boolean-like value and returns the -// first one as a State. Returns StateUnknown when no item parses — this -// is the correct outcome for edge-triggered topics such as +// extractState scans Data items for a boolean-like value, returning the +// first match. Returns StateUnknown for edge-triggered topics like // LineDetector/Crossed whose Data carries only an ObjectId. -// -// Iteration order over the original []SimpleItem is preserved so the -// behaviour stays deterministic per notification. (Map iteration is not -// involved; simpleItemsToMap is a separate path.) func extractState(items []event.SimpleItem) State { for _, it := range items { switch strings.ToLower(strings.TrimSpace(string(it.Value))) { @@ -71,9 +54,8 @@ func extractState(items []event.SimpleItem) State { return StateUnknown } -// parsePropertyOperation parses the wsnt:PropertyOperation attribute. -// The attribute is optional per WS-Notification; an empty or unrecognised -// value yields PropertyUnknown. +// parsePropertyOperation returns PropertyUnknown for absent (optional +// per WS-Notification) or unrecognised values. func parsePropertyOperation(s string) PropertyOperation { switch s { case "Initialized": @@ -87,15 +69,11 @@ func parsePropertyOperation(s string) PropertyOperation { } } -// parseDeviceTime parses the wsnt:UtcTime attribute, returning the zero -// time when the attribute is absent or unparseable. The result is -// normalised to UTC so equality comparisons across timezones work. -// -// xsd:dateTime in ONVIF messages is RFC 3339 in practice but real -// cameras emit several flavours: with/without sub-seconds, with colon -// or compact ("+0200") timezone offsets, and some older Hikvision -// firmwares omit the timezone entirely (treated as UTC per -// WS-BaseNotification which mandates UTC for UtcTime). +// parseDeviceTime parses wsnt:UtcTime, returning the zero time when +// absent or unparseable. Real cameras emit several flavours: with / +// without sub-seconds, colon or compact ("+0200") offsets, and some +// older Hikvision firmwares omit the timezone entirely (treated as +// UTC per WS-BaseNotification which mandates UTC for UtcTime). func parseDeviceTime(s string) time.Time { if s == "" { return time.Time{} @@ -108,14 +86,11 @@ func parseDeviceTime(s string) time.Time { return time.Time{} } -// deviceTimeLayouts lists the wsnt:UtcTime forms observed across vendor -// firmwares. Ordered from most-precise / most-common first so the -// happy path hits early. var deviceTimeLayouts = []string{ - time.RFC3339Nano, // 2026-05-21T10:30:00.500Z, ...+02:00 - time.RFC3339, // 2026-05-21T10:30:00Z, ...+02:00 - "2006-01-02T15:04:05.999-0700", // sub-second + compact offset (Geovision) - "2006-01-02T15:04:05-0700", // compact offset (some Dahua) - "2006-01-02T15:04:05.999", // no timezone, sub-second (rare) - "2006-01-02T15:04:05", // naked, no TZ (older Hikvision) + time.RFC3339Nano, + time.RFC3339, + "2006-01-02T15:04:05.999-0700", // Geovision + "2006-01-02T15:04:05-0700", // some Dahua + "2006-01-02T15:04:05.999", + "2006-01-02T15:04:05", // older Hikvision (no timezone) } diff --git a/event/stream/doc.go b/event/stream/doc.go index 8121525..ebd05aa 100644 --- a/event/stream/doc.go +++ b/event/stream/doc.go @@ -7,7 +7,7 @@ // // dev, _ := onvif.NewDevice(onvif.DeviceParams{Xaddr: "...", Username: "...", Password: "..."}) // s, err := stream.NewStream(ctx, dev, stream.Options{DeviceID: "front-door"}) -// if err != nil { /* construction failed: auth, network, or camera does not advertise events */ } +// if err != nil { /* construction failed: auth, network, or no event support */ } // defer s.Close() // // for ev := range s.Events() { @@ -17,43 +17,12 @@ // } // } // -// # Invariants +// NewStream performs network I/O so auth and reachability failures +// surface synchronously. Events and Errors close when the Stream stops; +// Errors sends are non-blocking so a stalled consumer drops older +// errors rather than blocking the pull loop. After a silent reconnect, +// the next batch's events carry Event.AfterReconnect=true. // -// NewStream performs network I/O. It returns once the -// CreatePullPointSubscription call has succeeded; auth and reachability -// failures surface as an error from NewStream rather than landing on -// the Errors channel later. -// -// Two goroutines back each Stream: a pull loop and a renew loop. Both -// exit when the context passed to NewStream is cancelled or when Close -// is called. Close is idempotent and bounded — see Stream.Close. -// -// Events is closed exactly when the Stream stops. Ranging over Events -// is safe; a closed channel terminates the loop without a Close call. -// Errors is also closed at stop time. Both channels are buffered (16 -// slots by default); sends to Errors are non-blocking so a stalled -// consumer drops older errors rather than the pull loop blocking on -// log output. -// -// The decoded Event preserves the wire form (Topic, raw Source and -// Data maps) so callers can fall back to inspecting non-standard -// payloads when Kind is KindUnknown. -// -// # Reconnect -// -// On ReconnectAfterFailures consecutive PullMessages failures the -// Stream silently recreates its pull-point subscription. ONVIF cameras -// replay each property's current value with PropertyInitialized on a -// new subscription; Events delivered between recreate and the first -// non-Initialized event carry Event.AfterReconnect=true so consumers -// can suppress duplicate handling. -// -// Set Options.DisableReconnect=true to opt out of recreate; the pull -// loop will retry against the original subscription until ctx cancel. -// -// # Topic classification -// -// Classify maps ONVIF topic strings to a small set of normalized Kind -// values across AXIS, Hikvision, Avigilon, Hanwha, Bosch and Dahua. See -// topics.go for the verified mapping table with public-doc citations. +// See topics.go for the verified topic→Kind mapping across AXIS, +// Hikvision, Avigilon, Hanwha, Bosch and Dahua. package stream diff --git a/event/stream/reconnect.go b/event/stream/reconnect.go index 52cb047..76bd791 100644 --- a/event/stream/reconnect.go +++ b/event/stream/reconnect.go @@ -6,31 +6,21 @@ import ( "time" ) -// maxRecreateBackoff caps exponential backoff between recreate attempts. -// Sized for fleet deployments: a 1000-camera setup recovering from a -// switch reboot would otherwise hammer the network with one recreate -// attempt per camera per 30s; 5 minutes gives the network time to -// settle while still recovering promptly when a single camera comes -// back. +// maxRecreateBackoff caps exponential backoff between recreate +// attempts. Sized for fleet deployments: at 30s a 1000-camera setup +// recovering from a switch reboot would generate sustained +// reconnect traffic; 5 minutes lets the network settle. const maxRecreateBackoff = 5 * time.Minute -// jitterFraction is the symmetric jitter applied to recreate backoff: -// the actual sleep is sampled from [backoff*(1-jitter), backoff*(1+jitter)]. -// Prevents thundering-herd reconnects when many cameras drop together -// (switch reboot, NAT timeout). +// jitterFraction prevents thundering-herd reconnects when many +// cameras drop together (switch reboot, NAT timeout). const jitterFraction = 0.25 -// pullLoop is the main pull goroutine of a Stream. It calls -// PullMessages in a tight loop, decodes results into Events and feeds -// the Events channel. -// -// After ReconnectAfterFailures consecutive pull errors it asks -// attemptRecreate to recreate the pull-point subscription, marking the -// next batch's events with AfterReconnect so consumers can suppress -// duplicate handling of the ONVIF Initialized-replay that follows a -// new subscription. -// -// Exits when ctx is cancelled. +// pullLoop runs PullMessages → decode → Events. After +// ReconnectAfterFailures consecutive errors it asks attemptRecreate +// to rebuild the subscription. The next batch's events carry +// AfterReconnect=true so consumers can suppress the Initialized +// replay ONVIF emits on a new subscription. func (s *Stream) pullLoop(ctx context.Context) { var failures int recreateBackoff := s.opts.RetryBackoff @@ -59,7 +49,6 @@ func (s *Stream) pullLoop(ctx context.Context) { } continue } - // Successful pull resets failure tracking. failures = 0 recreateBackoff = s.opts.RetryBackoff observedAt := s.now() @@ -67,11 +56,8 @@ func (s *Stream) pullLoop(ctx context.Context) { ev := decode(m, s.opts.DeviceID, observedAt) if afterReconnect { ev.AfterReconnect = true - // ONVIF replays current state with - // PropertyInitialized on a new subscription. - // Clear the flag as soon as we see anything - // other than Initialized — at that point we - // have transitioned to live events. + // Clear once the camera transitions past the + // Initialized replay to live events. if ev.Operation != PropertyInitialized { afterReconnect = false } @@ -85,11 +71,8 @@ func (s *Stream) pullLoop(ctx context.Context) { } } -// attemptRecreate calls CreatePullPointSubscription and on success -// installs the new endpoint atomically. The first return is true when -// recreate succeeded just now (caller flags the next batch with -// AfterReconnect). The second return is false only if ctx was cancelled -// during backoff (caller should exit the run loop). +// attemptRecreate returns (justRecreated, cont). cont is false only +// when ctx cancelled during backoff so the caller exits the loop. func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) (justRecreated, cont bool) { addr, err := createPullPoint(s.caller, s.opts) if err != nil { @@ -109,10 +92,8 @@ func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *ti return true, true } -// jitter returns d perturbed by ±jitterFraction. Used to spread -// recreate attempts across a fleet so a synchronised drop (switch -// reboot, DHCP storm) does not cause a synchronised reconnect surge. -// Returns at least 1ns to keep sleepCtx happy. +// jitter perturbs d by ±jitterFraction so synchronised drops do not +// produce a synchronised reconnect surge. func jitter(d time.Duration) time.Duration { if d <= 0 { return time.Nanosecond diff --git a/event/stream/renew.go b/event/stream/renew.go index 6a8763a..cd3f199 100644 --- a/event/stream/renew.go +++ b/event/stream/renew.go @@ -10,18 +10,15 @@ import ( "github.com/kerberos-io/onvif/xsd" ) -// renewLoop refreshes the subscription before InitialTermination expires. -// Exits when ctx is cancelled. Renew failures surface as ErrRenewFailed -// on the Errors channel; the loop continues because a permanently -// failing renew will eventually drop the subscription and the pull -// loop's reconnect path will recover (recreate is the only reliable -// recovery once a subscription is GC'd at the camera). +// renewLoop surfaces renew failures and continues. A permanently +// failing renew lets the subscription die at the camera; the pull +// loop's reconnect path then recreates it — recreate is the only +// reliable recovery once a subscription is GC'd. func (s *Stream) renewLoop(ctx context.Context) { interval := s.opts.InitialTermination - s.opts.RenewMargin if interval <= 0 { - // Pathological config (margin >= termination): fall back to - // renewing at half the termination so we still refresh, - // rather than busy-looping or never renewing. + // Pathological config (margin >= termination): renew at + // half termination so we still refresh. interval = s.opts.InitialTermination / 2 if interval <= 0 { interval = time.Second @@ -41,13 +38,10 @@ func (s *Stream) renewLoop(ctx context.Context) { } } -// renewPullPoint issues a wsnt:Renew SOAP against the given -// subscription endpoint with an absolute TerminationTime. -// -// WS-BaseNotification §6.1.1 declares TerminationTime as xsd:dateTime -// OR xsd:duration, but older Hikvision, some Dahua and some Bosch -// firmwares reject the relative-duration form. We send an absolute -// UTC datetime to match what production NVRs do. +// renewPullPoint sends Renew with an absolute UTC TerminationTime. +// WS-BaseNotification §6.1.1 also allows xsd:duration but older +// Hikvision, some Dahua and some Bosch firmwares reject the +// relative form. func renewPullPoint(c caller, endpoint string, opts Options) error { absoluteEnd := time.Now().UTC().Add(opts.InitialTermination).Format("2006-01-02T15:04:05Z") req := event.Renew{TerminationTime: xsd.String(absoluteEnd)} diff --git a/event/stream/soap.go b/event/stream/soap.go index 4d9bcf6..879b22c 100644 --- a/event/stream/soap.go +++ b/event/stream/soap.go @@ -16,16 +16,12 @@ import ( "github.com/kerberos-io/onvif/xsd" ) -// maxResponseBytes caps the size of a SOAP response we will buffer in -// memory. ONVIF PullMessages bodies are normally <100KB even with dense -// analytics payloads; 10 MiB is comfortably above legitimate traffic -// while keeping a hostile or buggy camera from OOMing the process. +// maxResponseBytes caps SOAP response buffering. ONVIF PullMessages +// bodies are normally <100KB even with dense analytics payloads; +// 10 MiB is comfortably above legitimate traffic while keeping a +// hostile or buggy camera from OOMing the process. const maxResponseBytes = 10 << 20 -// createPullPoint issues a CreatePullPointSubscription against the -// device service. Returns the SubscriptionReference Address, which is -// the endpoint subsequent PullMessages / Renew / Unsubscribe calls -// target. func createPullPoint(c caller, opts Options) (string, error) { term := xsd.String(durationToXSD(opts.InitialTermination)) req := event.CreatePullPointSubscription{InitialTerminationTime: &term} @@ -56,9 +52,8 @@ func createPullPoint(c caller, opts Options) (string, error) { return addr, nil } -// pullMessages issues PullMessages against an active subscription -// endpoint and returns the decoded NotificationMessage list. Empty -// slice (not error) when the camera had nothing within PullTimeout. +// pullMessages returns an empty slice (no error) when the camera had +// nothing within PullTimeout. func pullMessages(c caller, endpoint string, opts Options) ([]event.NotificationMessage, error) { req := event.PullMessages{ Timeout: xsd.Duration(durationToXSD(opts.PullTimeout)), @@ -83,9 +78,8 @@ func pullMessages(c caller, endpoint string, opts Options) ([]event.Notification return decoded.NotificationMessage, nil } -// unsubscribePullPoint sends a best-effort Unsubscribe to release the -// subscription server-side. Empty endpoint is a no-op (the construction -// failed before installing one). +// unsubscribePullPoint is best-effort. Empty endpoint is a no-op +// (construction failed before installing one). func unsubscribePullPoint(c caller, endpoint string) error { if endpoint == "" { return nil @@ -102,9 +96,6 @@ func unsubscribePullPoint(c caller, endpoint string) error { return err } -// readClose reads at most maxResponseBytes from resp.Body and closes -// it. LimitReader prevents a hostile or buggy camera from OOMing the -// agent by streaming an unbounded response. func readClose(resp *http.Response) (string, error) { if resp == nil || resp.Body == nil { return "", errors.New("nil HTTP response") @@ -118,14 +109,13 @@ func readClose(resp *http.Response) (string, error) { } // unmarshalNode finds the first XML start element with the given local -// name and decodes it into out. ONVIF SOAP responses come wrapped in an -// envelope with multiple namespace prefixes; this helper sidesteps -// namespace matching by keying on local name only. +// name and decodes it into out. ONVIF SOAP responses are wrapped in an +// envelope with many namespace prefixes; keying on local name only +// sidesteps namespace matching. // -// When the camera returns a SOAP Fault instead of the expected -// response, the fault reason is surfaced as the error so callers can -// distinguish "auth failed" / "subscription expired" from "unparseable -// response". +// When the camera returns a SOAP Fault, the fault reason is returned +// as the error so callers can distinguish auth / expired-subscription +// from "unparseable response". func unmarshalNode(body, localName string, out any) error { if reason := extractSOAPFault(body); reason != "" { return fmt.Errorf("ONVIF SOAP fault: %s", reason) @@ -160,9 +150,9 @@ var ( soap12FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Reason\b[^>]*>.*?<(?:[^:>\s]+:)?Text[^>]*>(.*?)\s]+:)?Text>`) ) -// extractSOAPFault returns the human-readable reason text from a SOAP -// fault, or empty string when the body is not a fault. Handles both -// SOAP 1.1 (faultstring) and SOAP 1.2 (Reason/Text) shapes. +// extractSOAPFault returns the reason text from a SOAP fault or empty +// when the body is not a fault. Handles SOAP 1.1 (faultstring) and +// SOAP 1.2 (Reason/Text) shapes. func extractSOAPFault(body string) string { if !strings.Contains(body, "Fault") { return "" @@ -176,10 +166,9 @@ func extractSOAPFault(body string) string { return "" } -// durationToXSD formats a Go time.Duration as an xsd:duration string in -// PTnS form. Second precision is sufficient — ONVIF cameras do not -// honour sub-second pull timeouts and intermediate routers may round in -// any case. +// durationToXSD formats a duration as xsd:duration PTnS. Second +// precision is sufficient — ONVIF cameras do not honour sub-second +// pull timeouts. func durationToXSD(d time.Duration) string { secs := int(d.Round(time.Second).Seconds()) if secs <= 0 { diff --git a/event/stream/stream.go b/event/stream/stream.go index 4f11415..e548d3c 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -10,70 +10,51 @@ import ( "github.com/kerberos-io/onvif" ) -// closeUnsubscribeTimeout bounds the SOAP Unsubscribe call issued by -// Close so a hung camera connection cannot wedge the caller. The -// subscription expires at the camera anyway once InitialTermination -// elapses, so a missed unsubscribe is at worst cosmetic. +// closeUnsubscribeTimeout bounds the Unsubscribe SOAP call issued by +// Close. A subscription expires at the camera once InitialTermination +// elapses without a renew, so a missed unsubscribe is at worst +// cosmetic. const closeUnsubscribeTimeout = 5 * time.Second // Options configures a Stream. // -// Zero-value policy: every duration / int field treats zero as "use the -// default". To opt out of reconnect entirely set DisableReconnect=true -// (sentinel `ReconnectAfterFailures=0` would otherwise collide with the -// default-injection policy). To get a synchronous (unbuffered) channel -// pair set BufferSize=-1. +// Zero-value policy: every duration / int field treats zero as "use +// the default". To opt out of reconnect set DisableReconnect=true +// (ReconnectAfterFailures=0 would otherwise collide with the default +// injection). For unbuffered Events / Errors channels set +// BufferSize=-1. type Options struct { - // DeviceID identifies the camera in emitted Events. Recommended so - // a single channel can fan in multiple cameras. Empty is allowed. DeviceID string - // RawTopicFilter is the raw ONVIF ConcreteSet TopicExpression - // filter passed to CreatePullPointSubscription. Empty means no - // filter — required for AXIS, accepted by every other vendor we - // support. The name carries 'Raw' because the value is fed verbatim - // into the SOAP envelope: callers should normally leave it empty - // and rely on Classify for routing rather than ask the camera to - // filter server-side, which is fragile across vendors. + // RawTopicFilter is the ONVIF ConcreteSet TopicExpression filter + // passed verbatim to CreatePullPointSubscription. Callers should + // normally leave this empty and rely on Classify for routing — + // server-side filtering is fragile across vendors and empty is + // required for AXIS. RawTopicFilter string - // PullTimeout is the server-side wait time in each PullMessages - // call (xsd:duration). The camera returns early when messages are - // available; otherwise it returns empty after this timeout. Zero - // means default (5s). + // PullTimeout — zero means default (5s). PullTimeout time.Duration - // MessageLimit caps the number of NotificationMessage entries - // returned per PullMessages call. Zero means default (32). A busy - // AXIS with many configured inputs can burst beyond 10 per pull; - // 32 covers that without significantly enlarging quiet pulls. + // MessageLimit — zero means default (32). Busy AXIS cameras with + // many configured rules can burst beyond 10 per pull. MessageLimit int - // InitialTermination is the requested subscription lifetime passed - // to CreatePullPointSubscription. The renew loop refreshes well - // before this expires. Zero means default (60s). + // InitialTermination — zero means default (60s). InitialTermination time.Duration - // RenewMargin is how long before InitialTermination expiry the - // renew loop fires. Larger margins tolerate slower networks at the - // cost of more renew SOAP calls. Zero means default (10s). + // RenewMargin — larger margins tolerate slower networks at the + // cost of more renew calls. Zero means default (10s). RenewMargin time.Duration - // ReconnectAfterFailures is the consecutive PullMessages failure - // count that triggers a CreatePullPointSubscription recreate. The - // camera or pull-point can die for many reasons (camera reboot, - // subscription garbage-collected after a renew miss, intermediate - // NAT timeout); rebuilding the subscription is the only reliable - // recovery. Zero means default (3). To disable reconnect entirely - // set DisableReconnect=true. + // ReconnectAfterFailures — pull-points die for many reasons + // (camera reboot, subscription GC after a renew miss, NAT + // timeout); rebuilding the subscription is the only reliable + // recovery. Zero means default (3). Set DisableReconnect=true + // to disable. ReconnectAfterFailures int - // DisableReconnect skips automatic CreatePullPointSubscription - // recreate. The pull loop will continue retrying against the - // original endpoint until ctx is cancelled. Useful for tests or - // callers managing recovery externally. + // DisableReconnect makes the pull loop retry against the + // original endpoint until ctx is cancelled. DisableReconnect bool - // RetryBackoff is the initial sleep between a pull/recreate failure - // and the next attempt. Recreate failures double this up to a 30s - // ceiling. Zero means default (1s). + // RetryBackoff is the base sleep between pull/recreate failures. + // Recreate failures double this up to maxRecreateBackoff. Zero + // means default (1s). RetryBackoff time.Duration - // BufferSize is the buffer size of the Events and Errors channels. - // Larger buffers absorb consumer hiccups at the cost of memory. - // Zero means default (16); use -1 for unbuffered (synchronous) - // channels. + // BufferSize — zero means default (16); use -1 for unbuffered. BufferSize int } @@ -109,7 +90,6 @@ func (o Options) withDefaults() Options { if o.RetryBackoff > 0 { d.RetryBackoff = o.RetryBackoff } - // BufferSize: zero -> default; negative -> 0 (unbuffered). switch { case o.BufferSize > 0: d.BufferSize = o.BufferSize @@ -122,13 +102,9 @@ func (o Options) withDefaults() Options { return d } -// caller is the subset of *onvif.Device the Stream depends on. Tests -// substitute a fake; production code uses the device adapter. -// -// Implementations must be safe for concurrent use: the pull loop and -// renew loop call into caller from separate goroutines. *onvif.Device -// satisfies this because its HTTP client is the goroutine-safe -// http.Client. +// caller is the *onvif.Device subset Stream depends on. Implementations +// must be safe for concurrent use — pull and renew goroutines call in +// from separate goroutines. *onvif.Device satisfies this via http.Client. type caller interface { CallMethod(method any) (*http.Response, error) SendSoap(endpoint, body string) (*http.Response, error) @@ -144,12 +120,9 @@ func (d deviceCaller) SendSoap(endpoint, body string) (*http.Response, error) { return d.dev.SendSoap(endpoint, body) } -// Stream owns a single ONVIF pull-point subscription and surfaces the -// decoded notifications on a typed channel. Close stops the background -// goroutine and unsubscribes from the camera. -// -// A Stream is safe for concurrent use by Close from any goroutine while -// readers consume Events / Errors; Close is idempotent. +// Stream owns a single ONVIF pull-point subscription. Safe for Close +// from any goroutine while readers consume Events / Errors. Close is +// idempotent. type Stream struct { caller caller opts Options @@ -166,7 +139,7 @@ type Stream struct { closeOnce sync.Once closeErr error - // now is overridable in tests to make timestamps deterministic. + // now is overridable so tests can make timestamps deterministic. now func() time.Time } @@ -182,14 +155,11 @@ func (s *Stream) setPullPoint(addr string) { s.pullPoint = addr } -// NewStream creates a Stream against an ONVIF device. It performs the -// CreatePullPointSubscription call synchronously so connectivity and -// authentication problems surface immediately as an error rather than -// landing on the Errors channel later. The background pull loop starts -// before NewStream returns. +// NewStream creates a Stream and performs CreatePullPointSubscription +// synchronously so connectivity and authentication failures surface +// from NewStream rather than landing on Errors later. // -// The returned Stream stops when ctx is cancelled or when Close is -// called. +// The returned Stream stops when ctx is cancelled or Close is called. func NewStream(ctx context.Context, dev *onvif.Device, opts Options) (*Stream, error) { return newStream(ctx, deviceCaller{dev: dev}, opts) } @@ -215,22 +185,20 @@ func newStream(ctx context.Context, c caller, opts Options) (*Stream, error) { return s, nil } -// Events returns the channel of decoded notifications. The channel is -// closed when the Stream stops. +// Events returns the channel of decoded notifications. Closed when +// the Stream stops. func (s *Stream) Events() <-chan Event { return s.events } -// Errors returns the channel of non-fatal errors encountered while -// pulling. Sends are non-blocking, so consumers that fall behind drop -// older errors. The channel is closed when the Stream stops. +// Errors returns the channel of non-fatal errors. Sends are +// non-blocking; consumers that fall behind drop older errors. Closed +// when the Stream stops. func (s *Stream) Errors() <-chan error { return s.errors } -// Close stops the background goroutine, waits for it to exit, and -// unsubscribes from the camera. Subsequent calls are no-ops. +// Close stops the background goroutines, waits for them to exit and +// Unsubscribes from the camera. Subsequent calls are no-ops. // // Unsubscribe is bounded by closeUnsubscribeTimeout so a hung camera -// connection cannot wedge the caller. On timeout Close still returns -// promptly; the subscription will expire at the camera once -// InitialTermination + RenewMargin elapses without a renew. +// connection cannot wedge the caller. func (s *Stream) Close() error { s.closeOnce.Do(func() { s.cancel() @@ -252,8 +220,6 @@ func (s *Stream) Close() error { return s.closeErr } -// run orchestrates the pull and renew goroutines and closes the -// emission channels once both have exited. func (s *Stream) run(ctx context.Context) { var wg sync.WaitGroup wg.Add(1) @@ -265,15 +231,13 @@ func (s *Stream) run(ctx context.Context) { wg.Wait() // Explicit close order after both goroutines have exited so a - // future maintainer extending this function does not accidentally - // rely on defer-ordering for channel-close safety. + // future maintainer extending this function does not rely on + // defer-ordering for channel-close safety. close(s.errors) close(s.events) close(s.done) } -// surfaceError sends err on the errors channel non-blockingly so a -// stalled consumer cannot block the pull or renew loop. func (s *Stream) surfaceError(err error) { select { case s.errors <- err: @@ -281,8 +245,7 @@ func (s *Stream) surfaceError(err error) { } } -// sleepCtx blocks for d or until ctx is cancelled. Returns true if d -// elapsed, false if ctx was cancelled. +// sleepCtx returns false if ctx was cancelled, true if d elapsed. func sleepCtx(ctx context.Context, d time.Duration) bool { t := time.NewTimer(d) defer t.Stop() diff --git a/event/stream/topics.go b/event/stream/topics.go index 469027b..9071dad 100644 --- a/event/stream/topics.go +++ b/event/stream/topics.go @@ -2,24 +2,23 @@ package stream import "strings" -// Classify maps an ONVIF topic string (e.g. "tns1:VideoSource/MotionAlarm") -// to the normalized Kind that callers should switch on. Returns +// Classify maps an ONVIF topic string to the normalized Kind. Returns // KindUnknown when no rule matches. // -// The classifier strips XML-namespace prefixes (tns1:, tnsaxis:, -// tnssamsung:, ...) from each "/"-separated segment of the topic so it is -// robust to vendor namespace variants. Matching is case-sensitive because -// ONVIF topic identifiers are case-sensitive per the spec. +// The classifier strips XML-namespace prefixes from each "/"-separated +// segment so it is robust to vendor namespaces (tns1:, tnsaxis:, +// tnssamsung:, ...). Matching is case-sensitive — ONVIF topics are +// case-sensitive per the spec. // // Sources cross-checked when building the rule set below: // - ONVIF Topic Namespace XML // https://www.onvif.org/onvif/ver10/topics/topicns.xml -// - ONVIF Analytics Service Spec (RuleEngine topics) +// - ONVIF Analytics Service Spec // https://www.onvif.org/specs/srv/analytics/ONVIF-VideoAnalytics-Service-Spec-v220.pdf -// - ONVIF Device IO Service Spec (DigitalInput, Relay) +// - ONVIF Device IO Service Spec // https://www.onvif.org/specs/srv/io/ONVIF-DeviceIo-Service-Spec.pdf // - openvideolibs/onvif-parsers (Apache-2.0) — empirical topic table -// extracted from Home Assistant ONVIF integration +// extracted from Home Assistant // https://github.com/openvideolibs/onvif-parsers func Classify(topic string) Kind { if topic == "" { @@ -34,15 +33,10 @@ func Classify(topic string) Kind { return KindUnknown } -// canonicalizeTopic strips the XML-namespace prefix (anything up to and -// including the first ':') from each "/"-separated segment. This collapses -// vendor variants like "tns1:Device/tns1:Trigger/tns1:Relay" (Avigilon -// serialisation) and "tns1:Device/Trigger/Relay" (everyone else) to a -// single matchable form. -// -// A segment that is only a prefix (e.g. "tns1:") canonicalizes to the -// empty string. Multiple colons in one segment are not expected in real -// ONVIF topics; the first colon wins. +// canonicalizeTopic strips the XML-namespace prefix from each +// "/"-separated segment, collapsing Avigilon's per-segment-prefixed +// form ("tns1:Device/tns1:Trigger/tns1:Relay") and the plain form +// ("tns1:Device/Trigger/Relay") to the same matchable path. func canonicalizeTopic(topic string) string { segments := strings.Split(topic, "/") for i, seg := range segments { @@ -53,133 +47,88 @@ func canonicalizeTopic(topic string) string { return strings.Join(segments, "/") } -// topicRules is evaluated in order; first match wins. Keep more specific -// rules ahead of broader ones — e.g. "ObjectAnalytics/" must precede any -// future bare "Analytics" rule, and "MyRuleDetector/HumanDetect" must -// precede a hypothetical broader "MyRuleDetector" entry. Each rule cites -// the documentation that supports including it. -// -// Substring matching is intentional so vendor-specific path prefixes -// outside the standard tns1: namespace (e.g. -// tnsaxis:CameraApplicationPlatform/...) still match. -// -// Note on edge-triggered topics: tns1:RuleEngine/LineDetector/Crossed -// carries an ObjectId rather than a State boolean. Consumers of Crossed -// must not expect a level-triggered Active/Inactive semantic — the Stream -// decoder will leave State as StateUnknown for these. +// topicRules is evaluated in order — first match wins. Keep more +// specific rules ahead of broader ones. LineDetector/Crossed is +// edge-triggered (no boolean State); the decoder leaves State as +// StateUnknown for it. var topicRules = []struct { needle string kind Kind }{ - // ---------- Motion ------------------------------------------------- - - // tns1:VideoSource/MotionAlarm — Profile S basic motion. Emitted by - // AXIS (basic VMD), Bosch, Dahua, Hikvision (newer firmware) and - // Hanwha as a fallback. Data SimpleItem: State (xsd:boolean). + // tns1:VideoSource/MotionAlarm — Profile S basic motion. // https://www.onvif.org/ver10/topics/topicns.xml // https://developer.axis.com/vapix/network-video/event-and-action-services/ {"VideoSource/MotionAlarm", KindMotion}, // tns1:VideoAnalytics/MotionAlarm — Bosch publishes motion under - // VideoAnalytics rather than VideoSource. Data: State. + // VideoAnalytics rather than VideoSource. // https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf {"VideoAnalytics/MotionAlarm", KindMotion}, - // tns1:VideoAnalytics/tnssamsung:MotionDetection — Hanwha/Samsung - // Wisenet vendor-namespaced motion. Data: Motion ("0"/"1"). + // tns1:VideoAnalytics/tnssamsung:MotionDetection — Hanwha vendor. // https://github.com/home-assistant/core/issues/66493 {"VideoAnalytics/MotionDetection", KindMotion}, // tns1:RuleEngine/CellMotionDetector/Motion — ONVIF Analytics - // standard cell-motion rule. Emitted by AXIS (VMD3+), Hikvision, - // Avigilon analytics, others. Data: IsMotion (xsd:boolean). + // standard cell-motion rule. // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.3 // https://www.hikvisioneurope.com/eu/portal/portal/Technical%20Materials/24%20How%20To/CCTV/How%20to%20solve%20third%20party%20camera%20motion%20detection%20issue.pdf {"CellMotionDetector/Motion", KindMotion}, - // tns1:RuleEngine/MotionRegionDetector/Motion — AXIS-specific region - // motion rule. Data: IsMotion (xsd:boolean). + // tns1:RuleEngine/MotionRegionDetector/Motion — AXIS region rule. // https://developer.axis.com/vapix/network-video/event-and-action-services/ {"MotionRegionDetector/Motion", KindMotion}, - // AXIS Guard suite — vendor analytics apps that fire motion-like - // events with CameraProfile suffixes. Treated as motion so - // they can drive motion-triggered recording on cameras configured - // with these apps instead of basic VMD. + // AXIS Guard suite — vendor analytics apps with CameraProfile + // suffixes. Treated as motion so they can drive motion-triggered + // recording on cameras using these apps instead of basic VMD. // https://developer.axis.com/vapix/applications/motion-guard {"CameraApplicationPlatform/MotionGuard/", KindMotion}, {"CameraApplicationPlatform/FenceGuard/", KindMotion}, {"CameraApplicationPlatform/LoiteringGuard/", KindMotion}, - // ---------- Tampering --------------------------------------------- - - // tns1:RuleEngine/TamperDetector/Tamper — standard ONVIF tamper - // rule. Data: IsTamper (xsd:boolean). Anchored on the rule-name - // segment so "TamperDetectorLog" (hypothetical) does not match. + // tns1:RuleEngine/TamperDetector/Tamper — standard ONVIF tamper rule. // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.5 {"TamperDetector/Tamper", KindTampering}, - // tns1:VideoSource/GlobalSceneChange/ImagingService — Hikvision (and - // others) emit this on real lens-cover / scene substitution. This is - // the proper tamper signal on firmwares without TamperDetector. + // tns1:VideoSource/GlobalSceneChange/ImagingService — the proper + // lens-cover signal on firmwares without TamperDetector. // https://www.onvif.org/ver10/topics/topicns.xml {"GlobalSceneChange", KindTampering}, - // tns1:VideoAnalytics/tnssamsung:TamperingDetection — Hanwha vendor. + // tns1:VideoAnalytics/tnssamsung:TamperingDetection — Hanwha. // https://github.com/home-assistant/core/issues/66493 {"VideoAnalytics/TamperingDetection", KindTampering}, - // ---------- Image quality ----------------------------------------- - - // tns1:VideoSource/ImageTooDark|ImageTooBright|ImageTooBlurry — - // imaging-quality alarms. Integrators (Milestone, Genetec, Frigate) - // route these separately from tamper because they fire on legitimate - // sunset/dawn/condensation transitions, not on actual interference. + // VideoSource/ImageToo* — imaging-quality alarms. See KindImageQuality + // for the rationale on splitting these out from KindTampering. // https://www.onvif.org/ver10/topics/topicns.xml {"VideoSource/ImageTooDark", KindImageQuality}, {"VideoSource/ImageTooBright", KindImageQuality}, {"VideoSource/ImageTooBlurry", KindImageQuality}, - // ---------- Digital I/O ------------------------------------------- - - // tns1:Device/Trigger/DigitalInput — standard ONVIF DeviceIO topic. - // Avigilon emits the per-segment-prefixed variant - // "tns1:Device/tns1:Trigger/tns1:DigitalInput"; canonicalization - // folds both to the same path. Data: LogicalState (xsd:boolean), - // Source: InputToken. + // tns1:Device/Trigger/DigitalInput — standard. Avigilon's per-segment- + // prefixed serialisation ("tns1:Device/tns1:Trigger/tns1:DigitalInput") + // folds to the same canonical path. // ONVIF-DeviceIo-Service-Spec.pdf §5.2 {"Trigger/DigitalInput", KindDigitalInput}, - - // tns1:Device/Trigger/Relay — standard ONVIF DeviceIO topic. Same - // canonicalisation note as DigitalInput. Data: LogicalState, - // Source: RelayToken. // ONVIF-DeviceIo-Service-Spec.pdf §5.3 {"Trigger/Relay", KindDigitalOutput}, - // ---------- Object analytics -------------------------------------- - // tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario - // — AXIS Object Analytics. Scenario suffixes are numeric per the - // AOA configuration (Device1Scenario1, Device1Scenario2, ...). Data: - // active ("0"/"1") plus classType / confidence when configured. + // — Scenario suffixes are numeric per AOA configuration. Prefix-match + // because of the dynamic suffix. // https://developer.axis.com/analytics/axis-object-analytics/how-to-guides/axis-object-analytics-counting-data/ {"ObjectAnalytics/", KindObjectDetected}, - // tns1:RuleEngine/LineDetector/Crossed — line crossing (Hikvision, - // Bosch IVA, others). Data: ObjectId (xsd:int); edge-triggered, no - // State boolean. - // https://www.onvif.org/specs/srv/analytics/ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4 + // ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4 {"LineDetector/Crossed", KindObjectDetected}, - - // tns1:RuleEngine/FieldDetector/ObjectsInside — intrusion / region - // detector (Hikvision, Bosch, Dahua). Data: IsInside (xsd:boolean). {"FieldDetector/ObjectsInside", KindObjectDetected}, - // tns1:RuleEngine/MyRuleDetector/ — vendor-defined rule - // names under the ONVIF MyRuleDetector container. We whitelist - // object-class rules emitted by Bosch IVA, Dahua SMD and Hikvision - // AcuSense so non-object rules under the same container (Bosch - // Counter, Occupancy) do not get mis-classified. + // tns1:RuleEngine/MyRuleDetector/ — vendor rules under the + // ONVIF MyRuleDetector container. Explicitly whitelisted because the + // same container also carries non-object rules (Bosch Counter, + // Occupancy) that must not classify as ObjectDetected. // https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf {"MyRuleDetector/HumanDetect", KindObjectDetected}, {"MyRuleDetector/VehicleDetect", KindObjectDetected}, @@ -187,16 +136,8 @@ var topicRules = []struct { {"MyRuleDetector/ObjectsInside", KindObjectDetected}, {"MyRuleDetector/FaceDetect", KindObjectDetected}, - // ---------- Audio -------------------------------------------------- - - // tns1:AudioAnalytics/Audio/DetectedSound — standard ONVIF audio - // detection. Data: State (xsd:boolean). {"Audio/DetectedSound", KindAudioAlarm}, - - // tns1:AudioSource/tnsaxis:TriggerLevel — AXIS audio level alarm. // https://developer.axis.com/vapix/network-video/event-and-action-services/ {"AudioSource/TriggerLevel", KindAudioAlarm}, - - // tns1:AudioAnalytics/tnssamsung:SoundDetection — Hanwha vendor. {"AudioAnalytics/SoundDetection", KindAudioAlarm}, } diff --git a/event/stream/types.go b/event/stream/types.go index a52f314..6e84419 100644 --- a/event/stream/types.go +++ b/event/stream/types.go @@ -10,32 +10,19 @@ import ( type Kind uint8 const ( - // KindUnknown is the zero value; used when a topic does not match any - // known classification. KindUnknown Kind = iota - // KindMotion covers motion detection from any vendor (e.g. AXIS - // VideoSource/MotionAlarm, Hikvision RuleEngine/CellMotionDetector). KindMotion - // KindTampering covers true tamper alarms (lens cover, scene - // substitution). Imaging-quality alarms map to KindImageQuality. KindTampering - // KindImageQuality covers VideoSource imaging alarms such as - // ImageTooDark, ImageTooBright and ImageTooBlurry. Most integrators - // treat these separately from tamper because they fire on legitimate - // sunset/dawn/condensation transitions. + // KindImageQuality covers VideoSource imaging alarms. Kept separate + // from KindTampering because they fire on legitimate sunset / dawn / + // condensation transitions, not on interference. KindImageQuality - // KindDigitalInput covers external sensor inputs wired to the camera. KindDigitalInput - // KindDigitalOutput covers relay output state changes on the camera. KindDigitalOutput - // KindObjectDetected covers analytics-based object/person/vehicle - // detection events. KindObjectDetected - // KindAudioAlarm covers audio-level / loud-noise alarms. KindAudioAlarm ) -// String implements fmt.Stringer. func (k Kind) String() string { switch k { case KindUnknown: @@ -60,9 +47,8 @@ func (k Kind) String() string { } // State is the active/inactive level carried by a boolean ONVIF property -// event (e.g. IsMotion=true/false). StateUnknown is used both when the -// value cannot be parsed and when the topic is edge-triggered and carries -// no boolean state (e.g. LineDetector/Crossed). +// event. StateUnknown is used both when the value cannot be parsed and +// when the topic is edge-triggered and carries no boolean state. type State uint8 const ( @@ -71,7 +57,6 @@ const ( StateInactive ) -// String implements fmt.Stringer. func (s State) String() string { switch s { case StateUnknown: @@ -85,11 +70,9 @@ func (s State) String() string { } } -// PropertyOperation mirrors the ONVIF wsnt:PropertyOperation attribute and -// indicates whether a message is the first sighting of a property -// (Initialized), a transition (Changed) or the property going away -// (Deleted). PropertyUnknown is used both when the attribute is absent on -// the wire (the spec allows it) and when the value is unrecognised. +// PropertyOperation mirrors the wsnt:PropertyOperation attribute. +// PropertyUnknown covers both "absent on the wire" (the attribute is +// optional) and "unrecognised value". type PropertyOperation uint8 const ( @@ -99,7 +82,6 @@ const ( PropertyDeleted ) -// String implements fmt.Stringer. func (p PropertyOperation) String() string { switch p { case PropertyUnknown: @@ -117,62 +99,32 @@ func (p PropertyOperation) String() string { // Event is a single normalized notification from an ONVIF device. // -// Kind, State and Operation are the normalized fields most callers should -// switch on. Topic, Source and Data preserve the original ONVIF data so -// callers can inspect the wire form without re-parsing SOAP. -// -// Source and Data are maps from ONVIF SimpleItem Name to Value because -// notifications can carry multiple items: AXIS Object Analytics for -// example emits active, classType and confidence in the same Data list, -// and standard DigitalInput notifications carry both InputToken in Source +// Source and Data are maps because ONVIF notifications can carry +// multiple SimpleItems — AXIS Object Analytics emits active+classType+ +// confidence in one Data list, DigitalInput carries InputToken in Source // and LogicalState in Data. type Event struct { - // Kind is the normalized event category. - Kind Kind - // State is the active/inactive value carried by a boolean event. - // StateUnknown for edge-triggered events (LineDetector/Crossed) that - // carry no boolean property. - State State - // Operation is the ONVIF property lifecycle - // (Initialized/Changed/Deleted). + Kind Kind + State State Operation PropertyOperation - // DeviceID identifies the camera that produced the event. Set by the - // Stream from the caller-supplied identifier so a single channel can - // fan in events from multiple devices. - DeviceID string - // Source is the ONVIF Source SimpleItem map (e.g. InputToken, - // VideoSourceConfigurationToken, Rule). Empty when the notification - // has no Source section. - Source map[string]string - // Data is the ONVIF Data SimpleItem map (e.g. IsMotion, LogicalState, - // active, classType). Empty when the notification has no Data - // section. - Data map[string]string - // Topic is the raw ONVIF topic string, e.g. - // tns1:VideoSource/MotionAlarm. - Topic string - // Timestamp is when the stream observed the event locally. + DeviceID string + Source map[string]string + Data map[string]string + Topic string Timestamp time.Time - // DeviceTime is the camera-reported wsnt:UtcTime, when present and - // parseable. Zero if the camera omits the attribute or sends an - // unparseable value. Many cameras have drifting clocks; prefer - // Timestamp for ordering and DeviceTime only for forensics or - // cross-camera correlation when caller manages NTP. + // DeviceTime is the camera-reported wsnt:UtcTime. Cameras drift — + // prefer Timestamp for ordering and DeviceTime only for forensics or + // cross-camera correlation when the caller manages NTP. DeviceTime time.Time // AfterReconnect is true for events delivered after the Stream - // silently recreated its pull-point subscription. ONVIF cameras - // replay each property's current value with PropertyInitialized on - // a new subscription, which would otherwise look like a flood of - // new state changes to a consumer doing edge-detection. Watch this - // flag to suppress duplicate handling, or treat it as a normal - // event if you only care about steady-state level. Cleared on the - // first event whose Operation is not PropertyInitialized. + // silently recreated its subscription. Cameras replay current state + // with PropertyInitialized on a new subscription; watch this flag to + // suppress duplicate edge-detection. Cleared on the first non- + // Initialized event. AfterReconnect bool } -// Op identifies which Stream operation failed. Used by ErrPullFailed, -// ErrRenewFailed and ErrRecreateFailed so consumers can branch with -// errors.As without parsing the wrapped message. +// Op identifies which Stream operation failed. type Op string const ( @@ -182,26 +134,24 @@ const ( ) // ErrPullFailed wraps a transient PullMessages failure. The pull loop -// surfaces it on the Errors channel and continues. Consumers can match -// with errors.As(err, &stream.ErrPullFailed{}). +// surfaces it and continues. type ErrPullFailed struct{ Err error } func (e ErrPullFailed) Error() string { return fmt.Sprintf("pull messages: %v", e.Err) } func (e ErrPullFailed) Unwrap() error { return e.Err } func (ErrPullFailed) Op() Op { return OpPull } -// ErrRenewFailed wraps a Renew SOAP failure. Renew errors are usually -// recovered implicitly: the subscription dies, pull starts failing, -// and the reconnect logic recreates it. +// ErrRenewFailed wraps a Renew SOAP failure. Recovered implicitly: a +// permanently failing renew lets the subscription die, pull starts +// failing, and the reconnect path recreates it. type ErrRenewFailed struct{ Err error } func (e ErrRenewFailed) Error() string { return fmt.Sprintf("renew pull point: %v", e.Err) } func (e ErrRenewFailed) Unwrap() error { return e.Err } func (ErrRenewFailed) Op() Op { return OpRenew } -// ErrRecreateFailed wraps a failed CreatePullPointSubscription during -// the reconnect path. The loop continues with exponential backoff; -// consumers seeing this repeatedly should consider the camera offline. +// ErrRecreateFailed wraps a failed CreatePullPointSubscription. Consumers +// seeing this repeatedly should consider the camera offline. type ErrRecreateFailed struct{ Err error } func (e ErrRecreateFailed) Error() string { return fmt.Sprintf("recreate pull point: %v", e.Err) } From 70e6765a7d44b5960460e91d4fbbdac405137d8a Mon Sep 17 00:00:00 2001 From: Sebastian Norling <1932208+Bazze@users.noreply.github.com> Date: Thu, 21 May 2026 20:48:15 +0200 Subject: [PATCH 23/23] fix(event/stream): bound Close drain to survive a hung HTTP caller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrency audit (third review) flagged that caller.SendSoap is not ctx-aware: cancelling ctx does not unblock a pull or renew goroutine parked in the underlying http.Client.Do. The previous Close() unconditionally did <-s.done before its 5s unsubscribe timeout, so a wedged SendSoap could hang Close indefinitely — taking the agent's shutdown down with it. Adds closeDrainTimeout (5s) to bound the wait for the run goroutines to exit. When the drain times out: * Close returns a 'did not drain' error so the caller can move on. * Unsubscribe is skipped; the subscription expires at the camera once InitialTermination elapses without a Renew. * The wedged goroutines exit later, when the HTTP transport eventually gives up. They are effectively leaked until then — documented in the caller interface comment as the contract callers must accept (or fix, by configuring an http.Client.Timeout). The caller interface doc-comment now states both invariants explicitly: must be goroutine-safe AND must enforce its own per- request timeout, because we cannot from here. Test ---- TestClose_BoundedWhenLoopsStuckOnHungHTTP: drives the fakeCaller with blockAllSendSoap (new flag) so every SendSoap parks. Waits for pullLoop to actually reach the blocked SendSoap before calling Close (a race the previous attempt had: Close raced the loop and exited via the ctx pre-check). Asserts Close returns within closeDrainTimeout + 2s slack with a drain-timeout error. Other concurrency audit findings disposition -------------------------------------------- * unsubscribe goroutine leaks past 5s: intentional, already documented at closeUnsubscribeTimeout. * now func() time.Time data race: written once before goroutines start; safe by happens-before. Tests do not swap it today. * closeOnce self-deadlock if Close called from inside a loop: no path exists; not exposed via the API. --- event/stream/stream.go | 40 ++++++++++++++++++++++----- event/stream/stream_test.go | 55 +++++++++++++++++++++++++++++++++++-- 2 files changed, 86 insertions(+), 9 deletions(-) diff --git a/event/stream/stream.go b/event/stream/stream.go index e548d3c..9e39f06 100644 --- a/event/stream/stream.go +++ b/event/stream/stream.go @@ -10,6 +10,14 @@ import ( "github.com/kerberos-io/onvif" ) +// closeDrainTimeout bounds Close's wait for the pull and renew +// goroutines to exit. The loops block in caller.SendSoap which is not +// ctx-aware (the underlying http.Client is the only thing that can +// unblock them — see caller below). On a hung HTTP transport Close +// would otherwise wait forever; instead it returns an error and lets +// the calling agent move on. +const closeDrainTimeout = 5 * time.Second + // closeUnsubscribeTimeout bounds the Unsubscribe SOAP call issued by // Close. A subscription expires at the camera once InitialTermination // elapses without a renew, so a missed unsubscribe is at worst @@ -103,8 +111,17 @@ func (o Options) withDefaults() Options { } // caller is the *onvif.Device subset Stream depends on. Implementations -// must be safe for concurrent use — pull and renew goroutines call in -// from separate goroutines. *onvif.Device satisfies this via http.Client. +// must: +// +// - Be safe for concurrent use — pull and renew goroutines call in +// from separate goroutines. *onvif.Device satisfies this via +// http.Client. +// - Enforce a per-request timeout via the underlying HTTP client. +// The methods do not take a ctx, so ctx-cancel cannot interrupt a +// hung request; only the HTTP client's own timeout can. Close +// bounds its drain wait at closeDrainTimeout to survive a misbehaving +// caller, but a leaking goroutine remains until the HTTP call +// eventually returns. type caller interface { CallMethod(method any) (*http.Response, error) SendSoap(endpoint, body string) (*http.Response, error) @@ -194,15 +211,24 @@ func (s *Stream) Events() <-chan Event { return s.events } // when the Stream stops. func (s *Stream) Errors() <-chan error { return s.errors } -// Close stops the background goroutines, waits for them to exit and -// Unsubscribes from the camera. Subsequent calls are no-ops. +// Close stops the background goroutines, waits up to closeDrainTimeout +// for them to exit, and then Unsubscribes from the camera (also bounded, +// by closeUnsubscribeTimeout). Subsequent calls are no-ops. // -// Unsubscribe is bounded by closeUnsubscribeTimeout so a hung camera -// connection cannot wedge the caller. +// If the drain times out the goroutines are likely wedged inside a +// non-ctx-aware caller.SendSoap; they will exit on their own once the +// HTTP call returns. Unsubscribe is skipped in that case — the +// subscription expires at the camera anyway. func (s *Stream) Close() error { s.closeOnce.Do(func() { s.cancel() - <-s.done + + select { + case <-s.done: + case <-time.After(closeDrainTimeout): + s.closeErr = fmt.Errorf("close: pull/renew loops did not drain within %s (likely stuck in caller HTTP)", closeDrainTimeout) + return + } errCh := make(chan error, 1) go func() { diff --git a/event/stream/stream_test.go b/event/stream/stream_test.go index 062cac7..f5da324 100644 --- a/event/stream/stream_test.go +++ b/event/stream/stream_test.go @@ -22,8 +22,9 @@ import ( // require tests to enumerate every call. // // blockUnsubscribe, when non-nil, causes SendSoap calls whose body -// contains "Unsubscribe" to block until the channel is closed. Used to -// verify Close's timeout path. +// contains "Unsubscribe" to block until the channel is closed. +// blockAllSendSoap, when non-nil, blocks every SendSoap call until +// closed (simulates a hung HTTP transport). type fakeCaller struct { mu sync.Mutex callMethodResps []fakeResp @@ -33,6 +34,7 @@ type fakeCaller struct { callMethodCalls []any sendSoapCalls [][2]string blockUnsubscribe chan struct{} + blockAllSendSoap chan struct{} } type fakeResp struct { @@ -84,8 +86,12 @@ func (f *fakeCaller) SendSoap(endpoint, body string) (*http.Response, error) { f.sendSoapResps = f.sendSoapResps[1:] } block := f.blockUnsubscribe + blockAll := f.blockAllSendSoap f.mu.Unlock() + if blockAll != nil { + <-blockAll + } if block != nil && strings.Contains(body, "Unsubscribe") { <-block } @@ -444,3 +450,48 @@ func TestFakeCaller_QueueThenDefaultFallback(t *testing.T) { assert.Contains(t, string(b3[:n]), "PullMessagesResponse", "default SendSoap should be an empty PullMessagesResponse envelope") } + +func TestClose_BoundedWhenLoopsStuckOnHungHTTP(t *testing.T) { + // Simulates a hung HTTP transport: every SendSoap blocks + // indefinitely. The pull and renew loops are wedged inside + // SendSoap and ctx-cancel cannot unblock them. Close must still + // return within its bounded budget so the agent's shutdown does + // not hang. + fc := newFakeCaller() + fc.queueCallMethod(createPullPointResp, nil) + blockAll := make(chan struct{}) + defer close(blockAll) + fc.mu.Lock() + fc.blockAllSendSoap = blockAll + fc.mu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s, err := newStream(ctx, fc, Options{ + PullTimeout: 100 * time.Millisecond, + InitialTermination: 30 * time.Second, + }) + require.NoError(t, err) + + // Wait until pullLoop is actually parked inside the blocked + // SendSoap. Without this, Close races with the loop's first + // iteration and exits via the ctx pre-check instead of + // exercising the drain-timeout path. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) && fc.sendSoapCallCount() == 0 { + time.Sleep(10 * time.Millisecond) + } + require.GreaterOrEqual(t, fc.sendSoapCallCount(), 1, "pullLoop never reached SendSoap") + + start := time.Now() + err = s.Close() + elapsed := time.Since(start) + + require.Error(t, err) + assert.Contains(t, err.Error(), "drain", "expected a drain-timeout error") + // Total budget is closeDrainTimeout for the wait + ~0 for unsubscribe + // (which is skipped when drain times out). Give plenty of slack for + // scheduling on a loaded CI machine. + assert.Less(t, elapsed, closeDrainTimeout+2*time.Second, + "Close exceeded bound (%s); expected ~%s", elapsed, closeDrainTimeout) +}