mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
AXIS encodes pull-point subscription identity in
<wsa:ReferenceParameters> inside the CreatePullPointSubscription
response — a generic /onvif/services endpoint plus a
<dom0:SubscriptionId> child — rather than a per-subscription URL.
We were discarding the ReferenceParameters and POSTing to the
generic endpoint, which AXIS rejected with ter:InvalidArgs on every
PullMessages/Renew/Unsubscribe.
Per WS-Addressing 1.0 §3.1 each reference parameter MUST be echoed
as a SOAP Header block carrying wsa:IsReferenceParameter="true".
- subscriptionRef now carries Address + the verbatim
ReferenceParameters inner XML extracted from the create response.
- buildRefParamsHeader walks the children, adds the attribute, and
produces the SOAP Header content.
- pullMessages, renewPullPoint, unsubscribePullPoint switch from
SendSoap to a new SendSoapWithHeader path on the caller interface.
- onvif.Device gains SendSoapWithHeader as a thin variant of
SendSoap (existing SendSoap is now a one-liner delegating to it
with empty header content, so all external callers are unaffected).
Verified end-to-end against an AXIS camera at 192.168.1.10: pulls
now stream the full topic tree (VMD, Object Analytics, IO, storage,
hardware-failure topics) instead of looping on ter:InvalidArgs.
301 lines
9.1 KiB
Go
301 lines
9.1 KiB
Go
package stream
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kerberos-io/onvif"
|
|
)
|
|
|
|
// closeDrainTimeout bounds Close's wait for the pull and renew
|
|
// goroutines to exit. The loops block in caller.SendSoap which is not
|
|
// ctx-aware (the underlying http.Client is the only thing that can
|
|
// unblock them — see caller below). On a hung HTTP transport Close
|
|
// would otherwise wait forever; instead it returns an error and lets
|
|
// the calling agent move on.
|
|
const closeDrainTimeout = 5 * time.Second
|
|
|
|
// closeUnsubscribeTimeout bounds the Unsubscribe SOAP call issued by
|
|
// Close. A subscription expires at the camera once InitialTermination
|
|
// elapses without a renew, so a missed unsubscribe is at worst
|
|
// cosmetic.
|
|
const closeUnsubscribeTimeout = 5 * time.Second
|
|
|
|
// Options configures a Stream.
|
|
//
|
|
// Zero-value policy: every duration / int field treats zero as "use
|
|
// the default". To opt out of reconnect set DisableReconnect=true
|
|
// (ReconnectAfterFailures=0 would otherwise collide with the default
|
|
// injection). For unbuffered Events / Errors channels set
|
|
// BufferSize=-1.
|
|
type Options struct {
|
|
DeviceID string
|
|
// RawTopicFilter is the ONVIF ConcreteSet TopicExpression filter
|
|
// passed verbatim to CreatePullPointSubscription. Callers should
|
|
// normally leave this empty and rely on Classify for routing —
|
|
// server-side filtering is fragile across vendors and empty is
|
|
// required for AXIS.
|
|
RawTopicFilter string
|
|
// PullTimeout — zero means default (5s).
|
|
PullTimeout time.Duration
|
|
// MessageLimit — zero means default (32). Busy AXIS cameras with
|
|
// many configured rules can burst beyond 10 per pull.
|
|
MessageLimit int
|
|
// InitialTermination — zero means default (60s).
|
|
InitialTermination time.Duration
|
|
// RenewMargin — larger margins tolerate slower networks at the
|
|
// cost of more renew calls. Zero means default (10s).
|
|
RenewMargin time.Duration
|
|
// ReconnectAfterFailures — pull-points die for many reasons
|
|
// (camera reboot, subscription GC after a renew miss, NAT
|
|
// timeout); rebuilding the subscription is the only reliable
|
|
// recovery. Zero means default (3). Set DisableReconnect=true
|
|
// to disable.
|
|
ReconnectAfterFailures int
|
|
// DisableReconnect makes the pull loop retry against the
|
|
// original endpoint until ctx is cancelled.
|
|
DisableReconnect bool
|
|
// RetryBackoff is the base sleep between pull/recreate failures.
|
|
// Recreate failures double this up to maxRecreateBackoff. Zero
|
|
// means default (1s).
|
|
RetryBackoff time.Duration
|
|
// BufferSize — zero means default (16); use -1 for unbuffered.
|
|
BufferSize int
|
|
}
|
|
|
|
func defaultOptions() Options {
|
|
return Options{
|
|
PullTimeout: 5 * time.Second,
|
|
MessageLimit: 32,
|
|
InitialTermination: 60 * time.Second,
|
|
RenewMargin: 10 * time.Second,
|
|
ReconnectAfterFailures: 3,
|
|
RetryBackoff: time.Second,
|
|
BufferSize: 16,
|
|
}
|
|
}
|
|
|
|
func (o Options) withDefaults() Options {
|
|
d := defaultOptions()
|
|
if o.PullTimeout > 0 {
|
|
d.PullTimeout = o.PullTimeout
|
|
}
|
|
if o.MessageLimit > 0 {
|
|
d.MessageLimit = o.MessageLimit
|
|
}
|
|
if o.InitialTermination > 0 {
|
|
d.InitialTermination = o.InitialTermination
|
|
}
|
|
if o.RenewMargin > 0 {
|
|
d.RenewMargin = o.RenewMargin
|
|
}
|
|
if o.ReconnectAfterFailures > 0 {
|
|
d.ReconnectAfterFailures = o.ReconnectAfterFailures
|
|
}
|
|
if o.RetryBackoff > 0 {
|
|
d.RetryBackoff = o.RetryBackoff
|
|
}
|
|
switch {
|
|
case o.BufferSize > 0:
|
|
d.BufferSize = o.BufferSize
|
|
case o.BufferSize < 0:
|
|
d.BufferSize = 0
|
|
}
|
|
d.DeviceID = o.DeviceID
|
|
d.RawTopicFilter = o.RawTopicFilter
|
|
d.DisableReconnect = o.DisableReconnect
|
|
return d
|
|
}
|
|
|
|
// subscriptionRef holds the result of CreatePullPointSubscription.
|
|
// AXIS encodes the subscription identity in RefParamsXML (a generic
|
|
// /onvif/services Address plus a <wsa:ReferenceParameters> child);
|
|
// other vendors put the identity in the Address itself, leaving
|
|
// RefParamsXML empty. Subscription-scoped requests must echo a
|
|
// non-empty RefParamsXML — see extractReferenceParameters.
|
|
type subscriptionRef struct {
|
|
Address string
|
|
RefParamsXML string
|
|
}
|
|
|
|
// caller is the *onvif.Device subset Stream depends on. Implementations
|
|
// must:
|
|
//
|
|
// - Be safe for concurrent use — pull and renew goroutines call in
|
|
// from separate goroutines. *onvif.Device satisfies this via
|
|
// http.Client.
|
|
// - Enforce a per-request timeout via the underlying HTTP client.
|
|
// The methods do not take a ctx, so ctx-cancel cannot interrupt a
|
|
// hung request; only the HTTP client's own timeout can. Close
|
|
// bounds its drain wait at closeDrainTimeout to survive a misbehaving
|
|
// caller, but a leaking goroutine remains until the HTTP call
|
|
// eventually returns.
|
|
type caller interface {
|
|
CallMethod(method any) (*http.Response, error)
|
|
SendSoap(endpoint, body string) (*http.Response, error)
|
|
SendSoapWithHeader(endpoint, body, headerXML string) (*http.Response, error)
|
|
}
|
|
|
|
type deviceCaller struct{ dev *onvif.Device }
|
|
|
|
func (d deviceCaller) CallMethod(m any) (*http.Response, error) {
|
|
return d.dev.CallMethod(m)
|
|
}
|
|
|
|
func (d deviceCaller) SendSoap(endpoint, body string) (*http.Response, error) {
|
|
return d.dev.SendSoap(endpoint, body)
|
|
}
|
|
|
|
func (d deviceCaller) SendSoapWithHeader(endpoint, body, headerXML string) (*http.Response, error) {
|
|
return d.dev.SendSoapWithHeader(endpoint, body, headerXML)
|
|
}
|
|
|
|
// Stream owns a single ONVIF pull-point subscription. Safe for Close
|
|
// from any goroutine while readers consume Events / Errors. Close is
|
|
// idempotent.
|
|
type Stream struct {
|
|
caller caller
|
|
opts Options
|
|
|
|
pullPointMu sync.Mutex
|
|
pullPoint subscriptionRef
|
|
|
|
events chan Event
|
|
errors chan error
|
|
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
|
|
// now is overridable so tests can make timestamps deterministic.
|
|
now func() time.Time
|
|
}
|
|
|
|
func (s *Stream) getPullPoint() subscriptionRef {
|
|
s.pullPointMu.Lock()
|
|
defer s.pullPointMu.Unlock()
|
|
return s.pullPoint
|
|
}
|
|
|
|
func (s *Stream) setPullPoint(ref subscriptionRef) {
|
|
s.pullPointMu.Lock()
|
|
defer s.pullPointMu.Unlock()
|
|
s.pullPoint = ref
|
|
}
|
|
|
|
// NewStream creates a Stream and performs CreatePullPointSubscription
|
|
// synchronously so connectivity and authentication failures surface
|
|
// from NewStream rather than landing on Errors later.
|
|
//
|
|
// The returned Stream stops when ctx is cancelled or Close is called.
|
|
func NewStream(ctx context.Context, dev *onvif.Device, opts Options) (*Stream, error) {
|
|
return newStream(ctx, deviceCaller{dev: dev}, opts)
|
|
}
|
|
|
|
func newStream(ctx context.Context, c caller, opts Options) (*Stream, error) {
|
|
opts = opts.withDefaults()
|
|
ref, err := createPullPoint(c, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("create pull point subscription: %w", err)
|
|
}
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
s := &Stream{
|
|
caller: c,
|
|
opts: opts,
|
|
pullPoint: ref,
|
|
events: make(chan Event, opts.BufferSize),
|
|
errors: make(chan error, opts.BufferSize),
|
|
cancel: cancel,
|
|
done: make(chan struct{}),
|
|
now: time.Now,
|
|
}
|
|
go s.run(runCtx)
|
|
return s, nil
|
|
}
|
|
|
|
// Events returns the channel of decoded notifications. Closed when
|
|
// the Stream stops.
|
|
func (s *Stream) Events() <-chan Event { return s.events }
|
|
|
|
// Errors returns the channel of non-fatal errors. Sends are
|
|
// non-blocking; consumers that fall behind drop older errors. Closed
|
|
// when the Stream stops.
|
|
func (s *Stream) Errors() <-chan error { return s.errors }
|
|
|
|
// Close stops the background goroutines, waits up to closeDrainTimeout
|
|
// for them to exit, and then Unsubscribes from the camera (also bounded,
|
|
// by closeUnsubscribeTimeout). Subsequent calls are no-ops.
|
|
//
|
|
// If the drain times out the goroutines are likely wedged inside a
|
|
// non-ctx-aware caller.SendSoap; they will exit on their own once the
|
|
// HTTP call returns. Unsubscribe is skipped in that case — the
|
|
// subscription expires at the camera anyway.
|
|
func (s *Stream) Close() error {
|
|
s.closeOnce.Do(func() {
|
|
s.cancel()
|
|
|
|
select {
|
|
case <-s.done:
|
|
case <-time.After(closeDrainTimeout):
|
|
s.closeErr = fmt.Errorf("close: pull/renew loops did not drain within %s (likely stuck in caller HTTP)", closeDrainTimeout)
|
|
return
|
|
}
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- unsubscribePullPoint(s.caller, s.getPullPoint())
|
|
}()
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil {
|
|
s.closeErr = fmt.Errorf("unsubscribe pull point: %w", err)
|
|
}
|
|
case <-time.After(closeUnsubscribeTimeout):
|
|
s.closeErr = fmt.Errorf("unsubscribe pull point: timeout after %s", closeUnsubscribeTimeout)
|
|
}
|
|
})
|
|
return s.closeErr
|
|
}
|
|
|
|
func (s *Stream) run(ctx context.Context) {
|
|
var wg sync.WaitGroup
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
s.renewLoop(ctx)
|
|
}()
|
|
s.pullLoop(ctx)
|
|
wg.Wait()
|
|
|
|
// Explicit close order after both goroutines have exited so a
|
|
// future maintainer extending this function does not rely on
|
|
// defer-ordering for channel-close safety.
|
|
close(s.errors)
|
|
close(s.events)
|
|
close(s.done)
|
|
}
|
|
|
|
func (s *Stream) surfaceError(err error) {
|
|
select {
|
|
case s.errors <- err:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// sleepCtx returns false if ctx was cancelled, true if d elapsed.
|
|
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-t.C:
|
|
return true
|
|
}
|
|
}
|