feat(event/stream): scaffold package with normalized event types

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.
This commit is contained in:
Sebastian Norling
2026-05-21 13:57:21 +02:00
parent a732b9fa82
commit ce67879ee5
3 changed files with 226 additions and 0 deletions

13
event/stream/doc.go Normal file
View File

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

130
event/stream/types.go Normal file
View File

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

View File

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