mirror of
https://github.com/kerberos-io/onvif.git
synced 2026-08-23 15:08:33 +00:00
Previously stream.go was a 621-line monolith holding the Stream type,
SOAP plumbing, pull loop, renew loop, recreate logic and jitter. The
test side had grown five orphan files (renew_test.go, reconnect_test.go,
soap_test.go, jitter_test.go, coverage_test.go) with no matching source
files. The mismatch made it harder than necessary to find the code
that backed a given test.
This commit splits stream.go by concern so each source file has its
own test file alongside it. Files <100 LOC (errors, jitter) were folded
into their conceptual parents rather than left as fragments.
New layout — 8 source + 8 test + helpers (test utility) + doc:
stream.go <-> stream_test.go Stream type, Options, lifecycle
soap.go <-> soap_test.go SOAP plumbing + fault detection
renew.go <-> renew_test.go Renew loop and absolute time
reconnect.go <-> reconnect_test.go Pull loop, recreate, jitter
decode.go <-> decode_test.go NotificationMessage -> Event
types.go <-> types_test.go Event types + typed errors
topics.go <-> topics_test.go Classifier table
doc.go Package godoc landing page
helpers_test.go waitFor (test-only utility)
Mergers
-------
* errors.go (typed error wrappers, 42 LOC) -> types.go. ErrPullFailed /
ErrRenewFailed / ErrRecreateFailed are part of the type system, not a
separate concern.
* jitter.go (40 LOC) -> reconnect.go. jitter is an implementation detail
of attemptRecreate, used nowhere else.
Test distribution
-----------------
* coverage_test.go was a catch-all; tests moved to the file matching
the function under test:
- Close*, NewStream_*, FakeCaller_* -> stream_test.go
- DisableReconnect_*, RecreateResets_*, PullPointMutation_* ->
reconnect_test.go
- Decode_*, ExtractState_* -> decode_test.go
* soap_test.go shed the two orphans that did not belong there:
- TestRenew_SendsAbsoluteDateTimeNotDuration -> renew_test.go
- TestClose_BoundedByTimeoutOnHungUnsubscribe -> stream_test.go
* errors_test.go's pure type tests -> types_test.go
* errors_test.go's Stream-integration tests -> reconnect_test.go
* jitter_test.go -> reconnect_test.go
No behaviour change. Test suite passes -race clean.
296 lines
9.5 KiB
Go
296 lines
9.5 KiB
Go
package stream
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kerberos-io/onvif"
|
|
)
|
|
|
|
// closeUnsubscribeTimeout bounds the SOAP Unsubscribe call issued by
|
|
// Close so a hung camera connection cannot wedge the caller. The
|
|
// subscription expires at the camera anyway once InitialTermination
|
|
// elapses, 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 entirely set DisableReconnect=true
|
|
// (sentinel `ReconnectAfterFailures=0` would otherwise collide with the
|
|
// default-injection policy). To get a synchronous (unbuffered) channel
|
|
// pair set BufferSize=-1.
|
|
type Options struct {
|
|
// DeviceID identifies the camera in emitted Events. Recommended so
|
|
// a single channel can fan in multiple cameras. Empty is allowed.
|
|
DeviceID string
|
|
// RawTopicFilter is the raw ONVIF ConcreteSet TopicExpression
|
|
// filter passed to CreatePullPointSubscription. Empty means no
|
|
// filter — required for AXIS, accepted by every other vendor we
|
|
// support. The name carries 'Raw' because the value is fed verbatim
|
|
// into the SOAP envelope: callers should normally leave it empty
|
|
// and rely on Classify for routing rather than ask the camera to
|
|
// filter server-side, which is fragile across vendors.
|
|
RawTopicFilter string
|
|
// PullTimeout is the server-side wait time in each PullMessages
|
|
// call (xsd:duration). The camera returns early when messages are
|
|
// available; otherwise it returns empty after this timeout. Zero
|
|
// means default (5s).
|
|
PullTimeout time.Duration
|
|
// MessageLimit caps the number of NotificationMessage entries
|
|
// returned per PullMessages call. Zero means default (32). A busy
|
|
// AXIS with many configured inputs can burst beyond 10 per pull;
|
|
// 32 covers that without significantly enlarging quiet pulls.
|
|
MessageLimit int
|
|
// InitialTermination is the requested subscription lifetime passed
|
|
// to CreatePullPointSubscription. The renew loop refreshes well
|
|
// before this expires. Zero means default (60s).
|
|
InitialTermination time.Duration
|
|
// RenewMargin is how long before InitialTermination expiry the
|
|
// renew loop fires. Larger margins tolerate slower networks at the
|
|
// cost of more renew SOAP calls. Zero means default (10s).
|
|
RenewMargin time.Duration
|
|
// ReconnectAfterFailures is the consecutive PullMessages failure
|
|
// count that triggers a CreatePullPointSubscription recreate. The
|
|
// camera or pull-point can die for many reasons (camera reboot,
|
|
// subscription garbage-collected after a renew miss, intermediate
|
|
// NAT timeout); rebuilding the subscription is the only reliable
|
|
// recovery. Zero means default (3). To disable reconnect entirely
|
|
// set DisableReconnect=true.
|
|
ReconnectAfterFailures int
|
|
// DisableReconnect skips automatic CreatePullPointSubscription
|
|
// recreate. The pull loop will continue retrying against the
|
|
// original endpoint until ctx is cancelled. Useful for tests or
|
|
// callers managing recovery externally.
|
|
DisableReconnect bool
|
|
// RetryBackoff is the initial sleep between a pull/recreate failure
|
|
// and the next attempt. Recreate failures double this up to a 30s
|
|
// ceiling. Zero means default (1s).
|
|
RetryBackoff time.Duration
|
|
// BufferSize is the buffer size of the Events and Errors channels.
|
|
// Larger buffers absorb consumer hiccups at the cost of memory.
|
|
// Zero means default (16); use -1 for unbuffered (synchronous)
|
|
// channels.
|
|
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
|
|
}
|
|
// BufferSize: zero -> default; negative -> 0 (unbuffered).
|
|
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
|
|
}
|
|
|
|
// caller is the subset of *onvif.Device the Stream depends on. Tests
|
|
// substitute a fake; production code uses the device adapter.
|
|
//
|
|
// Implementations must be safe for concurrent use: the pull loop and
|
|
// renew loop call into caller from separate goroutines. *onvif.Device
|
|
// satisfies this because its HTTP client is the goroutine-safe
|
|
// http.Client.
|
|
type caller interface {
|
|
CallMethod(method any) (*http.Response, error)
|
|
SendSoap(endpoint, body 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)
|
|
}
|
|
|
|
// Stream owns a single ONVIF pull-point subscription and surfaces the
|
|
// decoded notifications on a typed channel. Close stops the background
|
|
// goroutine and unsubscribes from the camera.
|
|
//
|
|
// A Stream is safe for concurrent use by Close from any goroutine while
|
|
// readers consume Events / Errors; Close is idempotent.
|
|
type Stream struct {
|
|
caller caller
|
|
opts Options
|
|
|
|
pullPointMu sync.Mutex
|
|
pullPoint string
|
|
|
|
events chan Event
|
|
errors chan error
|
|
|
|
cancel context.CancelFunc
|
|
done chan struct{}
|
|
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
|
|
// now is overridable in tests to make timestamps deterministic.
|
|
now func() time.Time
|
|
}
|
|
|
|
func (s *Stream) getPullPoint() string {
|
|
s.pullPointMu.Lock()
|
|
defer s.pullPointMu.Unlock()
|
|
return s.pullPoint
|
|
}
|
|
|
|
func (s *Stream) setPullPoint(addr string) {
|
|
s.pullPointMu.Lock()
|
|
defer s.pullPointMu.Unlock()
|
|
s.pullPoint = addr
|
|
}
|
|
|
|
// NewStream creates a Stream against an ONVIF device. It performs the
|
|
// CreatePullPointSubscription call synchronously so connectivity and
|
|
// authentication problems surface immediately as an error rather than
|
|
// landing on the Errors channel later. The background pull loop starts
|
|
// before NewStream returns.
|
|
//
|
|
// The returned Stream stops when ctx is cancelled or when 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()
|
|
addr, 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: addr,
|
|
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. The channel is
|
|
// closed when the Stream stops.
|
|
func (s *Stream) Events() <-chan Event { return s.events }
|
|
|
|
// Errors returns the channel of non-fatal errors encountered while
|
|
// pulling. Sends are non-blocking, so consumers that fall behind drop
|
|
// older errors. The channel is closed when the Stream stops.
|
|
func (s *Stream) Errors() <-chan error { return s.errors }
|
|
|
|
// Close stops the background goroutine, waits for it to exit, and
|
|
// unsubscribes from the camera. Subsequent calls are no-ops.
|
|
//
|
|
// Unsubscribe is bounded by closeUnsubscribeTimeout so a hung camera
|
|
// connection cannot wedge the caller. On timeout Close still returns
|
|
// promptly; the subscription will expire at the camera once
|
|
// InitialTermination + RenewMargin elapses without a renew.
|
|
func (s *Stream) Close() error {
|
|
s.closeOnce.Do(func() {
|
|
s.cancel()
|
|
<-s.done
|
|
|
|
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
|
|
}
|
|
|
|
// run orchestrates the pull and renew goroutines and closes the
|
|
// emission channels once both have exited.
|
|
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 accidentally
|
|
// rely on defer-ordering for channel-close safety.
|
|
close(s.errors)
|
|
close(s.events)
|
|
close(s.done)
|
|
}
|
|
|
|
// surfaceError sends err on the errors channel non-blockingly so a
|
|
// stalled consumer cannot block the pull or renew loop.
|
|
func (s *Stream) surfaceError(err error) {
|
|
select {
|
|
case s.errors <- err:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// sleepCtx blocks for d or until ctx is cancelled. Returns true if d
|
|
// elapsed, false if ctx was cancelled.
|
|
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
|
|
}
|
|
}
|