Files
onvif/event/stream/types.go
Sebastian Norling 6fc9b23e9f feat(event/stream): typed errors and AfterReconnect observability
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.
2026-05-21 14:55:58 +02:00

172 lines
5.6 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
// 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
}