Files
onvif/event/stream/decode.go
Sebastian Norling b461ec8ded feat(event/stream): decode NotificationMessage into normalized Event
Adds the Decode entry point that converts the ONVIF wire form into the
package's typed Event. The agent (and any other consumer) no longer has
to walk NotificationMessage.Message.Message.Data.SimpleItem chains and
hand-special-case per-vendor data item names.

Decoding rules
--------------
* Topic -> Kind via the verified Classify table.
* PropertyOperation parses Initialized/Changed/Deleted; absent or
  unrecognised -> PropertyUnknown (the attribute is optional per
  WS-Notification).
* UtcTime parses RFC3339Nano first, RFC3339 second, normalised to UTC.
  Absent or unparseable -> DeviceTime is zero. Camera clocks drift; the
  type doc already steers callers to prefer Timestamp.
* State extraction scans Data items in order for the first boolean-like
  value (true/false/1/0/active/inactive, case-insensitive). This handles
  every vendor data item in the verified table — IsMotion, State,
  IsTamper, LogicalState, active, Motion, triggered, SoundDetection,
  TamperingDetection — without a per-kind switch.
* Edge-triggered topics (LineDetector/Crossed with only ObjectId) yield
  StateUnknown, matching the topic-rule doc note.
* Source and Data are full ONVIF SimpleItem name->value maps so callers
  retain multi-item info (AXIS AOA active+classType+confidence, digital
  I/O InputToken+LogicalState, analytics VideoSourceConfigurationToken+
  Rule). Empty notifications yield nil maps, matching the Event
  zero-value contract from types_test.go.
* Topic, Source and Data are always populated even when Kind is
  KindUnknown, so consumers can log/route unclassified events.

Tests cover the AXIS motion happy path, the inactive case, the Hanwha
numeric-string variant, the Avigilon 'active' literal, multi-item AOA
decode, the LineDetector edge-trigger semantic, unknown-topic wire
preservation, every PropertyOperation literal, RFC3339 with sub-second
and timezone offsets, and case-insensitive State extraction.
2026-05-21 14:30:50 +02:00

105 lines
3.4 KiB
Go

package stream
import (
"strings"
"time"
"github.com/kerberos-io/onvif/event"
)
// Decode converts a single ONVIF NotificationMessage into the package's
// normalized Event representation.
//
// deviceID is supplied by the caller because the message itself does not
// identify the originating camera. observedAt is recorded verbatim as
// Event.Timestamp; the camera-reported wsnt:UtcTime attribute (when
// present and parseable) populates Event.DeviceTime.
//
// When the Topic does not match any classifier rule the returned Event
// has Kind == KindUnknown but Source, Data and Topic are still populated
// so consumers can fall back to inspecting the wire form.
func Decode(msg event.NotificationMessage, deviceID string, observedAt time.Time) Event {
topic := string(msg.Topic.TopicKinds)
desc := msg.Message.Message
return Event{
Kind: Classify(topic),
State: extractState(desc.Data.SimpleItem),
Operation: parsePropertyOperation(string(desc.PropertyOperation)),
DeviceID: deviceID,
Source: simpleItemsToMap(desc.Source.SimpleItem),
Data: simpleItemsToMap(desc.Data.SimpleItem),
Topic: topic,
Timestamp: observedAt,
DeviceTime: parseDeviceTime(string(desc.UtcTime)),
}
}
// simpleItemsToMap collapses ONVIF SimpleItem lists to a Name->Value map.
// Returns nil for an empty list so empty notifications do not allocate
// and match the Event zero-value contract.
func simpleItemsToMap(items []event.SimpleItem) map[string]string {
if len(items) == 0 {
return nil
}
m := make(map[string]string, len(items))
for _, it := range items {
m[string(it.Name)] = string(it.Value)
}
return m
}
// extractState scans Data items for a boolean-like value and returns the
// first one as a State. Returns StateUnknown when no item parses — this
// is the correct outcome for edge-triggered topics such as
// LineDetector/Crossed whose Data carries only an ObjectId.
//
// Iteration order over the original []SimpleItem is preserved so the
// behaviour stays deterministic per notification. (Map iteration is not
// involved; simpleItemsToMap is a separate path.)
func extractState(items []event.SimpleItem) State {
for _, it := range items {
switch strings.ToLower(strings.TrimSpace(string(it.Value))) {
case "true", "1", "active":
return StateActive
case "false", "0", "inactive":
return StateInactive
}
}
return StateUnknown
}
// parsePropertyOperation parses the wsnt:PropertyOperation attribute.
// The attribute is optional per WS-Notification; an empty or unrecognised
// value yields PropertyUnknown.
func parsePropertyOperation(s string) PropertyOperation {
switch s {
case "Initialized":
return PropertyInitialized
case "Changed":
return PropertyChanged
case "Deleted":
return PropertyDeleted
default:
return PropertyUnknown
}
}
// parseDeviceTime parses the wsnt:UtcTime attribute, returning the zero
// time when the attribute is absent or unparseable. The result is
// normalised to UTC so equality comparisons across timezones work.
//
// xsd:dateTime in ONVIF messages is RFC 3339 in practice; we try
// time.RFC3339Nano first (covers sub-second precision) and fall back to
// time.RFC3339 for cameras that drop the fractional part.
func parseDeviceTime(s string) time.Time {
if s == "" {
return time.Time{}
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}