Files
onvif/event/stream/types.go
Sebastian Norling da1ecf8e0a 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.
2026-05-21 14:25:59 +02:00

163 lines
5.1 KiB
Go

package stream
import (
"fmt"
"time"
)
// Kind is the normalized category of an ONVIF event, independent of the
// camera vendor's topic naming.
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
// 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:
return "Unknown"
case KindMotion:
return "Motion"
case KindTampering:
return "Tampering"
case KindImageQuality:
return "ImageQuality"
case KindDigitalInput:
return "DigitalInput"
case KindDigitalOutput:
return "DigitalOutput"
case KindObjectDetected:
return "ObjectDetected"
case KindAudioAlarm:
return "AudioAlarm"
default:
return fmt.Sprintf("Kind(%d)", uint8(k))
}
}
// 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 State = iota
StateActive
StateInactive
)
// String implements fmt.Stringer.
func (s State) String() string {
switch s {
case StateUnknown:
return "Unknown"
case StateActive:
return "Active"
case StateInactive:
return "Inactive"
default:
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). 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 (
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, 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 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
// 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.
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
}