feat(event/stream): echo WS-Addressing ReferenceParameters for AXIS

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.
This commit is contained in:
Sebastian Norling
2026-05-27 18:07:56 +02:00
parent a796a23058
commit de3f049a63
9 changed files with 352 additions and 30 deletions

View File

@@ -335,26 +335,32 @@ func (dev *Device) GetEndpointByRequestStruct(requestStruct interface{}) (string
// CallMethod functions call an method, defined <method> struct with authentication data
func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Response, error) {
return dev.SendSoapWithHeader(endpoint, xmlRequestBody, "")
}
// SendSoapWithHeader is SendSoap plus arbitrary inner-Header XML —
// needed to echo WS-Addressing ReferenceParameters (with
// wsa:IsReferenceParameter="true") back to vendors like AXIS that
// identify pull-point subscriptions through them rather than the URL.
func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) {
soap := gosoap.NewEmptySOAP()
soap.AddStringBodyContent(xmlRequestBody)
soap.AddRootNamespaces(Xlmns)
soap.AddAction()
//Auth Handling
if xmlHeaderContent != "" {
_ = soap.AddStringHeaderContent(xmlHeaderContent)
}
if dev.params.Username != "" && dev.params.Password != "" {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
}
servResp, err := networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
if err != nil {
// Close server response body to reuse the connection
if servResp != nil {
servResp.Body.Close()
}
servResp, err = networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
}
return servResp, err
}

View File

@@ -1,9 +1,14 @@
package onvif
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDevice_SetDeviceInfoFromScopes(t *testing.T) {
@@ -22,3 +27,47 @@ func TestDevice_SetDeviceInfoFromScopes(t *testing.T) {
assert.Equal(t, device.info.Name, name)
assert.Equal(t, device.info.Model, hardware)
}
// TestDevice_SendSoapWithHeader_InjectsHeaderXML verifies that the
// supplied header XML lands inside the SOAP <Header> element of the
// outgoing request. AXIS-style WS-Addressing reference parameter
// echoing depends on this — without it the camera returns
// ter:InvalidArgs on every PullMessages.
func TestDevice_SendSoapWithHeader_InjectsHeaderXML(t *testing.T) {
const headerXML = `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event" wsa:IsReferenceParameter="true">297</dom0:SubscriptionId>`
const bodyXML = `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"><tev:Timeout>PT5S</tev:Timeout><tev:MessageLimit>32</tev:MessageLimit></tev:PullMessages>`
var captured string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
captured = string(b)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
dev := Device{
params: DeviceParams{
Xaddr: strings.TrimPrefix(srv.URL, "http://"),
HttpClient: srv.Client(),
},
}
resp, err := dev.SendSoapWithHeader(srv.URL, bodyXML, headerXML)
require.NoError(t, err)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
headerStart := strings.Index(captured, "Header>")
headerEnd := strings.Index(captured, "</")
require.NotEqual(t, -1, headerStart, "envelope must contain <Header>; got: %s", captured)
assert.Greater(t, headerEnd, headerStart, "envelope must close the header")
headerSlice := captured[headerStart:strings.Index(captured, "Body>")]
assert.Contains(t, headerSlice, "SubscriptionId",
"injected header element must land inside SOAP <Header>")
assert.Contains(t, headerSlice, "297")
bodySlice := captured[strings.Index(captured, "Body>"):]
assert.Contains(t, bodySlice, "PullMessages",
"body content must land inside SOAP <Body>")
}

View File

@@ -74,7 +74,7 @@ func (s *Stream) pullLoop(ctx context.Context) {
// attemptRecreate returns (justRecreated, cont). cont is false only
// when ctx cancelled during backoff so the caller exits the loop.
func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *time.Duration) (justRecreated, cont bool) {
addr, err := createPullPoint(s.caller, s.opts)
ref, err := createPullPoint(s.caller, s.opts)
if err != nil {
s.surfaceError(ErrRecreateFailed{Err: err})
if !sleepCtx(ctx, jitter(*backoff)) {
@@ -86,7 +86,7 @@ func (s *Stream) attemptRecreate(ctx context.Context, failures *int, backoff *ti
}
return false, true
}
s.setPullPoint(addr)
s.setPullPoint(ref)
*failures = 0
*backoff = s.opts.RetryBackoff
return true, true

View File

@@ -42,14 +42,18 @@ func (s *Stream) renewLoop(ctx context.Context) {
// WS-BaseNotification §6.1.1 also allows xsd:duration but older
// Hikvision, some Dahua and some Bosch firmwares reject the
// relative form.
func renewPullPoint(c caller, endpoint string, opts Options) error {
func renewPullPoint(c caller, ref subscriptionRef, opts Options) error {
absoluteEnd := time.Now().UTC().Add(opts.InitialTermination).Format("2006-01-02T15:04:05Z")
req := event.Renew{TerminationTime: xsd.String(absoluteEnd)}
body, err := xml.Marshal(req)
if err != nil {
return fmt.Errorf("marshal Renew: %w", err)
}
resp, err := c.SendSoap(endpoint, string(body))
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
if err != nil {
return fmt.Errorf("build ref params header: %w", err)
}
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
if err != nil {
return enrichSOAPErr(resp, err)
}

View File

@@ -175,7 +175,7 @@ func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) {
func TestRenewPullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap(renewFaultBody, errors.New("400 Bad Request"))
err := renewPullPoint(fc, "http://camera/sub", defaultOptions())
err := renewPullPoint(fc, subscriptionRef{Address: "http://camera/sub"}, defaultOptions())
require.Error(t, err)
assert.Contains(t, err.Error(), "renew-specific complaint",
"renewPullPoint must enrich transport errors with the camera's SOAP fault")
@@ -187,3 +187,17 @@ const renewFaultBody = `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-
<env:Reason><env:Text xml:lang="en">renew-specific complaint</env:Text></env:Reason>
</env:Fault></env:Body>
</env:Envelope>`
func TestRenewPullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
ref := subscriptionRef{
Address: "http://192.168.1.10/onvif/services",
RefParamsXML: `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`,
}
fc := newFakeCaller()
require.NoError(t, renewPullPoint(fc, ref, defaultOptions()))
require.Len(t, fc.sendSoapHeaders, 1)
hdr := fc.sendSoapHeaders[0]
assert.Contains(t, hdr, "SubscriptionId")
assert.Contains(t, hdr, "297")
assert.Contains(t, hdr, `IsReferenceParameter="true"`)
}

View File

@@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/beevik/etree"
"github.com/kerberos-io/onvif/event"
"github.com/kerberos-io/onvif/xsd"
)
@@ -22,7 +23,7 @@ import (
// hostile or buggy camera from OOMing the process.
const maxResponseBytes = 10 << 20
func createPullPoint(c caller, opts Options) (string, error) {
func createPullPoint(c caller, opts Options) (subscriptionRef, error) {
term := xsd.String(durationToXSD(opts.InitialTermination))
req := event.CreatePullPointSubscription{InitialTerminationTime: &term}
if opts.RawTopicFilter != "" {
@@ -35,26 +36,26 @@ func createPullPoint(c caller, opts Options) (string, error) {
}
resp, err := c.CallMethod(req)
if err != nil {
return "", enrichSOAPErr(resp, err)
return subscriptionRef{}, enrichSOAPErr(resp, err)
}
body, err := readClose(resp)
if err != nil {
return "", err
return subscriptionRef{}, err
}
var decoded event.CreatePullPointSubscriptionResponse
if err := unmarshalNode(body, "CreatePullPointSubscriptionResponse", &decoded); err != nil {
return "", err
return subscriptionRef{}, err
}
addr := string(decoded.SubscriptionReference.Address)
if addr == "" {
return "", errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address")
return subscriptionRef{}, errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address")
}
return addr, nil
return subscriptionRef{Address: addr, RefParamsXML: extractReferenceParameters(body)}, nil
}
// pullMessages returns an empty slice (no error) when the camera had
// nothing within PullTimeout.
func pullMessages(c caller, endpoint string, opts Options) ([]event.NotificationMessage, error) {
func pullMessages(c caller, ref subscriptionRef, opts Options) ([]event.NotificationMessage, error) {
req := event.PullMessages{
Timeout: xsd.Duration(durationToXSD(opts.PullTimeout)),
MessageLimit: xsd.Int(opts.MessageLimit),
@@ -63,7 +64,11 @@ func pullMessages(c caller, endpoint string, opts Options) ([]event.Notification
if err != nil {
return nil, fmt.Errorf("marshal PullMessages: %w", err)
}
resp, err := c.SendSoap(endpoint, string(body))
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
if err != nil {
return nil, fmt.Errorf("build ref params header: %w", err)
}
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
if err != nil {
return nil, enrichSOAPErr(resp, err)
}
@@ -78,17 +83,21 @@ func pullMessages(c caller, endpoint string, opts Options) ([]event.Notification
return decoded.NotificationMessage, nil
}
// unsubscribePullPoint is best-effort. Empty endpoint is a no-op
// (construction failed before installing one).
func unsubscribePullPoint(c caller, endpoint string) error {
if endpoint == "" {
// unsubscribePullPoint is best-effort. Empty Address is a no-op
// (construction failed before installing a subscription).
func unsubscribePullPoint(c caller, ref subscriptionRef) error {
if ref.Address == "" {
return nil
}
body, err := xml.Marshal(event.Unsubscribe{})
if err != nil {
return fmt.Errorf("marshal Unsubscribe: %w", err)
}
resp, err := c.SendSoap(endpoint, string(body))
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
if err != nil {
return fmt.Errorf("build ref params header: %w", err)
}
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
if err != nil {
return enrichSOAPErr(resp, err)
}
@@ -168,6 +177,51 @@ func extractSOAPFault(body string) string {
return ""
}
var refParamsRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?ReferenceParameters\b[^>]*>(.*?)</(?:[^:>\s]+:)?ReferenceParameters>`)
// buildRefParamsHeader produces the SOAP <Header> inner XML for a set
// of WS-Addressing ReferenceParameters: each top-level child element
// is re-emitted with wsa:IsReferenceParameter="true" added, as the
// spec requires. Empty input yields empty output (no-op for vendors
// that encode subscription identity in the URL).
func buildRefParamsHeader(rawXML string) (string, error) {
if strings.TrimSpace(rawXML) == "" {
return "", nil
}
doc := etree.NewDocument()
if err := doc.ReadFromString("<wrap>" + rawXML + "</wrap>"); err != nil {
return "", fmt.Errorf("parse ref params: %w", err)
}
wrap := doc.SelectElement("wrap")
if wrap == nil {
return "", errors.New("parse ref params: missing wrap root")
}
var out strings.Builder
for _, child := range wrap.ChildElements() {
child.CreateAttr("wsa:IsReferenceParameter", "true")
d := etree.NewDocument()
d.SetRoot(child.Copy())
s, err := d.WriteToString()
if err != nil {
return "", fmt.Errorf("serialise ref param child: %w", err)
}
out.WriteString(strings.TrimRight(s, "\n"))
}
return out.String(), nil
}
// extractReferenceParameters returns the verbatim inner XML so callers
// can echo it (with wsa:IsReferenceParameter="true") into the SOAP
// Header of subscription-scoped requests per WS-Addressing 1.0 §3.1.
// Without that echo, AXIS rejects PullMessages with ter:InvalidArgs.
func extractReferenceParameters(body string) string {
m := refParamsRE.FindStringSubmatch(body)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
}
// extractSOAPSubcode is the fallback when Reason/Text is empty — AXIS
// routinely sends an empty <Text/> alongside a populated Subcode
// (e.g. "ter:InvalidArgs"), and that subcode is the only actionable

View File

@@ -210,7 +210,7 @@ func TestCreatePullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
func TestPullMessages_EnrichesTransportErrWithFaultReason(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap(faultBody, errors.New("400 Bad Request"))
_, err := pullMessages(fc, "http://camera/sub", defaultOptions())
_, err := pullMessages(fc, subscriptionRef{Address: "http://camera/sub"}, defaultOptions())
require.Error(t, err)
assert.Contains(t, err.Error(), "camera-specific complaint",
"pullMessages must enrich transport errors with the camera's SOAP fault")
@@ -219,8 +219,176 @@ func TestPullMessages_EnrichesTransportErrWithFaultReason(t *testing.T) {
func TestUnsubscribePullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap(faultBody, errors.New("400 Bad Request"))
err := unsubscribePullPoint(fc, "http://camera/sub")
err := unsubscribePullPoint(fc, subscriptionRef{Address: "http://camera/sub"})
require.Error(t, err)
assert.Contains(t, err.Error(), "camera-specific complaint",
"unsubscribePullPoint must enrich transport errors with the camera's SOAP fault")
}
// --- ReferenceParameters extraction (WS-Addressing 1.0 §3.1) ---------
//
// AXIS encodes the subscription identity in <wsa:ReferenceParameters>
// inside CreatePullPointSubscriptionResponse rather than in the URL
// itself. Subsequent PullMessages/Renew/Unsubscribe MUST echo those
// elements verbatim into the SOAP Header, or the camera responds with
// ter:InvalidArgs. The auto-generated event.ReferenceParametersType is
// an empty struct (drops children), so we extract the raw inner XML.
func TestExtractReferenceParameters_AXISShape(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa5="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body><tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa5:Address>http://192.168.1.10/onvif/services</wsa5:Address>
<wsa5:ReferenceParameters>
<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>
</wsa5:ReferenceParameters>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
got := extractReferenceParameters(body)
assert.Contains(t, got, "SubscriptionId")
assert.Contains(t, got, "297")
assert.Contains(t, got, `xmlns:dom0="http://www.axis.com/2009/event"`,
"namespace declaration on the SubscriptionId child must survive extraction")
}
func TestExtractReferenceParameters_AbsentReturnsEmpty(t *testing.T) {
// Geovision/Hikvision-style: Address only, no ReferenceParameters.
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing">
<env:Body><tev:CreatePullPointSubscriptionResponse xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<tev:SubscriptionReference>
<wsa:Address>http://camera/onvif/Events/Sub_1</wsa:Address>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
assert.Empty(t, extractReferenceParameters(body))
}
func TestExtractReferenceParameters_EmptyBodyReturnsEmpty(t *testing.T) {
assert.Empty(t, extractReferenceParameters(""))
}
// --- createPullPoint returns both address and ref params -------------
func TestCreatePullPoint_ReturnsRefParamsAlongsideAddress(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa5="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body><tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa5:Address>http://192.168.1.10/onvif/services</wsa5:Address>
<wsa5:ReferenceParameters>
<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>
</wsa5:ReferenceParameters>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
fc := newFakeCaller()
fc.queueCallMethod(body, nil)
ref, err := createPullPoint(fc, defaultOptions())
require.NoError(t, err)
assert.Equal(t, "http://192.168.1.10/onvif/services", ref.Address)
assert.Contains(t, ref.RefParamsXML, "SubscriptionId")
assert.Contains(t, ref.RefParamsXML, "297")
}
func TestCreatePullPoint_VendorWithoutRefParams_RefParamsEmpty(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ref, err := createPullPoint(fc, defaultOptions())
require.NoError(t, err)
assert.NotEmpty(t, ref.Address)
assert.Empty(t, ref.RefParamsXML)
}
// --- Reference-parameter echoing in subscription-scoped calls --------
//
// WS-Addressing 1.0 §3.1 requires each <wsa:ReferenceParameters> child
// to be echoed as a SOAP Header block carrying wsa:IsReferenceParameter
// ="true". AXIS rejects PullMessages with ter:InvalidArgs when this is
// absent.
func TestPullMessages_EchoesRefParamsWithIsReferenceParameterAttribute(t *testing.T) {
ref := subscriptionRef{
Address: "http://192.168.1.10/onvif/services",
RefParamsXML: `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`,
}
fc := newFakeCaller()
_, err := pullMessages(fc, ref, defaultOptions())
require.NoError(t, err)
require.Len(t, fc.sendSoapHeaders, 1)
hdr := fc.sendSoapHeaders[0]
assert.Contains(t, hdr, "SubscriptionId", "ref param element must be echoed")
assert.Contains(t, hdr, "297", "ref param value must be echoed")
assert.Contains(t, hdr, `IsReferenceParameter="true"`,
"WS-Addressing 1.0 §3.1 requires the attribute on each echoed element")
}
func TestPullMessages_NoRefParams_HeaderEmpty(t *testing.T) {
ref := subscriptionRef{Address: "http://camera/sub", RefParamsXML: ""}
fc := newFakeCaller()
_, err := pullMessages(fc, ref, defaultOptions())
require.NoError(t, err)
require.Len(t, fc.sendSoapHeaders, 1)
assert.Empty(t, fc.sendSoapHeaders[0], "vendors without ref params get no extra header")
}
func TestPullMessages_PostsToAddressFromRef(t *testing.T) {
ref := subscriptionRef{Address: "http://camera/specific-sub-endpoint", RefParamsXML: ""}
fc := newFakeCaller()
_, err := pullMessages(fc, ref, defaultOptions())
require.NoError(t, err)
require.NotEmpty(t, fc.sendSoapCalls)
assert.Equal(t, "http://camera/specific-sub-endpoint", fc.sendSoapCalls[0][0])
}
// --- Building the header XML from raw ref params ----------------------
func TestBuildRefParamsHeader_AddsIsReferenceParameter(t *testing.T) {
raw := `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`
got, err := buildRefParamsHeader(raw)
require.NoError(t, err)
assert.Contains(t, got, "SubscriptionId")
assert.Contains(t, got, "297")
assert.Contains(t, got, `xmlns:dom0="http://www.axis.com/2009/event"`,
"original namespace declaration must survive")
assert.Contains(t, got, `IsReferenceParameter="true"`)
}
func TestBuildRefParamsHeader_MultipleTopLevelChildren(t *testing.T) {
raw := `<a:Foo xmlns:a="ns/a">1</a:Foo><b:Bar xmlns:b="ns/b">2</b:Bar>`
got, err := buildRefParamsHeader(raw)
require.NoError(t, err)
assert.Equal(t, 2, strings.Count(got, `IsReferenceParameter="true"`),
"attribute must be added to every top-level child, not just the first")
assert.Contains(t, got, "Foo")
assert.Contains(t, got, "Bar")
}
func TestBuildRefParamsHeader_EmptyInputReturnsEmpty(t *testing.T) {
got, err := buildRefParamsHeader("")
require.NoError(t, err)
assert.Empty(t, got)
}
func TestUnsubscribePullPoint_EchoesRefParamsWithIsReferenceParameter(t *testing.T) {
ref := subscriptionRef{
Address: "http://192.168.1.10/onvif/services",
RefParamsXML: `<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>`,
}
fc := newFakeCaller()
require.NoError(t, unsubscribePullPoint(fc, ref))
require.Len(t, fc.sendSoapHeaders, 1)
hdr := fc.sendSoapHeaders[0]
assert.Contains(t, hdr, "SubscriptionId")
assert.Contains(t, hdr, `IsReferenceParameter="true"`)
}
func TestUnsubscribePullPoint_EmptyAddressIsNoOp(t *testing.T) {
fc := newFakeCaller()
require.NoError(t, unsubscribePullPoint(fc, subscriptionRef{}))
assert.Empty(t, fc.sendSoapCalls, "no SOAP call should happen when there is no subscription endpoint")
}

View File

@@ -110,6 +110,17 @@ func (o Options) withDefaults() Options {
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:
//
@@ -125,6 +136,7 @@ func (o Options) withDefaults() Options {
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 }
@@ -137,6 +149,10 @@ 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.
@@ -145,7 +161,7 @@ type Stream struct {
opts Options
pullPointMu sync.Mutex
pullPoint string
pullPoint subscriptionRef
events chan Event
errors chan error
@@ -160,16 +176,16 @@ type Stream struct {
now func() time.Time
}
func (s *Stream) getPullPoint() string {
func (s *Stream) getPullPoint() subscriptionRef {
s.pullPointMu.Lock()
defer s.pullPointMu.Unlock()
return s.pullPoint
}
func (s *Stream) setPullPoint(addr string) {
func (s *Stream) setPullPoint(ref subscriptionRef) {
s.pullPointMu.Lock()
defer s.pullPointMu.Unlock()
s.pullPoint = addr
s.pullPoint = ref
}
// NewStream creates a Stream and performs CreatePullPointSubscription
@@ -183,7 +199,7 @@ func NewStream(ctx context.Context, dev *onvif.Device, opts Options) (*Stream, e
func newStream(ctx context.Context, c caller, opts Options) (*Stream, error) {
opts = opts.withDefaults()
addr, err := createPullPoint(c, opts)
ref, err := createPullPoint(c, opts)
if err != nil {
return nil, fmt.Errorf("create pull point subscription: %w", err)
}
@@ -191,7 +207,7 @@ func newStream(ctx context.Context, c caller, opts Options) (*Stream, error) {
s := &Stream{
caller: c,
opts: opts,
pullPoint: addr,
pullPoint: ref,
events: make(chan Event, opts.BufferSize),
errors: make(chan error, opts.BufferSize),
cancel: cancel,

View File

@@ -33,6 +33,7 @@ type fakeCaller struct {
defaultCall fakeResp
callMethodCalls []any
sendSoapCalls [][2]string
sendSoapHeaders []string
blockUnsubscribe chan struct{}
blockAllSendSoap chan struct{}
}
@@ -104,6 +105,16 @@ func (f *fakeCaller) SendSoap(endpoint, body string) (*http.Response, error) {
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, r.err
}
// SendSoapWithHeader delegates body+endpoint recording to SendSoap so
// existing assertions on sendSoapCalls keep working, and records the
// header XML in a parallel slice for ref-params wiring tests.
func (f *fakeCaller) SendSoapWithHeader(endpoint, body, headerXML string) (*http.Response, error) {
f.mu.Lock()
f.sendSoapHeaders = append(f.sendSoapHeaders, headerXML)
f.mu.Unlock()
return f.SendSoap(endpoint, body)
}
func (f *fakeCaller) sendSoapCallCount() int {
f.mu.Lock()
defer f.mu.Unlock()