refactor(event/stream): address API and classifier review findings

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.
This commit is contained in:
Sebastian Norling
2026-05-21 14:25:59 +02:00
parent 4da4842f61
commit da1ecf8e0a
5 changed files with 267 additions and 136 deletions

View File

@@ -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

View File

@@ -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 Camera<N>Profile<ID> 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<N>
// — AXIS Object Analytics. The Scenario<N> 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/<RuleName> — 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 --------------------------------------------------

View File

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

View File

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

View File

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