72 Commits

Author SHA1 Message Date
Cédric Verstraeten
1aecf54890 Merge pull request #12 from kerberos-io/feature/enhance-ptz-failure-logging
feature/enhance-ptz-failure-logging
2026-08-05 13:18:10 +02:00
Cédric Verstraeten
fe86ea942d Return errors for failed SOAP responses
Propagate HTTP 4xx/5xx errors from SOAP and digest requests, preserve responses, and ensure PTZ zero coordinates are serialized. Add regression tests for both behaviors.
2026-08-05 13:13:42 +02:00
Cédric Verstraeten
42ac2bf892 Merge pull request #9 from sharedjourney/feat/axis-vmd4-motion-topic
feat(event/stream): classify AXIS VMD 3 and VMD 4 topics as motion
2026-08-05 12:51:08 +02:00
Cédric Verstraeten
3af23e5756 Merge pull request #11 from sharedjourney/fix/guard-pull-timeout-vs-client-timeout
fix(event/stream): reject a client timeout that cannot outlast PullTimeout
2026-08-05 12:50:41 +02:00
T. Tradesman
bc9bab3de0 test(event/stream): guard the AXIS VMD needles against overmatch
Both VMD rules shipped with positive cases only. The file already keeps
a "Substring guards" section because Classify matches with
strings.Contains, so a needle that is a prefix of a sibling name
silently captures it — the same reason MyRuleDetector's sub-rules are
whitelisted individually.

Pin the two properties the needles rely on: the trailing slash makes
them match a whole path segment, and VMD3 is scoped to RuleEngine.
Verified by dropping each from the rule and watching these fail.

Also drops a site-specific note from the VMD 3 comment — where it was
first seen is not something an upstream reader can act on.
2026-07-23 14:56:20 +02:00
T. Tradesman
3634cee483 fix(event/stream): require real headroom and type the options error
Two gaps in the previous commit's guard.

Strict inequality was not enough. A client timeout one millisecond
above PullTimeout passed, and the test pinned that as valid — but the
client ceiling also has to cover dial, TLS and the response transfer on
top of the poll it outlasts, which on a cellular bearer is hundreds of
milliseconds. Require minClientHeadroom (5s) above PullTimeout.

The error was a bare fmt.Errorf, so callers could not tell it from the
transient pull/renew/recreate failures they retry. A consumer that
retries this one loops forever on a configuration that can never
succeed. ErrInvalidOptions is a sentinel they can short-circuit on.

Zero stays accepted: it is the SDK's default when a caller passes no
client, so rejecting it would break every default consumer. The comment
no longer claims that is safe — the caller interface documents that ctx
cannot interrupt an in-flight SOAP call, so an unbounded client is the
one case nothing can unwedge.
2026-07-23 14:56:20 +02:00
T. Tradesman
513c0a8473 fix(event/stream): classify AXIS VMD 3 topics as motion
VMD 3 is the firmware-builtin motion rule, published as
tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_<N>. It predates the VMD 4 ACAP
and lives under RuleEngine rather than CameraApplicationPlatform, so the
VMD 4 rule added in the previous commit does not cover it.

Confirmed against a deployed AXIS camera: with debug logging on, its
event stream emits vmd3_video_1 on every motion trigger, and every one
was discarded as KindUnknown.

The VMD 4 rule stays — both generations are in the field.
2026-07-23 14:56:20 +02:00
T. Tradesman
43bc40babd fix(event/stream): reject a client timeout that cannot outlast the pull
PullMessages is a long-poll: the camera holds the connection open for
up to PullTimeout waiting for an event. http.Client.Timeout bounds the
whole exchange — dial, write, wait-for-headers — and starts before the
camera has parsed the request, so a client ceiling equal to or below
PullTimeout expires first on every interval with no event.

The failure mode is quiet and easy to misread. Pulls fail continuously,
but the stream stays alive because ReconnectAfterFailures recreates the
subscription, and each recreate makes the camera replay its full
property state. Events keep arriving, in bursts, on the reconnect
cadence rather than when they happen — so it reads as a slow camera
rather than a misconfiguration.

Observed in the field with both values at 5s: every pull timed out,
recovery landed after exactly 3 failures, and ~90 property-state events
were replayed every 18s.

Validated in NewStream, before the subscription call, since the config
can only fail. A zero client timeout stays legal — unbounded is safe
because the pull loop is already bounded by ctx.
2026-07-23 14:56:20 +02:00
T. Tradesman
685b65c35e fix(event/stream): classify AXIS VMD 4 topics as motion
The topic table covered the AXIS Guard suite (MotionGuard, FenceGuard,
LoiteringGuard) but not VMD — the stock motion app shipped on every
AXIS camera, and the one an installer configures before reaching for a
paid Guard product.

VMD publishes on tnsaxis:CameraApplicationPlatform/VMD/Camera<N>Profile
<ID>, which fell through every rule to KindUnknown. Consumers that key
recording off KindMotion therefore never recorded, and because an
unrecognised Kind is normally discarded at debug level the failure
presents as silence rather than an error.

Found on a site where an AXIS camera with VMD and a motion recording
trigger produced no recordings across a 9h41m window while its ONVIF
subscription stayed healthy throughout.
2026-07-23 14:56:20 +02:00
Cédric Verstraeten
4c67d896e3 Merge pull request #8 from kerberos-io/fix/authentication-mechanism
fix/authentication-mechanism
2026-07-07 16:11:37 +02:00
Cédric Verstraeten
9a2e9ce9fe feat: add GitHub workflows for pull request build, release validation, and version bumping 2026-07-07 13:43:38 +00:00
Cédric Verstraeten
96918255a9 feat: enhance authentication mechanism and improve event handling
- Added EndpointRefAddress to DeviceParams for better endpoint management.
- Updated FilterType to use pointers for TopicExpression and MessageContent.
- Refactored SendSoapWithDigest to strip WS-Security headers to avoid credential duplication.
- Updated package imports to reflect new repository structure.
- Introduced ReadAndParse function for improved HTTP response handling in SDK.
2026-07-07 12:49:51 +00:00
Cédric Verstraeten
2fb619deda Merge branch 'master' into fix/authentication-mechanism 2026-07-07 14:18:26 +02:00
Cédric Verstraeten
119a6511b1 feat(auth): add AuthMode-driven auth with HTTP digest support
On use-go/onvif the only authenticated transport was WS-Security
UsernameToken. Cameras whose firmware requires HTTP digest for
authenticated operations therefore onboarded fine (GetCapabilities is
allowed unauthenticated) but rejected every authenticated call such as
PTZ ContinuousMove, making pan/tilt appear completely broken.

Add an AuthMode selector on DeviceParams and route CallMethod through a
new AuthMode-aware sendSOAP helper:

  - none:          no auth
  - usernametoken: WS-Security only (never escalates to digest)
  - digest:        HTTP digest only
  - both / unset:  WS-Security when credentials exist, plus an HTTP
                   digest retry only when the device answers HTTP 401

networking.SendSoapWithDigest performs a standards-compliant RFC 2617
digest handshake (MD5 / MD5-sess, qop=auth) using only the standard
library, and never strips an existing WS-Security header. The default
(unset AuthMode) stays backward compatible: digest is attempted only on
a genuine 401 challenge, so WS-Security-only cameras are unaffected.

Adds unit tests covering challenge parsing, the RFC 2617 worked example,
authorization-header consistency, and rejection of unsupported qop.
2026-07-07 12:00:16 +00:00
Cédric Verstraeten
0e0bc0ed0a Merge pull request #6 from sharedjourney/feat/event-stream-axis-compat
fix(event/stream): make `event/stream` work against AXIS cameras
2026-07-07 13:51:22 +02:00
Cédric Verstraeten
3bd8516f94 Merge pull request #4 from mohitsolanki026/fix/preset-tour-multiple-spots
fix: support multiple TourSpots in PresetTour struct.
2026-07-07 13:51:05 +02:00
Sebastian Norling
16312b236d docs: fix WS-Addressing spec citations
The reference-parameter-to-SOAP-header mapping rule (with the
wsa:IsReferenceParameter='true' attribute) lives in WS-Addressing 1.0
SOAP Binding §3.4 (Binding Message Addressing Properties), not Core
§3.1 (Abstract Property Definitions). The earlier citations were
wrong on both the section number and the document. Corrected across
doc comments, test descriptions, and the PR description.

The "ReferenceParameters can appear in any endpoint reference" claim
in the anchored-extraction test now cites Core §2.1 (Information
Model for Endpoint References), which is where the [reference
parameters] property is defined on the abstract EPR.

Verified against the W3C Recommendations:
 - https://www.w3.org/TR/ws-addr-core/   §2.1
 - https://www.w3.org/TR/ws-addr-soap/   §3.4

Also adds PR_event_stream_axis_compat.md — the branch's PR
description, framed independently of the prior event/stream PR.
2026-05-27 18:11:28 +02:00
Sebastian Norling
07b0f6c2e0 fix(event/stream): address round-3 review findings
Critical
  - Two-step lock race in renewLoop: getPullPoint() + pullPointGen()
    were separate Lock/Unlock pairs, leaving a window in which a
    concurrent setPullPoint could advance gen between the two reads.
    The renew then ran against ref-N but believed its captured gen
    was N+1, and updateGrantedTerminationIfGen "succeeded" writing
    the old subscription's grant onto the new one — same class of
    bug the generation counter was meant to fix. New snapshotPullPoint
    accessor reads both under one lock.
  - wssePasswordRE was missing the close-tag-or-EOF alternative that
    wsseSecurityRE got last round; a <Password> element truncated at
    the 64 KiB error cap escaped redaction. Pattern is now symmetric.

Important
  - Dropped unused (granted, now) params from nextRenewIntervalAfterError;
    only opts.RetryBackoff is consulted. Tests adjusted.
  - Moved gen field next to pullPointMu with explicit guard comment.
  - Inlined the single-use rootTag helper; nil case handled directly.
  - Loosened TestNextRenewIntervalAfterError_FallsBackToRetryBackoff
    so future jitter doesn't break it.

Docs
  - Extended trust-boundary godoc on SendSoap* to name the full set of
    weaponisable WS-* headers (wsa:To/ReplyTo/FaultTo/MessageID,
    wsu:Timestamp).
  - Mirrored the trust-boundary warning on gosoap.AddStringHeaderContents
    so library consumers see it at the package entry point too.
  - Noted that updateGrantedTerminationIfGen intentionally doesn't bump
    gen (would defeat rotation detection).
  - Documented the wsseSecurityRE truncation-branch trade-off
    (max-redact > max-context for log lines).

Tests
  - TestSnapshotPullPoint_AtomicReadOfRefAndGen pins the new accessor.
  - TestEnrichSOAPErr_RedactsTruncatedPasswordOutsideSecurity exercises
    the now-symmetric password redaction.
  - TestEnrichSOAPErr_RedactsMultipleSecurityBlocks pins existing
    multi-block behaviour.
  - TestBuildRefParamsHeader_RejectsNonReferenceParametersRoot_Table
    covers vendor-suffix, multi-root, and unrelated-element cases.
  - One-line comment on the &Stream{} tests explaining nil-now safety.
2026-05-27 18:07:57 +02:00
Sebastian Norling
d7cfee56a1 fix(event/stream): address round-2 review findings
Critical
  - Renew busy-loop on persistent failure: after a failed
    renewPullPoint, the loop re-read the (now in-past)
    GrantedTermination and nextRenewInterval floored to 1s, hammering
    the camera at 1 Hz until reconnect. nextRenewIntervalAfterError
    decouples the failure path from the stale grant and backs off
    at opts.RetryBackoff. renewLoop also routes through s.now() so
    test clocks can drive it deterministically.
  - Lost-update race on GrantedTermination: a renew result for an
    old subscription could overwrite the grant on a new one if
    attemptRecreate swapped pullPoint mid-flight. A generation
    counter on Stream tracks subscription rotation; the renew loop
    captures the generation before the SOAP call and discards the
    result if the subscription was rotated.
  - Credential leak when <Security> straddled the 64 KiB error cap:
    the non-greedy regex required a closing tag and missed the
    truncated case. wsseSecurityRE now matches close-tag-or-EOF.
    Belt-and-braces wssePasswordRE redacts <Password> elements
    outside any Security wrapper.
  - addHeaderChildren accepted well-formed-but-element-free input
    and produced a header-less request. Now errors out.

Important
  - Wrapper detection in buildRefParamsHeader was HasSuffix-based and
    misfired on children named *ReferenceParameters. Replaced with the
    unambiguous wrapper-only contract: input must be the full
    <*:ReferenceParameters> element returned by extractReferenceParameters.
  - Renamed SoapOption → SendSoapOption and WithHeader → WithSOAPHeader
    to disambiguate at call sites.
  - Renamed WithHeader's `xml` parameter to headerContent to avoid
    shadowing the encoding/xml package name.
  - Documented terminationTimeRE's "first match only" semantics so
    nobody re-uses it from PullMessages context where multiple
    TerminationTime elements appear.
  - goleak is now a direct require (go mod tidy).

Performance
  - Added gosoap.AddStringHeaderContents (plural) for multi-root
    header content. AddStringHeaderContent remains as-is for
    backwards compatibility with external consumers. Device.go's
    addHeaderChildren workaround is gone — one etree parse per
    SendSoapWithOptions call instead of two.

Migration
  - Device.go's own CallOnvifFunction and the three examples now
    call SendSoapWithOptions, modelling the canonical path.

Docs / tests
  - Trust-boundary warning on SendSoapWithHeader/Options godoc.
  - Comments on createPullPointResp/Alt explain why TerminationTime
    is intentionally omitted (renew timing fixtures).
  - New tests: digest retry strips WS-Security, duplicate
    WithSOAPHeader is last-wins, malformed TerminationTime yields
    zero, margin==base falls to base/2, empty SubscriptionReference
    yields empty ref params, Security straddling cap is redacted,
    bare Password redacted, wrapper-only contract is enforced.
2026-05-27 18:07:56 +02:00
Sebastian Norling
c7ef445d6a refactor(onvif): add SendSoapWithOptions as the variadic workhorse
Future per-call knobs (timeout, context, custom namespaces) will arrive
sooner than later. Adding them as SendSoapWithHeader2 / 3 / N would
turn the Device API into a combinatorial mess; adding them as new
required positional args breaks every existing consumer.

SendSoapWithOptions accepts variadic SoapOption values. SendSoap and
SendSoapWithHeader become one-line delegates so all current callers
keep their signatures, and the public surface is purely additive.

Only WithHeader ships today — wiring the AXIS ReferenceParameters
path through the same plumbing. New options land as WithX constructors
in this file rather than as new Device methods.
2026-05-27 18:07:56 +02:00
Sebastian Norling
d726ed8edb fix(event/stream): address review findings on AXIS compat work
Critical
  - Device.SendSoapWithHeader now parses the supplied header content
    with etree and adds each top-level child as its own SOAP Header
    block. gosoap.AddStringHeaderContent only accepts a single root
    element; previously a multi-child ref-params header silently
    produced a header-less request because the parse error was
    discarded. Errors are now propagated.
  - enrichSOAPErr scrubs <*:Security> blocks from response bodies
    before fault extraction or excerpt slicing so a camera that
    echoes the WS-Security header in a fault response cannot leak
    Username/Password into operator logs.

Important
  - extractSOAPFault falls back to the SOAP 1.2 Subcode (e.g.
    ter:InvalidArgs) when Reason/Text is empty — consistent with
    enrichSOAPErr and surfaces actionable detail on 200-OK fault
    bodies reached via unmarshalNode.
  - subscriptionRef captures the camera-granted TerminationTime
    from CreatePullPointSubscription and Renew responses. renewLoop
    schedules from it via the new nextRenewInterval helper so we
    never miss a renew when the camera grants less than requested.
    renew is now a sleep-loop driven by the latest granted time.
  - enrichSOAPErr reads at most 64 KiB from the body (vs. 10 MiB
    on success paths). Fault bodies are always small; the prior cap
    let a wedged camera churn 10 MiB/s through the retry loop.

Suggestions
  - extractReferenceParameters anchors to <SubscriptionReference> so
    a wsa:ReplyTo / wsa:FaultTo that also carries ReferenceParameters
    elsewhere in the envelope cannot leak through and break PullMessages.
  - buildRefParamsHeader accepts either raw children or the full
    <*:ReferenceParameters> wrapper, and propagates ancestor xmlns:*
    onto each child so a vendor that declares the prefix on the
    parent (not the child itself, as AXIS does) still produces valid
    standalone children on the wire.
  - SendSoapWithHeader documents that xmlHeaderContent must be
    well-formed XML and that the caller is responsible for escaping
    any externally sourced data.
  - Error-message ordering is now context-first
    ("SOAP fault: X: <wrapped err>") per Go convention.
  - Dead headerEnd slicing removed from the SendSoapWithHeader test.

Tests
  - End-to-end multi-child wiring through pullMessages.
  - Digest auth retry preserves the injected header.
  - Malformed-XML header content fast-fails before any request.
  - buildRefParamsHeader malformed / whitespace-only edge cases.
  - goleak.VerifyTestMain in event/stream catches any pull/renew
    goroutine that outlives its Stream.

No new behavioural surface added to onvif core; SendSoap retains
its signature, SendSoapWithHeader is the only new public method.
2026-05-27 18:07:56 +02:00
Sebastian Norling
de3f049a63 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.
2026-05-27 18:07:56 +02:00
Sebastian Norling
a796a23058 feat(event/stream): enrich SOAP transport errors with fault detail
Pull/renew/recreate/unsubscribe errors against a misbehaving camera
previously surfaced as "Post with digest error: 400: 400 Bad Request"
— operators had no way to tell auth failure from a malformed request
from an expired subscription. The body always carried the answer; we
just discarded it.

enrichSOAPErr now reads the response body alongside transport errors
and appends, in order of preference:

  1. SOAP 1.1 faultstring / SOAP 1.2 Reason/Text — the reason text.
  2. SOAP 1.2 Subcode/Value — AXIS routinely sends an empty Text and
     leaves Subcode as the only actionable signal (ter:InvalidArgs etc).
  3. Raw body excerpt, truncated to maxErrExcerpt — for non-SOAP
     bodies (HTML error pages, plain text) without flooding logs.

The original error is preserved via %w so errors.Is/As in
logStreamError keep working. Wired into all four SOAP call sites
(createPullPoint, pullMessages, renewPullPoint, unsubscribePullPoint).
2026-05-27 18:07:56 +02:00
Cédric Verstraeten
cad23c996e Merge pull request #5 from sharedjourney/feature/event-stream
feat(event/stream): add channel-based ONVIF event consumer
2026-05-25 20:26:56 +02:00
Sebastian Norling
70e6765a7d fix(event/stream): bound Close drain to survive a hung HTTP caller
Concurrency audit (third review) flagged that caller.SendSoap is not
ctx-aware: cancelling ctx does not unblock a pull or renew goroutine
parked in the underlying http.Client.Do. The previous Close()
unconditionally did <-s.done before its 5s unsubscribe timeout, so a
wedged SendSoap could hang Close indefinitely — taking the agent's
shutdown down with it.

Adds closeDrainTimeout (5s) to bound the wait for the run goroutines
to exit. When the drain times out:
  * Close returns a 'did not drain' error so the caller can move on.
  * Unsubscribe is skipped; the subscription expires at the camera
    once InitialTermination elapses without a Renew.
  * The wedged goroutines exit later, when the HTTP transport
    eventually gives up. They are effectively leaked until then —
    documented in the caller interface comment as the contract
    callers must accept (or fix, by configuring an http.Client.Timeout).

The caller interface doc-comment now states both invariants
explicitly: must be goroutine-safe AND must enforce its own per-
request timeout, because we cannot from here.

Test
----
TestClose_BoundedWhenLoopsStuckOnHungHTTP: drives the fakeCaller
with blockAllSendSoap (new flag) so every SendSoap parks. Waits for
pullLoop to actually reach the blocked SendSoap before calling
Close (a race the previous attempt had: Close raced the loop and
exited via the ctx pre-check). Asserts Close returns within
closeDrainTimeout + 2s slack with a drain-timeout error.

Other concurrency audit findings disposition
--------------------------------------------
* unsubscribe goroutine leaks past 5s: intentional, already
  documented at closeUnsubscribeTimeout.
* now func() time.Time data race: written once before goroutines
  start; safe by happens-before. Tests do not swap it today.
* closeOnce self-deadlock if Close called from inside a loop:
  no path exists; not exposed via the API.
2026-05-21 20:48:15 +02:00
Sebastian Norling
fcc3a90f9b docs(event/stream): trim comments to WHY, drop noise
Audit against the standard 'default to no comments; only add one when
the WHY is non-obvious'. Net: 238 lines removed across 8 files, no
behaviour change, tests still pass -race.

What went
---------
* Section banners (// ---------- Motion ----------): noise once
  per-rule citations exist.
* Per-rule 'Data: IsMotion (xsd:boolean)' wire-format lines in
  topics.go: that's WHAT; the spec citation carries WHY.
* Per-field doc on Event struct restating each field name (// Kind
  is the normalized event category) and the type-doc preamble.
* Stringer doc comments ('// String implements fmt.Stringer.') and
  similar conventional-method noise.
* 'Used by ErrPullFailed / ErrRenewFailed / ErrRecreateFailed' in
  the Op doc — the rule-named anti-pattern.
* doc.go Invariants and Reconnect sections duplicating per-function
  docs.
* Internal helper doc-comments restating what the function does
  (surfaceError, run, simpleItemsToMap first sentence, etc.).

What stayed
-----------
* Every spec / vendor-doc citation in topics.go.
* Race-condition WHY in stream.go run() close ordering.
* Workaround WHY in renew.go (absolute datetime vs duration).
* WS-BaseNotification UTC rationale + vendor format list in
  decode.go.
* Fleet-sizing and thundering-herd rationale in reconnect.go.
* Stream consumer invariants (NewStream synchronous I/O, Errors
  non-blocking, Close idempotent + bounded).

The change matches the codebase's stated style (CLAUDE.md): WHY only,
no WHAT, no cross-file references, no current-task narration.
2026-05-21 18:43:08 +02:00
Sebastian Norling
badcc8fba2 docs(development): point readers at event/stream higher-level helper
Development.md describes the wire-layer convention (one directory per
Onvif Web Service, gen_commands.py for new SOAP command types) but
does not mention that some directories also ship hand-written
higher-level helpers built on top of those types. A new contributor
reading the doc could reasonably assume event/ is purely
auto-generated and miss event/stream.

Adds a 'Higher-level helpers' section that calls out:
* event/stream — the new channel-based event consumer.
* event/topic — the existing topic identifier helpers.

Also documents the placement convention (sub-package under the
relevant web service directory) so future helpers land in a
predictable spot.
2026-05-21 15:19:15 +02:00
Sebastian Norling
a1fc7832ef refactor(event/stream): align source and test files 1:1 by concern
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.
2026-05-21 15:14:40 +02:00
Sebastian Norling
1ceef725ec fix(examples): secure credential handling and Errors-arm bug
Two findings from the resource/security and API reviews of the
streamtest example.

Credentials
-----------
* loadPassword resolves the camera password in order:
    1. ONVIF_PASSWORD environment variable (recommended).
    2. -password-file <path> (newline trimmed).
    3. Interactive prompt when nothing else is set.
* -password flag still works but now logs a WARNING that the value
  leaks into shell history and process listings. Documented as
  'INSECURE' in the flag help.
* Updated package godoc with a Credentials section.

Errors-arm bug
--------------
* Previous code: case e := <-s.Errors() with no ok check. When the
  Stream closed, this arm would spin on a closed channel printing
  '<nil>' forever (until ctx-done elsewhere unblocked it). Mirror
  the Events arm's ok pattern.
* Switched the error-log branch to inspect the typed errors added
  in the previous commit: ErrRecreateFailed gets a louder 'camera
  may be offline' log line; ErrPullFailed is a quieter
  'will retry' since the loop handles transient pull errors
  automatically.

Also prints '[after-reconnect]' on events carrying that flag so the
operator can see when the stream silently recovered a dropped
subscription — confirms the new observability surface is useful at
the CLI level.
2026-05-21 15:02:23 +02:00
Sebastian Norling
c6cad2c35d test(event/stream): close review-2 coverage gaps
Adds the missing test coverage flagged by the test-rigor reviewer.

Coverage / behaviour
--------------------
* TestClose_ReturnsUnsubscribeError: previously closeErr plumbing was
  effectively dead code in the suite. Inject an Unsubscribe failure
  and assert the error wraps it.
* TestNewStream_CtxAlreadyCancelled: pins the behaviour for a
  pre-cancelled parent context (construction succeeds because
  createPullPoint does not consult ctx; run goroutine exits
  immediately and Events closes).
* TestStream_DisableReconnectKeepsRetryingOriginalEndpoint: proves
  the new opt-out actually disables CreatePullPoint recreate.
* TestStream_RecreateResetsFailuresAndBackoffOnSuccess: locks the
  attemptRecreate success path resetting *failures and *backoff so a
  later failure does not accidentally enter exponential backoff
  immediately.

Race detection
--------------
* TestStream_PullPointMutationVisibleToRenewLoopUnderRace: drives the
  pullPoint write-by-pullLoop / read-by-renewLoop race so -race
  actually exercises the mutex critical sections. Previously the
  mutex was structurally correct but no test produced contention.

Decoder edge cases
------------------
* TestDecode_PropertyOperationIsCaseSensitive: per WS-Notification
  §3.3, values are PascalCase. Lowercased forms fall through to
  PropertyUnknown.
* TestDecode_StateValueTrimsWhitespace: explicit assertions for
  '  true  ', tabs, newlines and whitespace-only.
* TestDecode_SimpleItemEmptyValueIsUnknownState: empty value yields
  StateUnknown but the empty entry is still preserved in Data map.
* TestDecode_DeviceTimeAdditionalLayouts: the +0200 compact offset
  and naked-no-TZ formats added in the hardening commit.
* TestDecode_DeviceTimeStillRejectsNonsense: the broader layout list
  did not start accepting garbage.
* TestExtractState_FirstBooleanLikeWins: uses explicit slice
  construction (pair{k,v} -> SimpleItem) so the assertion does not
  depend on map iteration order, the latent flake risk in the AOA
  test pointed out by the reviewer.

Helpers
-------
* helpers_test.go waitFor(t, d, msg, cond) centralises the
  10ms-poll-until-deadline pattern that previously appeared four
  times across stream_test / renew_test / reconnect_test.
* TestFakeCaller_QueueThenDefaultFallback: self-test for the fake.
  When the fake grows to 100+ LOC, debugging a flaky stream test
  should not also require investigating whether the fake itself
  behaves correctly.
2026-05-21 15:01:35 +02:00
Sebastian Norling
4fd92dd229 feat(event/stream): raise recreate backoff cap and add jitter
The previous 30-second cap meant a 1000-camera fleet recovering from
a switch reboot would generate a sustained 33 RPS of doomed
CreatePullPointSubscription traffic against still-booting cameras,
and the synchronised retries would arrive in phase. Two changes:

* Cap raised to 5 minutes. Single-camera recovery latency goes from
  '<=30s after camera comes back' to '<=300s', which is fine because
  by the time we are this deep in backoff the camera has already been
  unreachable through 6+ attempts (1s, 2s, 4s, 8s, 16s, 30s under the
  old cap) — the marginal recovery delay is acceptable to avoid the
  network melt.

* Symmetric ±25% jitter on every recreate sleep so synchronised
  drops (switch reboot, DHCP storm, NTP slew) do not cause
  synchronised reconnect surges. Standard practice — same shape AWS,
  Cloudflare and HA event_manager use.

Tests assert the jitter range, the documented cap value (so a future
maintainer flipping it back to 30s notices in CI), and that jitter
varies across calls (proves the rand source is wired).
2026-05-21 14:59:44 +02:00
Sebastian Norling
94572504fc style(examples): gofmt alignment for stream example Options literal 2026-05-21 14:58:48 +02:00
Sebastian Norling
fd71109514 refactor(event/stream): tighten public surface per v1 review
API-shape changes flagged as 'hard to reverse after v1' by the
architect reviewer. Acceptable to do now while no external code
imports the package; would be breaking later.

Surface tightening
------------------
* Decode unexported to decode. The Stream is the only intended caller;
  exposing the helper invited future API drift. Same-package tests
  still reach it.
* TopicFilter renamed to RawTopicFilter to signal that the value is
  fed verbatim into the SOAP envelope and is the 'advanced escape
  hatch', not the supported routing surface. Callers should normally
  leave it empty and rely on Classify.

Options zero-value policy clarified
-----------------------------------
* Field godoc on every numeric option now explicitly states 'zero
  means default' so the policy is local, not buried in
  withDefaults().
* New DisableReconnect bool — addresses the
  ReconnectAfterFailures=0-as-disable footgun the API reviewer flagged.
  Reader can no longer confuse 'unset, fallback to default' with 'opt
  out of reconnect'.
* BufferSize semantics extended: zero -> default (16), negative ->
  unbuffered (0), positive -> explicit size. Lets callers ask for
  back-pressure-only channels.

Default tuning
--------------
* MessageLimit default raised from 10 to 32. Busy AXIS cameras with
  several configured inputs / analytics rules can burst beyond 10
  per pull; the lower cap meant up to one PullTimeout of added
  latency for the queued overflow without saving anything
  meaningful. 32 covers observed bursts with no real overhead on
  quiet pulls.

Caller interface
----------------
* Doc comment now states the goroutine-safety contract Stream
  depends on (pull loop and renew loop call from separate
  goroutines). *onvif.Device satisfies it via http.Client.

Package documentation
---------------------
* doc.go rewritten as a real godoc landing page: usage snippet,
  invariants (channel close, Close idempotency, NewStream does I/O,
  buffer semantics), reconnect behaviour and AfterReconnect, and a
  pointer to topics.go for the classifier table. Replaces the
  earlier stub that referenced unimplemented identifiers.
2026-05-21 14:58:34 +02:00
Sebastian Norling
6fc9b23e9f feat(event/stream): typed errors and AfterReconnect observability
Replaces the bare fmt.Errorf wrappers on the Errors channel with three
typed errors and adds an Event.AfterReconnect flag so consumers can
distinguish post-recreate replay events from live ones.

Typed errors
------------
ErrPullFailed, ErrRenewFailed, ErrRecreateFailed all implement
Unwrap() and Op() Op. Consumers can branch with errors.As without
parsing strings:

    var pull ErrPullFailed
    if errors.As(e, &pull) { /* transient; logged */ }
    var recreate ErrRecreateFailed
    if errors.As(e, &recreate) { /* alert: camera may be offline */ }

Op() returns OpPull / OpRenew / OpRecreate for cases where the caller
wants to log the operation name without unwrapping. Both addressed
the review's 'highest-leverage v1 change' concern about bare error on
the Errors channel.

AfterReconnect observability
----------------------------
ONVIF cameras replay each property's current value with
PropertyInitialized whenever a new pull-point subscription is
established (per the Event Service spec). A consumer doing edge
detection on motion = StateActive would otherwise see a phantom
'motion started' for every active property after every reconnect.

The pull loop now tracks an afterReconnect flag local to the
goroutine: set to true when attemptRecreate returns justRecreated,
applied to every emitted event, cleared on the first non-Initialized
event we see. This bounds the replay window naturally — once the
camera has finished sending current state, the next event tells us
we're live.

attemptRecreate now returns (justRecreated, cont) so the pull loop
knows whether the just-completed recreate succeeded vs. the call
returning due to ctx-cancel during backoff.

Test coverage
-------------
* errors_test.go: typed-error Unwrap/Op assertions plus
  Stream-level proof that pull and recreate failures arrive on the
  Errors channel wearing the right type.
* AfterReconnect flag: drives the stream through a failure, observes
  the next event carries the flag and the one after does not.
2026-05-21 14:55:58 +02:00
Sebastian Norling
d718145bd3 style(event/stream): gofmt decode.go layout table alignment 2026-05-21 14:54:25 +02:00
Sebastian Norling
93620f04a3 fix(event/stream): production-grade SOAP and lifecycle hardening
Addresses the five ship-blocker findings from the second review:

1. Bounded body read (review F1 / R-HIGH)
   readClose now wraps resp.Body with io.LimitReader(10 MiB). A
   hostile or buggy camera streaming an unbounded body cannot OOM
   the agent. Legitimate PullMessages payloads are <200KB even with
   dense analytics.

2. SOAP Fault detection (review F3)
   unmarshalNode now scans for SOAP 1.1 faultstring and SOAP 1.2
   Reason/Text BEFORE the missing-element error path. Auth failures
   ('not authorized'), InvalidFilterFault and expired-subscription
   faults now surface their reason text instead of collapsing to
   the unhelpful 'response missing PullMessagesResponse element'.
   This is the difference between a debuggable error and a hidden
   one when a customer's credentials change.

3. Absolute Renew TerminationTime (review F1 wire-correctness)
   renewPullPoint now sends an RFC3339 UTC datetime
   ('2026-05-21T10:30:00Z') instead of a relative xsd:duration
   ('PT60S'). WS-BaseNotification §6.1.1 accepts both, but older
   Hikvision, some Dahua and Bosch firmwares only accept the
   absolute form — the library's own type comment even flags this
   ('BUG(r) Bad AbsoluteOrRelativeTimeType type').

4. Bounded Close (review P0)
   Close now wraps Unsubscribe in a 5s timeout. Previously a
   TCP-accepted-but-never-replying camera would wedge Close
   indefinitely; now Close returns with a timeout error and the
   subscription expires on its own at InitialTermination.

5. Explicit channel-close ordering after wg.Wait
   The run goroutine previously relied on defer-LIFO to guarantee
   renew exits before close(errors). Future maintainers extending
   run() could invert that order silently. Closes are now explicit
   sequential statements after wg.Wait() so the invariant is
   local, not order-of-defers magic.

Also expands wsnt:UtcTime parsing in decode.go to cover the four
formats observed across vendor firmwares: RFC3339 with sub-seconds,
compact offsets ('+0200', Geovision/Dahua), and naked timestamps
without timezone (older Hikvision; per spec UTC is implied).

Caller interface gains a doc comment noting it must be safe for
concurrent use, documenting the contract Stream depends on (*onvif.
Device satisfies it via http.Client).

Tests added: SOAP 1.1 and 1.2 fault extraction, fault surfacing
through unmarshalNode, Renew absolute-datetime assertion,
Close-with-blocked-Unsubscribe returning within the timeout. -race
clean.
2026-05-21 14:54:05 +02:00
Sebastian Norling
176e0d8f3c feat(examples): add event/stream CLI for live camera verification
Adds a small command at examples/event/stream that opens a real ONVIF
event stream against a camera and prints decoded events one per line.
Intended for verifying the classifier against actual hardware (AXIS in
particular) and as runnable documentation for new consumers of the
package — point it at a configured camera, trigger motion, watch the
events arrive.

Behaviour
---------
* Required flags: -xaddr, -username, -password (matches existing
  examples/event/* commands so anyone running the older subscribe /
  pullmessage demos already knows the shape).
* Optional -filter passes through to Options.TopicFilter; default
  empty so AXIS works out of the box.
* -duration N stops after N (default 0 = run until Ctrl-C).
* Prints kind/state/op/topic on each event plus source and data maps
  when present, so multi-item ONVIF payloads (AXIS AOA
  active+classType+confidence, DigitalInput InputToken+LogicalState)
  are visible without re-reading PullMessages SOAP.
* Errors channel surfaced to stderr via log; the stream auto-recovers
  per the reconnect logic in stream.go so transient errors do not
  terminate the demo.

Not part of any CI; not a production tool — this is a verification
harness.
2026-05-21 14:42:02 +02:00
Sebastian Norling
6465564f2a style(event/stream): align reconnect_test Options struct literal
gofmt -w pass on reconnect_test.go. The Options struct field names had
mismatched alignment; reformatted to match gofmt canonical layout. No
behaviour change.
2026-05-21 14:40:57 +02:00
Sebastian Norling
82f98cb824 feat(event/stream): recreate subscription after consecutive pull failures
Adds automatic CreatePullPointSubscription recreation when the pull
loop hits ReconnectAfterFailures (default 3) consecutive errors.
Mirrors what production ONVIF clients (Home Assistant event_manager,
Milestone integration) do because pull points die for many reasons
none of which surface as a clean SOAP fault: camera reboot, NAT
session timeout, subscription garbage-collected after a renew miss,
firmware bug. Recreating is the only reliable recovery; Renew alone
cannot save an already-dropped subscription.

Two new options
---------------
* ReconnectAfterFailures int (default 3) — how many consecutive pull
  failures trigger recreate. Conservative default; tunable for
  always-on cameras vs flaky NAT.
* RetryBackoff time.Duration (default 1s) — base sleep between pull
  retries; recreate failures double this up to a 30s cap so a
  permanently broken camera does not hammer the network.

Lifecycle changes
-----------------
* Stream.pullPoint is now mutex-protected — the renew goroutine reads
  it concurrently with the pull loop installing a new address after
  recreate. getPullPoint/setPullPoint accessors keep the locking
  contained.
* On successful recreate, failure count and backoff reset to defaults
  so the loop is back to its happy-path cadence.
* On recreate failure, the loop continues retrying (until ctx cancel)
  with exponentially increasing sleep — never blocks Close.

Tests cover: post-failure recreate hits a different SubscriptionRef
Address and subsequent events come from the new endpoint; exponential
backoff drives multiple recreate attempts when the camera stays down;
defaults match production-sensible 3 failures / 1s backoff. -race
clean.
2026-05-21 14:40:42 +02:00
Sebastian Norling
9b5b626113 feat(event/stream): renew subscription before termination expires
Adds a background renew loop alongside the pull loop. ONVIF pull-point
subscriptions expire at the InitialTerminationTime supplied to Create;
without periodic Renew calls the camera silently drops the
subscription and subsequent pulls start returning empty messages — the
shape the existing agent's heartbeat code in cloud/Cloud.go has been
papering over by occasionally recreating subscriptions.

Design
------
* New Options.RenewMargin (default 10s) — how far before
  InitialTermination expiry the renew fires. Smaller margins mean
  fewer SOAP round-trips; larger margins tolerate slow networks. With
  default 60s termination + 10s margin we renew every 50s, which is in
  line with what production NVRs (Milestone, Genetec) use.
* The renew loop runs in a separate goroutine sharing ctx with the
  pull loop. WaitGroup synchronisation in run() ensures both have
  exited before close()-of-channels happens, so a renew in flight
  during Close() cannot send on a closed Errors channel.
* Pathological config (RenewMargin >= InitialTermination) falls back
  to renewing at termination/2 rather than busy-looping or never
  renewing.
* renewPullPoint sends a wsnt:Renew SOAP against the SubscriptionRef
  Address with the same InitialTermination duration; renew errors
  surface on Errors non-blockingly, identically to pull errors.

Tests use very short termination/margin (80-100ms / 10ms) so a single
test run observes multiple renews within ~500ms, and assert that
renew calls target the SubscriptionReference endpoint (not the device
endpoint). -race clean.
2026-05-21 14:37:33 +02:00
Sebastian Norling
fb05c31d7d feat(event/stream): add Stream with pull-point lifecycle
Introduces Stream, the typed event consumer the package will eventually
present to callers, plus the caller seam needed to test it without
hitting a real camera.

Stream owns one ONVIF pull-point subscription end-to-end:
  * CreatePullPointSubscription on construction so authentication and
    reachability problems surface synchronously from NewStream rather
    than landing on the Errors channel after the goroutine starts.
  * Background pull loop calls PullMessages against the
    SubscriptionReference Address returned by Create. Each
    NotificationMessage is fed through Decode and pushed on the Events
    channel, with context cancellation honoured between every step so a
    Close cannot get stuck behind a long-server-side-wait pull.
  * Errors during a pull are surfaced on a separate Errors channel using
    a non-blocking send; a stalled consumer drops older errors instead
    of blocking the loop. The loop sleeps briefly (ctx-aware) and
    retries — automatic subscription recreation lands in the
    reconnect-on-error commit.
  * Close cancels the context, waits for the run goroutine to exit,
    Unsubscribes the pull point and closes Events/Errors. sync.Once
    keeps it idempotent.

Design seams
------------
* caller interface (CallMethod + SendSoap) abstracts *onvif.Device so
  tests can substitute fakeCaller without an HTTP server. deviceCaller
  is the production adapter; newStream takes the interface, NewStream
  takes the concrete *onvif.Device. The same shape lets a future commit
  add WithClassifier / WithClock / WithCaller options if the architect
  reviewer's pluggable-classifier note becomes urgent.
* now func() time.Time is a Stream field so a future clock-injecting
  test (renew timing, observed-at determinism) can swap it.
* unmarshalNode keys on the local XML name, sidestepping namespace
  matching since SOAP envelopes from different vendors prefix the
  PullMessagesResponse and CreatePullPointSubscriptionResponse with
  arbitrary tev:/tev1:/... bindings. This is the same trick the agent's
  getXMLNode used; lifting it here lets the agent eventually drop its
  copy.

Options and defaults
--------------------
PullTimeout 5s, MessageLimit 10, InitialTermination 60s, BufferSize 16
match what the existing agent code uses. TopicFilter defaults to empty
so AXIS cameras work out of the box — the verified topic table is
intentionally the routing layer, not a server-side filter, because the
agent will frequently want digital I/O and motion on the same stream.

Tests cover the create-then-pull-then-close happy path, that pulls
target the SubscriptionReference Address (not the device endpoint),
construction failure on CreatePullPoint error, context-cancel exits
the loop cleanly with channels closed, transient pull errors land on
Errors without stopping decode of subsequent good messages, idempotent
Close, and Options default values. -race clean.
2026-05-21 14:35:34 +02:00
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
Sebastian Norling
da1ecf8e0a refactor(event/stream): address API and classifier review findings
Parallel expert review of the four-commit scaffold surfaced 13 actionable
items split across API design, ONVIF domain accuracy, Go idiomaticity and
test rigor. This change addresses them before the Stream type lands, when
the public surface is still cheap to move.

API shape (hard-to-reverse before tagging)
------------------------------------------
* Rename EventKind -> Kind and EventState -> State to avoid the
  stream.EventKind / stream.EventState stutter when imported.
* Restructure Event for non-lossy decode:
  - Source string and RawValue string replaced with Source/Data maps so
    multi-item ONVIF Source and Data lists (e.g. AXIS AOA emitting
    active+classType+confidence; DigitalInput carrying InputToken+
    LogicalState) are preserved.
  - Add DeviceID so a single channel can fan in events from multiple
    cameras.
  - Add DeviceTime parsed from wsnt:UtcTime alongside the local
    observation Timestamp. The earlier doc-comment decision to bake-in
    'drop UtcTime' was a policy disguised as an API; expose both and let
    callers choose.

Classifier accuracy (ONVIF domain audit)
----------------------------------------
* Introduce KindImageQuality for tns1:VideoSource/ImageTooDark|Bright|
  Blurry. These are imaging-quality alarms that integrators route
  separately because they fire on sunset/dawn/condensation, not tamper.
  Previously mis-classified as KindTampering.
* Add tns1:VideoSource/GlobalSceneChange -> KindTampering, which is the
  real lens-cover signal on firmwares without TamperDetector.
* Anchor the TamperDetector rule to 'TamperDetector/Tamper' so a
  hypothetical 'TamperDetectorLog' path cannot match.
* Narrow MyRuleDetector from container-match to an explicit whitelist
  (HumanDetect, VehicleDetect, PeopleDetect, ObjectsInside, FaceDetect).
  Bosch publishes Counter and Occupancy under MyRuleDetector too; those
  must not classify as ObjectDetected.
* Add the AXIS Guard suite (MotionGuard, FenceGuard, LoiteringGuard) ->
  KindMotion. Common on AXIS deployments configured with these apps
  instead of basic VMD.
* Drop the bogus Device1ScenarioANY test fixture; AOA uses numeric
  scenarios (Device1Scenario1, Device1Scenario2). The 'ANY' suffix was a
  borrow from the older Guard suite's Camera1ProfileANY pattern.
* Document the edge-trigger semantics of LineDetector/Crossed in the rule
  comment so decoder consumers do not expect a State boolean.

Tests
-----
* String tests now use t.Run subtests so failures name the case.
* TestKindStringsAreUnique guards against accidental String() aliasing
  when adding new kinds.
* TestEventFieldAssignmentRoundTrip exercises the new field set
  including DeviceID, Source/Data maps and DeviceTime.
* Canonicalisation table now covers: double slash, colon-only segment,
  trailing colon, multi-colon-in-segment, leading/trailing slash,
  no-colon passthrough. Locks the actual behaviour so future refactors
  see regressions.
* False-positive negatives: Counter and Occupancy under MyRuleDetector,
  AudioEncoderConfiguration, RelayFailure, DigitalInputConfiguration,
  TamperDetectorLog, MotionRecording/Started — all assert KindUnknown.
* TestClassifyRuleOrder_ObjectAnalyticsBeforeGenericObjects pins the
  ordering invariant called out by the architect reviewer.

Documentation
-------------
* doc.go trimmed so it does not advertise NewStream / Events / Errors /
  Close before those identifiers exist — the godoc reader will no longer
  see dead names. Re-expanded when the Stream type lands.

Deferred to the Stream commit
-----------------------------
* PropertyUnknown vs PropertyUnset disambiguation — kept as
  PropertyUnknown for now with a clarified doc comment; revisit when the
  decoder needs to distinguish 'absent on wire' from 'unparseable'.
* Classifier pluggability (WithClassifier option) — meaningful only once
  there is a Stream; revisit at that commit.
2026-05-21 14:25:59 +02:00
Sebastian Norling
4da4842f61 test(event/stream): adopt testify to match existing lib style
The rest of github.com/kerberos-io/onvif uses stretchr/testify (assert,
require) consistently — Device_test.go, event/type_test.go,
media2/types_test.go, ws-discovery/networking_test.go. Migrate the two
new test files in event/stream from stdlib t.Errorf to the same testify
convention so the package fits in without local style variation.

No production-code change; no behaviour change.
2026-05-21 14:11:46 +02:00
Sebastian Norling
f679aab0d5 feat(event/stream): cross-reference vendor topic strings with public docs
Cross-checked each topic string against public sources before adding the
rule, and inlined the citation next to the rule it supports so future
maintainers can audit the table:

  * Hikvision motion: CellMotionDetector/Motion (Hikvision PDF on third
    party motion troubleshooting) plus the VideoSource/MotionAlarm
    fallback emitted by newer firmware.
  * Hikvision tamper-class scene change: VideoSource/ImageTooDark|Bright|
    Blurry — present in the ONVIF topic namespace; treated as Tampering
    for routing.
  * Bosch motion: VideoAnalytics/MotionAlarm (Bosch metadata/IVA PDF) —
    NOT VideoSource/MotionAlarm. The earlier 'tnsbosch:MotionAlarm' guess
    in PR #194 was wrong; Bosch uses standard tns1 namespace under
    VideoAnalytics.
  * Hanwha (Samsung Wisenet): VideoAnalytics/tnssamsung:MotionDetection,
    VideoAnalytics/tnssamsung:TamperingDetection,
    AudioAnalytics/tnssamsung:SoundDetection — confirmed via HA #66493
    capture.
  * Avigilon: per-segment-namespaced serialisation
    (tns1:Device/tns1:Trigger/tns1:Relay) folded by canonicalization.
    Documented in Avigilon's own ONVIF subscription guide.
  * Object analytics: LineDetector/Crossed, FieldDetector/ObjectsInside
    and the MyRuleDetector container for vendor rule names (Bosch IVA,
    Dahua SMD) — sourced from ONVIF Analytics Service Spec v22.06.
  * AXIS Object Analytics: prefix match on ObjectAnalytics/ to absorb
    the dynamic Device1Scenario<N> suffixes (AXIS counting-data docs).

Empirical topic table cross-checked with openvideolibs/onvif-parsers
(Apache-2.0), the package the Home Assistant ONVIF integration imports —
referenced from the package doc-comment.

Test cases now cover the verified topic for every supported vendor plus
case-sensitivity and canonicalization. No code change for callers: the
public API is still just Classify(topic) -> EventKind.
2026-05-21 14:10:07 +02:00
Sebastian Norling
2cc266714e feat(event/stream): classify vendor topics to normalized EventKind
Adds a Classify function that maps ONVIF topic strings to EventKind so the
agent does not need to know AXIS vs Hikvision vs Bosch topic conventions.

The classifier canonicalizes topics by stripping XML-namespace prefixes from
each path segment, which collapses vendor variants like
'tns1:Device/tnssamsung:DigitalInput' and 'tns1:Device/Trigger/DigitalInput'
to a single matchable form.

Motion coverage on day one:
  * tns1:VideoSource/MotionAlarm  (AXIS, Bosch, Dahua, ...)
  * tns1:VideoAnalytics/<vendor>:MotionAlarm
  * tns1:RuleEngine/CellMotionDetector/Motion  (ONVIF standard, Hikvision)
  * tns1:RuleEngine/MotionRegionDetector/Motion  (AXIS region rule)
  * tnsaxis:CameraApplicationPlatform/ObjectAnalytics/...

Also covers Tamper, DigitalInput, Relay (DigitalOutput), object analytics
and audio alarms, so the same Stream can replace the agent's ad-hoc digital
I/O polling without losing coverage.
2026-05-21 13:59:22 +02:00
Sebastian Norling
ce67879ee5 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.
2026-05-21 13:57:21 +02:00
Mohit Solanki
174de6954b fix: support multiple TourSpots in PresetTour struct.
TourSpot was defined as a single PTZPresetTourSpot value, making it
impossible to configure a preset tour with more than one stop. The
ONVIF spec allows multiple TourSpot entries per tour, so this changes
the field to a slice.
2026-02-21 18:02:09 +05:30
Cédric Verstraeten
a732b9fa82 Merge pull request #3 from Desivy/add-UtcTime-parsing
Add UtcTime attribute to MessageDescription struct
2025-05-20 14:26:20 +02:00
Cédric Verstraeten
03101ec271 Remove custom color settings from VSCode configuration 2025-05-20 10:14:19 +00:00
Cédric Verstraeten
4fc5593195 Add Dockerfile and devcontainer.json for development environment setup 2025-05-20 12:13:39 +02:00
stefan van der lee
9f92238275 Add UtcTime attribute to MessageDescription struct 2025-04-28 18:02:14 +02:00
Cédric Verstraeten
a7815a692c Merge pull request #2 from kerberos-io/fix/only-close-open-server-response
Fix server response closing
2025-01-19 10:37:10 +01:00
Cedric Verstraeten
d80b15daf0 Merge branch 'master' into fix/only-close-open-server-response 2025-01-19 10:28:29 +01:00
Cedric Verstraeten
9ca534b5bb correct workflow structure 2025-01-19 10:19:22 +01:00
Cedric Verstraeten
ede8b81fc8 Update Device.go 2025-01-19 09:58:53 +01:00
Cédric Verstraeten
76463bb61a Merge pull request #1 from kerberos-io/fix/call-method-do-memleak
Call method do memleak
2025-01-18 09:21:06 +01:00
Cedric Verstraeten
ca27d2a9d8 Merge branch 'master' into fix/call-method-do-memleak 2025-01-18 09:12:21 +01:00
Cedric Verstraeten
f3f03cc827 add pr description workflow 2025-01-18 09:12:05 +01:00
Cedric Verstraeten
542f83433b Update Device.go 2025-01-18 09:09:33 +01:00
Cedric Verstraeten
6fc6d9a99e reuse main http client + resolved memory leak (close previous response) before creating new one. 2025-01-16 21:43:28 +01:00
Edward
67386c9fec Merge pull request #48 from glebkap/master
Generate PullMessages and fix MessageContent message
2024-09-29 15:25:09 +08:00
Edward
703c8c836e Merge pull request #44 from Path-Variable/master
exports device params, fixes error in comments, adds gitignore file
2024-09-29 15:24:45 +08:00
Cedric Verstraeten
ee8a919932 support for latest hikivision ONVIF 19.12 2024-08-21 16:07:06 +02:00
Cedric Verstraeten
d1b78fa51a make array of relayoutputs and digitalinputs 2024-08-20 09:00:32 +02:00
glebkap
02115f9be9 MessageContent in FilterType may be empty 2024-03-28 14:15:17 +03:00
glebkap
ca935c7e96 Generate PullMessages 2024-03-26 15:22:17 +03:00
Cedric Verstraeten
4c9f12fc97 add 2023-12-25 21:31:14 +01:00
Cédric Verstraeten
04f0dfbc03 Delete Imaging/types.go 2023-12-25 21:29:06 +01:00
Cedric Verstraeten
add8ae2bad Revert "disable imaging"
This reverts commit 6c5db5fed6.
2023-12-25 21:26:55 +01:00
Cedric Verstraeten
6c5db5fed6 disable imaging 2023-12-25 21:21:12 +01:00
Ivan Šarić
fd737e6327 exports device params, fixes error in comments, adds gitignore file 2023-12-03 11:20:17 +01:00
51 changed files with 5226 additions and 46 deletions

8
.devcontainer/Dockerfile Normal file
View File

@@ -0,0 +1,8 @@
FROM mcr.microsoft.com/devcontainers/go:1.24-bookworm
# Install node environment
RUN apt-get update && \
apt-get install -y --no-install-recommends \
nodejs \
npm \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -0,0 +1,15 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "go:1.24.2-bookworm",
"dockerFile": "Dockerfile",
"customizations": {
"vscode": {
"extensions": [
"GitHub.copilot",
"golang.go",
"GitHub.vscode-pull-request-github"
]
}
}
}

51
.github/workflows/pr-build.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Pull Request Build
# Builds, vets and tests the Go library on every pull request.
# This repository is a Go library (no Dockerfile), so unlike the Docker-oriented
# uug-ai/workflows pr-build.yml it validates the module directly instead of
# building and pushing a container image.
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
build:
name: Build & Test (Go)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
cache-dependency-path: go.sum
- name: Build
run: go build -v ./...
- name: Vet (non-blocking)
continue-on-error: true
run: go vet ./...
- name: Gofmt check (non-blocking)
continue-on-error: true
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
echo "The following files are not gofmt-clean:"
echo "$unformatted"
exit 1
fi
- name: Test
# ws-discovery/TestDevicesFromProbeResponses is a network-dependent
# integration test that probes real ONVIF cameras on the LAN, so it
# cannot pass on hosted runners. Run it locally against real devices.
run: go test $(go list ./... | grep -v '/ws-discovery')

19
.github/workflows/pr-description.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Autofill PR description
on: pull_request
jobs:
openai-pr-description:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Autofill PR description if empty using OpenAI
uses: cedricve/azureopenai-pr-description@master
with:
github_token: ${{ secrets.TOKEN }}
openai_api_key: ${{ secrets.OPENAI_API_KEY }}
azure_openai_api_key: ${{ secrets.AZURE_OPENAI_API_KEY }}
azure_openai_endpoint: ${{ secrets.AZURE_OPENAI_ENDPOINT }}
azure_openai_version: ${{ secrets.AZURE_OPENAI_VERSION }}
overwrite_description: true

28
.github/workflows/release-bump.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Bump release
# Manually bump the semantic version: determines the next tag from the latest
# v* tag, creates a GitHub release with generated notes and exposes the new tag.
# Thin wrapper around the reusable uug-ai/workflows release-bump workflow.
on:
workflow_dispatch:
inputs:
bump:
description: "Which part of the version to bump"
required: true
default: patch
type: choice
options:
- major
- minor
- patch
permissions:
contents: write
jobs:
bump-release:
uses: uug-ai/workflows/.github/workflows/release-bump.yml@main
with:
bump: ${{ github.event.inputs.bump }}
secrets: inherit

36
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Release
# Validates the tagged code when a release is published (build + test).
# For a Go library a "release" is simply a git tag consumers depend on, so this
# workflow guards that a released tag builds and passes its tests rather than
# publishing a container image.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
name: Validate release (Go)
runs-on: ubuntu-22.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: true
cache-dependency-path: go.sum
- name: Build
run: go build -v ./...
- name: Test
# See pr-build.yml: the ws-discovery probe test needs real cameras.
run: go test $(go list ./... | grep -v '/ws-discovery')

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.idea
*.iml
.idea/
.vscode/

18
.vscode/settings.json vendored
View File

@@ -1,24 +1,6 @@
{
"editor.tabSize": 2,
"extensions.ignoreRecommendations": true,
"workbench.colorCustomizations": {
"activityBar.background": "#759570",
"activityBar.activeBorder": "#cfd3ef",
"activityBar.foreground": "#e7e7e7",
"activityBar.hoverBackground": "#352cea",
"activityBar.inactiveForeground": "#e7e7e799",
"activityBarBadge.background": "#cfd3ef",
"activityBarBadge.foreground": "#15202b",
"titleBar.activeBackground": "#5e7959",
"titleBar.inactiveBackground": "#5e795999",
"titleBar.activeForeground": "#e7e7e7",
"titleBar.inactiveForeground": "#e7e7e799",
"statusBarItem.hoverBackground": "#352cea",
"statusBar.foreground": "#e7e7e7",
"panel.border": "#759570",
"sideBar.border": "#759570",
"editorGroup.border": "#759570"
},
"go.languageServerFlags": [],
"go.lintOnSave": "file",
"go.vetOnSave": "package",

135
Device.go
View File

@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"github.com/kerberos-io/onvif/networking"
"github.com/kerberos-io/onvif/xsd/onvif"
"github.com/beevik/etree"
@@ -253,7 +254,7 @@ func (dev *Device) getEndpoint(endpoint string) (string, error) {
// CallMethod functions call an method, defined <method> struct.
// You should use Authenticate method to call authorized requests.
func (dev *Device) CallMethod(method interface{}) (*http.Response, error) {
func (dev Device) CallMethod(method interface{}) (*http.Response, error) {
pkgPath := strings.Split(reflect.TypeOf(method).PkgPath(), "/")
pkg := strings.ToLower(pkgPath[len(pkgPath)-1])
@@ -261,11 +262,63 @@ func (dev *Device) CallMethod(method interface{}) (*http.Response, error) {
if err != nil {
return nil, err
}
requestBody, err := xml.Marshal(method)
return dev.callMethodDo(endpoint, method)
}
// CallMethod functions call an method, defined <method> struct with authentication data
func (dev Device) callMethodDo(endpoint string, method interface{}) (*http.Response, error) {
output, err := xml.MarshalIndent(method, " ", " ")
if err != nil {
return nil, err
}
return dev.SendSoap(endpoint, string(requestBody))
soap, err := dev.buildMethodSOAP(string(output))
if err != nil {
return nil, err
}
soap.AddRootNamespaces(Xlmns)
soap.AddAction()
return dev.sendSOAP(endpoint, soap)
}
// sendSOAP dispatches an assembled SOAP message to the endpoint using the
// authentication mechanism selected through DeviceParams.AuthMode.
//
// Behaviour per AuthMode:
// - NoAuth ("none"): no authentication is added.
// - UsernameTokenAuth: WS-Security UsernameToken only; never falls
// back to HTTP digest, which keeps cameras that authenticate exclusively
// through WS-Security working.
// - DigestAuth ("digest"): HTTP digest only; no WS-Security header.
// - Both ("both") / unset (""): WS-Security credentials are added (when
// available) and HTTP digest is attempted only if the device answers with
// an authentication challenge (HTTP 401 Unauthorized). On that digest
// retry the WS-Security header is dropped so the credentials are not sent
// twice.
func (dev Device) sendSOAP(endpoint string, soap gosoap.SoapMessage) (*http.Response, error) {
hasCredentials := dev.params.Username != "" || dev.params.Password != ""
switch dev.params.AuthMode {
case NoAuth:
return networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
case UsernameTokenAuth:
if hasCredentials {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
}
return networking.SendSoap(dev.params.HttpClient, endpoint, soap.String())
case DigestAuth:
return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
default: // Both and the empty/unset default.
if hasCredentials {
soap.AddWSSecurity(dev.params.Username, dev.params.Password)
}
return networking.SendSoapWithDigest(dev.params.HttpClient, endpoint, soap.String(), dev.params.Username, dev.params.Password)
}
}
func (dev *Device) GetDeviceParams() DeviceParams {
@@ -283,7 +336,7 @@ func (dev *Device) GetEndpointByRequestStruct(requestStruct interface{}) (string
return endpoint, err
}
func (dev *Device) SendSoap(endpoint string, xmlRequestBody string) (resp *http.Response, err error) {
/*func (dev *Device) SendSoap(endpoint string, xmlRequestBody string) (resp *http.Response, err error) {
soap := gosoap.NewEmptySOAP()
soap.AddStringBodyContent(xmlRequestBody)
soap.AddRootNamespaces(Xlmns)
@@ -302,6 +355,78 @@ func (dev *Device) SendSoap(endpoint string, xmlRequestBody string) (resp *http.
resp, err = dev.params.HttpClient.Do(req)
}
return resp, err
}*/
// SendSoap POSTs the given body wrapped in a SOAP envelope.
func (dev Device) SendSoap(endpoint string, xmlRequestBody string) (*http.Response, error) {
return dev.SendSoapWithOptions(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.
//
// xmlHeaderContent must be well-formed XML representing one or more
// SOAP Header child elements (siblings are supported; the spec lets
// each reference parameter be its own header block). Malformed or
// element-free content errors before any request is made.
//
// SECURITY: do not pass content sourced from untrusted clients. The
// API assumes the caller is authoritative for the envelope. Header
// content forwards verbatim — among others, <wsse:Security> overrides
// auth, <wsa:Action> overrides intent, <wsa:To>/<wsa:ReplyTo>/
// <wsa:FaultTo> redirect responses, <wsa:MessageID> enables replay-
// token forgery, and <wsu:Timestamp> bypasses freshness checks.
func (dev Device) SendSoapWithHeader(endpoint, xmlRequestBody, xmlHeaderContent string) (*http.Response, error) {
return dev.SendSoapWithOptions(endpoint, xmlRequestBody, WithSOAPHeader(xmlHeaderContent))
}
// SendSoapOption tweaks a single SendSoapWithOptions call. New options
// (per-call timeout, context, custom envelope namespaces, ...) should
// be added as WithX constructors here rather than as new method
// variants on Device.
type SendSoapOption func(*soapConfig)
type soapConfig struct {
headerContent string
}
// WithSOAPHeader adds inner-Header XML to the envelope. See
// SendSoapWithHeader for the content contract.
func WithSOAPHeader(headerContent string) SendSoapOption {
return func(c *soapConfig) { c.headerContent = headerContent }
}
// SendSoapWithOptions is the workhorse behind SendSoap and
// SendSoapWithHeader; call it directly when you need to combine
// options or pass options not surfaced by the convenience wrappers.
func (dev Device) SendSoapWithOptions(endpoint, xmlRequestBody string, opts ...SendSoapOption) (*http.Response, error) {
var cfg soapConfig
for _, o := range opts {
o(&cfg)
}
soap := gosoap.NewEmptySOAP()
soap.AddStringBodyContent(xmlRequestBody)
soap.AddRootNamespaces(Xlmns)
soap.AddAction()
if cfg.headerContent != "" {
if err := soap.AddStringHeaderContents(cfg.headerContent); err != nil {
return nil, fmt.Errorf("add header content: %w", err)
}
}
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 {
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
}
func createHttpRequest(httpMethod string, endpoint string, soap string) (req *http.Request, err error) {
@@ -334,7 +459,7 @@ func (dev *Device) CallOnvifFunction(serviceName, functionName string, data []by
}
xmlRequestBody := string(requestBody)
servResp, err := dev.SendSoap(endpoint, xmlRequestBody)
servResp, err := dev.SendSoapWithOptions(endpoint, xmlRequestBody)
if err != nil {
return nil, fmt.Errorf("fail to send the '%s' request for the web service '%s', %v", functionName, serviceName, 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,242 @@ 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>")
bodyStart := strings.Index(captured, "Body>")
require.NotEqual(t, -1, headerStart, "envelope must contain <Header>; got: %s", captured)
require.Greater(t, bodyStart, headerStart, "Body must follow Header in the envelope")
headerSlice := captured[headerStart:bodyStart]
assert.Contains(t, headerSlice, "SubscriptionId",
"injected header element must land inside SOAP <Header>")
assert.Contains(t, headerSlice, "297")
bodySlice := captured[bodyStart:]
assert.Contains(t, bodySlice, "PullMessages",
"body content must land inside SOAP <Body>")
}
// Per WS-Addressing 1.0 SOAP Binding §3.4 every reference parameter is a separate
// SOAP Header block. Vendors that declare two ref params would silently
// produce a header-less request if the implementation only accepts a
// single top-level element.
func TestDevice_SendSoapWithHeader_AcceptsMultipleTopLevelChildren(t *testing.T) {
const headerXML = `<a:Foo xmlns:a="ns/a">1</a:Foo><b:Bar xmlns:b="ns/b">2</b:Bar>`
const bodyXML = `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`
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()
}
headerSlice := captured[strings.Index(captured, "Header>"):strings.Index(captured, "Body>")]
assert.Contains(t, headerSlice, "Foo")
assert.Contains(t, headerSlice, "Bar")
}
func TestDevice_SendSoapWithHeader_RejectsElementFreeContent(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
// Well-formed XML but contains no child elements — would otherwise
// parse, yield zero ChildElements, and send a header-less request.
_, err := dev.SendSoapWithHeader(srv.URL, "<body/>", "just text content")
require.Error(t, err)
assert.Equal(t, 0, hits,
"non-empty header content with no element children must fail fast")
}
// SendSoapWithOptions is the variadic shape that future per-call
// options (timeout, context, ...) will hang off. SendSoap and
// SendSoapWithHeader stay as thin convenience wrappers so existing
// callers are not forced to migrate.
func TestDevice_SendSoapWithOptions_WithHeaderMatchesSendSoapWithHeader(t *testing.T) {
const headerXML = `<dom0:SubscriptionId xmlns:dom0="urn:test">42</dom0:SubscriptionId>`
const bodyXML = `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`
var captured string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
captured = string(b)
}))
t.Cleanup(srv.Close)
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
resp, err := dev.SendSoapWithOptions(srv.URL, bodyXML, WithSOAPHeader(headerXML))
require.NoError(t, err)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
assert.Contains(t, captured, "SubscriptionId")
assert.Contains(t, captured, "42")
}
func TestDevice_SendSoapWithOptions_NoOptsMatchesSendSoap(t *testing.T) {
var captured string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
captured = string(b)
}))
t.Cleanup(srv.Close)
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
resp, err := dev.SendSoapWithOptions(srv.URL, `<tev:Body xmlns:tev="x"/>`)
require.NoError(t, err)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
assert.NotContains(t, captured, "IsReferenceParameter",
"no opts should produce a header-less envelope")
}
// Digest auth fallback path: the camera 401s the first POST and the
// retry computes a digest. The ref-params header must survive the
// retry — losing it would silently re-introduce the AXIS regression
// on every authenticated camera.
func TestDevice_SendSoapWithHeader_PreservesHeaderAcrossDigestRetry(t *testing.T) {
const headerXML = `<dom0:SubscriptionId xmlns:dom0="urn:vendor:axis" wsa:IsReferenceParameter="true">297</dom0:SubscriptionId>`
var capturedSecondBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
w.Header().Set("WWW-Authenticate", `Digest realm="onvif", nonce="abc", qop="auth"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
capturedSecondBody = string(b)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
dev := Device{params: DeviceParams{
Xaddr: strings.TrimPrefix(srv.URL, "http://"),
HttpClient: srv.Client(),
Username: "admin",
Password: "secret",
}}
resp, err := dev.SendSoapWithHeader(srv.URL, `<tev:PullMessages xmlns:tev="http://www.onvif.org/ver10/events/wsdl"/>`, headerXML)
require.NoError(t, err)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
assert.Contains(t, capturedSecondBody, "SubscriptionId",
"digest retry must carry the same ref-params header as the first attempt")
assert.Contains(t, capturedSecondBody, "297")
}
func TestDevice_SendSoapWithHeader_PropagatesMalformedHeaderError(t *testing.T) {
var hits int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits++
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
_, err := dev.SendSoapWithHeader(srv.URL, "<body/>", "<not-closed")
require.Error(t, err)
assert.Equal(t, 0, hits,
"malformed header XML must fail fast — no request should reach the camera with a missing header block")
}
// Digest retry: networking.SendSoapWithDigest strips the wsse:Security
// element from the envelope before re-POSTing so credentials don't go
// on the wire twice (once via WS-Security, once via the digest header).
// Pin the behaviour from the Device layer.
func TestDevice_SendSoapWithOptions_DigestRetryStripsWSSE(t *testing.T) {
var authedBody string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
w.Header().Set("WWW-Authenticate", `Digest realm="onvif", nonce="abc", qop="auth"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
authedBody = string(b)
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
dev := Device{params: DeviceParams{
HttpClient: srv.Client(),
Username: "admin",
Password: "secret",
}}
resp, err := dev.SendSoapWithOptions(srv.URL, "<tev:X xmlns:tev='x'/>")
require.NoError(t, err)
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
require.NotEmpty(t, authedBody, "expected an authenticated POST after the 401 challenge")
assert.NotContains(t, authedBody, "UsernameToken",
"digest retry must strip wsse:Security/UsernameToken; otherwise credentials go on the wire twice")
}
// Last-write-wins on duplicate SendSoapOption — pin the behaviour so
// the next maintainer adding an option doesn't accidentally introduce
// a merge or first-wins semantic.
func TestDevice_SendSoapWithOptions_DuplicateWithSOAPHeaderLastWins(t *testing.T) {
var captured string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
captured = string(b)
}))
t.Cleanup(srv.Close)
dev := Device{params: DeviceParams{HttpClient: srv.Client()}}
_, err := dev.SendSoapWithOptions(srv.URL, "<body/>",
WithSOAPHeader(`<a:First xmlns:a="x"/>`),
WithSOAPHeader(`<b:Second xmlns:b="y"/>`),
)
require.NoError(t, err)
assert.NotContains(t, captured, "First", "first WithSOAPHeader must be overwritten")
assert.Contains(t, captured, "Second")
}

View File

@@ -122,6 +122,8 @@ func callNecessaryMethod(serviceName, methodName, acceptedData, username, passwo
switch strings.ToLower(serviceName) {
case "device":
methodStruct, err = getDeviceStructByName(methodName)
case "deviceio":
methodStruct, err = getDeviceStructByName(methodName)
case "ptz":
methodStruct, err = getPTZStructByName(methodName)
case "media":
@@ -150,7 +152,7 @@ func callNecessaryMethod(serviceName, methodName, acceptedData, username, passwo
servResp, err := networking.SendSoap(new(http.Client), endpoint, soap.String())
if err != nil {
return "", err
servResp, err = networking.SendSoapWithDigest(new(http.Client), endpoint, soap.String(), username, password)
}
rsp, err := ioutil.ReadAll(servResp.Body)

View File

@@ -680,7 +680,7 @@ type GetRelayOutputs struct {
}
type GetRelayOutputsResponse struct {
RelayOutputs onvif.RelayOutput
RelayOutputs []onvif.RelayOutput
}
type GetDigitalInputs struct {
@@ -688,7 +688,7 @@ type GetDigitalInputs struct {
}
type GetDigitalInputsResponse struct {
DigitalInputs onvif.DigitalInput
DigitalInputs []onvif.DigitalInput
}
type SetRelayOutputSettings struct {

View File

@@ -680,7 +680,7 @@ type GetRelayOutputs struct {
}
type GetRelayOutputsResponse struct {
RelayOutputs onvif.RelayOutput
RelayOutputs []onvif.RelayOutput
}
type GetDigitalInputs struct {
@@ -688,7 +688,7 @@ type GetDigitalInputs struct {
}
type GetDigitalInputsResponse struct {
DigitalInputs onvif.DigitalInput
DigitalInputs []onvif.DigitalInput
}
type SetRelayOutputSettings struct {

View File

@@ -32,3 +32,23 @@ python3 python/gen_commands.py
> **Note:** You can also typically run the generator within your IDE thanks to the `//go:generate` lines
> towards the top of the `types.go` files.
## Higher-level helpers
Some web service directories ship hand-written, higher-level helpers
built on top of the wire-layer commands. These are normal Go packages
**not** covered by the `gen_commands.py` workflow above and not
expected to be regenerated.
- [event/stream](../event/stream) — channel-based event consumer that
owns the pull-point subscription lifecycle (Create, Pull, Renew,
Unsubscribe, reconnect with jittered backoff) and decodes
notifications into normalized typed Events. Vendor topic strings
(AXIS, Hikvision, Avigilon, Hanwha, Bosch, Dahua) are classified
into a small set of `Kind` values. See the package `doc.go` for the
public surface and usage.
- [event/topic](../event/topic) — topic identifier helpers.
When adding a similar higher-level helper, place it under the relevant
web service directory as a sub-package so consumers find it next to
the wire-layer types it builds on.

96
event/stream/decode.go Normal file
View File

@@ -0,0 +1,96 @@
package stream
import (
"strings"
"time"
"github.com/kerberos-io/onvif/event"
)
// decode converts a single ONVIF NotificationMessage into a normalized
// Event. Topic, Source and Data are always populated even when Kind is
// KindUnknown so consumers can fall back to 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 returns nil for an empty list so empty notifications
// do not allocate.
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, returning the
// first match. Returns StateUnknown for edge-triggered topics like
// LineDetector/Crossed whose Data carries only an ObjectId.
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 returns PropertyUnknown for absent (optional
// per WS-Notification) or unrecognised values.
func parsePropertyOperation(s string) PropertyOperation {
switch s {
case "Initialized":
return PropertyInitialized
case "Changed":
return PropertyChanged
case "Deleted":
return PropertyDeleted
default:
return PropertyUnknown
}
}
// parseDeviceTime parses wsnt:UtcTime, returning the zero time when
// absent or unparseable. Real cameras emit several flavours: with /
// without sub-seconds, colon or compact ("+0200") offsets, and some
// older Hikvision firmwares omit the timezone entirely (treated as
// UTC per WS-BaseNotification which mandates UTC for UtcTime).
func parseDeviceTime(s string) time.Time {
if s == "" {
return time.Time{}
}
for _, layout := range deviceTimeLayouts {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
var deviceTimeLayouts = []string{
time.RFC3339Nano,
time.RFC3339,
"2006-01-02T15:04:05.999-0700", // Geovision
"2006-01-02T15:04:05-0700", // some Dahua
"2006-01-02T15:04:05.999",
"2006-01-02T15:04:05", // older Hikvision (no timezone)
}

350
event/stream/decode_test.go Normal file
View File

@@ -0,0 +1,350 @@
package stream
import (
"strings"
"testing"
"time"
"github.com/kerberos-io/onvif/event"
"github.com/kerberos-io/onvif/xsd"
"github.com/stretchr/testify/assert"
)
// msg builds a NotificationMessage from the topic and a (PropertyOperation,
// UtcTime, source items, data items) tuple so tests stay short and intent
// is visible at the call site.
func msg(topic, propOp, utcTime string, source, data map[string]string) event.NotificationMessage {
toItems := func(m map[string]string) []event.SimpleItem {
if len(m) == 0 {
return nil
}
items := make([]event.SimpleItem, 0, len(m))
for k, v := range m {
items = append(items, event.SimpleItem{
Name: xsd.AnyType(k),
Value: xsd.AnyType(v),
})
}
return items
}
return event.NotificationMessage{
Topic: event.Topic{TopicKinds: xsd.String(topic)},
Message: event.MessageBody{
Message: event.MessageDescription{
PropertyOperation: xsd.AnyType(propOp),
UtcTime: xsd.AnyType(utcTime),
Source: event.Source{SimpleItem: toItems(source)},
Data: event.Data{SimpleItem: toItems(data)},
},
},
}
}
func TestDecode_MotionActive(t *testing.T) {
observedAt := time.Date(2026, 5, 21, 10, 30, 1, 0, time.UTC)
in := msg(
"tns1:RuleEngine/CellMotionDetector/Motion",
"Changed",
"2026-05-21T10:30:00Z",
map[string]string{
"VideoSourceConfigurationToken": "VideoSourceConfigToken0",
"Rule": "MyMotionRule",
},
map[string]string{"IsMotion": "true"},
)
ev := decode(in, "axis-cam-01", observedAt)
assert.Equal(t, KindMotion, ev.Kind)
assert.Equal(t, StateActive, ev.State)
assert.Equal(t, PropertyChanged, ev.Operation)
assert.Equal(t, "axis-cam-01", ev.DeviceID)
assert.Equal(t, "VideoSourceConfigToken0", ev.Source["VideoSourceConfigurationToken"])
assert.Equal(t, "MyMotionRule", ev.Source["Rule"])
assert.Equal(t, "true", ev.Data["IsMotion"])
assert.Equal(t, "tns1:RuleEngine/CellMotionDetector/Motion", ev.Topic)
assert.True(t, ev.Timestamp.Equal(observedAt))
assert.Equal(t, time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC), ev.DeviceTime)
}
func TestDecode_MotionInactive(t *testing.T) {
in := msg(
"tns1:VideoSource/MotionAlarm",
"Changed",
"",
nil,
map[string]string{"State": "false"},
)
ev := decode(in, "dev", time.Now())
assert.Equal(t, KindMotion, ev.Kind)
assert.Equal(t, StateInactive, ev.State)
}
func TestDecode_HanwhaNumericMotionValue(t *testing.T) {
// Hanwha emits xsd:string values "0"/"1" instead of xsd:boolean.
in := msg(
"tns1:VideoAnalytics/tnssamsung:MotionDetection",
"Changed",
"",
nil,
map[string]string{"Motion": "1"},
)
ev := decode(in, "dev", time.Now())
assert.Equal(t, KindMotion, ev.Kind)
assert.Equal(t, StateActive, ev.State)
}
func TestDecode_AvigilonActiveLiteral(t *testing.T) {
// Avigilon and a handful of older firmwares emit "active"/"inactive"
// as the Data value rather than a boolean.
in := msg(
"tns1:Device/tns1:Trigger/tns1:Relay",
"Changed",
"",
map[string]string{"RelayToken": "Relay-1"},
map[string]string{"LogicalState": "active"},
)
ev := decode(in, "dev", time.Now())
assert.Equal(t, KindDigitalOutput, ev.Kind)
assert.Equal(t, StateActive, ev.State)
assert.Equal(t, "Relay-1", ev.Source["RelayToken"])
}
func TestDecode_AxisObjectAnalyticsMultiItem(t *testing.T) {
// AOA emits active + classType + confidence in the same Data list.
// The decoder must preserve every item; State picks the first
// boolean-like value, which is 'active'.
in := msg(
"tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1",
"Changed",
"",
map[string]string{"Source": "device1Scene1"},
map[string]string{
"active": "1",
"classType": "Human",
"confidence": "92",
},
)
ev := decode(in, "dev", time.Now())
assert.Equal(t, KindObjectDetected, ev.Kind)
assert.Equal(t, StateActive, ev.State)
assert.Equal(t, "Human", ev.Data["classType"])
assert.Equal(t, "92", ev.Data["confidence"])
assert.Equal(t, "1", ev.Data["active"])
}
func TestDecode_LineDetectorCrossedHasNoState(t *testing.T) {
// Edge-triggered topic — Data carries ObjectId, not a boolean. State
// must remain Unknown so consumers do not misread it as level-Active.
in := msg(
"tns1:RuleEngine/LineDetector/Crossed",
"Changed",
"",
map[string]string{"VideoSourceConfigurationToken": "vsct0", "Rule": "LineRule"},
map[string]string{"ObjectId": "42"},
)
ev := decode(in, "dev", time.Now())
assert.Equal(t, KindObjectDetected, ev.Kind)
assert.Equal(t, StateUnknown, ev.State)
assert.Equal(t, "42", ev.Data["ObjectId"])
}
func TestDecode_UnknownTopicStillPreservesWireData(t *testing.T) {
// Kind unknown does not mean discard: consumers may want to log or
// route on the raw topic when classification misses.
in := msg(
"tns1:UserAlarm/IVA",
"",
"",
nil,
map[string]string{"Custom": "true"},
)
ev := decode(in, "dev", time.Now())
assert.Equal(t, KindUnknown, ev.Kind)
assert.Equal(t, "tns1:UserAlarm/IVA", ev.Topic)
assert.Equal(t, "true", ev.Data["Custom"])
}
func TestDecode_PropertyOperationVariants(t *testing.T) {
tests := []struct {
name string
in string
want PropertyOperation
}{
{"initialized", "Initialized", PropertyInitialized},
{"changed", "Changed", PropertyChanged},
{"deleted", "Deleted", PropertyDeleted},
{"absent", "", PropertyUnknown},
{"unrecognised", "Bogus", PropertyUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
in := msg("tns1:VideoSource/MotionAlarm", tc.in, "", nil, nil)
ev := decode(in, "dev", time.Now())
assert.Equal(t, tc.want, ev.Operation)
})
}
}
func TestDecode_DeviceTimeParsing(t *testing.T) {
tests := []struct {
name string
in string
want time.Time
}{
{"rfc3339_utc", "2026-05-21T10:30:00Z", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
{"rfc3339_with_offset", "2026-05-21T12:30:00+02:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
{"rfc3339_subsecond", "2026-05-21T10:30:00.500Z", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)},
{"absent", "", time.Time{}},
{"unparseable", "not-a-date", time.Time{}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil)
ev := decode(in, "dev", time.Now())
if tc.want.IsZero() {
assert.True(t, ev.DeviceTime.IsZero(), "DeviceTime=%v", ev.DeviceTime)
} else {
assert.True(t, ev.DeviceTime.Equal(tc.want), "got=%v want=%v", ev.DeviceTime, tc.want)
}
})
}
}
func TestDecode_EmptySourceAndDataYieldNilMaps(t *testing.T) {
// Matches the zero-value contract in types_test.go: callers can
// safely len() and index into Source/Data without nil-checking, but
// we do not allocate an empty map for empty notifications.
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil)
ev := decode(in, "dev", time.Now())
assert.Nil(t, ev.Source)
assert.Nil(t, ev.Data)
}
func TestDecode_StateValueIsCaseInsensitive(t *testing.T) {
tests := []struct {
name string
value string
want State
}{
{"true_lower", "true", StateActive},
{"true_upper", "TRUE", StateActive},
{"true_mixed", "True", StateActive},
{"false_lower", "false", StateInactive},
{"false_mixed", "False", StateInactive},
{"active_mixed", "Active", StateActive},
{"inactive_mixed", "Inactive", StateInactive},
{"one", "1", StateActive},
{"zero", "0", StateInactive},
{"empty", "", StateUnknown},
{"nonsense", "maybe", StateUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "",
nil, map[string]string{"State": tc.value})
ev := decode(in, "dev", time.Now())
assert.Equal(t, tc.want, ev.State)
})
}
}
// --- Edge cases for state extraction and time parsing ----------------
func TestDecode_PropertyOperationIsCaseSensitive(t *testing.T) {
// Per WS-Notification §3.3 PropertyOperation values are
// 'Initialized' / 'Changed' / 'Deleted'. Lowercased forms are
// malformed and should fall through to PropertyUnknown.
in := msg("tns1:VideoSource/MotionAlarm", "changed", "", nil, nil)
ev := decode(in, "dev", time.Now())
assert.Equal(t, PropertyUnknown, ev.Operation)
}
func TestDecode_StateValueTrimsWhitespace(t *testing.T) {
tests := []struct {
name string
value string
want State
}{
{"leading_trailing", " true ", StateActive},
{"tab_newline", "\ttrue\n", StateActive},
{"only_spaces", " ", StateUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "",
nil, map[string]string{"State": tc.value})
ev := decode(in, "dev", time.Now())
assert.Equal(t, tc.want, ev.State)
})
}
}
func TestDecode_SimpleItemEmptyValueIsUnknownState(t *testing.T) {
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "",
nil, map[string]string{"State": ""})
ev := decode(in, "dev", time.Now())
assert.Equal(t, StateUnknown, ev.State)
v, ok := ev.Data["State"]
assert.True(t, ok)
assert.Equal(t, "", v)
}
func TestDecode_DeviceTimeAdditionalLayouts(t *testing.T) {
tests := []struct {
name string
in string
want time.Time
}{
{"compact_offset", "2026-05-21T12:30:00+0200", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
{"compact_offset_subsec", "2026-05-21T12:30:00.500+0200", time.Date(2026, 5, 21, 10, 30, 0, 500_000_000, time.UTC)},
{"naked_no_tz", "2026-05-21T10:30:00", time.Date(2026, 5, 21, 10, 30, 0, 0, time.UTC)},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
in := msg("tns1:VideoSource/MotionAlarm", "Changed", tc.in, nil, nil)
ev := decode(in, "dev", time.Now())
assert.True(t, ev.DeviceTime.Equal(tc.want),
"input=%q got=%v want=%v", tc.in, ev.DeviceTime, tc.want)
})
}
}
func TestDecode_DeviceTimeStillRejectsNonsense(t *testing.T) {
for _, s := range []string{"hello", "2026-13-45T99:99:99", strings.Repeat("9", 50)} {
in := msg("tns1:VideoSource/MotionAlarm", "Changed", s, nil, nil)
ev := decode(in, "dev", time.Now())
assert.True(t, ev.DeviceTime.IsZero(), "input=%q should yield zero, got %v", s, ev.DeviceTime)
}
}
// --- First-boolean-wins with explicit slice order --------------------
type pair struct{ k, v string }
func simpleItemsFromPairs(pairs []pair) []event.SimpleItem {
out := make([]event.SimpleItem, len(pairs))
for i, p := range pairs {
out[i] = event.SimpleItem{
Name: xsd.AnyType(p.k),
Value: xsd.AnyType(p.v),
}
}
return out
}
func TestExtractState_FirstBooleanLikeWins(t *testing.T) {
// Documented behaviour: when multiple Data items have boolean-like
// values, the first by slice order wins. Use explicit slice
// construction so the assertion does not depend on map iteration
// order.
in := msg("tns1:VideoSource/MotionAlarm", "Changed", "", nil, nil)
in.Message.Message.Data.SimpleItem = simpleItemsFromPairs([]pair{
{"ObjectId", "42"},
{"State", "true"},
{"Trailer", "false"},
})
ev := decode(in, "dev", time.Now())
assert.Equal(t, StateActive, ev.State,
"first boolean-like value (State=true) must win, not Trailer=false")
}

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

@@ -0,0 +1,29 @@
// Package stream is a typed, channel-based consumer for ONVIF device
// events. It hides the SOAP/XML, pull-point subscription lifecycle,
// subscription renewal and vendor-specific topic conventions behind a
// single Event stream.
//
// # Usage
//
// dev, _ := onvif.NewDevice(onvif.DeviceParams{Xaddr: "...", Username: "...", Password: "..."})
// s, err := stream.NewStream(ctx, dev, stream.Options{DeviceID: "front-door"})
// if err != nil { /* construction failed: auth, network, or no event support */ }
// defer s.Close()
//
// for ev := range s.Events() {
// switch ev.Kind {
// case stream.KindMotion:
// if ev.State == stream.StateActive { /* start recording */ }
// }
// }
//
// NewStream performs network I/O so auth and reachability failures
// surface synchronously, and rejects a client timeout that cannot
// outlast PullTimeout with ErrInvalidOptions. Events and Errors close when the Stream stops;
// Errors sends are non-blocking so a stalled consumer drops older
// errors rather than blocking the pull loop. After a silent reconnect,
// the next batch's events carry Event.AfterReconnect=true.
//
// See topics.go for the verified topic→Kind mapping across AXIS,
// Hikvision, Avigilon, Hanwha, Bosch and Dahua.
package stream

View File

@@ -0,0 +1,21 @@
package stream
import (
"testing"
"time"
)
// waitFor polls cond at 10ms intervals up to d. Fails the test with msg
// if cond never returns true. Centralises the pattern that appears in
// renew/reconnect/stream tests so retries are uniform.
func waitFor(t *testing.T, d time.Duration, msg string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("waitFor timed out after %s: %s", d, msg)
}

14
event/stream/main_test.go Normal file
View File

@@ -0,0 +1,14 @@
package stream
import (
"testing"
"go.uber.org/goleak"
)
// Catches any pull, renew, or recreate goroutine that outlives its
// Stream — a regression class that's silent in production until
// goroutine count drifts up and triggers OOM.
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}

View File

@@ -0,0 +1,51 @@
package stream
import (
"errors"
"fmt"
"time"
"github.com/kerberos-io/onvif"
)
// ErrInvalidOptions marks a configuration that cannot succeed. Callers
// retry the pull/renew/recreate errors; retrying this one never helps,
// so it is a distinct sentinel they can short-circuit on.
var ErrInvalidOptions = errors.New("stream: invalid options")
// minClientHeadroom is how far http.Client.Timeout must exceed
// PullTimeout. The client ceiling covers dial, TLS and the response
// transfer on top of the poll it has to outlast, and starts before the
// camera has parsed the request; on a cellular bearer that overhead
// runs to hundreds of milliseconds.
const minClientHeadroom = 5 * time.Second
// validateClientTimeout rejects a client ceiling that cannot outlast
// the PullMessages long-poll plus minClientHeadroom.
//
// Zero means unbounded and is accepted: it is the SDK's default when a
// caller passes no client, so rejecting it would break every default
// consumer. Note it is not risk-free — the caller interface documents
// that ctx cannot interrupt an in-flight SOAP call, so only the client
// timeout can unwedge a stalled camera.
func validateClientTimeout(clientTimeout, pullTimeout time.Duration) error {
if clientTimeout == 0 || clientTimeout >= pullTimeout+minClientHeadroom {
return nil
}
return fmt.Errorf(
"%w: http.Client.Timeout (%s) must exceed PullTimeout (%s) by at least %s; PullMessages is a long-poll and the client would abort every quiet pull",
ErrInvalidOptions, clientTimeout, pullTimeout, minClientHeadroom)
}
// clientTimeoutOf reports the device's HTTP client ceiling, or 0 when
// the SDK is using its own default (unbounded) client.
func clientTimeoutOf(dev *onvif.Device) time.Duration {
if dev == nil {
return 0
}
c := dev.GetDeviceParams().HttpClient
if c == nil {
return 0
}
return c.Timeout
}

View File

@@ -0,0 +1,68 @@
package stream
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateClientTimeout — PullMessages is a long-poll: the camera
// holds the connection open for PullTimeout waiting for an event. An
// http.Client.Timeout covers the whole exchange and starts before the
// camera has parsed the request, so a client ceiling at or below
// PullTimeout loses the race on every quiet interval and the pull can
// only ever fail. This shipped once (both were 5s) and presented as a
// slow camera rather than a misconfiguration.
//
// Strict inequality is not enough: the client also has to cover dial,
// TLS and the response transfer, which on a cellular bearer runs to
// hundreds of milliseconds. Hence a real headroom floor.
func TestValidateClientTimeout(t *testing.T) {
tests := []struct {
name string
client time.Duration
pull time.Duration
wantErr bool
}{
{"unbounded client is the caller's risk, not an error", 0, 30 * time.Second, false},
{"comfortable headroom", 40 * time.Second, 30 * time.Second, false},
{"exactly the minimum headroom", 30*time.Second + minClientHeadroom, 30 * time.Second, false},
{"a hair under the minimum headroom", 30*time.Second + minClientHeadroom - time.Millisecond, 30 * time.Second, true},
{"strictly greater but no headroom", 30*time.Second + time.Millisecond, 30 * time.Second, true},
{"equal timeouts always lose", 5 * time.Second, 5 * time.Second, true},
{"client below pull", 4 * time.Second, 30 * time.Second, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateClientTimeout(tt.client, tt.pull)
if tt.wantErr {
require.Error(t, err, "client=%s pull=%s must be rejected", tt.client, tt.pull)
assert.ErrorIs(t, err, ErrInvalidOptions,
"callers need a sentinel to tell a permanent misconfiguration from a transient failure")
assert.Contains(t, err.Error(), "PullTimeout",
"the error must name the option the caller has to change")
return
}
assert.NoError(t, err, "client=%s pull=%s must be accepted", tt.client, tt.pull)
})
}
}
// TestErrInvalidOptions_IsDistinctFromStreamErrors — the pull/renew/
// recreate errors are transient and callers retry them. A bad Options
// never becomes valid by retrying, so it must not be mistaken for one.
func TestErrInvalidOptions_IsDistinctFromStreamErrors(t *testing.T) {
err := validateClientTimeout(5*time.Second, 5*time.Second)
require.Error(t, err)
var pull ErrPullFailed
var renew ErrRenewFailed
var recreate ErrRecreateFailed
assert.False(t, errors.As(err, &pull))
assert.False(t, errors.As(err, &renew))
assert.False(t, errors.As(err, &recreate))
}

108
event/stream/reconnect.go Normal file
View File

@@ -0,0 +1,108 @@
package stream
import (
"context"
"math/rand"
"time"
)
// maxRecreateBackoff caps exponential backoff between recreate
// attempts. Sized for fleet deployments: at 30s a 1000-camera setup
// recovering from a switch reboot would generate sustained
// reconnect traffic; 5 minutes lets the network settle.
const maxRecreateBackoff = 5 * time.Minute
// jitterFraction prevents thundering-herd reconnects when many
// cameras drop together (switch reboot, NAT timeout).
const jitterFraction = 0.25
// pullLoop runs PullMessages → decode → Events. After
// ReconnectAfterFailures consecutive errors it asks attemptRecreate
// to rebuild the subscription. The next batch's events carry
// AfterReconnect=true so consumers can suppress the Initialized
// replay ONVIF emits on a new subscription.
func (s *Stream) pullLoop(ctx context.Context) {
var failures int
recreateBackoff := s.opts.RetryBackoff
var afterReconnect bool
for {
if ctx.Err() != nil {
return
}
msgs, err := pullMessages(s.caller, s.getPullPoint(), s.opts)
if err != nil {
s.surfaceError(ErrPullFailed{Err: err})
failures++
if !s.opts.DisableReconnect && failures >= s.opts.ReconnectAfterFailures {
justRecreated, cont := s.attemptRecreate(ctx, &failures, &recreateBackoff)
if !cont {
return
}
if justRecreated {
afterReconnect = true
}
continue
}
if !sleepCtx(ctx, s.opts.RetryBackoff) {
return
}
continue
}
failures = 0
recreateBackoff = s.opts.RetryBackoff
observedAt := s.now()
for _, m := range msgs {
ev := decode(m, s.opts.DeviceID, observedAt)
if afterReconnect {
ev.AfterReconnect = true
// Clear once the camera transitions past the
// Initialized replay to live events.
if ev.Operation != PropertyInitialized {
afterReconnect = false
}
}
select {
case <-ctx.Done():
return
case s.events <- ev:
}
}
}
}
// 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) {
ref, err := createPullPoint(s.caller, s.opts)
if err != nil {
s.surfaceError(ErrRecreateFailed{Err: err})
if !sleepCtx(ctx, jitter(*backoff)) {
return false, false
}
*backoff *= 2
if *backoff > maxRecreateBackoff {
*backoff = maxRecreateBackoff
}
return false, true
}
s.setPullPoint(ref)
*failures = 0
*backoff = s.opts.RetryBackoff
return true, true
}
// jitter perturbs d by ±jitterFraction so synchronised drops do not
// produce a synchronised reconnect surge.
func jitter(d time.Duration) time.Duration {
if d <= 0 {
return time.Nanosecond
}
spread := float64(d) * jitterFraction
delta := (rand.Float64()*2 - 1) * spread
out := time.Duration(float64(d) + delta)
if out <= 0 {
out = time.Nanosecond
}
return out
}

View File

@@ -0,0 +1,315 @@
package stream
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createPullPointRespAlt mirrors the first fixture but returns a
// different SubscriptionReference Address so a test can prove that
// subsequent pulls hit the recreated endpoint. Like createPullPointResp,
// it intentionally omits <TerminationTime> so renew scheduling stays
// driven by opts.
const createPullPointRespAlt = `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body>
<tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa:Address>http://camera.local/onvif/Events/PullSub_2</wsa:Address>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse>
</env:Body>
</env:Envelope>`
// --- Recreate after pull failures ------------------------------------
func TestStream_RecreatesSubscriptionAfterRepeatedPullErrors(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueCallMethod(createPullPointRespAlt, nil)
fc.queueSendSoap("", errors.New("transient failure"))
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
DeviceID: "cam-1",
PullTimeout: 50 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
defer s.Close()
ev := receive(t, s.Events(), 2*time.Second)
assert.Equal(t, KindMotion, ev.Kind)
fc.mu.Lock()
defer fc.mu.Unlock()
require.Len(t, fc.callMethodCalls, 2,
"expected exactly 2 CallMethod calls (initial + recreate)")
var newEndpointPulls int
for _, c := range fc.sendSoapCalls {
if c[0] == "http://camera.local/onvif/Events/PullSub_2" {
newEndpointPulls++
}
}
assert.GreaterOrEqual(t, newEndpointPulls, 1,
"expected pulls against the recreated subscription endpoint")
}
func TestStream_BackoffWhenRecreateFails(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.mu.Lock()
fc.defaultCall = fakeResp{err: errors.New("recreate fail")}
fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 10 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(2 * time.Second)
var calls atomic.Int32
for time.Now().Before(deadline) {
fc.mu.Lock()
calls.Store(int32(len(fc.callMethodCalls)))
fc.mu.Unlock()
if calls.Load() >= 4 {
break
}
time.Sleep(20 * time.Millisecond)
}
assert.GreaterOrEqual(t, calls.Load(), int32(4),
"expected stream to retry recreate (>=3 retries on top of the initial create)")
}
func TestStream_ReconnectAfterFailuresDefault(t *testing.T) {
o := defaultOptions()
assert.Equal(t, 3, o.ReconnectAfterFailures)
}
func TestStream_RetryBackoffDefault(t *testing.T) {
o := defaultOptions()
assert.Equal(t, time.Second, o.RetryBackoff)
}
func TestStream_DisableReconnectKeepsRetryingOriginalEndpoint(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.mu.Lock()
fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 10 * time.Millisecond,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
DisableReconnect: true,
})
require.NoError(t, err)
defer s.Close()
time.Sleep(200 * time.Millisecond)
fc.mu.Lock()
calls := len(fc.callMethodCalls)
fc.mu.Unlock()
assert.Equal(t, 1, calls, "DisableReconnect must prevent recreate; got %d CallMethod calls", calls)
}
func TestStream_RecreateResetsFailuresAndBackoffOnSuccess(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueCallMethod(createPullPointRespAlt, nil)
fc.queueSendSoap("", errors.New("first failure"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 10 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
defer s.Close()
time.Sleep(200 * time.Millisecond)
fc.mu.Lock()
calls := len(fc.callMethodCalls)
fc.mu.Unlock()
assert.Equal(t, 2, calls,
"after one failure + successful recreate, no further recreates expected; got %d", calls)
}
func TestStream_PullPointMutationVisibleToRenewLoopUnderRace(t *testing.T) {
// Drives the pullPoint write-by-pullLoop / read-by-renewLoop race
// so -race actually exercises the mutex critical sections.
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
for i := 0; i < 50; i++ {
fc.queueCallMethod(createPullPointRespAlt, nil)
}
fc.mu.Lock()
fc.defaultSendSoap = fakeResp{err: errors.New("recurring pull fail")}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 5 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 1 * time.Millisecond,
InitialTermination: 20 * time.Millisecond,
RenewMargin: 2 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
time.Sleep(300 * time.Millisecond)
}
// --- Typed errors from the reconnect path ----------------------------
func TestStream_PullErrorIsTypedErrPullFailed(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueSendSoap("", errors.New("transient"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 50 * time.Millisecond,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
defer s.Close()
select {
case e := <-s.Errors():
var pullErr ErrPullFailed
require.True(t, errors.As(e, &pullErr), "expected ErrPullFailed, got %T: %v", e, e)
assert.Contains(t, pullErr.Err.Error(), "transient")
case <-time.After(time.Second):
t.Fatal("expected ErrPullFailed on Errors channel")
}
}
func TestStream_RecreateErrorIsTypedErrRecreateFailed(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.mu.Lock()
fc.defaultCall = fakeResp{err: errors.New("recreate fail")}
fc.defaultSendSoap = fakeResp{err: errors.New("pull fail")}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 10 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(time.Second)
var sawRecreate bool
for time.Now().Before(deadline) && !sawRecreate {
select {
case e := <-s.Errors():
var rec ErrRecreateFailed
if errors.As(e, &rec) {
sawRecreate = true
assert.Contains(t, rec.Err.Error(), "recreate fail")
}
case <-time.After(50 * time.Millisecond):
}
}
assert.True(t, sawRecreate, "expected at least one ErrRecreateFailed on Errors")
}
func TestStream_AfterReconnectFlagSetOnFirstPostRecreateBatch(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueCallMethod(createPullPointRespAlt, nil)
fc.queueSendSoap("", errors.New("transient"))
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
fc.queueSendSoap(pullMessagesResp(motionMsg("false")), nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 50 * time.Millisecond,
ReconnectAfterFailures: 1,
RetryBackoff: 10 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
defer s.Close()
ev1 := receive(t, s.Events(), 2*time.Second)
assert.True(t, ev1.AfterReconnect, "first event after recreate must carry AfterReconnect=true")
assert.Equal(t, StateActive, ev1.State)
ev2 := receive(t, s.Events(), 2*time.Second)
assert.False(t, ev2.AfterReconnect, "subsequent events should not carry AfterReconnect")
assert.Equal(t, StateInactive, ev2.State)
}
// --- Jitter ----------------------------------------------------------
func TestJitter_StaysWithinFraction(t *testing.T) {
const base = time.Second
low := time.Duration(float64(base) * (1 - jitterFraction))
high := time.Duration(float64(base) * (1 + jitterFraction))
for i := 0; i < 200; i++ {
got := jitter(base)
assert.GreaterOrEqual(t, got, low, "iteration %d", i)
assert.LessOrEqual(t, got, high, "iteration %d", i)
}
}
func TestJitter_ZeroAndNegativeReturnPositive(t *testing.T) {
assert.Greater(t, jitter(0), time.Duration(0))
assert.Greater(t, jitter(-time.Second), time.Duration(0))
}
func TestJitter_VariesAcrossCalls(t *testing.T) {
first := jitter(time.Second)
allEqual := true
for i := 0; i < 10; i++ {
if jitter(time.Second) != first {
allEqual = false
break
}
}
assert.False(t, allEqual, "jitter is producing a constant; rand seed not working")
}
func TestMaxRecreateBackoff_Is5Minutes(t *testing.T) {
assert.Equal(t, 5*time.Minute, maxRecreateBackoff)
}

97
event/stream/renew.go Normal file
View File

@@ -0,0 +1,97 @@
package stream
import (
"context"
"encoding/xml"
"fmt"
"time"
"github.com/kerberos-io/onvif/event"
"github.com/kerberos-io/onvif/xsd"
)
// renewLoop sleeps until the next deadline (camera-granted termination
// minus RenewMargin), renews, and repeats. On failure it backs off via
// nextRenewIntervalAfterError so the loop doesn't busy-loop against
// the 1s floor when the previous grant has just expired. A permanently
// failing renew lets the subscription die at the camera; the pull
// loop's reconnect path then recreates it — recreate is the only
// reliable recovery once a subscription is GC'd.
func (s *Stream) renewLoop(ctx context.Context) {
for {
ref, gen := s.snapshotPullPoint()
if !sleepCtx(ctx, nextRenewInterval(ref.GrantedTermination, s.opts, s.now())) {
return
}
granted, err := renewPullPoint(s.caller, s.getPullPoint(), s.opts)
if err != nil {
s.surfaceError(ErrRenewFailed{Err: err})
if !sleepCtx(ctx, nextRenewIntervalAfterError(s.opts)) {
return
}
continue
}
if !granted.IsZero() {
s.updateGrantedTerminationIfGen(gen, granted)
}
}
}
// nextRenewInterval prefers the camera-granted termination so we never
// schedule a renew past the actual expiry, with opts.InitialTermination
// as the fallback when the camera didn't supply one.
func nextRenewInterval(granted time.Time, opts Options, now time.Time) time.Duration {
var base time.Duration
if !granted.IsZero() {
base = granted.Sub(now)
} else {
base = opts.InitialTermination
}
d := base - opts.RenewMargin
if d <= 0 {
d = base / 2
}
if d <= 0 {
d = time.Second
}
return d
}
// nextRenewIntervalAfterError returns the post-failure sleep. The
// grant is typically already in the past by the time renew has failed
// once, so nextRenewInterval would floor to 1s and hammer the camera.
// Recovery is the pull loop's reconnect path; we just need to not
// accelerate retries past the configured RetryBackoff.
func nextRenewIntervalAfterError(opts Options) time.Duration {
if opts.RetryBackoff > 0 {
return opts.RetryBackoff
}
return time.Second
}
// renewPullPoint sends Renew with an absolute UTC TerminationTime.
// WS-BaseNotification §6.1.1 also allows xsd:duration but older
// Hikvision, some Dahua and some Bosch firmwares reject the
// relative form. Returns the camera-granted TerminationTime parsed
// from the response (zero on absence) so the caller can reschedule.
func renewPullPoint(c caller, ref subscriptionRef, opts Options) (time.Time, 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 time.Time{}, fmt.Errorf("marshal Renew: %w", err)
}
headerXML, err := buildRefParamsHeader(ref.RefParamsXML)
if err != nil {
return time.Time{}, fmt.Errorf("build ref params header: %w", err)
}
resp, err := c.SendSoapWithHeader(ref.Address, string(body), headerXML)
if err != nil {
return time.Time{}, enrichSOAPErr(resp, err)
}
respBody, err := readClose(resp)
if err != nil {
return time.Time{}, err
}
return extractTerminationTime(respBody), nil
}

277
event/stream/renew_test.go Normal file
View File

@@ -0,0 +1,277 @@
package stream
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// countSendSoapMatching counts how many recorded SendSoap calls have a
// body containing needle. Safe to call concurrently with the run loop.
func countSendSoapMatching(fc *fakeCaller, needle string) int {
fc.mu.Lock()
defer fc.mu.Unlock()
n := 0
for _, c := range fc.sendSoapCalls {
if strings.Contains(c[1], needle) {
n++
}
}
return n
}
func TestStream_RenewsSubscriptionBeforeExpiry(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 100 ms termination with 10 ms margin -> renew every ~90 ms.
s, err := newStream(ctx, fc, Options{
DeviceID: "cam-1",
InitialTermination: 100 * time.Millisecond,
RenewMargin: 10 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(500 * time.Millisecond)
var renewCount int
for time.Now().Before(deadline) {
renewCount = countSendSoapMatching(fc, "Renew")
if renewCount >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
assert.GreaterOrEqual(t, renewCount, 1, "expected at least one Renew SendSoap call within 500ms")
}
func TestStream_RenewSendsToSubscriptionEndpoint(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
InitialTermination: 80 * time.Millisecond,
RenewMargin: 10 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if countSendSoapMatching(fc, "Renew") >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
fc.mu.Lock()
defer fc.mu.Unlock()
var renewEndpoint string
for _, c := range fc.sendSoapCalls {
if strings.Contains(c[1], "Renew") {
renewEndpoint = c[0]
break
}
}
require.NotEmpty(t, renewEndpoint, "no Renew call found")
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", renewEndpoint,
"Renew must target the SubscriptionReference Address")
}
func TestStream_RenewMarginAppliesDefault(t *testing.T) {
o := defaultOptions()
assert.Equal(t, 10*time.Second, o.RenewMargin)
}
func TestStream_RenewErrorSurfacedOnErrorsChannel(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
// Defaults return empty pulls indefinitely so the pull loop is clean.
// Override defaultSendSoap on the fly to return a Renew error for
// any body that looks like a Renew. We do that by tagging the
// default response with an err, then resetting after capturing one.
// Simpler: just queue several explicit Renew-error responses; the
// fake's queue is consumed in FIFO and the pull body never matches
// 'Renew', so queued errors will land on the renew call only if
// queued before any pulls. To bias the order we drain via a custom
// default.
fc.mu.Lock()
fc.defaultSendSoap = fakeResp{err: errInjected{}}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
InitialTermination: 80 * time.Millisecond,
RenewMargin: 10 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
select {
case e := <-s.Errors():
assert.Contains(t, e.Error(), "injected")
case <-time.After(time.Second):
t.Fatal("expected an error on Errors channel from failing Renew/pull")
}
}
// errInjected is a sentinel error type so the test message has a stable
// substring without depending on a wrapped string match.
type errInjected struct{}
func (errInjected) Error() string { return "injected fake error" }
func TestRenew_SendsAbsoluteDateTimeNotDuration(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
InitialTermination: 30 * time.Millisecond,
RenewMargin: 5 * time.Millisecond,
})
require.NoError(t, err)
defer s.Close()
deadline := time.Now().Add(500 * time.Millisecond)
for time.Now().Before(deadline) {
if countSendSoapMatching(fc, "Renew") >= 1 {
break
}
time.Sleep(10 * time.Millisecond)
}
fc.mu.Lock()
defer fc.mu.Unlock()
var renewBody string
for _, c := range fc.sendSoapCalls {
if strings.Contains(c[1], "Renew") {
renewBody = c[1]
break
}
}
require.NotEmpty(t, renewBody, "no Renew call observed")
// Absolute form is "YYYY-MM-DDTHH:MM:SSZ" not "PTnS".
assert.NotContains(t, renewBody, "PT", "Renew should not send relative duration; some firmwares reject it")
assert.Regexp(t, `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z`, renewBody, "Renew should send absolute RFC3339 UTC")
}
// --- Wiring: renew surfaces SOAP fault detail -------------------------
func TestRenewPullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap(renewFaultBody, errors.New("400 Bad Request"))
_, 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")
}
const renewFaultBody = `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Body><env:Fault>
<env:Code><env:Value>env:Sender</env:Value></env:Code>
<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: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
}
fc := newFakeCaller()
_, err := renewPullPoint(fc, ref, defaultOptions())
require.NoError(t, err)
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"`)
}
// Regression: when GrantedTermination has just passed (renew failed
// at or after the deadline), nextRenewInterval floors to one second
// and the loop hammers the camera at 1 Hz until reconnect. Original
// ticker design retried at the configured cadence regardless. After
// a failure the loop must use a backoff decoupled from the stale
// grant.
func TestNextRenewIntervalAfterError_BacksOffAtLeastRetryBackoff(t *testing.T) {
opts := Options{RetryBackoff: time.Second}
got := nextRenewIntervalAfterError(opts)
assert.GreaterOrEqual(t, got, opts.RetryBackoff,
"failure path must back off at least RetryBackoff, not 1s floor on stale grant")
}
func TestNextRenewIntervalAfterError_DefaultsToOneSecondWhenRetryBackoffZero(t *testing.T) {
got := nextRenewIntervalAfterError(Options{})
assert.Equal(t, time.Second, got,
"zero RetryBackoff must yield the safety floor, not a tight 0-duration sleep")
}
// Lost-update race: renew snapshots the ref, the SOAP call returns,
// and meanwhile attemptRecreate replaced pullPoint with a fresh
// subscription. If renew blindly writes the OLD subscription's
// granted time onto the NEW subscription, the new schedule is wrong.
// Update must be conditioned on "same subscription as when I read."
func TestUpdateGrantedTermination_DropsWriteWhenSubscriptionRotated(t *testing.T) {
// s.now is intentionally nil — this test does not exercise any
// time-dependent path; only the gen-counter accessors.
s := &Stream{}
original := subscriptionRef{Address: "http://camera/sub-A"}
s.setPullPoint(original)
gen := s.pullPointGen()
// Simulate recreate happening between snapshot and write.
s.setPullPoint(subscriptionRef{Address: "http://camera/sub-B"})
// Old generation's renew result must NOT overwrite sub-B's grant.
bogus := time.Date(1999, 1, 1, 0, 0, 0, 0, time.UTC)
s.updateGrantedTerminationIfGen(gen, bogus)
assert.True(t, s.getPullPoint().GrantedTermination.IsZero(),
"stale renew result must be discarded after a subscription rotation")
}
func TestUpdateGrantedTermination_AppliesWhenGenMatches(t *testing.T) {
// s.now is intentionally nil — gen-counter path only.
s := &Stream{}
s.setPullPoint(subscriptionRef{Address: "http://camera/sub"})
gen := s.pullPointGen()
t1 := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
s.updateGrantedTerminationIfGen(gen, t1)
assert.Equal(t, t1, s.getPullPoint().GrantedTermination)
}
// renewLoop must capture (ref, gen) atomically. Two separate
// getPullPoint() + pullPointGen() reads leave a window in which a
// concurrent setPullPoint advances gen between the two reads — the
// renew then runs against ref-N but believes its captured gen is N+1,
// and updateGrantedTerminationIfGen("succeeds") writing the old
// subscription's grant onto the new one. Single snapshot closes it.
func TestSnapshotPullPoint_AtomicReadOfRefAndGen(t *testing.T) {
// s.now is intentionally nil — gen-counter path only.
s := &Stream{}
s.setPullPoint(subscriptionRef{Address: "A"}) // gen=1
ref, gen := s.snapshotPullPoint()
assert.Equal(t, "A", ref.Address)
assert.Equal(t, uint64(1), gen)
s.setPullPoint(subscriptionRef{Address: "B"}) // gen=2
ref, gen = s.snapshotPullPoint()
assert.Equal(t, "B", ref.Address)
assert.Equal(t, uint64(2), gen)
}

362
event/stream/soap.go Normal file
View File

@@ -0,0 +1,362 @@
package stream
import (
"bytes"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"github.com/beevik/etree"
"github.com/kerberos-io/onvif/event"
"github.com/kerberos-io/onvif/xsd"
)
// maxResponseBytes caps SOAP response buffering on success paths.
// ONVIF PullMessages bodies are normally <100KB even with dense
// analytics payloads; 10 MiB is well above legitimate traffic while
// keeping a hostile or buggy camera from OOMing the process.
const maxResponseBytes = 10 << 20
// maxErrorBodyBytes caps the body read by enrichSOAPErr. The pull
// retry loop runs every RetryBackoff (~1s) so an unbounded read on
// the error path would churn 10 MiB/s per wedged camera. Fault bodies
// are always small.
const maxErrorBodyBytes = 64 << 10
func createPullPoint(c caller, opts Options) (subscriptionRef, error) {
term := xsd.String(durationToXSD(opts.InitialTermination))
req := event.CreatePullPointSubscription{InitialTerminationTime: &term}
if opts.RawTopicFilter != "" {
req.Filter = &event.FilterType{
TopicExpression: &event.TopicExpressionType{
Dialect: xsd.String("http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet"),
TopicKinds: xsd.String(opts.RawTopicFilter),
},
}
}
resp, err := c.CallMethod(req)
if err != nil {
return subscriptionRef{}, enrichSOAPErr(resp, err)
}
body, err := readClose(resp)
if err != nil {
return subscriptionRef{}, err
}
var decoded event.CreatePullPointSubscriptionResponse
if err := unmarshalNode(body, "CreatePullPointSubscriptionResponse", &decoded); err != nil {
return subscriptionRef{}, err
}
addr := string(decoded.SubscriptionReference.Address)
if addr == "" {
return subscriptionRef{}, errors.New("CreatePullPointSubscription response has empty SubscriptionReference Address")
}
return subscriptionRef{
Address: addr,
RefParamsXML: extractReferenceParameters(body),
GrantedTermination: extractTerminationTime(body),
}, nil
}
// extractTerminationTime parses the absolute UTC instant the camera
// granted as the subscription expiry. Returns zero on absence or parse
// failure — callers fall back to opts.InitialTermination.
func extractTerminationTime(body string) time.Time {
m := terminationTimeRE.FindStringSubmatch(body)
if len(m) < 2 {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, strings.TrimSpace(m[1]))
if err != nil {
return time.Time{}
}
return t
}
// terminationTimeRE matches the first <*:TerminationTime> in the body.
// Only safe on responses that contain exactly one — currently
// CreatePullPointSubscriptionResponse and RenewResponse via
// extractTerminationTime. PullMessagesResponse also has a
// TerminationTime element; do not call extractTerminationTime on pull
// bodies.
var terminationTimeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?TerminationTime\b[^>]*>(.*?)</(?:[^:>\s]+:)?TerminationTime>`)
// pullMessages returns an empty slice (no error) when the camera had
// nothing within PullTimeout.
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),
}
body, err := xml.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal PullMessages: %w", err)
}
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)
}
respBody, err := readClose(resp)
if err != nil {
return nil, err
}
var decoded event.PullMessagesResponse
if err := unmarshalNode(respBody, "PullMessagesResponse", &decoded); err != nil {
return nil, err
}
return decoded.NotificationMessage, nil
}
// 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)
}
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)
}
_, err = readClose(resp)
return err
}
func readClose(resp *http.Response) (string, error) {
if resp == nil || resp.Body == nil {
return "", errors.New("nil HTTP response")
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
if err != nil {
return "", fmt.Errorf("read response body: %w", err)
}
return string(b), nil
}
// unmarshalNode finds the first XML start element with the given local
// name and decodes it into out. ONVIF SOAP responses are wrapped in an
// envelope with many namespace prefixes; keying on local name only
// sidesteps namespace matching.
//
// When the camera returns a SOAP Fault, the fault reason is returned
// as the error so callers can distinguish auth / expired-subscription
// from "unparseable response".
func unmarshalNode(body, localName string, out any) error {
if reason := extractSOAPFault(body); reason != "" {
return fmt.Errorf("ONVIF SOAP fault: %s", reason)
}
dec := xml.NewDecoder(bytes.NewBufferString(body))
for {
tok, err := dec.Token()
if err != nil {
if errors.Is(err, io.EOF) {
return fmt.Errorf("ONVIF response missing %s element", localName)
}
return fmt.Errorf("scan ONVIF response: %w", err)
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != localName {
continue
}
if err := dec.DecodeElement(out, &start); err != nil {
return fmt.Errorf("decode %s: %w", localName, err)
}
return nil
}
}
var (
// SOAP 1.1: <faultstring>reason</faultstring>
soap11FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?faultstring[^>]*>(.*?)</(?:[^:>\s]+:)?faultstring>`)
// SOAP 1.2: <Fault>...<Reason><Text>reason</Text></Reason>...</Fault>
soap12FaultRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Reason\b[^>]*>.*?<(?:[^:>\s]+:)?Text[^>]*>(.*?)</(?:[^:>\s]+:)?Text>`)
// SOAP 1.2 Subcode: <Code>...<Subcode><Value>ter:InvalidArgs</Value></Subcode>...
soap12SubcodeRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Subcode\b[^>]*>.*?<(?:[^:>\s]+:)?Value[^>]*>(.*?)</(?:[^:>\s]+:)?Value>`)
// WS-Security blocks may carry our Username/Password if the camera
// echoes the request in a fault; scrub before logging. The
// alternation handles the truncated case where the read cap fell
// between <Security> and </Security>: in that case nothing past
// the opening tag is safe to retain — the replacement re-emits a
// synthetic close tag, dropping the remainder of the body excerpt
// (max-redact preferred to max-context for log lines).
// wssePasswordRE is the belt-and-braces fallback for
// non-conformant cameras emitting Password / UsernameToken outside
// a Security wrapper; same truncation handling.
wsseSecurityRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Security\b[^>]*>(?:.*?</(?:[^:>\s]+:)?Security>|.*)`)
wssePasswordRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?Password\b[^>]*>(?:.*?</(?:[^:>\s]+:)?Password>|.*)`)
)
// extractSOAPFault returns the reason text from a SOAP fault, falling
// back to the Subcode value when Reason/Text is empty (AXIS pattern).
// Returns "" when the body is not a fault.
func extractSOAPFault(body string) string {
if !strings.Contains(body, "Fault") {
return ""
}
if m := soap11FaultRE.FindStringSubmatch(body); len(m) > 1 {
if r := strings.TrimSpace(m[1]); r != "" {
return r
}
}
if m := soap12FaultRE.FindStringSubmatch(body); len(m) > 1 {
if r := strings.TrimSpace(m[1]); r != "" {
return r
}
}
return extractSOAPSubcode(body)
}
// Anchored to SubscriptionReference because other WS-Addressing
// endpoint references in the same envelope (wsa:ReplyTo, wsa:FaultTo,
// wsa:From) may also carry ReferenceParameters that are not ours.
var (
subscriptionRefRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?SubscriptionReference\b[^>]*>(.*?)</(?:[^:>\s]+:)?SubscriptionReference>`)
refParamsRE = regexp.MustCompile(`(?s)<(?:[^:>\s]+:)?ReferenceParameters\b[^>]*>(.*?)</(?:[^:>\s]+:)?ReferenceParameters>`)
)
// buildRefParamsHeader produces the SOAP <Header> inner XML from the
// full <*:ReferenceParameters> element returned by
// extractReferenceParameters. Each child element is re-emitted with
// wsa:IsReferenceParameter="true" added and any xmlns:* declared on
// the parent inherited onto it (so the standalone child stays valid).
// Empty input yields empty output.
func buildRefParamsHeader(refParamsXML string) (string, error) {
if strings.TrimSpace(refParamsXML) == "" {
return "", nil
}
doc := etree.NewDocument()
if err := doc.ReadFromString(refParamsXML); err != nil {
return "", fmt.Errorf("parse ref params: %w", err)
}
wrapper := doc.Root()
if wrapper == nil {
return "", errors.New("ref params has no root element")
}
if wrapper.Tag != "ReferenceParameters" {
return "", fmt.Errorf("ref params root must be <*:ReferenceParameters>, got <%s>", wrapper.Tag)
}
var out strings.Builder
for _, child := range wrapper.ChildElements() {
c := child.Copy()
inheritXmlns(c, wrapper)
c.CreateAttr("wsa:IsReferenceParameter", "true")
d := etree.NewDocument()
d.SetRoot(c)
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
}
// inheritXmlns copies xmlns / xmlns:* declarations from src onto dst
// when dst doesn't already declare them, so a child whose namespace
// prefix was declared on an ancestor stays valid in isolation.
func inheritXmlns(dst, src *etree.Element) {
for _, attr := range src.Attr {
isDefault := attr.Space == "" && attr.Key == "xmlns"
isPrefixed := attr.Space == "xmlns"
if !isDefault && !isPrefixed {
continue
}
key := attr.Key
if isPrefixed {
key = "xmlns:" + attr.Key
}
if dst.SelectAttr(key) != nil {
continue
}
dst.CreateAttr(key, attr.Value)
}
}
// 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 SOAP Binding §3.4.
// Without that echo, AXIS rejects PullMessages with ter:InvalidArgs.
func extractReferenceParameters(body string) string {
sub := subscriptionRefRE.FindStringSubmatch(body)
if len(sub) < 2 {
return ""
}
return strings.TrimSpace(refParamsRE.FindString(sub[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
// signal the operator gets.
func extractSOAPSubcode(body string) string {
m := soap12SubcodeRE.FindStringSubmatch(body)
if len(m) > 1 {
return strings.TrimSpace(m[1])
}
return ""
}
// maxErrExcerpt caps the body excerpt appended to an enriched error so
// a wedged camera streaming a multi-megabyte HTML error page can not
// flood logs with every retry.
const maxErrExcerpt = 512
// enrichSOAPErr appends the camera's actual complaint (SOAP Fault
// reason, then Subcode, then raw body excerpt) to a transport error so
// operators see *why* the camera said 400 instead of just "400 Bad
// Request". The original err is preserved via %w for errors.Is/As.
func enrichSOAPErr(resp *http.Response, err error) error {
if err == nil {
return nil
}
if resp == nil || resp.Body == nil {
return err
}
defer resp.Body.Close()
b, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
if readErr != nil || len(b) == 0 {
return err
}
body := wsseSecurityRE.ReplaceAllString(string(b), "<Security>[REDACTED]</Security>")
body = wssePasswordRE.ReplaceAllString(body, "<Password>[REDACTED]</Password>")
if reason := extractSOAPFault(body); reason != "" {
return fmt.Errorf("SOAP fault: %s: %w", reason, err)
}
excerpt := strings.TrimSpace(body)
if len(excerpt) > maxErrExcerpt {
excerpt = excerpt[:maxErrExcerpt] + "...(truncated)"
}
return fmt.Errorf("response body: %s: %w", excerpt, err)
}
// durationToXSD formats a duration as xsd:duration PTnS. Second
// precision is sufficient — ONVIF cameras do not honour sub-second
// pull timeouts.
func durationToXSD(d time.Duration) string {
secs := int(d.Round(time.Second).Seconds())
if secs <= 0 {
secs = 1
}
return "PT" + strconv.Itoa(secs) + "S"
}

721
event/stream/soap_test.go Normal file
View File

@@ -0,0 +1,721 @@
package stream
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- SOAP fault detection ---------------------------------------------
func TestExtractSOAPFault_SOAP11(t *testing.T) {
body := `<?xml version="1.0"?>
<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
<env:Body>
<env:Fault>
<faultcode>env:Client</faultcode>
<faultstring>The action requested requires authorization and the sender is not authorized</faultstring>
</env:Fault>
</env:Body>
</env:Envelope>`
got := extractSOAPFault(body)
assert.Contains(t, got, "not authorized")
}
func TestExtractSOAPFault_SOAP12(t *testing.T) {
body := `<?xml version="1.0"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Body>
<env:Fault>
<env:Code><env:Value>env:Sender</env:Value></env:Code>
<env:Reason><env:Text xml:lang="en">Subscription has expired</env:Text></env:Reason>
</env:Fault>
</env:Body>
</env:Envelope>`
got := extractSOAPFault(body)
assert.Contains(t, got, "Subscription has expired")
}
func TestExtractSOAPFault_NotAFault(t *testing.T) {
assert.Empty(t, extractSOAPFault(createPullPointResp))
}
func TestExtractSOAPFault_EmptyBody(t *testing.T) {
assert.Empty(t, extractSOAPFault(""))
}
func TestUnmarshalNode_ReturnsFaultReasonInsteadOfMissingElement(t *testing.T) {
body := `<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
<env:Body><env:Fault><faultstring>not authorized</faultstring></env:Fault></env:Body>
</env:Envelope>`
var out struct{}
err := unmarshalNode(body, "PullMessagesResponse", &out)
require.Error(t, err)
assert.Contains(t, err.Error(), "not authorized")
assert.NotContains(t, err.Error(), "missing PullMessagesResponse")
}
// --- Bounded body read -----------------------------------------------
func TestReadClose_LimitsBodySize(t *testing.T) {
if maxResponseBytes < 1024 {
t.Skip("limit too small for this test")
}
big := strings.Repeat("A", maxResponseBytes+1024)
body := "<env:Envelope><env:Body>" + big + "</env:Body></env:Envelope>"
fc := newFakeCaller()
fc.queueCallMethod(body, nil)
// Construction will fail because the truncated body has no
// CreatePullPointSubscriptionResponse — that's fine; what matters is
// the read completes without OOM.
_, err := newStream(testContext(t), fc, Options{})
assert.Error(t, err)
}
// testContext returns a Background context already wired to cancel via
// t.Cleanup so the test does not need to manage the cancellation
// goroutine inline.
func testContext(t *testing.T) context.Context {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
return ctx
}
// --- Error enrichment from SOAP response bodies ----------------------
func fakeResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusBadRequest,
Body: io.NopCloser(strings.NewReader(body)),
}
}
func TestEnrichSOAPErr_NilErrReturnsNil(t *testing.T) {
assert.NoError(t, enrichSOAPErr(fakeResponse("anything"), nil))
}
func TestEnrichSOAPErr_NilRespPreservesOriginal(t *testing.T) {
orig := errors.New("transport boom")
got := enrichSOAPErr(nil, orig)
assert.ErrorIs(t, got, orig)
assert.Equal(t, orig.Error(), got.Error(), "no body, no extra context to add")
}
func TestEnrichSOAPErr_SOAP11FaultStringAppearsInError(t *testing.T) {
body := `<env:Envelope xmlns:env="http://schemas.xmlsoap.org/soap/envelope/">
<env:Body><env:Fault><faultstring>not authorized</faultstring></env:Fault></env:Body>
</env:Envelope>`
got := enrichSOAPErr(fakeResponse(body), errors.New("400 Bad Request"))
require.Error(t, got)
assert.Contains(t, got.Error(), "400 Bad Request")
assert.Contains(t, got.Error(), "not authorized")
}
func TestEnrichSOAPErr_SOAP12ReasonAppearsInError(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Body><env:Fault>
<env:Code><env:Value>env:Sender</env:Value></env:Code>
<env:Reason><env:Text xml:lang="en">Subscription has expired</env:Text></env:Reason>
</env:Fault></env:Body>
</env:Envelope>`
got := enrichSOAPErr(fakeResponse(body), errors.New("400 Bad Request"))
require.Error(t, got)
assert.Contains(t, got.Error(), "Subscription has expired")
}
// Pins the AXIS case: a Fault with populated Subcode but an empty
// <Reason><Text/></Reason>. Without subcode fallback, the only signal
// the operator sees is "400 Bad Request".
func TestEnrichSOAPErr_EmptyReasonFallsBackToSubcode(t *testing.T) {
body := `<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://www.w3.org/2003/05/soap-envelope" xmlns:ter="http://www.onvif.org/ver10/error">
<SOAP-ENV:Body><SOAP-ENV:Fault>
<SOAP-ENV:Code>
<SOAP-ENV:Value>SOAP-ENV:Sender</SOAP-ENV:Value>
<SOAP-ENV:Subcode><SOAP-ENV:Value>ter:InvalidArgs</SOAP-ENV:Value></SOAP-ENV:Subcode>
</SOAP-ENV:Code>
<SOAP-ENV:Reason><SOAP-ENV:Text xml:lang="en"/></SOAP-ENV:Reason>
</SOAP-ENV:Fault></SOAP-ENV:Body>
</SOAP-ENV:Envelope>`
got := enrichSOAPErr(fakeResponse(body), errors.New("Post with digest error: 400: 400 Bad Request"))
require.Error(t, got)
assert.Contains(t, got.Error(), "ter:InvalidArgs",
"AXIS-style empty-Reason Faults must surface their Subcode")
}
func TestEnrichSOAPErr_NonFaultBodyIncludesExcerpt(t *testing.T) {
body := `<html><body>404 Not Found — /onvif/services missing</body></html>`
got := enrichSOAPErr(fakeResponse(body), errors.New("404 Not Found"))
require.Error(t, got)
assert.Contains(t, got.Error(), "/onvif/services missing")
}
func TestEnrichSOAPErr_LargeNonFaultBodyTruncated(t *testing.T) {
// A misbehaving camera could stream a multi-megabyte body. The
// helper must cap the excerpt so a wedged camera does not flood
// logs.
body := strings.Repeat("X", 8192)
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
require.Error(t, got)
assert.Less(t, len(got.Error()), 2048,
"enriched error must stay log-line sized even on huge bodies")
}
func TestEnrichSOAPErr_PreservesOriginalForErrorsIs(t *testing.T) {
// Callers wrap pull/renew/recreate errors with errors.As in
// logStreamError; enrichment must keep the original wrappable.
orig := errors.New("sentinel")
got := enrichSOAPErr(fakeResponse(`<env:Fault><faultstring>x</faultstring></env:Fault>`), orig)
assert.ErrorIs(t, got, orig)
}
// --- Subcode extraction ----------------------------------------------
func TestExtractSOAPSubcode_Present(t *testing.T) {
body := `<SOAP-ENV:Code>
<SOAP-ENV:Value>SOAP-ENV:Sender</SOAP-ENV:Value>
<SOAP-ENV:Subcode><SOAP-ENV:Value>ter:InvalidArgs</SOAP-ENV:Value></SOAP-ENV:Subcode>
</SOAP-ENV:Code>`
assert.Equal(t, "ter:InvalidArgs", extractSOAPSubcode(body))
}
func TestExtractSOAPSubcode_Absent(t *testing.T) {
assert.Empty(t, extractSOAPSubcode(`<env:Code><env:Value>env:Sender</env:Value></env:Code>`))
}
// --- Wiring: each SOAP call site routes errors through enrichSOAPErr -
const faultBody = `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Body><env:Fault>
<env:Code><env:Value>env:Sender</env:Value></env:Code>
<env:Reason><env:Text xml:lang="en">camera-specific complaint</env:Text></env:Reason>
</env:Fault></env:Body>
</env:Envelope>`
func TestCreatePullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(faultBody, errors.New("400 Bad Request"))
_, err := createPullPoint(fc, defaultOptions())
require.Error(t, err)
assert.Contains(t, err.Error(), "camera-specific complaint",
"createPullPoint must enrich transport errors with the camera's SOAP fault")
}
func TestPullMessages_EnrichesTransportErrWithFaultReason(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap(faultBody, errors.New("400 Bad Request"))
_, 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")
}
func TestUnsubscribePullPoint_EnrichesTransportErrWithFaultReason(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap(faultBody, errors.New("400 Bad Request"))
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 SOAP Binding §3.4) ---------
//
// 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 SOAP Binding §3.4 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: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
}
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 SOAP Binding §3.4 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 := `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
`<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>` +
`</wsa:ReferenceParameters>`
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 := `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
`<a:Foo xmlns:a="ns/a">1</a:Foo><b:Bar xmlns:b="ns/b">2</b:Bar>` +
`</wsa:ReferenceParameters>`
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: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing"><dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId></wsa:ReferenceParameters>`,
}
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")
}
// End-to-end multi-child wiring through the production caller, not
// just the unit-tested builder. Without the fix to addHeaderChildren
// in Device.SendSoapWithHeader, the second child would silently
// vanish from the wire envelope.
func TestPullMessages_TwoRefParamsEachLandsOnTheWire(t *testing.T) {
ref := subscriptionRef{
Address: "http://camera/sub",
RefParamsXML: `<wsa:ReferenceParameters xmlns:wsa="http://www.w3.org/2005/08/addressing">` +
`<a:Foo xmlns:a="ns/a">1</a:Foo>` +
`<b:Bar xmlns:b="ns/b">2</b:Bar>` +
`</wsa:ReferenceParameters>`,
}
fc := newFakeCaller()
_, err := pullMessages(fc, ref, defaultOptions())
require.NoError(t, err)
require.Len(t, fc.sendSoapHeaders, 1)
hdr := fc.sendSoapHeaders[0]
assert.Equal(t, 2, strings.Count(hdr, `IsReferenceParameter="true"`))
assert.Contains(t, hdr, "Foo")
assert.Contains(t, hdr, "Bar")
}
// A camera echoing our request in a fault response (some debug-mode
// firmwares do) or a fault that includes the Security header verbatim
// would otherwise leak the WS-Security Username/Password into operator
// logs. The body excerpt must scrub the Security block before the
// fault extractor and the excerpt fallback see it.
func TestEnrichSOAPErr_RedactsWSSESecurityBlock(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Header><wsse:Security xmlns:wsse="x"><wsse:UsernameToken>
<wsse:Username>admin</wsse:Username>
<wsse:Password>hunter2</wsse:Password>
</wsse:UsernameToken></wsse:Security></env:Header>
<env:Body>plain text excerpt</env:Body>
</env:Envelope>`
got := enrichSOAPErr(fakeResponse(body), errors.New("400"))
require.Error(t, got)
assert.NotContains(t, got.Error(), "hunter2", "Password must never reach logs")
assert.NotContains(t, got.Error(), "admin", "Username must never reach logs")
assert.Contains(t, got.Error(), "REDACTED", "redaction marker must remain visible")
}
// Same vendor pattern as the enrichSOAPErr case but reached via
// unmarshalNode → extractSOAPFault on a 200 OK response carrying a
// Fault. Diverging from enrichSOAPErr's fallback chain would mean
// PullMessages reports "missing PullMessagesResponse element" instead
// of the actionable ter:InvalidArgs.
func TestExtractSOAPFault_FallsBackToSubcodeWhenReasonEmpty(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope">
<env:Body><env:Fault>
<env:Code>
<env:Value>env:Sender</env:Value>
<env:Subcode><env:Value>ter:InvalidArgs</env:Value></env:Subcode>
</env:Code>
<env:Reason><env:Text xml:lang="en"/></env:Reason>
</env:Fault></env:Body>
</env:Envelope>`
assert.Equal(t, "ter:InvalidArgs", extractSOAPFault(body))
}
// WS-Addressing 1.0 Core §2.1 allows ReferenceParameters in any endpoint
// reference (wsa:From, wsa:ReplyTo, wsa:FaultTo, ...). An unanchored
// search would silently pick up the wrong one.
func TestExtractReferenceParameters_AnchoredToSubscriptionReference(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Header>
<wsa:ReplyTo>
<wsa:Address>http://anon</wsa:Address>
<wsa:ReferenceParameters>
<decoy:NotTheRealOne xmlns:decoy="urn:decoy">DO-NOT-PICK</decoy:NotTheRealOne>
</wsa:ReferenceParameters>
</wsa:ReplyTo>
</env:Header>
<env:Body><tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa:Address>http://camera/sub</wsa:Address>
<wsa:ReferenceParameters>
<dom0:SubscriptionId xmlns:dom0="http://www.axis.com/2009/event">297</dom0:SubscriptionId>
</wsa:ReferenceParameters>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
got := extractReferenceParameters(body)
assert.Contains(t, got, "SubscriptionId")
assert.Contains(t, got, "297")
assert.NotContains(t, got, "DO-NOT-PICK",
"ref params from wsa:ReplyTo must not leak through — only SubscriptionReference's children belong on PullMessages")
}
// When a vendor declares the namespace prefix on the parent
// <ReferenceParameters> element rather than the child (legal XML, just
// different from AXIS's shape), naïve inner-only extraction strips the
// declaration and produces children with orphaned prefixes that fail
// to round-trip. Inheritance must propagate ancestor xmlns onto each
// child before serialisation.
func TestBuildRefParamsHeader_InheritsParentXmlns(t *testing.T) {
parentScopedXmlns := `<wsa:ReferenceParameters xmlns:dom0="urn:vendor:axis">` +
`<dom0:SubscriptionId>297</dom0:SubscriptionId>` +
`</wsa:ReferenceParameters>`
got, err := buildRefParamsHeader(parentScopedXmlns)
require.NoError(t, err)
assert.NotContains(t, got, "ReferenceParameters",
"the wrapping element must not appear in output — each param child is its own header block")
assert.Contains(t, got, "SubscriptionId")
assert.Contains(t, got, "297")
assert.Contains(t, got, `xmlns:dom0="urn:vendor:axis"`,
"the dom0 prefix is undeclared on the child itself — it must be inherited from the parent so the standalone child stays valid XML")
assert.Contains(t, got, `IsReferenceParameter="true"`)
}
// Pins the contract change: extractReferenceParameters returns the
// full <ReferenceParameters> element (including its own attributes),
// not just the inner content, so parent-scoped xmlns survives into
// buildRefParamsHeader.
func TestExtractReferenceParameters_IncludesParentElementForXmlnsPreservation(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body><tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa:Address>http://camera</wsa:Address>
<wsa:ReferenceParameters xmlns:dom0="urn:vendor:axis">
<dom0:SubscriptionId>297</dom0:SubscriptionId>
</wsa:ReferenceParameters>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
got := extractReferenceParameters(body)
assert.Contains(t, got, "ReferenceParameters",
"extractor must include the wrapping element so parent-scoped xmlns survives")
assert.Contains(t, got, `xmlns:dom0="urn:vendor:axis"`)
assert.Contains(t, got, "SubscriptionId")
}
// --- Camera-granted TerminationTime -----------------------------------
//
// Cameras may grant a shorter subscription than we ask for. Scheduling
// the next renew from opts.InitialTermination instead of what the
// camera actually granted leads to expired subscriptions and the
// recreate-recovery path firing unnecessarily.
func TestCreatePullPoint_CapturesGrantedTermination(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body><tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference><wsa:Address>http://camera/sub</wsa:Address></tev:SubscriptionReference>
<wsnt:CurrentTime>2026-05-27T13:19:11Z</wsnt:CurrentTime>
<wsnt:TerminationTime>2026-05-27T13:21:11Z</wsnt:TerminationTime>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
fc := newFakeCaller()
fc.queueCallMethod(body, nil)
ref, err := createPullPoint(fc, defaultOptions())
require.NoError(t, err)
expected, _ := time.Parse(time.RFC3339, "2026-05-27T13:21:11Z")
assert.Equal(t, expected, ref.GrantedTermination)
}
func TestCreatePullPoint_NoTerminationTimeYieldsZeroTime(t *testing.T) {
body := `<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body><tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference><wsa:Address>http://camera/sub</wsa:Address></tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
fc := newFakeCaller()
fc.queueCallMethod(body, nil)
ref, err := createPullPoint(fc, defaultOptions())
require.NoError(t, err)
assert.True(t, ref.GrantedTermination.IsZero(),
"absent TerminationTime must yield zero so renew falls back to opts")
}
func TestNextRenewInterval_UsesGrantedTerminationMinusMargin(t *testing.T) {
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
granted := now.Add(60 * time.Second)
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second}
assert.Equal(t, 50*time.Second, nextRenewInterval(granted, opts, now))
}
func TestNextRenewInterval_FallsBackToInitialTerminationWhenGrantedZero(t *testing.T) {
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second}
assert.Equal(t, 50*time.Second, nextRenewInterval(time.Time{}, opts, now))
}
func TestNextRenewInterval_FloorsAtOneSecondIfAlreadyExpired(t *testing.T) {
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
granted := now.Add(-1 * time.Second) // camera says we're already expired
opts := Options{InitialTermination: 60 * time.Second, RenewMargin: 10 * time.Second}
assert.Equal(t, time.Second, nextRenewInterval(granted, opts, now),
"never sleep zero or negative — recreate-recovery handles the truly-dead case")
}
func TestBuildRefParamsHeader_MalformedXMLReturnsError(t *testing.T) {
_, err := buildRefParamsHeader("<not-closed")
require.Error(t, err)
}
func TestBuildRefParamsHeader_WhitespaceOnlyReturnsEmpty(t *testing.T) {
got, err := buildRefParamsHeader(" \n\t ")
require.NoError(t, err)
assert.Empty(t, got)
}
// Worst case: the response body's <Security> block starts within the
// 64 KiB error cap but its </Security> is past it. The non-greedy
// regex needs a close tag — without one the redaction misses and
// raw Username/Password reaches the excerpt. Verify the helper
// strips from <Security to EOF when no close tag is present.
func TestEnrichSOAPErr_RedactsSecurityBlockMissingCloseTag(t *testing.T) {
body := `<env:Envelope xmlns:env="x"><env:Header>` +
`<wsse:Security xmlns:wsse="y">` +
`<wsse:Username>admin</wsse:Username>` +
`<wsse:Password>hunter2</wsse:Password>` +
// no </wsse:Security> — simulates a Security block truncated
// at the 64 KiB read cap.
strings.Repeat("padding ", 1000)
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
require.Error(t, got)
assert.NotContains(t, got.Error(), "hunter2",
"truncated Security block must not leak Password to the excerpt")
assert.NotContains(t, got.Error(), "admin",
"truncated Security block must not leak Username to the excerpt")
}
// The wrapper-only contract means an element whose local name merely
// ends in "ReferenceParameters" cannot be mistaken for the wrapper —
// the root must be exactly <*:ReferenceParameters>. Anything else is
// a contract violation by the caller and surfaces as an error.
func TestBuildRefParamsHeader_RejectsNonReferenceParametersRoot(t *testing.T) {
raw := `<my:MyReferenceParameters xmlns:my="urn:vendor:my">X</my:MyReferenceParameters>`
_, err := buildRefParamsHeader(raw)
require.Error(t, err)
assert.Contains(t, err.Error(), "ReferenceParameters")
}
// A non-conformant camera echoing UsernameToken/Password outside a
// <Security> wrapper would still leak credentials through the body
// excerpt. Belt-and-braces: redact Password elements directly too.
func TestEnrichSOAPErr_RedactsBarePasswordElement(t *testing.T) {
body := `<env:Envelope xmlns:env="x"><env:Body>` +
`<wsse:UsernameToken xmlns:wsse="y">` +
`<wsse:Username>admin</wsse:Username>` +
`<wsse:Password>hunter2</wsse:Password>` +
`</wsse:UsernameToken>` +
`</env:Body></env:Envelope>`
got := enrichSOAPErr(fakeResponse(body), errors.New("400"))
require.Error(t, got)
assert.NotContains(t, got.Error(), "hunter2",
"Password must be redacted regardless of whether it's wrapped in Security")
}
// --- Edge-case coverage flagged in review -----------------------------
func TestExtractTerminationTime_MalformedDateYieldsZero(t *testing.T) {
body := `<env:Body><wsnt:TerminationTime>not-a-date</wsnt:TerminationTime></env:Body>`
assert.True(t, extractTerminationTime(body).IsZero(),
"unparseable datetime must not panic and must not return a garbage time — fall back to opts")
}
func TestNextRenewInterval_MarginEqualsBaseFallsToHalf(t *testing.T) {
now := time.Date(2026, 5, 27, 13, 0, 0, 0, time.UTC)
opts := Options{InitialTermination: 30 * time.Second, RenewMargin: 30 * time.Second}
got := nextRenewInterval(time.Time{}, opts, now)
assert.Equal(t, 15*time.Second, got,
"when margin == base, the helper must fall through to base/2 rather than the 1s floor")
}
func TestExtractReferenceParameters_EmptySubscriptionReferenceReturnsEmpty(t *testing.T) {
body := `<env:Envelope xmlns:env="x" xmlns:tev="y">
<env:Body><tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference/>
</tev:CreatePullPointSubscriptionResponse></env:Body>
</env:Envelope>`
assert.Empty(t, extractReferenceParameters(body))
}
func TestEnrichSOAPErr_RedactsTruncatedPasswordOutsideSecurity(t *testing.T) {
body := `<env:Envelope xmlns:env="x"><env:Body>` +
`<wsse:UsernameToken xmlns:wsse="y">` +
`<wsse:Username>admin</wsse:Username>` +
`<wsse:Password>hunter2` // truncated — no </Password>, no </UsernameToken>, no </Envelope>
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
require.Error(t, got)
assert.NotContains(t, got.Error(), "hunter2",
"Password without a closing tag (truncated at cap) must still be redacted")
}
func TestEnrichSOAPErr_RedactsMultipleSecurityBlocks(t *testing.T) {
body := `<E>` +
`<wsse:Security xmlns:wsse="y"><wsse:Password>secret1</wsse:Password></wsse:Security>` +
`<wsse:Security xmlns:wsse="y"><wsse:Password>secret2</wsse:Password></wsse:Security>` +
`</E>`
got := enrichSOAPErr(fakeResponse(body), errors.New("500"))
require.Error(t, got)
assert.NotContains(t, got.Error(), "secret1")
assert.NotContains(t, got.Error(), "secret2",
"ReplaceAllString must catch every Security block, not just the first")
}
func TestBuildRefParamsHeader_RejectsNonReferenceParametersRoot_Table(t *testing.T) {
cases := []struct {
name, raw string
}{
{"vendor suffix", `<my:MyReferenceParameters xmlns:my="urn:x">X</my:MyReferenceParameters>`},
{"multi-root", `<a:Foo xmlns:a="ns/a"/><b:Bar xmlns:b="ns/b"/>`},
{"unrelated element", `<not-the-wrapper>content</not-the-wrapper>`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := buildRefParamsHeader(c.raw)
require.Error(t, err)
})
}
}

348
event/stream/stream.go Normal file
View File

@@ -0,0 +1,348 @@
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). The device's
// http.Client.Timeout must exceed this by minClientHeadroom or
// NewStream returns ErrInvalidOptions.
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.
//
// GrantedTermination is the absolute time the camera says the
// subscription will expire if not renewed. May be less than
// requested; renewLoop schedules from this rather than opts.
type subscriptionRef struct {
Address string
RefParamsXML string
GrantedTermination time.Time
}
// 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 // guards pullPoint and gen
pullPoint subscriptionRef
gen uint64 // bumped on every setPullPoint so renews detect mid-flight recreate
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
s.gen++
}
// pullPointGen returns the current generation. Pair with
// updateGrantedTerminationIfGen so a renew result issued against a
// subscription that was rotated mid-flight (recreate path) is
// discarded instead of overwriting the new subscription's grant.
func (s *Stream) pullPointGen() uint64 {
s.pullPointMu.Lock()
defer s.pullPointMu.Unlock()
return s.gen
}
// snapshotPullPoint reads ref + gen under one lock so a concurrent
// setPullPoint can't slip in between two separate accessor calls and
// leave the caller with mismatched halves.
func (s *Stream) snapshotPullPoint() (subscriptionRef, uint64) {
s.pullPointMu.Lock()
defer s.pullPointMu.Unlock()
return s.pullPoint, s.gen
}
// updateGrantedTerminationIfGen writes the granted time only when the
// caller's snapshot is still current. Use setPullPoint to replace the
// full ref; this updates GrantedTermination in place after a renew
// response. Intentionally does not bump gen — that would defeat the
// rotation-detection it implements.
func (s *Stream) updateGrantedTerminationIfGen(gen uint64, t time.Time) {
s.pullPointMu.Lock()
defer s.pullPointMu.Unlock()
if s.gen != gen {
return
}
s.pullPoint.GrantedTermination = t
}
// 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) {
// Checked before the subscription call: this config can only fail,
// so surfacing it here beats a stream that appears to work and
// silently survives on reconnects alone.
if err := validateClientTimeout(clientTimeoutOf(dev), opts.withDefaults().PullTimeout); err != nil {
return nil, err
}
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
}
}

512
event/stream/stream_test.go Normal file
View File

@@ -0,0 +1,512 @@
package stream
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- fakeCaller --------------------------------------------------------
// fakeCaller is a test double for the caller interface. Each method
// returns the next queued response; when the queue is exhausted it falls
// back to a default response so the indefinite pull loop does not
// require tests to enumerate every call.
//
// blockUnsubscribe, when non-nil, causes SendSoap calls whose body
// contains "Unsubscribe" to block until the channel is closed.
// blockAllSendSoap, when non-nil, blocks every SendSoap call until
// closed (simulates a hung HTTP transport).
type fakeCaller struct {
mu sync.Mutex
callMethodResps []fakeResp
sendSoapResps []fakeResp
defaultSendSoap fakeResp
defaultCall fakeResp
callMethodCalls []any
sendSoapCalls [][2]string
sendSoapHeaders []string
blockUnsubscribe chan struct{}
blockAllSendSoap chan struct{}
}
type fakeResp struct {
body string
err error
}
func newFakeCaller() *fakeCaller {
return &fakeCaller{
// Default: indefinite empty pulls, indefinite OK unsubscribes.
defaultSendSoap: fakeResp{body: pullMessagesResp()},
defaultCall: fakeResp{err: errors.New("fakeCaller: no default CallMethod response")},
}
}
func (f *fakeCaller) queueCallMethod(body string, err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.callMethodResps = append(f.callMethodResps, fakeResp{body: body, err: err})
}
func (f *fakeCaller) queueSendSoap(body string, err error) {
f.mu.Lock()
defer f.mu.Unlock()
f.sendSoapResps = append(f.sendSoapResps, fakeResp{body: body, err: err})
}
func (f *fakeCaller) CallMethod(m any) (*http.Response, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.callMethodCalls = append(f.callMethodCalls, m)
r := f.defaultCall
if len(f.callMethodResps) > 0 {
r = f.callMethodResps[0]
f.callMethodResps = f.callMethodResps[1:]
}
// Mirror networking.SendSoap*: a 4xx/5xx returns body alongside
// err. Tests opt into that shape by queueing body + err together.
if r.err != nil && r.body == "" {
return nil, r.err
}
return &http.Response{Body: io.NopCloser(strings.NewReader(r.body))}, r.err
}
func (f *fakeCaller) SendSoap(endpoint, body string) (*http.Response, error) {
f.mu.Lock()
f.sendSoapCalls = append(f.sendSoapCalls, [2]string{endpoint, body})
r := f.defaultSendSoap
if len(f.sendSoapResps) > 0 {
r = f.sendSoapResps[0]
f.sendSoapResps = f.sendSoapResps[1:]
}
block := f.blockUnsubscribe
blockAll := f.blockAllSendSoap
f.mu.Unlock()
if blockAll != nil {
<-blockAll
}
if block != nil && strings.Contains(body, "Unsubscribe") {
<-block
}
if r.err != nil && r.body == "" {
return nil, r.err
}
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()
return len(f.sendSoapCalls)
}
// --- fixture SOAP envelopes -------------------------------------------
// createPullPointResp is the minimal SOAP envelope the lib's existing
// xml.Decoder + getXMLNode path can extract a pull-point address from.
// Intentionally omits <TerminationTime> so renewLoop falls back to
// opts.InitialTermination — tests that drive renew timing depend on
// that path. Tests that need the camera-granted termination capture
// path use a dedicated fixture instead.
const createPullPointResp = `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsa="http://www.w3.org/2005/08/addressing"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl">
<env:Body>
<tev:CreatePullPointSubscriptionResponse>
<tev:SubscriptionReference>
<wsa:Address>http://camera.local/onvif/Events/PullSub_1</wsa:Address>
</tev:SubscriptionReference>
</tev:CreatePullPointSubscriptionResponse>
</env:Body>
</env:Envelope>`
func pullMessagesResp(messages ...string) string {
return `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:tev="http://www.onvif.org/ver10/events/wsdl"
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2"
xmlns:tt="http://www.onvif.org/ver10/schema">
<env:Body>
<tev:PullMessagesResponse>
<tev:CurrentTime>2026-05-21T10:30:05Z</tev:CurrentTime>
<tev:TerminationTime>2026-05-21T10:31:05Z</tev:TerminationTime>
` + strings.Join(messages, "\n") + `
</tev:PullMessagesResponse>
</env:Body>
</env:Envelope>`
}
func motionMsg(value string) string {
return `<wsnt:NotificationMessage>
<wsnt:Topic Dialect="http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet">tns1:RuleEngine/CellMotionDetector/Motion</wsnt:Topic>
<wsnt:Message>
<tt:Message PropertyOperation="Changed" UtcTime="2026-05-21T10:30:00Z">
<tt:Source>
<tt:SimpleItem Name="VideoSourceConfigurationToken" Value="VSC0"/>
</tt:Source>
<tt:Data>
<tt:SimpleItem Name="IsMotion" Value="` + value + `"/>
</tt:Data>
</tt:Message>
</wsnt:Message>
</wsnt:NotificationMessage>`
}
const unsubscribeResp = `<?xml version="1.0" encoding="UTF-8"?>
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"
xmlns:wsnt="http://docs.oasis-open.org/wsn/b-2">
<env:Body>
<wsnt:UnsubscribeResponse/>
</env:Body>
</env:Envelope>`
// --- helpers -----------------------------------------------------------
// receive waits up to d for an event on ch, failing the test if none
// arrives.
func receive(t *testing.T, ch <-chan Event, d time.Duration) Event {
t.Helper()
select {
case ev, ok := <-ch:
if !ok {
t.Fatalf("event channel closed before receiving")
}
return ev
case <-time.After(d):
t.Fatalf("timed out waiting for event after %s", d)
}
return Event{} // unreachable
}
// --- tests -------------------------------------------------------------
func TestNewStream_CreatesPullPointAtConstruction(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
// Queue an empty pull so the run loop can spin without exploding.
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
require.NotNil(t, s)
require.NoError(t, s.Close())
// CreatePullPointSubscription was called exactly once.
fc.mu.Lock()
defer fc.mu.Unlock()
require.Len(t, fc.callMethodCalls, 1, "expected one CallMethod call (CreatePullPointSubscription)")
}
func TestStream_DeliversDecodedEvents(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
// Provide subsequent empty pulls so the loop doesn't starve before Close.
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
defer s.Close()
ev := receive(t, s.Events(), 2*time.Second)
assert.Equal(t, KindMotion, ev.Kind)
assert.Equal(t, StateActive, ev.State)
assert.Equal(t, "cam-1", ev.DeviceID)
assert.Equal(t, "tns1:RuleEngine/CellMotionDetector/Motion", ev.Topic)
assert.Equal(t, "VSC0", ev.Source["VideoSourceConfigurationToken"])
assert.Equal(t, "true", ev.Data["IsMotion"])
}
func TestStream_PullsAgainstSubscriptionAddress(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
// Wait until at least one pull happened, then close.
for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ {
time.Sleep(10 * time.Millisecond)
}
require.NoError(t, s.Close())
fc.mu.Lock()
defer fc.mu.Unlock()
require.NotEmpty(t, fc.sendSoapCalls, "expected at least one PullMessages SendSoap call")
endpoint := fc.sendSoapCalls[0][0]
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", endpoint,
"PullMessages must target the SubscriptionReference Address returned by CreatePullPoint")
// Last call (Close) should target the same endpoint with an Unsubscribe body.
last := fc.sendSoapCalls[len(fc.sendSoapCalls)-1]
assert.Equal(t, "http://camera.local/onvif/Events/PullSub_1", last[0])
assert.Contains(t, last[1], "Unsubscribe")
}
func TestNewStream_ReturnsErrorWhenCreatePullPointFails(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod("", errors.New("network down"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
assert.Error(t, err)
assert.Nil(t, s)
}
func TestStream_ClosedContextStopsRunLoop(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
// Many empty pulls so the loop is hot when we cancel.
for i := 0; i < 20; i++ {
fc.queueSendSoap(pullMessagesResp(), nil)
}
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
// Wait for at least one pull.
for i := 0; i < 50 && fc.sendSoapCallCount() == 0; i++ {
time.Sleep(10 * time.Millisecond)
}
cancel()
// Close should still complete cleanly; the goroutine must drain.
require.NoError(t, s.Close())
// Events channel must close so consumers can range-loop safely.
select {
case _, ok := <-s.Events():
assert.False(t, ok, "Events channel should be closed after Close()")
case <-time.After(time.Second):
t.Fatal("Events channel was not closed within 1s")
}
}
func TestStream_PullErrorSurfacedOnErrorsChannel(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.queueSendSoap("", errors.New("transient pull failure"))
// Then a clean pull so the loop keeps running.
fc.queueSendSoap(pullMessagesResp(motionMsg("true")), nil)
fc.queueSendSoap(pullMessagesResp(), nil)
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
defer s.Close()
select {
case e := <-s.Errors():
assert.Contains(t, e.Error(), "transient pull failure")
case <-time.After(2 * time.Second):
t.Fatal("expected an error on the Errors channel")
}
// After the transient failure the loop continued and decoded.
ev := receive(t, s.Events(), 2*time.Second)
assert.Equal(t, KindMotion, ev.Kind)
}
func TestStream_OptionsApplyDefaults(t *testing.T) {
o := defaultOptions()
assert.Equal(t, 5*time.Second, o.PullTimeout)
assert.Equal(t, 32, o.MessageLimit)
assert.Equal(t, 60*time.Second, o.InitialTermination)
assert.Equal(t, 16, o.BufferSize)
}
func TestStream_DoesNotPanicOnPullExitingDuringClose(t *testing.T) {
// Regression guard: Close should not race with the run goroutine
// in a way that double-closes the events/errors channels.
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
for i := 0; i < 5; i++ {
fc.queueSendSoap(pullMessagesResp(), nil)
}
fc.queueSendSoap(unsubscribeResp, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{DeviceID: "cam-1"})
require.NoError(t, err)
assert.NotPanics(t, func() {
require.NoError(t, s.Close())
// Double-close should be a no-op, not a panic.
_ = s.Close()
})
}
// --- Close error / timeout paths -------------------------------------
func TestClose_ReturnsUnsubscribeError(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
fc.mu.Lock()
fc.defaultSendSoap = fakeResp{err: errors.New("simulated transport failure")}
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
require.NoError(t, err)
err = s.Close()
require.Error(t, err)
assert.Contains(t, err.Error(), "unsubscribe pull point")
assert.Contains(t, err.Error(), "simulated transport failure")
}
func TestClose_BoundedByTimeoutOnHungUnsubscribe(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
block := make(chan struct{})
defer close(block) // release the hung Unsubscribe so the fake's goroutine exits
fc.mu.Lock()
fc.blockUnsubscribe = block
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
require.NoError(t, err)
start := time.Now()
err = s.Close()
elapsed := time.Since(start)
require.Error(t, err)
assert.Contains(t, err.Error(), "timeout")
assert.Less(t, elapsed, closeUnsubscribeTimeout+time.Second,
"Close exceeded bound (%s); expected ~%s", elapsed, closeUnsubscribeTimeout)
}
// --- NewStream edge cases --------------------------------------------
func TestNewStream_CtxAlreadyCancelled(t *testing.T) {
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel before NewStream
s, err := newStream(ctx, fc, Options{InitialTermination: 30 * time.Second})
require.NoError(t, err)
require.NotNil(t, s)
select {
case _, ok := <-s.Events():
assert.False(t, ok, "events channel should be closed when ctx is pre-cancelled")
case <-time.After(time.Second):
t.Fatal("events channel was not closed within 1s")
}
_ = s.Close()
}
// --- fakeCaller self-test --------------------------------------------
func TestFakeCaller_QueueThenDefaultFallback(t *testing.T) {
fc := newFakeCaller()
fc.queueSendSoap("first", nil)
fc.queueSendSoap("second", nil)
r1, err := fc.SendSoap("ep", "body")
require.NoError(t, err)
b1 := make([]byte, 10)
n, _ := r1.Body.Read(b1)
assert.Equal(t, "first", string(b1[:n]))
r2, _ := fc.SendSoap("ep", "body")
b2 := make([]byte, 10)
n, _ = r2.Body.Read(b2)
assert.Equal(t, "second", string(b2[:n]))
// Queue is exhausted; default kicks in.
r3, err := fc.SendSoap("ep", "body")
require.NoError(t, err)
require.NotNil(t, r3)
b3 := make([]byte, 2048)
n, _ = r3.Body.Read(b3)
assert.Contains(t, string(b3[:n]), "PullMessagesResponse",
"default SendSoap should be an empty PullMessagesResponse envelope")
}
func TestClose_BoundedWhenLoopsStuckOnHungHTTP(t *testing.T) {
// Simulates a hung HTTP transport: every SendSoap blocks
// indefinitely. The pull and renew loops are wedged inside
// SendSoap and ctx-cancel cannot unblock them. Close must still
// return within its bounded budget so the agent's shutdown does
// not hang.
fc := newFakeCaller()
fc.queueCallMethod(createPullPointResp, nil)
blockAll := make(chan struct{})
defer close(blockAll)
fc.mu.Lock()
fc.blockAllSendSoap = blockAll
fc.mu.Unlock()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := newStream(ctx, fc, Options{
PullTimeout: 100 * time.Millisecond,
InitialTermination: 30 * time.Second,
})
require.NoError(t, err)
// Wait until pullLoop is actually parked inside the blocked
// SendSoap. Without this, Close races with the loop's first
// iteration and exits via the ctx pre-check instead of
// exercising the drain-timeout path.
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) && fc.sendSoapCallCount() == 0 {
time.Sleep(10 * time.Millisecond)
}
require.GreaterOrEqual(t, fc.sendSoapCallCount(), 1, "pullLoop never reached SendSoap")
start := time.Now()
err = s.Close()
elapsed := time.Since(start)
require.Error(t, err)
assert.Contains(t, err.Error(), "drain", "expected a drain-timeout error")
// Total budget is closeDrainTimeout for the wait + ~0 for unsubscribe
// (which is skipped when drain times out). Give plenty of slack for
// scheduling on a loaded CI machine.
assert.Less(t, elapsed, closeDrainTimeout+2*time.Second,
"Close exceeded bound (%s); expected ~%s", elapsed, closeDrainTimeout)
}

152
event/stream/topics.go Normal file
View File

@@ -0,0 +1,152 @@
package stream
import "strings"
// Classify maps an ONVIF topic string to the normalized Kind. Returns
// KindUnknown when no rule matches.
//
// The classifier strips XML-namespace prefixes from each "/"-separated
// segment so it is robust to vendor namespaces (tns1:, tnsaxis:,
// tnssamsung:, ...). Matching is case-sensitive — ONVIF topics are
// case-sensitive per the spec.
//
// Sources cross-checked when building the rule set below:
// - ONVIF Topic Namespace XML
// https://www.onvif.org/onvif/ver10/topics/topicns.xml
// - ONVIF Analytics Service Spec
// https://www.onvif.org/specs/srv/analytics/ONVIF-VideoAnalytics-Service-Spec-v220.pdf
// - ONVIF Device IO Service Spec
// https://www.onvif.org/specs/srv/io/ONVIF-DeviceIo-Service-Spec.pdf
// - openvideolibs/onvif-parsers (Apache-2.0) — empirical topic table
// extracted from Home Assistant
// https://github.com/openvideolibs/onvif-parsers
func Classify(topic string) Kind {
if topic == "" {
return KindUnknown
}
canonical := canonicalizeTopic(topic)
for _, rule := range topicRules {
if strings.Contains(canonical, rule.needle) {
return rule.kind
}
}
return KindUnknown
}
// canonicalizeTopic strips the XML-namespace prefix from each
// "/"-separated segment, collapsing Avigilon's per-segment-prefixed
// form ("tns1:Device/tns1:Trigger/tns1:Relay") and the plain form
// ("tns1:Device/Trigger/Relay") to the same matchable path.
func canonicalizeTopic(topic string) string {
segments := strings.Split(topic, "/")
for i, seg := range segments {
if idx := strings.Index(seg, ":"); idx >= 0 {
segments[i] = seg[idx+1:]
}
}
return strings.Join(segments, "/")
}
// topicRules is evaluated in order — first match wins. Keep more
// specific rules ahead of broader ones. LineDetector/Crossed is
// edge-triggered (no boolean State); the decoder leaves State as
// StateUnknown for it.
var topicRules = []struct {
needle string
kind Kind
}{
// tns1:VideoSource/MotionAlarm — Profile S basic motion.
// https://www.onvif.org/ver10/topics/topicns.xml
// https://developer.axis.com/vapix/network-video/event-and-action-services/
{"VideoSource/MotionAlarm", KindMotion},
// tns1:VideoAnalytics/MotionAlarm — Bosch publishes motion under
// VideoAnalytics rather than VideoSource.
// https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf
{"VideoAnalytics/MotionAlarm", KindMotion},
// tns1:VideoAnalytics/tnssamsung:MotionDetection — Hanwha vendor.
// https://github.com/home-assistant/core/issues/66493
{"VideoAnalytics/MotionDetection", KindMotion},
// tns1:RuleEngine/CellMotionDetector/Motion — ONVIF Analytics
// standard cell-motion rule.
// ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.3
// https://www.hikvisioneurope.com/eu/portal/portal/Technical%20Materials/24%20How%20To/CCTV/How%20to%20solve%20third%20party%20camera%20motion%20detection%20issue.pdf
{"CellMotionDetector/Motion", KindMotion},
// tns1:RuleEngine/MotionRegionDetector/Motion — AXIS region rule.
// https://developer.axis.com/vapix/network-video/event-and-action-services/
{"MotionRegionDetector/Motion", KindMotion},
// tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_<N> — AXIS VMD 3, the
// firmware-builtin predecessor of the VMD 4 ACAP. It lives under
// RuleEngine, not CameraApplicationPlatform, so the VMD rule below
// does not cover it. Still shipping on deployed cameras.
// https://developer.axis.com/vapix/network-video/event-and-action-services/
{"RuleEngine/VMD3/", KindMotion},
// AXIS ACAP motion apps with Camera<N>Profile<ID> suffixes. VMD 4 is
// the stock app shipped on the camera; the Guard suite are the
// paid analytics products. Prefix-match because of the suffix.
// https://developer.axis.com/vapix/applications/vmd4
// https://developer.axis.com/vapix/applications/motion-guard
{"CameraApplicationPlatform/VMD/", KindMotion},
{"CameraApplicationPlatform/MotionGuard/", KindMotion},
{"CameraApplicationPlatform/FenceGuard/", KindMotion},
{"CameraApplicationPlatform/LoiteringGuard/", KindMotion},
// tns1:RuleEngine/TamperDetector/Tamper — standard ONVIF tamper rule.
// ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.5
{"TamperDetector/Tamper", KindTampering},
// tns1:VideoSource/GlobalSceneChange/ImagingService — the proper
// lens-cover signal on firmwares without TamperDetector.
// https://www.onvif.org/ver10/topics/topicns.xml
{"GlobalSceneChange", KindTampering},
// tns1:VideoAnalytics/tnssamsung:TamperingDetection — Hanwha.
// https://github.com/home-assistant/core/issues/66493
{"VideoAnalytics/TamperingDetection", KindTampering},
// VideoSource/ImageToo* — imaging-quality alarms. See KindImageQuality
// for the rationale on splitting these out from KindTampering.
// https://www.onvif.org/ver10/topics/topicns.xml
{"VideoSource/ImageTooDark", KindImageQuality},
{"VideoSource/ImageTooBright", KindImageQuality},
{"VideoSource/ImageTooBlurry", KindImageQuality},
// tns1:Device/Trigger/DigitalInput — standard. Avigilon's per-segment-
// prefixed serialisation ("tns1:Device/tns1:Trigger/tns1:DigitalInput")
// folds to the same canonical path.
// ONVIF-DeviceIo-Service-Spec.pdf §5.2
{"Trigger/DigitalInput", KindDigitalInput},
// ONVIF-DeviceIo-Service-Spec.pdf §5.3
{"Trigger/Relay", KindDigitalOutput},
// tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario<N>
// — Scenario suffixes are numeric per AOA configuration. Prefix-match
// because of the dynamic suffix.
// https://developer.axis.com/analytics/axis-object-analytics/how-to-guides/axis-object-analytics-counting-data/
{"ObjectAnalytics/", KindObjectDetected},
// ONVIF-VideoAnalytics-Service-Spec-v220.pdf §5.4
{"LineDetector/Crossed", KindObjectDetected},
{"FieldDetector/ObjectsInside", KindObjectDetected},
// tns1:RuleEngine/MyRuleDetector/<RuleName> — vendor rules under the
// ONVIF MyRuleDetector container. Explicitly whitelisted because the
// same container also carries non-object rules (Bosch Counter,
// Occupancy) that must not classify as ObjectDetected.
// https://media.boschsecurity.com/fs/media/pb/media/partners_1/integration_tools_1/developer/bosch-metadata-and-iva-events.pdf
{"MyRuleDetector/HumanDetect", KindObjectDetected},
{"MyRuleDetector/VehicleDetect", KindObjectDetected},
{"MyRuleDetector/PeopleDetect", KindObjectDetected},
{"MyRuleDetector/ObjectsInside", KindObjectDetected},
{"MyRuleDetector/FaceDetect", KindObjectDetected},
{"Audio/DetectedSound", KindAudioAlarm},
// https://developer.axis.com/vapix/network-video/event-and-action-services/
{"AudioSource/TriggerLevel", KindAudioAlarm},
{"AudioAnalytics/SoundDetection", KindAudioAlarm},
}

160
event/stream/topics_test.go Normal file
View File

@@ -0,0 +1,160 @@
package stream
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestClassifyTopic(t *testing.T) {
tests := []struct {
name string
topic string
want Kind
}{
// --- Motion -----------------------------------------------------
{"video_source_motion_alarm", "tns1:VideoSource/MotionAlarm", KindMotion},
{"cell_motion_detector", "tns1:RuleEngine/CellMotionDetector/Motion", KindMotion},
{"motion_region_detector", "tns1:RuleEngine/MotionRegionDetector/Motion", KindMotion},
{"bosch_video_analytics_motion", "tns1:VideoAnalytics/MotionAlarm", KindMotion},
{"hanwha_samsung_motion", "tns1:VideoAnalytics/tnssamsung:MotionDetection", KindMotion},
// AXIS Guard suite — vendor analytics apps.
{"axis_motion_guard", "tnsaxis:CameraApplicationPlatform/MotionGuard/Camera1ProfileANY", KindMotion},
{"axis_fence_guard", "tnsaxis:CameraApplicationPlatform/FenceGuard/Camera1ProfileANY", KindMotion},
{"axis_loitering_guard", "tnsaxis:CameraApplicationPlatform/LoiteringGuard/Camera1ProfileANY", KindMotion},
// AXIS VMD 4 — the stock motion app, and the one an installer
// reaches for before any Guard product. The profile suffix
// varies with the configured VMD profile.
{"axis_vmd4_profile_any", "tnsaxis:CameraApplicationPlatform/VMD/Camera1ProfileANY", KindMotion},
{"axis_vmd4_profile_numbered", "tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1", KindMotion},
// AXIS VMD 3 — the firmware-builtin predecessor, published under
// RuleEngine rather than CameraApplicationPlatform. Still
// shipping on deployed cameras.
{"axis_vmd3_video_1", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_1", KindMotion},
{"axis_vmd3_video_2", "tns1:RuleEngine/tnsaxis:VMD3/vmd3_video_2", KindMotion},
// --- Tampering --------------------------------------------------
{"tamper_detector", "tns1:RuleEngine/TamperDetector/Tamper", KindTampering},
{"global_scene_change", "tns1:VideoSource/GlobalSceneChange/ImagingService", KindTampering},
{"hanwha_tampering", "tns1:VideoAnalytics/tnssamsung:TamperingDetection", KindTampering},
// --- Image quality (separated from Tampering) ------------------
{"image_too_dark", "tns1:VideoSource/ImageTooDark/ImagingService", KindImageQuality},
{"image_too_bright", "tns1:VideoSource/ImageTooBright/ImagingService", KindImageQuality},
{"image_too_blurry", "tns1:VideoSource/ImageTooBlurry/ImagingService", KindImageQuality},
// --- Digital input ---------------------------------------------
{"digital_input", "tns1:Device/Trigger/DigitalInput", KindDigitalInput},
{"digital_input_avigilon", "tns1:Device/tns1:Trigger/tns1:DigitalInput", KindDigitalInput},
// --- Digital output --------------------------------------------
{"relay", "tns1:Device/Trigger/Relay", KindDigitalOutput},
{"relay_avigilon", "tns1:Device/tns1:Trigger/tns1:Relay", KindDigitalOutput},
// --- Object analytics ------------------------------------------
// AXIS Object Analytics uses numeric scenario suffixes.
{"axis_object_analytics_scenario_1", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", KindObjectDetected},
{"axis_object_analytics_scenario_2", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario2", KindObjectDetected},
// Standard rule-engine analytics topics.
{"line_detector_crossed", "tns1:RuleEngine/LineDetector/Crossed", KindObjectDetected},
{"field_detector_objects_inside", "tns1:RuleEngine/FieldDetector/ObjectsInside", KindObjectDetected},
// Whitelisted MyRuleDetector sub-rules.
{"my_rule_detector_human", "tns1:RuleEngine/MyRuleDetector/HumanDetect", KindObjectDetected},
{"my_rule_detector_vehicle", "tns1:RuleEngine/MyRuleDetector/VehicleDetect", KindObjectDetected},
{"my_rule_detector_people", "tns1:RuleEngine/MyRuleDetector/PeopleDetect", KindObjectDetected},
{"my_rule_detector_face", "tns1:RuleEngine/MyRuleDetector/FaceDetect", KindObjectDetected},
{"my_rule_detector_objects_inside", "tns1:RuleEngine/MyRuleDetector/ObjectsInside", KindObjectDetected},
// --- Audio -----------------------------------------------------
{"audio_detected_sound", "tns1:AudioAnalytics/Audio/DetectedSound", KindAudioAlarm},
{"axis_audio_trigger_level", "tns1:AudioSource/tnsaxis:TriggerLevel", KindAudioAlarm},
{"hanwha_sound_detection", "tns1:AudioAnalytics/tnssamsung:SoundDetection", KindAudioAlarm},
// --- Negative cases --------------------------------------------
{"empty", "", KindUnknown},
{"unknown_topic", "tns1:UserAlarm/IVA", KindUnknown},
{"unrelated_recording_config", "tns1:RecordingConfig/JobState", KindUnknown},
// MyRuleDetector overmatch guard — Bosch publishes counter and
// occupancy under the same container and these must not be
// classified as object detection.
{"my_rule_detector_counter_not_object", "tns1:RuleEngine/MyRuleDetector/Counter", KindUnknown},
{"my_rule_detector_occupancy_not_object", "tns1:RuleEngine/MyRuleDetector/Occupancy", KindUnknown},
// Substring guards.
{"motion_recording_not_motion", "tns1:Recording/MotionRecording/Started", KindUnknown},
{"audio_encoder_config_not_audio_alarm", "tns1:Configuration/AudioEncoderConfiguration", KindUnknown},
{"relay_failure_not_digital_output", "tns1:Device/HardwareFailure/RelayFailure", KindUnknown},
{"digital_input_config_not_digital_input", "tns1:Device/IO/DigitalInputConfiguration", KindUnknown},
{"tamper_detector_log_not_tampering", "tns1:Device/Diagnostics/TamperDetectorLog", KindUnknown},
// The two AXIS VMD needles carry a trailing slash so they match a
// whole path segment. Without it, any sibling app or rule whose
// name merely starts with VMD / VMD3 would classify as motion and
// drive recording.
{"vmd_statistics_app_not_motion", "tnsaxis:CameraApplicationPlatform/VMDStatistics/Camera1", KindUnknown},
{"vmd3_config_rule_not_motion", "tns1:RuleEngine/tnsaxis:VMD3Config/Changed", KindUnknown},
// VMD3 is scoped to RuleEngine; the same name under another
// container is a different thing.
{"vmd3_outside_rule_engine_not_motion", "tnsaxis:Storage/VMD3/Status", KindUnknown},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, Classify(tc.topic), "topic=%q", tc.topic)
})
}
}
func TestClassifyIsCaseSensitive(t *testing.T) {
// ONVIF topic identifiers are case-sensitive per the spec; a
// lowercased topic must not match a capitalised pattern.
assert.Equal(t, KindUnknown, Classify("tns1:videosource/motionalarm"))
}
func TestCanonicalizeTopicStripsNamespaces(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{"single_namespace", "tns1:VideoSource/MotionAlarm", "VideoSource/MotionAlarm"},
{"per_segment_namespace", "tns1:Device/tns1:Trigger/tns1:Relay", "Device/Trigger/Relay"},
{"vendor_namespace_inner", "tns1:VideoAnalytics/tnssamsung:MotionDetection", "VideoAnalytics/MotionDetection"},
{"axis_outer_namespace", "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1", "CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1"},
{"empty", "", ""},
{"no_colon_passthrough", "Foo/Bar", "Foo/Bar"},
{"double_slash_keeps_empty_segment", "tns1://Foo", "//Foo"},
{"colon_only_segment_collapses_to_empty", "tns1:/Foo", "/Foo"},
{"trailing_colon_segment", "tns1:", ""},
{"multi_colon_takes_first", "tns1:Foo:Bar/Baz", "Foo:Bar/Baz"},
{"leading_slash_kept", "/tns1:Foo", "/Foo"},
{"trailing_slash_kept", "tns1:Foo/", "Foo/"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, canonicalizeTopic(tc.in), "input=%q", tc.in)
})
}
}
func TestClassifyRuleOrder_ObjectAnalyticsBeforeGenericObjects(t *testing.T) {
// Locks the invariant that the prefix rule "ObjectAnalytics/" is
// matched before the broader "ObjectsInside" rule. Without this
// ordering, AXIS AOA topics that contain neither would still classify
// correctly via the ObjectAnalytics/ rule; we encode the dependency.
topic := "tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1"
assert.Equal(t, KindObjectDetected, Classify(topic))
}

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

@@ -0,0 +1,159 @@
package stream
import (
"fmt"
"time"
)
// Kind is the normalized category of an ONVIF event, independent of the
// camera vendor's topic naming.
type Kind uint8
const (
KindUnknown Kind = iota
KindMotion
KindTampering
// KindImageQuality covers VideoSource imaging alarms. Kept separate
// from KindTampering because they fire on legitimate sunset / dawn /
// condensation transitions, not on interference.
KindImageQuality
KindDigitalInput
KindDigitalOutput
KindObjectDetected
KindAudioAlarm
)
func (k Kind) String() string {
switch k {
case KindUnknown:
return "Unknown"
case KindMotion:
return "Motion"
case KindTampering:
return "Tampering"
case KindImageQuality:
return "ImageQuality"
case KindDigitalInput:
return "DigitalInput"
case KindDigitalOutput:
return "DigitalOutput"
case KindObjectDetected:
return "ObjectDetected"
case KindAudioAlarm:
return "AudioAlarm"
default:
return fmt.Sprintf("Kind(%d)", uint8(k))
}
}
// State is the active/inactive level carried by a boolean ONVIF property
// event. StateUnknown is used both when the value cannot be parsed and
// when the topic is edge-triggered and carries no boolean state.
type State uint8
const (
StateUnknown State = iota
StateActive
StateInactive
)
func (s State) String() string {
switch s {
case StateUnknown:
return "Unknown"
case StateActive:
return "Active"
case StateInactive:
return "Inactive"
default:
return fmt.Sprintf("State(%d)", uint8(s))
}
}
// PropertyOperation mirrors the wsnt:PropertyOperation attribute.
// PropertyUnknown covers both "absent on the wire" (the attribute is
// optional) and "unrecognised value".
type PropertyOperation uint8
const (
PropertyUnknown PropertyOperation = iota
PropertyInitialized
PropertyChanged
PropertyDeleted
)
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.
//
// Source and Data are maps because ONVIF notifications can carry
// multiple SimpleItems — AXIS Object Analytics emits active+classType+
// confidence in one Data list, DigitalInput carries InputToken in Source
// and LogicalState in Data.
type Event struct {
Kind Kind
State State
Operation PropertyOperation
DeviceID string
Source map[string]string
Data map[string]string
Topic string
Timestamp time.Time
// DeviceTime is the camera-reported wsnt:UtcTime. Cameras drift —
// prefer Timestamp for ordering and DeviceTime only for forensics or
// cross-camera correlation when the caller manages NTP.
DeviceTime time.Time
// AfterReconnect is true for events delivered after the Stream
// silently recreated its subscription. Cameras replay current state
// with PropertyInitialized on a new subscription; watch this flag to
// suppress duplicate edge-detection. Cleared on the first non-
// Initialized event.
AfterReconnect bool
}
// Op identifies which Stream operation failed.
type Op string
const (
OpPull Op = "pull"
OpRenew Op = "renew"
OpRecreate Op = "recreate"
)
// ErrPullFailed wraps a transient PullMessages failure. The pull loop
// surfaces it and continues.
type ErrPullFailed struct{ Err error }
func (e ErrPullFailed) Error() string { return fmt.Sprintf("pull messages: %v", e.Err) }
func (e ErrPullFailed) Unwrap() error { return e.Err }
func (ErrPullFailed) Op() Op { return OpPull }
// ErrRenewFailed wraps a Renew SOAP failure. Recovered implicitly: a
// permanently failing renew lets the subscription die, pull starts
// failing, and the reconnect path recreates it.
type ErrRenewFailed struct{ Err error }
func (e ErrRenewFailed) Error() string { return fmt.Sprintf("renew pull point: %v", e.Err) }
func (e ErrRenewFailed) Unwrap() error { return e.Err }
func (ErrRenewFailed) Op() Op { return OpRenew }
// ErrRecreateFailed wraps a failed CreatePullPointSubscription. Consumers
// seeing this repeatedly should consider the camera offline.
type ErrRecreateFailed struct{ Err error }
func (e ErrRecreateFailed) Error() string { return fmt.Sprintf("recreate pull point: %v", e.Err) }
func (e ErrRecreateFailed) Unwrap() error { return e.Err }
func (ErrRecreateFailed) Op() Op { return OpRecreate }

143
event/stream/types_test.go Normal file
View File

@@ -0,0 +1,143 @@
package stream
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestKindString(t *testing.T) {
tests := []struct {
name string
kind Kind
want string
}{
{"unknown", KindUnknown, "Unknown"},
{"motion", KindMotion, "Motion"},
{"tampering", KindTampering, "Tampering"},
{"image_quality", KindImageQuality, "ImageQuality"},
{"digital_input", KindDigitalInput, "DigitalInput"},
{"digital_output", KindDigitalOutput, "DigitalOutput"},
{"object_detected", KindObjectDetected, "ObjectDetected"},
{"audio_alarm", KindAudioAlarm, "AudioAlarm"},
{"out_of_range", Kind(255), "Kind(255)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tc.kind.String())
})
}
}
func TestKindStringsAreUnique(t *testing.T) {
seen := map[string]Kind{}
for k := KindUnknown; k <= KindAudioAlarm; k++ {
s := k.String()
prev, dup := seen[s]
assert.False(t, dup, "duplicate String %q for Kind(%d) and Kind(%d)", s, prev, k)
seen[s] = k
}
}
func TestStateString(t *testing.T) {
tests := []struct {
name string
state State
want string
}{
{"unknown", StateUnknown, "Unknown"},
{"active", StateActive, "Active"},
{"inactive", StateInactive, "Inactive"},
{"out_of_range", State(255), "State(255)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tc.state.String())
})
}
}
func TestPropertyOperationString(t *testing.T) {
tests := []struct {
name string
op PropertyOperation
want string
}{
{"unknown", PropertyUnknown, "Unknown"},
{"initialized", PropertyInitialized, "Initialized"},
{"changed", PropertyChanged, "Changed"},
{"deleted", PropertyDeleted, "Deleted"},
{"out_of_range", PropertyOperation(255), "PropertyOperation(255)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, tc.op.String())
})
}
}
func TestEventZeroValue(t *testing.T) {
var e Event
assert.Equal(t, KindUnknown, e.Kind)
assert.Equal(t, StateUnknown, e.State)
assert.Equal(t, PropertyUnknown, e.Operation)
assert.Empty(t, e.DeviceID)
assert.Nil(t, e.Source)
assert.Nil(t, e.Data)
assert.Empty(t, e.Topic)
assert.True(t, e.Timestamp.IsZero())
assert.True(t, e.DeviceTime.IsZero())
}
func TestEventFieldAssignmentRoundTrip(t *testing.T) {
now := time.Now().UTC()
deviceTime := now.Add(-2 * time.Second)
e := Event{
Kind: KindMotion,
State: StateActive,
Operation: PropertyChanged,
DeviceID: "axis-camera-01",
Source: map[string]string{"InputToken": "DI1"},
Data: map[string]string{"LogicalState": "true"},
Topic: "tns1:Device/Trigger/DigitalInput",
Timestamp: now,
DeviceTime: deviceTime,
}
assert.Equal(t, KindMotion, e.Kind)
assert.Equal(t, StateActive, e.State)
assert.Equal(t, PropertyChanged, e.Operation)
assert.Equal(t, "axis-camera-01", e.DeviceID)
assert.Equal(t, "DI1", e.Source["InputToken"])
assert.Equal(t, "true", e.Data["LogicalState"])
assert.Equal(t, "tns1:Device/Trigger/DigitalInput", e.Topic)
assert.True(t, e.Timestamp.Equal(now))
assert.True(t, e.DeviceTime.Equal(deviceTime))
}
// --- Typed errors -----------------------------------------------------
func TestTypedErrors_UnwrapAndOp(t *testing.T) {
inner := errors.New("boom")
tests := []struct {
name string
err error
op Op
}{
{"pull", ErrPullFailed{Err: inner}, OpPull},
{"renew", ErrRenewFailed{Err: inner}, OpRenew},
{"recreate", ErrRecreateFailed{Err: inner}, OpRecreate},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.True(t, errors.Is(tc.err, inner), "errors.Is should unwrap to inner")
assert.Contains(t, tc.err.Error(), "boom")
if e, ok := tc.err.(interface{ Op() Op }); ok {
assert.Equal(t, tc.op, e.Op())
} else {
t.Fatalf("%T does not expose Op()", tc.err)
}
})
}
}

View File

@@ -133,6 +133,7 @@ type MessageBody struct {
type MessageDescription struct {
PropertyOperation xsd.AnyType `xml:"PropertyOperation,attr"`
UtcTime xsd.AnyType `xml:"UtcTime,attr"`
Source Source `json:",omitempty" xml:",omitempty"`
Data Data `json:",omitempty" xml:",omitempty"`
}

View File

@@ -82,7 +82,7 @@ func main() {
if err != nil {
log.Fatalln(err)
}
res, err := dev.SendSoap(endPoint, string(requestBody))
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
if err != nil {
log.Fatalln("fail to CallMethod:", err)
}

View File

@@ -84,7 +84,7 @@ func main() {
if err != nil {
log.Fatalln(err)
}
res, err := dev.SendSoap(endPoint, string(requestBody))
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
if err != nil {
log.Fatalln("fail to CallMethod:", err)
}

View File

@@ -0,0 +1,176 @@
// Command streamtest opens an event stream against an ONVIF camera and
// prints decoded events as they arrive. Useful for verifying the
// classifier against real-camera topics; not intended as a production
// tool.
//
// # Usage
//
// go run ./examples/event/stream \
// -xaddr 192.168.1.10 \
// -username root \
// -duration 60s
//
// # Credentials
//
// The camera password is read, in order of preference:
//
// 1. The ONVIF_PASSWORD environment variable.
// 2. A file pointed at by -password-file (newline stripped).
// 3. Interactive prompt when stdin is a tty.
//
// -password is also accepted but DISCOURAGED — it leaks the credential
// into shell history and the system process listing. Use only for
// throwaway dev cameras.
package main
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/kerberos-io/onvif"
"github.com/kerberos-io/onvif/event/stream"
)
func main() {
xaddr := flag.String("xaddr", "", "camera host or host:port (required)")
username := flag.String("username", "", "ONVIF user (required)")
insecurePassword := flag.String("password", "", "INSECURE — leaks into shell history; prefer ONVIF_PASSWORD env or -password-file")
passwordFile := flag.String("password-file", "", "read password from this file (newline trimmed)")
deviceID := flag.String("device-id", "", "logical name printed with each event (default: xaddr)")
filter := flag.String("filter", "", "raw ONVIF ConcreteSet topic filter (empty = all topics, works on AXIS)")
pullTimeout := flag.Duration("pull-timeout", 5*time.Second, "server-side wait per PullMessages call")
duration := flag.Duration("duration", 0, "stop after this long (0 = run until Ctrl-C)")
flag.Parse()
if *xaddr == "" || *username == "" {
flag.Usage()
os.Exit(2)
}
if *deviceID == "" {
*deviceID = *xaddr
}
password, err := loadPassword(*insecurePassword, *passwordFile)
if err != nil {
log.Fatalf("password: %v", err)
}
dev, err := onvif.NewDevice(onvif.DeviceParams{
Xaddr: *xaddr,
Username: *username,
Password: password,
AuthMode: onvif.UsernameTokenAuth,
})
if err != nil {
log.Fatalf("connect: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if *duration > 0 {
var done context.CancelFunc
ctx, done = context.WithTimeout(ctx, *duration)
defer done()
}
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigs
cancel()
}()
s, err := stream.NewStream(ctx, dev, stream.Options{
DeviceID: *deviceID,
RawTopicFilter: *filter,
PullTimeout: *pullTimeout,
})
if err != nil {
log.Fatalf("open stream: %v", err)
}
defer func() {
if err := s.Close(); err != nil {
log.Printf("stream close: %v", err)
}
}()
log.Printf("streaming from %s (device-id=%s, filter=%q)", *xaddr, *deviceID, *filter)
for {
select {
case <-ctx.Done():
log.Printf("done (%v)", ctx.Err())
return
case ev, ok := <-s.Events():
if !ok {
return
}
fmt.Printf("%s kind=%-15s state=%-9s op=%-12s topic=%s",
ev.Timestamp.Format(time.RFC3339), ev.Kind, ev.State, ev.Operation, ev.Topic)
if ev.AfterReconnect {
fmt.Print(" [after-reconnect]")
}
if len(ev.Source) > 0 {
fmt.Printf(" source=%v", ev.Source)
}
if len(ev.Data) > 0 {
fmt.Printf(" data=%v", ev.Data)
}
fmt.Println()
case e, ok := <-s.Errors():
if !ok {
return
}
var pull stream.ErrPullFailed
var recreate stream.ErrRecreateFailed
switch {
case errors.As(e, &recreate):
log.Printf("RECREATE failed: %v (camera may be offline)", recreate.Err)
case errors.As(e, &pull):
log.Printf("pull error (will retry): %v", pull.Err)
default:
log.Printf("stream error: %v", e)
}
}
}
}
// loadPassword resolves the camera password from the environment first
// (ONVIF_PASSWORD), then -password-file, then an interactive prompt as
// a last resort. The insecure -password flag is honoured only if
// nothing else is set, and a warning is logged.
func loadPassword(insecure, file string) (string, error) {
if env := os.Getenv("ONVIF_PASSWORD"); env != "" {
return env, nil
}
if file != "" {
b, err := os.ReadFile(file)
if err != nil {
return "", fmt.Errorf("read %s: %w", file, err)
}
return strings.TrimRight(string(b), "\r\n"), nil
}
if insecure != "" {
log.Print("WARNING: -password leaks into shell history and process listings; prefer ONVIF_PASSWORD env or -password-file")
return insecure, nil
}
// Interactive prompt — works when stdin is a tty. We use a plain
// reader (rather than golang.org/x/term hidden input) to keep
// this example dependency-free; in production, callers should
// integrate term.ReadPassword.
fmt.Fprint(os.Stderr, "ONVIF password (visible): ")
r := bufio.NewReader(os.Stdin)
line, err := r.ReadString('\n')
if err != nil {
return "", errors.New("no password supplied (set ONVIF_PASSWORD, -password-file, or pipe input)")
}
return strings.TrimRight(line, "\r\n"), nil
}

View File

@@ -65,7 +65,7 @@ func main() {
if err != nil {
log.Fatalln(err)
}
res, err := dev.SendSoap(endPoint, string(requestBody))
res, err := dev.SendSoapWithOptions(endPoint, string(requestBody))
if err != nil {
log.Fatalln("fail to CallMethod:", err)
}

3
go.mod
View File

@@ -8,7 +8,10 @@ require (
github.com/elgs/gostrgen v0.0.0-20161222160715-9d61ae07eeae
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.4.0
github.com/icholy/digest v0.1.23
github.com/juju/errors v1.0.0
github.com/stretchr/testify v1.8.4
go.uber.org/goleak v1.3.0
golang.org/x/net v0.19.0
)

14
go.sum
View File

@@ -29,16 +29,22 @@ github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QX
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/icholy/digest v0.1.23 h1:4hX2pIloP0aDx7RJW0JewhPPy3R8kU+vWKdxPsCCGtY=
github.com/icholy/digest v0.1.23/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM=
github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
@@ -68,6 +74,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -81,14 +89,14 @@ golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -2,6 +2,7 @@ package gosoap
import (
"encoding/xml"
"errors"
"log"
"github.com/beevik/etree"
@@ -128,7 +129,13 @@ func (msg *SoapMessage) AddBodyContents(elements []*etree.Element) {
*msg = SoapMessage(res)
}
//AddStringHeaderContent for Envelope body
// AddStringHeaderContent appends a single root element to the SOAP
// Header. Use AddStringHeaderContents (plural) when the content
// contains multiple sibling elements — for example WS-Addressing
// reference parameters, which the spec requires as separate header
// blocks. The two coexist for backwards compatibility: external
// consumers of this library may rely on AddStringHeaderContent's
// single-root constraint and the matching error on multi-root input.
func (msg *SoapMessage) AddStringHeaderContent(data string) error {
doc := etree.NewDocument()
@@ -156,6 +163,44 @@ func (msg *SoapMessage) AddStringHeaderContent(data string) error {
return nil
}
// AddStringHeaderContents is the multi-root variant of
// AddStringHeaderContent: it accepts any number of top-level sibling
// elements (zero is an error) and appends each as its own SOAP Header
// child. Needed because WS-Addressing 1.0 SOAP Binding §3.4 requires each
// reference parameter to be a separate Header block, but a Go XML
// document only has one root. Comments and text outside elements are
// silently dropped.
//
// SECURITY: data forwards verbatim into the outbound envelope. Do not
// pass content sourced from untrusted clients — see the same caveat
// on onvif.Device.SendSoapWithOptions / SendSoapWithHeader.
func (msg *SoapMessage) AddStringHeaderContents(data string) error {
in := etree.NewDocument()
if err := in.ReadFromString("<wrap>" + data + "</wrap>"); err != nil {
return err
}
wrap := in.SelectElement("wrap")
if wrap == nil {
return errors.New("AddStringHeaderContents: missing wrap root")
}
children := wrap.ChildElements()
if len(children) == 0 {
return errors.New("AddStringHeaderContents: no element children in content")
}
doc := etree.NewDocument()
if err := doc.ReadFromString(msg.String()); err != nil {
return err
}
header := doc.Root().SelectElement("Header")
for _, child := range children {
header.AddChild(child.Copy())
}
res, _ := doc.WriteToString()
*msg = SoapMessage(res)
return nil
}
//AddHeaderContent for Envelope body
func (msg *SoapMessage) AddHeaderContent(element *etree.Element) {
doc := etree.NewDocument()

View File

@@ -75,7 +75,7 @@ const (
GetNetworkProtocols = "GetNetworkProtocols"
GetPkcs10Request = "GetPkcs10Request"
GetRelayOutputs = "GetRelayOutputs"
GetDigitalInputs = "GetDigitalInputs"
GetDigitalInputs = "GetDigitalInputs"
GetRemoteDiscoveryMode = "GetRemoteDiscoveryMode"
GetRemoteUser = "GetRemoteUser"
GetScopes = "GetScopes"

147
networking/digest_test.go Normal file
View File

@@ -0,0 +1,147 @@
package networking
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSendSoapWithDigestReturnsServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
response, err := SendSoapWithDigest(server.Client(), server.URL, "<Envelope/>", "user", "password")
if response == nil {
t.Fatal("SendSoapWithDigest returned a nil response")
}
defer response.Body.Close()
if err == nil {
t.Fatal("SendSoapWithDigest returned nil error for HTTP 500")
}
}
func TestSendSoapWithDigestReturnsServerErrorAfterAuthentication(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
requestCount++
if requestCount == 1 {
writer.Header().Set("WWW-Authenticate", `Digest realm="AXIS", nonce="nonce", qop="auth", algorithm=MD5`)
writer.WriteHeader(http.StatusUnauthorized)
return
}
if !strings.HasPrefix(request.Header.Get("Authorization"), "Digest ") {
t.Error("authenticated retry is missing Digest Authorization header")
}
writer.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
response, err := SendSoapWithDigest(server.Client(), server.URL, "<Envelope/>", "user", "password")
if response == nil {
t.Fatal("SendSoapWithDigest returned a nil response")
}
defer response.Body.Close()
if err == nil {
t.Fatal("SendSoapWithDigest returned nil error for authenticated HTTP 500")
}
if requestCount != 2 {
t.Fatalf("request count = %d, want 2", requestCount)
}
}
func TestParseDigestChallenge(t *testing.T) {
challenge := `Digest realm="testrealm@host.com", qop="auth,auth-int", nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", opaque="5ccc069c403ebaf9f0171e9517f40e41", algorithm=MD5`
parts := parseDigestChallenge(challenge)
cases := map[string]string{
"realm": "testrealm@host.com",
"qop": "auth,auth-int",
"nonce": "dcd98b7102dd2f0e8b11d0f600bfb0c093",
"opaque": "5ccc069c403ebaf9f0171e9517f40e41",
"algorithm": "MD5",
}
for key, want := range cases {
if got := parts[key]; got != want {
t.Errorf("parseDigestChallenge()[%q] = %q, want %q", key, got, want)
}
}
}
// TestMD5DigestVectors validates md5Hex and the response assembly order against
// the canonical RFC 2617 section 3.5 worked example.
func TestMD5DigestVectors(t *testing.T) {
ha1 := md5Hex("Mufasa:testrealm@host.com:Circle Of Life")
if want := "939e7578ed9e3c518a452acee763bce9"; ha1 != want {
t.Fatalf("HA1 = %q, want %q", ha1, want)
}
ha2 := md5Hex("GET:/dir/index.html")
if want := "39aff3a2bab6126f332b942af96d3366"; ha2 != want {
t.Fatalf("HA2 = %q, want %q", ha2, want)
}
response := md5Hex(strings.Join([]string{
ha1,
"dcd98b7102dd2f0e8b11d0f600bfb0c093", // nonce
"00000001", // nc
"0a4f113b", // cnonce
"auth", // qop
ha2,
}, ":"))
if want := "6629fae49393a05397450978507c4ef1"; response != want {
t.Fatalf("response = %q, want %q", response, want)
}
}
// TestNewDigestAuthorizationConsistency builds an Authorization header and then
// recomputes the response from the emitted cnonce/nc to confirm the header is
// internally consistent (correct field wiring and formula).
func TestNewDigestAuthorizationConsistency(t *testing.T) {
const (
username = "admin"
password = "s3cret"
realm = "IP Camera"
nonce = "0cc175b9c0f1b6a831c399e269772661"
)
challenge := `Digest realm="` + realm + `", nonce="` + nonce + `", qop="auth", algorithm=MD5`
header := newDigestAuthorization(challenge, "POST", "http://192.168.1.10/onvif/ptz_service", username, password)
if header == "" {
t.Fatal("newDigestAuthorization returned empty header")
}
if !strings.HasPrefix(header, "Digest ") {
t.Fatalf("header does not start with Digest scheme: %q", header)
}
fields := parseDigestChallenge(header)
if fields["username"] != username {
t.Errorf("username = %q, want %q", fields["username"], username)
}
if fields["uri"] != "/onvif/ptz_service" {
t.Errorf("uri = %q, want %q", fields["uri"], "/onvif/ptz_service")
}
if fields["qop"] != "auth" {
t.Errorf("qop = %q, want %q", fields["qop"], "auth")
}
ha1 := md5Hex(username + ":" + realm + ":" + password)
ha2 := md5Hex("POST:/onvif/ptz_service")
want := md5Hex(strings.Join([]string{ha1, nonce, fields["nc"], fields["cnonce"], "auth", ha2}, ":"))
if fields["response"] != want {
t.Errorf("response = %q, want %q", fields["response"], want)
}
}
// TestNewDigestAuthorizationRejectsUnsupported ensures an empty header is
// returned when required parameters are missing or the qop is unsupported.
func TestNewDigestAuthorizationRejectsUnsupported(t *testing.T) {
if got := newDigestAuthorization(`Digest realm="r"`, "POST", "http://host/x", "u", "p"); got != "" {
t.Errorf("expected empty header when nonce missing, got %q", got)
}
if got := newDigestAuthorization(`Digest realm="r", nonce="n", qop="auth-int"`, "POST", "http://host/x", "u", "p"); got != "" {
t.Errorf("expected empty header for unsupported qop, got %q", got)
}
}

View File

@@ -2,15 +2,212 @@ package networking
import (
"bytes"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/beevik/etree"
"github.com/juju/errors"
)
const soapContentType = "application/soap+xml; charset=utf-8"
// SendSoap send soap message
func SendSoap(httpClient *http.Client, endpoint, message string) (*http.Response, error) {
resp, err := httpClient.Post(endpoint, "application/soap+xml; charset=utf-8", bytes.NewBufferString(message))
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
if err != nil {
return resp, err
return resp, errors.Annotate(err, "Post")
}
return resp, nil
return resp, responseError(resp)
}
func responseError(resp *http.Response) error {
if resp.StatusCode >= 400 && resp.StatusCode < 600 {
return errors.Errorf("Server error: %d: %s", resp.StatusCode, resp.Status)
}
return nil
}
// SendSoapWithDigest sends a soap message and, when the device answers with an
// HTTP 401 digest challenge, transparently retries the request with the
// computed HTTP digest Authorization header.
//
// Any wsse:Security header present in the message is stripped before sending:
// when a device requires HTTP digest the credentials travel in the
// Authorization header, so keeping the WS-Security UsernameToken in the body
// would put the credentials on the wire twice. Other SOAP header blocks (for
// example WS-Addressing reference parameters) are preserved so vendor-specific
// routing keeps working across the retry.
func SendSoapWithDigest(httpClient *http.Client, endpoint, message, username, password string) (*http.Response, error) {
if httpClient == nil {
httpClient = new(http.Client)
}
// Avoid sending the credentials twice (WS-Security + digest) on the retry.
message = stripWSSecurityHeader(message)
resp, err := httpClient.Post(endpoint, soapContentType, bytes.NewBufferString(message))
if err != nil {
return resp, errors.Annotate(err, "Post")
}
// Only escalate to HTTP digest when the device explicitly asks for it.
if resp.StatusCode != http.StatusUnauthorized {
return resp, responseError(resp)
}
challenge := resp.Header.Get("WWW-Authenticate")
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(challenge)), "digest") {
// Not a digest challenge (e.g. Basic) - nothing more we can do here.
return resp, responseError(resp)
}
authorization := newDigestAuthorization(challenge, http.MethodPost, endpoint, username, password)
if authorization == "" {
return resp, errors.New("unsupported digest challenge")
}
// Release the challenge response before issuing the authenticated retry.
resp.Body.Close()
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBufferString(message))
if err != nil {
return nil, errors.Annotate(err, "new digest request")
}
req.Header.Set("Content-Type", soapContentType)
req.Header.Set("Authorization", authorization)
resp, err = httpClient.Do(req)
if err != nil {
return resp, errors.Annotate(err, "Post with digest")
}
return resp, responseError(resp)
}
// stripWSSecurityHeader removes the wsse:Security header block from a SOAP
// envelope, leaving all other header blocks intact. The message is returned
// unchanged if it cannot be parsed as XML or has no such header.
func stripWSSecurityHeader(message string) string {
doc := etree.NewDocument()
if err := doc.ReadFromString(message); err != nil {
return message
}
security := doc.FindElement("./Envelope/Header/Security")
if security == nil {
return message
}
header := doc.Root().SelectElement("Header")
if header == nil {
return message
}
header.RemoveChild(security)
data, err := doc.WriteToString()
if err != nil {
return message
}
return data
}
var digestParamRe = regexp.MustCompile(`(\w+)=(?:"([^"]*)"|([^,]+))`)
// parseDigestChallenge parses the parameters of a WWW-Authenticate: Digest header.
func parseDigestChallenge(challenge string) map[string]string {
challenge = strings.TrimSpace(challenge)
if i := strings.IndexAny(challenge, " \t"); i >= 0 && strings.EqualFold(challenge[:i], "Digest") {
challenge = challenge[i+1:]
}
result := make(map[string]string)
for _, m := range digestParamRe.FindAllStringSubmatch(challenge, -1) {
value := m[2]
if value == "" {
value = m[3]
}
result[strings.ToLower(m[1])] = strings.TrimSpace(value)
}
return result
}
// newDigestAuthorization builds an RFC 2617 HTTP digest Authorization header
// value. It supports the MD5 and MD5-sess algorithms and the "auth" qop, which
// covers the vast majority of ONVIF devices. It returns an empty string when the
// challenge is missing required parameters or requires an unsupported qop.
func newDigestAuthorization(challenge, method, uri, username, password string) string {
parts := parseDigestChallenge(challenge)
realm := parts["realm"]
nonce := parts["nonce"]
if realm == "" || nonce == "" {
return ""
}
opaque := parts["opaque"]
algorithm := parts["algorithm"]
qop := ""
if rawQop, ok := parts["qop"]; ok {
for _, candidate := range strings.Split(rawQop, ",") {
if strings.TrimSpace(candidate) == "auth" {
qop = "auth"
break
}
}
// The server offered qop but none we support (e.g. auth-int only).
if qop == "" {
return ""
}
}
digestURI := uri
if u, err := url.Parse(uri); err == nil {
digestURI = u.RequestURI()
}
cnonce := randomCnonce()
const nc = "00000001"
ha1 := md5Hex(username + ":" + realm + ":" + password)
if strings.EqualFold(algorithm, "MD5-sess") {
ha1 = md5Hex(ha1 + ":" + nonce + ":" + cnonce)
}
ha2 := md5Hex(method + ":" + digestURI)
var response string
if qop == "auth" {
response = md5Hex(strings.Join([]string{ha1, nonce, nc, cnonce, qop, ha2}, ":"))
} else {
response = md5Hex(ha1 + ":" + nonce + ":" + ha2)
}
var b strings.Builder
fmt.Fprintf(&b, `Digest username="%s", realm="%s", nonce="%s", uri="%s", response="%s"`,
username, realm, nonce, digestURI, response)
if qop == "auth" {
fmt.Fprintf(&b, `, qop=auth, nc=%s, cnonce="%s"`, nc, cnonce)
}
if algorithm != "" {
fmt.Fprintf(&b, `, algorithm=%s`, algorithm)
}
if opaque != "" {
fmt.Fprintf(&b, `, opaque="%s"`, opaque)
}
return b.String()
}
func md5Hex(s string) string {
sum := md5.Sum([]byte(s))
return hex.EncodeToString(sum[:])
}
func randomCnonce() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
return "00000000"
}
return hex.EncodeToString(b)
}

View File

@@ -220,7 +220,7 @@ type GetPresetTours struct {
}
type GetPresetToursResponse struct {
PresetTour onvif.PresetTour
PresetTour []onvif.PresetTour
}
type GetPresetTour struct {

25
ptz/types_test.go Normal file
View File

@@ -0,0 +1,25 @@
package ptz
import (
"encoding/xml"
"strings"
"testing"
"github.com/kerberos-io/onvif/xsd/onvif"
)
func TestContinuousMoveIncludesZeroPanTiltCoordinates(t *testing.T) {
request := ContinuousMove{
Velocity: onvif.PTZSpeedPanTilt{
PanTilt: onvif.Vector2D{X: 0.5, Y: 0},
},
}
encoded, err := xml.Marshal(request)
if err != nil {
t.Fatalf("xml.Marshal() error = %v", err)
}
if !strings.Contains(string(encoded), `x="0.5" y="0"`) {
t.Fatalf("ContinuousMove PanTilt = %s, want explicit x and y attributes", encoded)
}
}

View File

@@ -0,0 +1,31 @@
// Code generated : DO NOT EDIT.
// Copyright (c) 2022 Jean-Francois SMIGIELSKI
// Distributed under the MIT License
package event
import (
"context"
"github.com/juju/errors"
"github.com/kerberos-io/onvif"
"github.com/kerberos-io/onvif/event"
"github.com/kerberos-io/onvif/sdk"
)
// Call_PullMessages forwards the call to dev.CallMethod() then parses the payload of the reply as a PullMessagesResponse.
func Call_PullMessages(ctx context.Context, dev *onvif.Device, request event.PullMessages) (event.PullMessagesResponse, error) {
type Envelope struct {
Header struct{}
Body struct {
PullMessagesResponse event.PullMessagesResponse
}
}
var reply Envelope
if httpReply, err := dev.CallMethod(request); err != nil {
return reply.Body.PullMessagesResponse, errors.Annotate(err, "call")
} else {
err = sdk.ReadAndParse(ctx, httpReply, &reply, "PullMessages")
return reply.Body.PullMessagesResponse, errors.Annotate(err, "reply")
}
}

8
sdk/event/event.go Normal file
View File

@@ -0,0 +1,8 @@
package event
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event CreatePullPointSubscription
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetEventProperties
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event GetServiceCapabilities
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Subscribe
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event Unsubscribe
//go:generate go run github.com/kerberos-io/onvif/sdk/codegen event event PullMessages

25
sdk/sdk.go Normal file
View File

@@ -0,0 +1,25 @@
package sdk
import (
"context"
"encoding/xml"
"io"
"net/http"
"github.com/juju/errors"
)
// ReadAndParse reads the body of the given HTTP reply and unmarshals it into
// reply. The tag identifies the ONVIF action for diagnostic purposes.
func ReadAndParse(ctx context.Context, httpReply *http.Response, reply interface{}, tag string) error {
// TODO(jfsmig): extract the deadline from ctx.Deadline() and apply it on the reply reading
b, err := io.ReadAll(httpReply.Body)
if err != nil {
return errors.Annotate(err, "read")
}
httpReply.Body.Close()
err = xml.Unmarshal(b, reply)
return errors.Annotate(err, "decode")
}

View File

@@ -666,13 +666,13 @@ type PTZSpeedPanTilt struct {
}
type Vector2D struct {
X float64 `xml:"x,attr,omitempty"`
Y float64 `xml:"y,attr,omitempty"`
X float64 `xml:"x,attr"`
Y float64 `xml:"y,attr"`
Space *xsd.AnyURI `xml:"space,attr,omitempty"`
}
type Vector1D struct {
X float64 `xml:"x,attr,omitempty"`
X float64 `xml:"x,attr"`
Space *xsd.AnyURI `xml:"space,attr,omitempty"`
}
@@ -1176,7 +1176,7 @@ type PresetTour struct {
Status PTZPresetTourStatus `xml:"Status"`
AutoStart xsd.Boolean `xml:"AutoStart"`
StartingCondition PTZPresetTourStartingCondition `xml:"StartingCondition"`
TourSpot PTZPresetTourSpot `xml:"TourSpot"`
TourSpot []PTZPresetTourSpot `xml:"TourSpot"`
Extension PTZPresetTourExtension `xml:"Extension"`
}
@@ -1840,8 +1840,8 @@ type RelayOutputSettings struct {
}
type DigitalInput struct {
Token ReferenceToken `xml:"token,attr"`
IdleState InputIdleState `xml:"IdleState,attr"`
Token ReferenceToken `xml:"token,attr"`
IdleState InputIdleState `xml:"IdleState,attr"`
}
// TODO:enumeration