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)) }