Skip to content

feat(ptz): step keypad, home and speed, driven by what the camera reports - #66

Closed
iBinh wants to merge 6 commits into
OpenIPC:mainfrom
iBinh:feat/ptz-steps-and-home
Closed

iBinh wants to merge 6 commits into
OpenIPC:mainfrom
iBinh:feat/ptz-steps-and-home

Conversation

@iBinh

@iBinh iBinh commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Stacked on #65. Merge that first; this rebases cleanly on top. The diff shown
here includes its commit until then.

Summary

The PTZ surface is 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, one
RelativeMove per press. RelativeMove is the right operation for a step — 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 it fall back to a 350 ms self-stopping continuous move, so the button
does something everywhere. Home sits in the middle of the pad, where every NVR
keypad puts it; "set home here" is behind a confirmation, since overwriting it
cannot be undone from the app.

Nothing is offered blind. GetConfigurationOptions reports which move
spaces the node implements and what ranges it declares; GetServiceCapabilities
reports whether MoveStatus is maintained. Buttons that depend on an operation
are hidden on cameras that lack it, and 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 range midpoint rather than zero — so a step
touching only pan would also tilt, creeping the camera further off on every
press. PtzRange does the mapping in one place and PtzRangeTests pins it,
that case included.

Preset names are repaired 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 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 now nudges rather than sweeps:
a press under 220 ms 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. Deleting that class felt out of scope for this PR; happy to do it in a
follow-up if you want it gone.

Related

Phase 4 — ONVIF + PTZ (the web endpoints mirror the PTZ surface added in
Phase 21). No issue.

Type

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

Checklist

  • Builds with 0 warnings (TreatWarningsAsErrors=true). Desktop
    solution and the Android head both clean; the iOS head was not built here
    (workload not installed on this machine) — left to CI. The React console
    builds too (npm run build).
  • Tests pass (dotnet test); new Core logic has unit tests. 16 new tests
    over PtzRange and OnvifText; suites green at Core 331 / Devices 11 /
    Video 11.
  • No layering violation — App uses PtzController, PtzCapabilities and
    PtzVelocity, all of which live in Core. No new references.
  • Scope stays within one phase.
  • README / docs updated — the single-camera bullet now describes the step
    keypad and says the controls are read from the camera.

Platforms tested

Built and tested on macOS; the Android head builds clean. No head was actually
run — see the note below.

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

Screenshots / notes

No screenshots, and that is a real gap in this PR. I have no display to run
the desktop head on, so the keypad has never been seen rendered. Its layout is a
3x3 UniformGrid inside the existing PTZ panel, above the presets list, with
the zoom pair, a speed slider and the "set home" button stacked beneath it.
Bindings are compile-checked (AvaloniaUseCompiledBindingsByDefault), so the
commands and properties resolve, but spacing and fit are unverified. If the
layout is wrong I would rather hear it than have it merged.

Also unverified: a real PTZ camera. The SOAP bodies follow the spec and the
capability parsing and range arithmetic are covered by unit tests, but no move
in this PR has been sent to actual hardware. The fallback path is deliberately
conservative — a camera that answers nothing keeps exactly the behaviour it has
on main.

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.
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Add capability-driven PTZ steps, home, and ONVIF interoperability

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

Grey Divider

AI Description

• Adds capability-aware PTZ stepping, home controls, and speed selection across desktop and web.
• Improves ONVIF interoperability with Digest authentication, SOAP 1.1 fallback, and nested response
 parsing.
• Normalizes device ranges, repairs preset names, and covers protocol edge cases with tests.
Diagram

sequenceDiagram
    actor User
    participant Desktop as Desktop UI
    participant Browser as Web UI
    participant API as PTZ API
    participant Controller as PTZ Controller
    participant SOAP as SOAP Client
    participant Camera as ONVIF Camera
    User->>Desktop: Press step or home
    User->>Browser: Tap or hold control
    Browser->>API: Step home or capabilities
    Desktop->>Controller: Normalized command
    API->>Controller: Stateless step
    Controller->>SOAP: Query capabilities
    SOAP->>Camera: Configuration options
    Camera-->>SOAP: Spaces and ranges
    alt Relative move supported
        Controller->>SOAP: Relative move
    else Continuous fallback
        Controller->>SOAP: Timed move
    end
    SOAP->>Camera: SOAP 1.2 or 1.1
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Shared per-camera capability cache
  • ➕ Avoids repeated GetProfiles and GetConfigurationOptions calls from stateless web steps
  • ➕ Keeps desktop and web capability decisions consistent
  • ➕ Centralizes expiry and fallback policy
  • ➖ Requires cache invalidation when profiles or camera configuration change
  • ➖ Adds shared state and lifecycle management to the web backend

Recommendation: The PR's separation between UI, PtzController, and SoapOnvifClient is appropriate, and server-side capability enforcement should remain authoritative. A shared, expiring per-camera capability cache is the strongest follow-up because the web API currently creates a new controller for each step and therefore cannot reuse its instance cache.

Files changed (21) +1376 / -36

Enhancement (14) +560 / -4
Localizer.csLocalize desktop PTZ step and home controls +14/-0

Localize desktop PTZ step and home controls

• Adds English and Russian strings for home navigation, home replacement confirmation, movement speed, and step zoom actions.

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

SingleCameraPageViewModel.csDrive desktop PTZ steps and home commands +97/-0

Drive desktop PTZ steps and home commands

• Loads camera PTZ capabilities, exposes speed and home visibility, and adds directional, zoom, go-home, and confirmed set-home commands. Failures are logged without disrupting the camera page.

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

SingleCameraPage.axamlAdd desktop PTZ step keypad and speed UI +77/-0

Add desktop PTZ step keypad and speed UI

• Adds a styled 3x3 directional keypad, capability-gated home button, zoom step keys, speed slider, and set-home action to the PTZ panel.

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

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

Extend the ONVIF client PTZ contract

• Adds operations for capability discovery, relative movement, status retrieval, and home position management.

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

PtzCapabilities.csModel camera-reported PTZ capabilities +48/-0

Model camera-reported PTZ capabilities

• Defines supported movement modes, home and status support, coordinate ranges, field-of-view semantics, and a backward-compatible continuous-only fallback.

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

PtzController.csImplement normalized PTZ stepping and home control +68/-0

Implement normalized PTZ stepping and home control

• Caches capabilities, maps normalized steps into camera ranges, preserves untouched axes on asymmetric ranges, and falls back to a 350 ms continuous move. Also exposes home and status operations.

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

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

Map normalized PTZ values into device ranges

• Adds validated conversion, inverse conversion, and clamping helpers for arbitrary camera-reported coordinate spaces.

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

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

Represent PTZ position and movement state

• Adds PTZ movement states and a status record covering camera position, per-axis movement, timestamp, and aggregate motion.

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

OnvifCoreClient.csFail explicitly for unsupported legacy PTZ operations +22/-0

Fail explicitly for unsupported legacy PTZ operations

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

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

api.tsExpose web PTZ step, home, and capability APIs +16/-0

Expose web PTZ step, home, and capability APIs

• Defines the capability DTO and client calls for stateless step movement, home navigation, and capability discovery.

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

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

Add a PTZ home icon

• Adds a house glyph for the center home-position control in the web keypad.

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

PtzPad.tsxAdd tap-to-step and capability-aware home behavior +56/-3

Add tap-to-step and capability-aware home behavior

• Loads camera capabilities, converts short directional presses into relative steps, retains hold-to-sweep behavior, and replaces the center stop control with home when supported.

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

strings.tsLocalize the web home-position label +2/-0

Localize the web home-position label

• Adds English and Russian labels for the PTZ home action.

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

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

Add stateless PTZ step, home, and capability endpoints

• Adds validated endpoints for step movement, home navigation, and capability reporting. Step requests use PtzController for camera-range mapping and continuous fallback.

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

Bug fix (1) +64 / -0
OnvifText.csRepair misdecoded ONVIF preset names +64/-0

Repair misdecoded ONVIF preset names

• Introduces conservative Latin-1 and Windows-1252 byte recovery for UTF-8 preset names while preserving values that cannot be safely repaired.

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

Tests (4) +441 / -0
OnvifTextTests.csTest safe preset-name mojibake repair +54/-0

Test safe preset-name mojibake repair

• Covers ASCII, empty, Latin-1, Windows-1252, already-correct Unicode, and unsupported-character cases.

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

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

Test PTZ range conversion and clamping

• Covers symmetric, degree-based, asymmetric, invalid, and out-of-range mappings, including inverse absolute zoom conversion.

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

SoapOnvifClientInteropTests.csTest ONVIF transport interoperability +147/-0

Test ONVIF transport interoperability

• Exercises SOAP 1.1 fallback, SOAPAction handling, Digest challenges, actionable empty responses, fault preservation, and spec-compliant nested stream URIs against a socket-backed stub.

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

StubCamera.csAdd a socket-backed ONVIF camera test stub +133/-0

Add a socket-backed ONVIF camera test stub

• Provides a configurable local HTTP listener that records SOAP versions, actions, and authorization headers while supporting authentication challenges and custom responses.

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

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

Document capability-driven PTZ framing controls

• Expands the single-camera feature description with step framing, home position, speed control, and camera-driven control visibility.

README.md

Other (1) +306 / -30
SoapOnvifClient.csAdd PTZ operations and harden SOAP interoperability +306/-30

Add PTZ operations and harden SOAP interoperability

• Implements capability discovery, relative moves, status, and home operations with range parsing. Also adds Digest challenge support, SOAP 1.1 retry behavior, nested response parsing, actionable empty-body errors, and preset-name repair.

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

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

qodo-free-for-open-source-projects Bot commented Aug 25, 2026 •

Copy link
Copy Markdown

Code Review by Qodo

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

Grey Divider


Action required

1. Asymmetric steps leave range ✓ Resolved 🐞 Bug ≡ Correctness
Description
StepAsync maps into the declared range and then subtracts its midpoint, changing an asymmetric
range such as [0,100] into [-50,50]; negative steps are therefore sent below the camera's
declared minimum and can be refused or clamped. This defeats the range handling specifically added
to keep wire values valid.
Code

src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[R114-117]

+            var scaled = new PtzVelocity(
+                caps.RelativePan.FromNormalized(step.PanX) - Midpoint(caps.RelativePan),
+                caps.RelativeTilt.FromNormalized(step.TiltY) - Midpoint(caps.RelativeTilt),
+                caps.RelativeZoom.FromNormalized(step.Zoom) - Midpoint(caps.RelativeZoom));
Evidence
The range abstraction states that values outside Min/Max may be refused, while FromNormalized maps
to Min/Max and StepAsync subsequently shifts that result by the midpoint. For [0,100], the
tested mapping gives 0 for normalized -1, after which StepAsync sends -50, outside the declared
range.

src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs[3-10]
src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs[20-26]
src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[114-132]
tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs[32-42]

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

## Issue description
Relative-step scaling subtracts the range midpoint after mapping, producing values outside asymmetric declared ranges.
## Issue Context
Preserve zero for untouched axes without transforming the camera's valid interval into a different interval. Requested directions that the declared range cannot represent should be omitted or handled explicitly.
## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[114-132]
- src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs[17-27]
- tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs[32-43]

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


2. Relative axes are conflated ✓ Resolved 🐞 Bug ≡ Correctness
Description
SupportsRelative is derived only from the pan/tilt space even though relative zoom is advertised
separately. A pan/tilt-only camera is consequently sent unsupported zoom translations, while a
zoom-only camera is classified as lacking RelativeMove and never uses its supported operation.
Code

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[R289-292]

+        return new PtzCapabilities(
+            SupportsContinuous: continuousPanTilt is not null,
+            SupportsRelative: relativePanTilt is not null,
+            SupportsAbsolute: absoluteZoom is not null,
Evidence
The parser separately finds relativePanTilt and relativeZoom, but the only support flag uses
relativePanTilt. StepAsync chooses RelativeMove from that flag, and RelativeMoveAsync emits
Zoom solely from the requested value, without checking whether the parsed zoom space existed.

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[277-301]
src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[109-119]
src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[314-333]

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 aggregate relative capability loses the distinction between pan/tilt and zoom spaces, causing supported operations to be skipped and unsupported axes to be sent.
## Issue Context
Capability parsing already reads both spaces. Preserve both flags through `PtzCapabilities`, gate each UI control appropriately, and emit only axes supported by the camera.
## Fix Focus Areas
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[277-301]
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[314-333]
- src/OpenIPC.Viewer.Core/Onvif/PtzCapabilities.cs[14-31]
- src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[109-125]

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


3. Fallback ignores continuous support ✓ Resolved 🐞 Bug ≡ Correctness
Description
When RelativeMove is unavailable, StepAsync always issues ContinuousMoveAsync without checking
the parsed SupportsContinuous flag. Cameras that successfully report only absolute movement, or
otherwise omit both relative and continuous spaces, still get visible step controls whose requests
are guaranteed to use an unsupported operation.
Code

src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[R122-125]

+        await _client.ContinuousMoveAsync(
+            _endpoint, _profileToken,
+            new PtzVelocity(step.PanX * speed, step.TiltY * speed, step.Zoom * speed),
+            StepFallbackDuration, ct).ConfigureAwait(false);
Evidence
Capability parsing explicitly sets SupportsContinuous false when no continuous pan/tilt space
exists, but the new fallback path only checks SupportsRelative and unconditionally calls
ContinuousMove otherwise. The new desktop step buttons are not gated by continuous support.

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[277-291]
src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[109-125]
src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml[164-209]

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 step fallback invokes ContinuousMove even when configuration options explicitly report no continuous space.
## Issue Context
Use parsed per-axis continuous support before choosing the fallback, and expose that result so unsupported step controls can be hidden or rejected deterministically.
## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Onvif/PtzController.cs[109-125]
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[277-303]
- src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml[164-209]
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[125-142]

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


View action required (2)
4. Home support is fabricated ✓ Resolved 🐞 Bug ≡ Correctness
Description
Every camera that returns configuration options is marked SupportsHome: true, even though no
response value was inspected to establish support. This exposes Home and Set Home on unsupported
cameras and, in the web pad, replaces the Stop button with a command that fails.
Code

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[R293-296]

+            // Home is not advertised among the spaces. The operation is
+            // optional and the only honest test is calling it, so it is offered
+            // and a refusal surfaces as a normal error.
+            SupportsHome: true,
Evidence
The parser's own comment says home is optional and not advertised by the spaces, then sets the flag
true anyway. Both clients consume that flag directly; the web client conditionally replaces its
existing Stop button with Home when true.

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[289-303]
src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs[140-148]
src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml[175-178]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[177-186]

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 parsing unconditionally advertises optional home operations.
## Issue Context
Only set home support from camera-reported node/service data or a verified probe. If support cannot be established, retain the Stop control and do not expose destructive Set Home.
## Fix Focus Areas
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[289-303]
- src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml[175-178]
- src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml[204-209]
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[177-186]

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


5. Tap commands race each other ✓ Resolved 🐞 Bug ≡ Correctness
Description
A short web tap starts a continuous move on pointer-down, then fires Stop and RelativeMove
concurrently on pointer-up. Depending on request ordering, the tap either includes unwanted
continuous motion or the late Stop cancels the intended nudge, so one press is not reliably one
step.
Code

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[R132-138]

+    onPointerUp: () => {
+      const held = Date.now() - pressedAt.current
+      void stop()
+      // Too short to have swept anywhere: finish the gesture as a nudge. Only
+      // on cameras that implement RelativeMove — elsewhere the hold already
+      // did what it could.
+      if (held < TAP_MS && caps?.relative) step(dir)
Evidence
start immediately sends /ptz/move and starts a refresh timer. On release, stop() is
deliberately not awaited before step(dir) starts another independent fetch; the server maps these
to separate camera Stop and Step operations.

src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[80-100]
src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[123-142]
src/OpenIPC.Viewer.Web.Client/src/api.ts[311-321]
src/OpenIPC.Viewer.Web/Api/PtzApi.cs[51-76]

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

## Issue description
Tap handling starts sweep immediately and races Stop against the subsequent step request.
## Issue Context
Do not begin continuous movement until the hold threshold expires. A release before the threshold should issue only the step; a held release should stop only the established continuous move.
## Fix Focus Areas
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[80-100]
- src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx[123-142]

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



Remediation recommended

6. Status positions remain unnormalized 🐞 Bug ≡ Correctness
Description
GetPtzStatusAsync returns raw camera coordinates even though PtzStatus defines its position
fields as normalized UI values. Cameras declaring degree or other non-normalized spaces therefore
produce out-of-contract Pan, Tilt, and Zoom values.
Code

src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[R350-353]

+        return new PtzStatus(
+            Pan: ParseFloat(Attr(panTilt, "x")) ?? 0f,
+            Tilt: ParseFloat(Attr(panTilt, "y")) ?? 0f,
+            Zoom: ParseFloat(Attr(zoom, "x")) ?? 0f,
Evidence
The status record explicitly promises normalized positions, and PtzRange supplies conversion
logic, but the new parser directly assigns ParseFloat results from the SOAP attributes without any
range conversion.

src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs[14-23]
src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[345-356]
src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs[38-44]

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

## Issue description
PTZ status exposes raw device-space coordinates as normalized UI coordinates.
## Issue Context
Use the camera's absolute position ranges when parsing GetStatus, with separate signed pan/tilt and unit zoom conversions. Cache or pass the capability/range data needed by the client.
## Fix Focus Areas
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[336-356]
- src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs[14-23]
- src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs[38-44]

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


7. Repair corrupts valid names ✓ Resolved 🐞 Bug ≡ Correctness
Description
RepairMojibake rewrites any Latin-1/CP1252 character sequence that also forms valid UTF-8 bytes,
so legitimate names such as © are silently changed to ©. Because preset parsing applies this
heuristic unconditionally, correctly decoded camera-provided names can be corrupted in the UI.
Code

src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs[R46-48]

+        // A pure-ASCII name decodes to itself; anything else means the repair
+        // found something, and a replacement char means it guessed wrong.
+        return decoded.Contains('�') ? value : decoded;
Evidence
The function reconstructs one byte per input character and returns the UTF-8 result whenever
decoding succeeds. That rule maps the legitimate two-character value U+00C2 U+00A9 to bytes C2 A9
and then to U+00A9; GetPresetsAsync invokes it for every name without any camera-encoding signal.

src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs[24-48]
src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[205-213]
tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs[20-53]

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

## Issue description
Valid UTF-8 decoding of reconstructed bytes is insufficient to distinguish mojibake from legitimate Latin-1/CP1252 text.
## Issue Context
Avoid unconditional repair. Require a stronger mojibake signature or retain both original and repaired values where encoding cannot be determined; add ambiguous valid-text regression cases.
## Fix Focus Areas
- src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs[15-48]
- src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs[205-213]
- tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs[20-53]

ⓘ 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 hide the parts of a finding you never read, like the evidence or the agent prompt

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/OpenIPC.Viewer.Core/Onvif/PtzController.cs Outdated
Comment thread src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs
Comment thread src/OpenIPC.Viewer.Core/Onvif/PtzController.cs Outdated
Comment thread src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs Outdated
Comment thread src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx Outdated
Comment thread src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs
Comment thread src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs Outdated
@iBinh
iBinh force-pushed the feat/ptz-steps-and-home branch from 89bdb92 to 792c032 Compare August 25, 2026 08:50
iBinh added 2 commits August 25, 2026 16:00
…-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.
@iBinh
iBinh force-pushed the feat/ptz-steps-and-home branch from 792c032 to afb124b Compare August 25, 2026 09:07
…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).
@iBinh
iBinh force-pushed the feat/ptz-steps-and-home branch from afb124b to 5d0dcc3 Compare August 25, 2026 09:08
iBinh added 2 commits August 25, 2026 16:12
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.
@keyldev
keyldev self-requested a review August 27, 2026 16:46
@keyldev

keyldev commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #65, so #65 lands first. Verified locally: OpenIPC.Viewer.Desktop.slnx builds
with 0 warnings; Core 340/340 and Devices 16/16 pass.

The design is right. Capabilities are read per axis pair rather than as one flag, PtzRange
keeps the normalized↔device mapping in one place, RelativeMove is sent non-retryable
(consistent with #65's mutation rule), every web endpoint funnels through TryResolveAsync
so WebPermission.Ptz + DenyCamera cover them all, and the tap/hold state machine really
does remove the step-vs-stop race. [RelayCommand] StepAsync(string?) respects the project's
XAML parameter rule.

1. Blocker — hiding the Home button scrambles the keypad — verified

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

Nine buttons sit in a UniformGrid Rows="3" Columns="3" and the center one is hidden with
IsVisible="{Binding SupportsHome}". Avalonia's UniformGrid skips invisible children when
placing — it does not leave the cell empty. Measured against Avalonia 12.0.3:

UL   bounds=0,0      U    bounds=42,0     UR   bounds=84,0
L    bounds=0,34     HOME hidden          R    bounds=42,34   <- center
DL   bounds=84,34    D    bounds=0,68     DR   bounds=42,68

→ lands in the middle, ↙ on the right, ↓ bottom-left, ↘ bottom-center.

This fires on the default path: PtzCapabilities.ContinuousOnly has SupportsHome: false,
so every camera that cannot describe itself — plus every camera without home support —
gets the scrambled pad. Fix: keep the cell occupied (a placeholder, or swap Home for Stop
when home is unsupported), or use an explicit Grid with Grid.Row / Grid.Column.

2. The continuous step fallback never sends a stop

src/OpenIPC.Viewer.Core/Onvif/PtzController.cs:144 (StepAsync)

The fallback sends one ContinuousMove with a 350 ms Timeout and nothing else. The
joystick path always fires an explicit StopPtzAsync on release; this path trusts the
camera's Timeout handling alone, and PtzControllerStepTests only asserts
Assert.NotNull(timeout) — no stop is expected. Firmware that ignores Timeout on
ContinuousMove is common, and this is exactly the path taken by cameras with no
RelativeMove — the ones most likely to have the sloppier stack. A scheduled
StopPtzAsync after the step duration costs one request and removes the failure mode.

3. The web pad trades away its Stop button

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

Home is rendered instead of Stop when caps.home is true. Stop is the escape hatch for
exactly the situation in (2) — a move that did not stop when it should have. Keep both; it
also makes the two heads agree on what the center key means.

Worth a look

  • The capability probe blocks page setup. PtzCapabilities = await Ptz.GetCapabilitiesAsync(ct) sits ahead of ReloadPresetsAsync, and one probe is 6–7
    sequential SOAP calls (GetCapabilities twice via ResolveServiceAsync, GetProfiles,
    GetConfigurationOptions, GetConfiguration, GetNode, GetServiceCapabilities), each
    with the client's 8 s CallTimeout. A camera that half-answers stalls the presets list for
    a long time. Not awaiting it in the setup path fixes it; reusing the GetProfilesAsync
    result already fetched inside the probe trims another round trip.
  • SupportsMoveStatusAsync compares against "true" only
    (SoapOnvifClient.cs:209), while XmlBool exists two screens above for exactly this —
    MoveStatus="1" reads as false.
  • First space wins for FOV detection. Descendant(spaces, "RelativePanTiltTranslationSpace") takes whichever is declared first; cameras often
    declare both the generic translation space and the FOV one. Preferring the
    TranslationSpaceFov entry when present is what makes a step feel the same at any zoom —
    the stated goal. Nothing is wrong today (range and URI stay paired), it just misses the
    better space.
  • Mojibake repair covers preset names only. Profile names and GetDeviceInformation
    arrive through the same pipe with the same damage. The two causes are also worth
    separating: when the camera lies in its HTTP charset, the fix belongs in the transport
    (decode by the XML prolog) and covers every field at once — the heuristic is then only
    needed for names stored double-encoded. OnvifText itself is careful and the three-tier
    evidence rule is the right call.

keyldev added a commit that referenced this pull request Sep 22, 2026
keyldev added a commit that referenced this pull request Sep 22, 2026
- 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
@keyldev keyldev mentioned this pull request Sep 22, 2026
11 of 20 tasks
@keyldev keyldev closed this Sep 22, 2026
@keyldev

keyldev commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Thanks a lot for this — the work is merged, with your commits and authorship kept, into #68 . The review items are fixed on top there, so I'm closing this one in favour of it.

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.

2 participants