Skip to content

fix(onvif): xaddr fallback - #68

Merged
keyldev merged 12 commits into
mainfrom
fix/onvif-xaddr-fallback
Sep 22, 2026
Merged

keyldev merged 12 commits into
mainfrom
fix/onvif-xaddr-fallback

Conversation

@keyldev

@keyldev keyldev commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes #67 and supersedes #65 and #66, whose commits are merged here with authorship kept.

Test plan

  • Desktop solution + web SPA build with 0 warnings
  • Core.Tests / Devices.Tests
  • Keypad layout checked on a running desktop head
  • Real PTZ camera (ideally the reporter's A'Gold CAM-10)

Related

Type

  • Bug fix
  • Feature
  • Refactor / cleanup
  • Docs / CI
  • Other:

Checklist

  • Builds with 0 warnings (TreatWarningsAsErrors=true).
  • Tests pass (dotnet test); new Core logic has unit tests.
  • No layering violation — App references Core only (Infrastructure / Video / Devices wired via DI in a head).
  • Scope stays within one phase (didn't pull work from a later phase's "Не входит").
  • README / docs updated if public commands, options, or setup changed.

Platforms tested

  • Windows
  • Linux
  • macOS
  • Android
  • iOS
  • CI build only

Screenshots / notes

iBinh and others added 11 commits August 25, 2026 15:20
A Hikvision dome failed every probe with "GetCapabilities: empty SOAP
body" — HTTP 200, zero bytes, no fault to explain itself. Three separate
causes, each of which produces that same unhelpful result.

The client carried no credentials on its handler, so a 401 challenge was
never answered. All it sent was a preemptive Basic header, and Hikvision
wants Digest for ONVIF; the request was simply unauthorized and the
camera declined to say so. Each (host, user) now gets an HttpClient whose
handler holds the credentials, which is what lets HttpClient satisfy
Basic or Digest as the camera asks. PreAuthenticate stays off so the
camera states its terms first, and the preemptive Basic header stays for
onvif_simple_server, which enforces Basic at the transport and never
challenges.

Every request went out as SOAP 1.2 only. Several firmwares are built for
1.1 and answer 1.2 with nothing at all. A response with no usable
envelope is now retried once as SOAP 1.1 — different content type, action
moved into its own SOAPAction header. A fault counts as an answer, so a
camera that says why it refused is not asked twice.

And GetStreamUri read Uri as a direct child of the response, which only
matches the flatter shape onvif_simple_server sends. The spec nests it as
MediaUri/Uri, so a compliant camera looked like it had no stream at all;
SetPreset's token is nested the same way on some firmwares. Both now
search the response instead of assuming its depth.

When both versions come back empty the error names what to check — ONVIF
switched off on the camera, or an account without ONVIF rights, which is
what an empty body from a working camera almost always means — and the
response is logged at debug level.

Covered by a stub camera over a real socket, so the client's own HTTP
stack does the work: the 401 handshake, the content types and the
SOAPAction header are all exercised rather than mocked.
…-sent, authed clients recycled

Two holes the review caught, both real.

The SOAP 1.1 fallback retried every call whose response was unusable,
including SetPreset and RemovePreset. An unusable response does not
prove the request was not executed — a camera that ran SetPreset and
answered garbage would get a duplicate preset from the resend, and a
resend after a successful but unreadable remove would fault on the
now-missing preset and report failure for a removal that worked. The
dialect a host speaks is now learned once and remembered: the first time
a host answers 1.2 with nothing usable and 1.1 with something, the flip
is cached and later calls lead with 1.1. Since every authed operation is
preceded by the unauthenticated clock probe on first contact, the
dialect is already known by the time any mutation goes out. Mutations
never cross-dialect retry — they use what the host's reads taught and
fail honestly otherwise. The clock-skew retry stays, for mutations too:
a fault means the camera refused the request, not that it ran it.

And the per-credential HttpClient cache was keyed by host, user and
password together with no eviction: every password a camera has ever had
kept a live handler — old secret included — for the rest of the process,
and two threads missing the cache at once could each construct a client
only one of which was ever stored. Keyed by host:port now, since a
camera has one credential at a time; a lookup that finds a different
credential swaps the entry and disposes the superseded client (a request
in flight on it was sent with the old password and failing anyway), and
a plain lock replaces GetOrAdd so the losing constructor of a concurrent
miss never exists. Growth is bounded by the camera addresses spoken to.

Two tests pin the retry behaviour: the dialect is remembered (exactly
one 1.2 request ever reaches a 1.1-only host), and a SetPreset whose
response is empty is reported as failed after exactly one attempt.
…orts

The PTZ surface was a joystick: hold a direction, the camera sweeps,
release and it stops. That is right for scanning a scene and wrong for
framing one — at full zoom a doorway is a fraction of a degree away, and
no amount of care on a hold-to-move control lands on it.

This adds the other half. A 3x3 step keypad and two zoom keys send one
RelativeMove per press: a single request the camera runs to completion,
where a continuous move has to be started and stopped and leaves the
camera drifting if the stop is lost. Cameras without RelativeMove fall
back to a 350ms self-stopping continuous move, so the button does
something everywhere. Home sits in the middle of the pad, where every NVR
keypad puts it, with an explicit "set home here" behind a confirmation
since overwriting it cannot be undone from the app.

None of it is offered blind. GetConfigurationOptions says which move
spaces the node implements and what ranges it declares, and
GetServiceCapabilities says whether MoveStatus is maintained; the buttons
that depend on an operation are hidden on cameras that lack it. A camera
that will not describe itself gets the continuous-only profile, which is
what this app assumed of every camera until now — so nothing regresses.

The ranges matter more than they look. The UI thinks in normalized
[-1, 1]; cameras declare degrees, [0, 360], or something asymmetric, and
a value outside the declared range is quietly clamped or refused. On an
asymmetric range normalized zero maps to the midpoint rather than zero,
so a step that touches only pan would also tilt — every press creeping
the camera further off. PtzRange does the mapping in one place and
PtzRangeTests pins it, that case included.

Preset names get a repair on the way in: cameras routinely store UTF-8
and label the response Latin-1 (or Windows-1252, which differs only in
0x80-0x9F and is what a good number of firmwares actually send), so a
non-ASCII name arrives as mojibake. The bytes are intact, so recovering
them and decoding as UTF-8 gives the name back; anything that is not
valid UTF-8 underneath is left alone.

The browser gets the same controls. /ptz/step and /ptz/home are stateless
like the existing /ptz/move, and /ptz/capabilities lets the pad hide what
the camera cannot do. Tapping an arrow there now nudges rather than
sweeping — a press under 220ms was never going to move anything useful,
so it finishes as the step the user meant.

OnvifCoreClient, the superseded WCF path, throws NotSupportedException
for the new operations rather than returning a silent no-op: DI resolves
SoapOnvifClient, and a camera that ignored its buttons would be worse
than an error.
…ns, home from the node, raceless web tap

All six review findings were real; five are behaviour fixes pinned by
tests, the sixth is a contract fix.

Asymmetric ranges. Mapping a step through FromNormalized and then
subtracting the midpoint turned a declared [0, 100] into [-50, 50], so
negative steps went out below the camera's own minimum — defeating the
range handling this feature exists for. PtzRange.ScaleTranslation now
scales by the half-span and clamps into the declared bounds: zero stays
zero (an untouched axis must not creep) and nothing leaves the range —
[0, 100] simply refuses to go negative. FromNormalized is gone.

Conflated axes. SupportsRelative came from the pan/tilt space alone,
so a pan/tilt-only camera was sent relative zoom translations and a
zoom-only camera never used the RelativeMove it supports. The
capabilities now carry the four flags the spaces actually declare —
relative and continuous, per axis pair, ContinuousZoomVelocitySpace
included — and StepAsync decides each axis on its own.

Unconditional fallback. A step on an axis with no relative space fell
back to ContinuousMove without asking whether a continuous space was
declared either. The fallback now runs only where it is, and an axis the
camera can serve neither way is dropped rather than sent an operation
that must fault. The desktop keypad and the web pad hide keys for such
axes; a camera that will not describe itself still gets the
continuous-only profile, so nothing regresses.

Fabricated home. SupportsHome was hard-coded true for any camera that
answered GetConfigurationOptions, with a comment admitting the guess.
The node knows: GetConfiguration names it, GetNode reports HomeSupported
and FixedHomePosition. Home appears only when the node says so, Set Home
additionally requires the position not to be hardware-fixed, and a
camera that answers nothing about its node gets no home controls — the
web pad keeps its Stop button instead.

Web tap race. The pad started the sweep on pointer-down and, on a quick
release, fired Stop and the step concurrently — so one press was not
reliably one step. On step-capable axes the sweep now starts only after
the 220 ms tap window: release inside it sends exactly one step and
nothing to stop; a longer press swept, and release sends exactly one
stop. Axes without step support keep the immediate hold-to-sweep.

Status positions. PtzStatus promised normalized positions while the
client returned raw device units. Nothing consumes the positions yet,
and normalizing inside the client would mean re-fetching the declared
ranges on every poll — so the contract now states the truth: positions
are in the camera's own units, and a consumer normalizes through the
ranges the capability probe already read (PtzRange.ToUnit).

Also from the same pass: RelativeMove is marked non-retryable in the
SOAP dialect fallback (a re-send the camera already executed is a double
step), and the web /step endpoint seeds PtzController from a per-camera
capabilities cache so a step is one SOAP call, not a discovery per
press — /capabilities refreshes the entry on every pad mount.

New tests: controller steps against a recording fake (asymmetric ranges
never leave bounds, untouched axes stay zero, zoom falls back only where
continuous zoom is declared, unservable axes send nothing, seeded
capabilities skip discovery) and the capability probe against the stub
camera (flags come from the declared spaces, fixed home allows goto but
not set, a camera that will not describe its node gets no home).
The mojibake repair rewrote any name whose recovered bytes formed valid
UTF-8 — including names that were already right: "©" became "©",
because valid-UTF-8-underneath cannot by itself distinguish damage from
intent.

The discriminator that can: whether the decoded result leaves Latin-1.
A repair that stays inside it ("©" → "©", "Entrée" → "Entrée") proves
nothing, since the original is equally plausible as intentional text —
so the original stands. A result outside it (Cyrillic, Vietnamese,
Greek) could not have been intended as the Latin-1 characters it arrived
as, so those — the names that arrive as pure noise — are still repaired.
The cost is that mojibake whose true text is itself Latin-1 stays as
sent, which is at least legible; rewriting a correct name is not.
…ly ones that leave Latin-1

Field feedback on the leave-Latin-1 rule: it abandons every name whose
true text is itself Latin-1. "Entrée" stays mangled, and so does the
untoned half of Vietnamese — "Sân" decodes to "Sân", which never
leaves Latin-1 either.

Three tiers now. A decode that leaves Latin-1 is proof outright. A
decode that stays inside but contains a Latin-1 *letter* is proof too:
its mangled form is "Ã" chased by a currency sign, a pair nobody types
on purpose. Only a decode made purely of Latin-1 symbols — "©" to "©",
"°C" to "°C" — is ambiguous, and there the original still stands,
which keeps the review's false-positive class protected.
- Probe each candidate with a read-only call (GetProfiles / GetNodes)
  and cache the first that answers per device endpoint
- Candidates: advertised XAddr, same path on the reached host, other
  advertised XAddrs naming the service, conventional paths, device URI
- Fixes YooSee firmwares whose GetCapabilities shifts every XAddr (#67)
# Conflicts:
#	src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs
- Retry a VersionMismatch fault as SOAP 1.1; name a 401/403 as a bad login
- Key the learned SOAP dialect by host:port, like the client cache
- Candidate probes never flip the dialect; MoveStatus="1" reads as true
- Prefer the FOV relative space when a camera declares both
- Explicit Grid cells so a hidden key no longer shuffles the arrows
- Stop in the middle on desktop and web; home moves next to "set home"
- Continuous fallback step sends an explicit Stop after the step
- Capability probe no longer blocks presets and Majestic setup
@keyldev keyldev changed the title Fix/onvif xaddr fallback fix(onvif): xaddr fallback Sep 22, 2026
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Harden ONVIF discovery and add capability-aware PTZ controls

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Validates and caches ONVIF endpoints while adding Digest and SOAP 1.1 interoperability.
• Adds capability-aware PTZ stepping, home controls, and speed settings across desktop and web.
• Repairs nested responses and preset encodings, backed by controller and socket-level tests.
Diagram

sequenceDiagram
    actor User
    participant UI as Desktop/Web UI
    participant API as Web PTZ API
    participant Ctrl as PTZ Controller
    participant SOAP as SOAP Client
    participant Resolver as Service Resolver
    participant Camera as ONVIF Camera
    User->>UI: PTZ action
    alt Web client
        UI->>API: Step or home
        API->>Ctrl: Cached capabilities
    else Desktop client
        UI->>Ctrl: Step or home
    end
    Ctrl->>SOAP: Capability or move call
    SOAP->>Resolver: Resolve service
    Resolver->>Camera: Probe candidates
    Camera-->>Resolver: Matching response
    Resolver-->>SOAP: Verified endpoint
    SOAP->>Camera: SOAP 1.2 request
    opt Unusable read response
        SOAP->>Camera: Retry SOAP 1.1
    end
    Camera-->>SOAP: ONVIF response
    SOAP-->>Ctrl: Parsed result
    Ctrl-->>UI: Capability-aware state
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Trust and normalize advertised XAddrs
  • ➕ Requires fewer discovery requests.
  • ➕ Keeps service resolution logic simpler.
  • ➖ Cannot detect shifted service tables.
  • ➖ Path rewriting alone cannot prove the target implements the expected service.
  • ➖ Remains vulnerable to stale addresses and vendor-specific paths.
2. Use generated ONVIF proxy clients
  • ➕ Provides typed request and response contracts.
  • ➕ Reduces handwritten SOAP construction and parsing.
  • ➖ Previously encountered serializer compatibility failures in this codebase.
  • ➖ Offers less control over Digest challenges, SOAP dialect negotiation, and malformed firmware responses.
  • ➖ Would require substantial generated models for the expanded PTZ surface.

Recommendation: Keep the PR's active, read-only endpoint validation and custom SOAP transport. It adds discovery cost only until endpoints and dialects are cached, while directly addressing the malformed XAddr tables and protocol inconsistencies observed in real cameras; trusting metadata or returning to generated proxies would sacrifice that interoperability.

Files changed (25) +2342 / -61

Enhancement (13) +708 / -7
Localizer.csLocalize new desktop PTZ controls +16/-0

Localize new desktop PTZ controls

• Adds English and Russian labels for home, stop, set-home confirmation, movement speed, and step zoom actions.

src/OpenIPC.Viewer.App/Services/Localizer.cs

SingleCameraPageViewModel.csAdd capability-aware desktop PTZ commands +129/-0

Add capability-aware desktop PTZ commands

• Adds step, stop, home, and set-home commands with configurable speed and error logging. PTZ capabilities load asynchronously so preset initialization remains responsive and unsupported controls can be hidden.

src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs

SingleCameraPage.axamlAdd the desktop PTZ step keypad +87/-0

Add the desktop PTZ step keypad

• Introduces a fixed-cell directional keypad, zoom controls, speed slider, stop button, and capability-gated home controls. Dedicated styling keeps the keypad layout stable when optional controls are hidden.

src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml

IOnvifClient.csExpand the ONVIF PTZ client contract +19/-0

Expand the ONVIF PTZ client contract

• Adds capability discovery, relative movement, status, goto-home, and set-home operations to the core client abstraction.

src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs

PtzCapabilities.csModel camera-specific PTZ capabilities +66/-0

Model camera-specific PTZ capabilities

• Defines supported movement modes, home behavior, move status, coordinate ranges, and FOV-relative stepping. Includes a continuous-only fallback profile for cameras that cannot describe themselves.

src/OpenIPC.Viewer.Core/Onvif/PtzCapabilities.cs

PtzController.csImplement capability-driven PTZ stepping +99/-1

Implement capability-driven PTZ stepping

• Caches capabilities and routes each axis through RelativeMove when supported or a timed continuous move otherwise. The fallback explicitly stops the camera and exposes home and status operations.

src/OpenIPC.Viewer.Core/Onvif/PtzController.cs

PtzRange.csMap normalized PTZ values into device ranges +47/-0

Map normalized PTZ values into device ranges

• Adds range validation, translation scaling, absolute unit conversion, and clamping for camera-declared coordinate spaces.

src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs

PtzStatus.csRepresent ONVIF PTZ position and movement state +31/-0

Represent ONVIF PTZ position and movement state

• Introduces typed PTZ status data with per-axis movement states, camera coordinates, timestamps, and a combined moving indicator.

src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs

api.tsExpose web PTZ capability and step APIs +18/-0

Expose web PTZ capability and step APIs

• Adds PTZ capability DTOs and client methods for step movement, home positioning, and capability retrieval.

src/OpenIPC.Viewer.Web.Client/src/api.ts

Icon.tsxAdd a PTZ home icon +2/-0

Add a PTZ home icon

• Adds the house-shaped icon used by the web home-position control.

src/OpenIPC.Viewer.Web.Client/src/components/Icon.tsx

PtzPad.tsxMake the web PTZ pad capability-aware +99/-5

Make the web PTZ pad capability-aware

• Distinguishes taps from held sweeps, sending discrete steps where supported while preserving continuous movement elsewhere. It gates unsupported axes, adds home movement, and ensures cancellation cannot leave the camera moving.

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx

strings.tsLocalize the web PTZ home action +2/-0

Localize the web PTZ home action

• Adds English and Russian labels for the new home-position control.

src/OpenIPC.Viewer.Web.Client/src/strings.ts

PtzApi.csAdd PTZ step, home, and capability endpoints +93/-1

Add PTZ step, home, and capability endpoints

• Adds web endpoints for discrete steps, home movement, and hardware capability discovery. Per-camera capability caching avoids repeating the multi-call probe for every step request.

src/OpenIPC.Viewer.Web/Api/PtzApi.cs

Bug fix (3) +647 / -52
OnvifText.csRepair misdecoded ONVIF preset names +82/-0

Repair misdecoded ONVIF preset names

• Adds conservative recovery for UTF-8 preset names decoded as Latin-1 or Windows-1252 while preserving ambiguous or already-valid text.

src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs

OnvifServiceCandidates.csBuild resilient ONVIF service endpoint candidates +56/-0

Build resilient ONVIF service endpoint candidates

• Orders advertised, authority-corrected, path-matched, conventional, and device-service URIs for Media and PTZ discovery while removing duplicates.

src/OpenIPC.Viewer.Devices/Onvif/OnvifServiceCandidates.cs

SoapOnvifClient.csHarden SOAP transport, discovery, and PTZ support +509/-52

Harden SOAP transport, discovery, and PTZ support

• Adds HTTP Digest handling, SOAP 1.1 negotiation, verified service endpoint fallback, nested response parsing, preset text repair, and actionable authentication errors. It also implements PTZ capability probing, relative moves, status, and home operations while preventing unsafe retries of mutations.

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs

Refactor (1) +22 / -0
OnvifCoreClient.csMake unsupported WCF PTZ operations explicit +22/-0

Make unsupported WCF PTZ operations explicit

• Implements the expanded interface on the superseded WCF client by throwing clear NotSupportedException errors instead of silently ignoring operations.

src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs

Tests (7) +960 / -0
OnvifTextTests.csTest conservative preset-name repair +74/-0

Test conservative preset-name repair

• Covers Latin-1 and Windows-1252 mojibake recovery, valid Unicode preservation, and ambiguous symbol-only inputs.

tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs

PtzControllerStepTests.csTest capability-driven step routing +158/-0

Test capability-driven step routing

• Verifies relative moves, independent axis fallbacks, explicit stops, range safety, unsupported-axis suppression, and seeded capability caching.

tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzControllerStepTests.cs

PtzRangeTests.csTest PTZ range conversion and clamping +110/-0

Test PTZ range conversion and clamping

• Covers normalized and degree ranges, asymmetric coordinates, invalid ranges, absolute conversions, and boundary clamping.

tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs

OnvifServiceCandidatesTests.csTest ONVIF endpoint candidate ordering +74/-0

Test ONVIF endpoint candidate ordering

• Pins candidate generation against shifted YooSee XAddrs, stale authorities, conventional paths, deduplication, and device-service fallback.

tests/OpenIPC.Viewer.Devices.Tests/Onvif/OnvifServiceCandidatesTests.cs

PtzCapabilityProbeTests.csTest PTZ capability discovery +134/-0

Test PTZ capability discovery

• Uses a stub camera to verify movement spaces, FOV preference, move status, home support, fixed-home behavior, and partial probe failures.

tests/OpenIPC.Viewer.Devices.Tests/Onvif/PtzCapabilityProbeTests.cs

SoapOnvifClientInteropTests.csTest ONVIF firmware interoperability +275/-0

Test ONVIF firmware interoperability

• Exercises Digest challenges, SOAP 1.1 fallback, SOAPAction headers, safe mutation behavior, nested values, actionable errors, and shifted XAddr resolution over real HTTP sockets.

tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs

StubCamera.csAdd a socket-level ONVIF camera stub +135/-0

Add a socket-level ONVIF camera stub

• Provides a configurable local HTTP listener that captures SOAP dialect, action, authorization, and request path for end-to-end transport tests.

tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs

Documentation (1) +5 / -2
README.mdDocument capability-aware PTZ framing controls +5/-2

Document capability-aware PTZ framing controls

• Expands the single-camera feature description with step controls, home positioning, speed selection, and capability-based visibility.

README.md

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Relative-only cameras reject long presses ✓ Resolved 🐞 Bug ≡ Correctness
Description
PtzPad treats relative support as permission to delay and then start a continuous sweep without
checking the corresponding continuous capability. Holding a direction longer than 220 ms on a camera
that supports relative moves only sends an unsupported continuous move and surfaces a PTZ error.
Code

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[R159-163]

+      press.current = 'pending'
+      holdTimer.current = window.setTimeout(() => {
+        press.current = 'sweeping'
+        holdTimer.current = null
+        start(dir)
Evidence
The pointer handler checks only relative support before scheduling start, while the capability DTO
and visibility logic explicitly distinguish relative from continuous support. start always calls
the continuous /ptz/move endpoint, so a relative-only camera receives an operation it did not
advertise.

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[85-106]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[147-164]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[197-201]
src/OpenIPC.Viewer.Web.Client/src/api.ts[313-322]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The web PTZ pad starts a continuous sweep after the tap window whenever relative stepping is supported, even when the camera declares no continuous movement support.
## Fix Focus Areas
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[147-164]
## Recommended Fix
Determine relative and continuous support independently for the selected axis. Send a step for short presses, start the delayed sweep only when continuous movement is supported, and avoid offering an unsupported long-press action otherwise.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Preset changes fail on split ports ✓ Resolved 🐞 Bug ≡ Correctness
Description
AnswersAsync disables dialect retry while validating a service candidate, and mutating preset
calls also disable it. When the device endpoint and advertised service use different ports and only
the service port speaks SOAP 1.1, validation cannot learn that dialect and a first preset mutation
fails without trying the working envelope version.
Code

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[R584-587]

+        _shiftByHost.TryGetValue(endpoint.DeviceServiceUri.Host, out var shift);
+        try
+        {
+            var response = await CallAsync(service, action, body, endpoint.Credentials, shift, retryable: false, ct).ConfigureAwait(false);
Evidence
Dialect knowledge is keyed by service host and port, but the initial clock probe runs against the
device-service address. Candidate validation sends only the currently assumed dialect, and
SetPresetAsync and RemovePresetAsync cannot recover if that assumption is wrong.

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[573-588]
src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[599-613]
src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[653-685]
src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[266-286]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Service candidates on a different port cannot teach the client their SOAP dialect because validation disables retry, leaving first-use mutations unable to reach SOAP 1.1-only services.
## Fix Focus Areas
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[573-588]
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[653-685]
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[266-286]
## Recommended Fix
Allow the read-only candidate probe to retry the alternate SOAP dialect and cache the winner for that candidate's host and port. Keep mutation retries disabled, but ensure their service address has already completed dialect negotiation before sending them.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Canceled probes misclassify cameras ✓ Resolved 🐞 Bug ☼ Reliability
Description
SoapOnvifClient.GetPtzCapabilitiesAsync, PtzController.GetCapabilitiesAsync, and the web
endpoint catch OperationCanceledException as an ordinary probe failure, replace it with
ContinuousOnly, and cache that fallback as a valid capability profile. When page initialization, a
browser request, or lifecycle discovery is canceled, subsequent steps reuse that profile, switch to
continuous fallback, and may hide relative or home controls until a successful refresh replaces it.
Code

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[R309-313]

+        catch (Exception)
+        {
+            // Optional operation. A camera that will not describe itself gets
+            // the profile this app assumed of every camera before it asked.
+            return PtzCapabilities.ContinuousOnly;
Evidence
The broad exception handlers in both the SOAP capability probe and controller include cancellation
and convert it into a successful ContinuousOnly fallback. The web endpoint stores the returned
capabilities by camera ID, and the step endpoint seeds a controller from that cached profile without
repeating discovery, proving that an aborted request can affect later operations beyond the canceled
request itself.

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[299-314]
src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[89-104]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[109-139]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[74-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Capability discovery treats cancellation as a camera capability failure, converts it into a successful `ContinuousOnly` result, and allows that fallback to enter long-lived caches. Cancellation is lifecycle control flow and must not remove relative or home functionality from later operations.
## Fix Focus Areas
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[299-314]
- src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[89-104]
- src/OpenIPC.Viewer.Web/Api/PtzApi.cs[109-139]
## Recommended Fix
Add explicit `OperationCanceledException` handling that propagates cancellation when the supplied token is canceled, and ensure canceled requests never write fallback capability entries to any cache. Retain the `ContinuousOnly` fallback only for genuine non-cancellation camera, transport, or protocol failures.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Camera edits reuse stale movement support ✓ Resolved 🐞 Bug ≡ Correctness
Description
CapabilitiesCache stores capability results under camera ID alone even though discovery depends on
the resolved endpoint and profile token. After the host, port, credentials, or selected profile
changes under the same ID, a step request can reuse the previous hardware's flags and choose the
wrong movement operation.
Code

src/OpenIPC.Viewer.Web/Api/PtzApi.cs[R79-82]

+                CapabilitiesCache.TryGetValue(id, out var known);
+                var controller = new PtzController(
+                    target!.Value.Client, target.Value.Endpoint, target.Value.ProfileToken, known);
+                await controller.StepAsync(step, Speed(body?.Speed), ct);
Evidence
Camera updates retain the same ID while allowing the host and ONVIF port to change, and PTZ
capabilities are resolved using a specific endpoint and profile. The cached result directly controls
whether StepAsync sends a relative move or continuous fallback.

src/OpenIPC.Viewer.Web/Api/PtzApi.cs[74-84]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[205-209]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[235-249]
src/OpenIPC.Viewer.Core/Services/CameraDirectoryService.cs[93-124]
src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[119-151]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The web capability cache is keyed only by camera ID, allowing capabilities from an old endpoint or profile to drive a newly resolved target.
## Fix Focus Areas
- src/OpenIPC.Viewer.Web/Api/PtzApi.cs[74-84]
- src/OpenIPC.Viewer.Web/Api/PtzApi.cs[205-209]
- src/OpenIPC.Viewer.Web/Api/PtzApi.cs[211-249]
## Recommended Fix
Key cached capabilities by a stable target identity containing camera ID, device-service URI, and profile token, or invalidate the camera-ID entry whenever those values change. Ensure capability reads and step requests use the same derived key.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Some valid preset names are rewritten ✓ Resolved 🐞 Bug ≡ Correctness
Description
RepairMojibake treats every decoded character from U+00C0 through U+00FF as a letter even though
that interval also contains symbols such as multiplication and division signs. A valid preset name
such as × therefore decodes to × and is changed whenever presets are loaded, despite the
method's stated rule that symbol-only results are ambiguous.
Code

src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs[R57-63]

+        foreach (var c in decoded)
+        {
+            // Outside Latin-1: proof. A Latin-1 letter (0xC0–0xFF): also
+            // proof — its mangled form is a pair no one types on purpose.
+            // Symbols (©, °, ±) decide nothing.
+            if (c > '\u00FF') return decoded;
+            if (c is >= '\u00C0' and <= '\u00FF') return decoded;
Evidence
The implementation returns the decoded value for the entire U+00C0–U+00FF interval, while its tests
and comments require symbol-only decodes to remain unchanged. Every returned preset name is passed
through this method, so the false positive reaches the displayed data.

src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs[54-66]
tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs[56-73]
src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[241-249]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The mojibake evidence predicate classifies an entire code-point interval as letters, causing valid symbol-containing names to be rewritten.
## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs[57-66]
- tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs[56-73]
## Recommended Fix
Use Unicode letter classification rather than the U+00C0–U+00FF range check, while retaining the existing outside-Latin-1 proof. Add symbol-only cases such as `×` and `÷` to verify that ambiguous valid names remain unchanged.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View review recommended (3)
6. Camera pad sends the wrong movement ✓ Resolved 🐞 Bug ≡ Correctness
Description
PtzPad stores press and holdTimer in single component-wide refs, so a second directional
pointer overwrites the first pointer's pending/sweeping state. When either pointer is released,
endPress clears and acts on that shared state, so concurrent touch presses can cancel the other
direction, send its nudge, or stop its sweep.
Code

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[R159-163]

+      press.current = 'pending'
+      holdTimer.current = window.setTimeout(() => {
+        press.current = 'sweeping'
+        holdTimer.current = null
+        start(dir)
Evidence
The pad defines exactly one mode and timeout for the entire component, while every directional
button installs the same handler. Each release consumes the shared mode and invokes either the step
request or shared stop callback.

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[42-45]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[132-168]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[205-218]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
The PTZ pad uses one shared press mode and timeout for every directional button. Concurrent pointers overwrite each other's state, so releasing one pointer can stop, cancel, or step the other pointer's movement.
Fix Focus Areas
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[42-45]
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[132-168]
Recommended Fix
Track pending/sweeping state and hold timers by `pointerId` (or otherwise serialize and reject a second active pointer). Make each pointer-up/cancel handler clear and resolve only its own timer and movement state; ensure a stop is issued only for the sweep started by that pointer.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. New cameras inherit old control behavior ✓ Resolved 🐞 Bug ≡ Correctness
Description
The capabilities effect fetches for a changed cameraId but retains the previous caps value until
that request completes. The press handler immediately uses that retained value to choose a step
versus continuous move for the newly selected camera, while the backend treats those requests as
materially different operations.
Code

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[R109-116]

+  useEffect(() => {
+    let cancelled = false
+    api
+      .ptzCapabilities(cameraId)
+      .then((c) => { if (!cancelled) setCaps(c) })
+      // A camera that will not describe itself gets no Home button.
+      .catch(() => { if (!cancelled) setCaps(null) })
+    return () => { cancelled = true }
Evidence
Camera renders PtzPad with a changing cameraId prop and no key, so React can reuse the
component across camera selection. The added effect does not clear its existing state, and the added
handler uses that state to select /step rather than /move; those endpoints respectively issue a
relative/fallback nudge and a timed continuous movement.

src/OpenIPC.Viewer.Web.Client/src/pages/Camera.tsx[124-130]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[109-117]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[151-164]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[29-49]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[65-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
When the selected camera changes, the PTZ pad continues using the previous camera's capabilities until the new capability request resolves. A user interaction in that interval can use the wrong movement mode and the control visibility also reflects the old device.
Fix Focus Areas
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[109-117]
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[151-164]
Recommended Fix
Reset `caps` to `null` synchronously at the beginning of the `cameraId`-dependent effect. This restores the documented pre-discovery behavior until the current camera's response arrives, while retaining the cancellation guard against stale responses.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Rapid nudges cut each other short ✓ Resolved 🐞 Bug ≡ Correctness
Description
Each continuous-move fallback independently waits 350 ms and then always sends StopPtzAsync,
without coordinating with other in-flight steps. On cameras without RelativeMove, rapid web taps
create overlapping fallback requests, so the earlier tap's stop terminates the later tap before its
own duration completes.
Code

src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[R152-159]

+            // And an explicit Stop once the step is up: plenty of firmware
+            // ignores Timeout on ContinuousMove, and cameras without RelativeMove
+            // are the ones most likely to have the sloppier stack.
+            try { await Task.Delay(StepFallbackDuration, ct).ConfigureAwait(false); }
+            finally
+            {
+                try { await _client.StopPtzAsync(_endpoint, _profileToken, CancellationToken.None).ConfigureAwait(false); }
+                catch { /* camera may already be idle — some firmwares drop the socket on Stop */ }
Evidence
The fallback has no lock, generation, or cancellation source shared between calls and issues its
stop with CancellationToken.None. The web client dispatches step calls without awaiting or
disabling the next tap, and each API request creates a separate controller, allowing those fallback
lifetimes to overlap.

src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[138-160]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[69-83]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[74-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution
Issue description
Every continuous fallback step sends an unconditional Stop after its own delay. Overlapping step requests can therefore stop a newer move early, making rapid nudges inconsistent on continuous-only cameras.
Fix Focus Areas
- src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[138-160]
- src/OpenIPC.Viewer.Web/Api/PtzApi.cs[74-85]
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[69-83]
Recommended Fix
Coordinate continuous fallback steps per camera/controller: serialize them, or attach a monotonically increasing move generation and send Stop only if that fallback is still the latest active move. Preserve the explicit stop for firmware that ignores the ONVIF timeout.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can route each action level your way: inline, summary, both, or drop

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx
Comment thread src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs Outdated
Comment thread src/OpenIPC.Viewer.Web/Api/PtzApi.cs Outdated
Comment thread src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs Outdated
Comment thread src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs Outdated
Comment thread src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx
Comment thread src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx
Comment thread src/OpenIPC.Viewer.Core/Onvif/PtzController.cs
- Cancellation no longer caches a continuous-only capability profile
- Web capability cache keyed by endpoint + profile; candidate probes learn the SOAP dialect
- Web pad: no sweep on relative-only cameras, one pointer per press, steps serialized, caps reset per camera
- Mojibake repair ignores the × ÷ symbols among Latin-1 letters
@keyldev
keyldev merged commit 355ab6c into main Sep 22, 2026
4 of 6 checks passed
@keyldev
keyldev deleted the fix/onvif-xaddr-fallback branch September 26, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A'Gold CAM-10 / YooSee: PTZ not detected because GetCapabilities returns incorrect ONVIF XAddr values

2 participants