From 9c347c1b4233d5943f927fd68897743565754443 Mon Sep 17 00:00:00 2001 From: iBinh Date: Tue, 25 Aug 2026 15:20:31 +0700 Subject: [PATCH 01/10] fix(onvif): answer digest challenges, retry SOAP 1.1, read nested values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Onvif/SoapOnvifClient.cs | 149 ++++++++++++++---- .../Onvif/SoapOnvifClientInteropTests.cs | 147 +++++++++++++++++ .../Onvif/StubCamera.cs | 133 ++++++++++++++++ 3 files changed, 400 insertions(+), 29 deletions(-) create mode 100644 tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs create mode 100644 tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs diff --git a/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs b/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs index 1b408f7..52962e4 100644 --- a/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs +++ b/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Security; @@ -25,14 +26,18 @@ namespace OpenIPC.Viewer.Devices.Onvif; /// the "XmlType reflection error" on Onvif.Core.Client.Common.DeviceEntity. /// Same contract, so the swap is one DI registration. /// -/// Auth mirrors the old builder: preemptive HTTP Basic (OpenIPC's -/// onvif_simple_server enforces it at the transport) plus a WS-Security -/// UsernameToken password digest. GetSystemDateAndTime (unauthenticated) yields -/// the camera clock offset the digest's Created stamp needs; it's cached per host. +/// Auth is three things at once, because cameras disagree about which they +/// want: preemptive HTTP Basic (OpenIPC's onvif_simple_server enforces it at +/// the transport and never challenges), HTTP Digest answered on a 401 by an +/// whose handler carries the credentials, and a +/// WS-Security UsernameToken password digest in the envelope. +/// GetSystemDateAndTime (unauthenticated) yields the camera clock offset the +/// token's Created stamp needs; it's cached per host. /// public sealed class SoapOnvifClient : IOnvifClient { private const string Soap = "http://www.w3.org/2003/05/soap-envelope"; + private const string Soap11 = "http://schemas.xmlsoap.org/soap/envelope/"; private const string Tds = "http://www.onvif.org/ver10/device/wsdl"; private const string Trt = "http://www.onvif.org/ver10/media/wsdl"; private const string Tptz = "http://www.onvif.org/ver20/ptz/wsdl"; @@ -52,19 +57,43 @@ public sealed class SoapOnvifClient : IOnvifClient // first authed call, refreshed on an auth fault. private readonly ConcurrentDictionary _shiftByHost = new(StringComparer.OrdinalIgnoreCase); + // One client per (host, user). A handler that carries credentials is what + // lets HttpClient answer a 401 challenge on its own, which is the only way + // to satisfy a camera that asks for Digest rather than Basic. + private readonly ConcurrentDictionary _authedClients = new(StringComparer.Ordinal); + public SoapOnvifClient(ILogger logger) { _logger = logger; + _http = NewClient(credentials: null); + } + + private static HttpClient NewClient(NetworkCredential? credentials) + { // onvif_simple_server is CGI-style: one request per connection, then it // closes the socket. Disable pooling so we never reuse a dead socket. - _http = new HttpClient(new SocketsHttpHandler + var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.Zero, ConnectTimeout = CallTimeout, - }) - { - Timeout = CallTimeout, }; + if (credentials is not null) + { + handler.Credentials = credentials; + // Let the camera state its terms first: preemptive auth would send + // Basic to a device that only accepts Digest. + handler.PreAuthenticate = false; + } + return new HttpClient(handler) { Timeout = CallTimeout }; + } + + private HttpClient ClientFor(Uri service, CameraCredentials? credentials) + { + if (credentials is not { } c || string.IsNullOrEmpty(c.Username)) return _http; + + var key = $"{service.Host}:{service.Port}\u0000{c.Username}\u0000{c.Password}"; + return _authedClients.GetOrAdd(key, _ => + NewClient(new NetworkCredential(c.Username, c.Password ?? string.Empty))); } // --- Device service ----------------------------------------------------- @@ -126,7 +155,11 @@ public async Task GetStreamUriAsync(OnvifEndpoint endpoint, string profileT $"{Escape(profileToken)}"; var body = await CallAuthedAsync(media, endpoint, $"{Trt}/GetStreamUri", reqBody, ct).ConfigureAwait(false); - var uri = Value(body, "Uri"); + // The spec nests this as MediaUri/Uri, and Hikvision (among others) sends + // exactly that. Reading it as a direct child only matched the flatter + // shape onvif_simple_server returns, so a compliant camera looked like + // it had answered with no stream at all. + var uri = Descendant(body, "Uri")?.Value; if (string.IsNullOrWhiteSpace(uri)) throw new InvalidOperationException($"GetStreamUri returned no URI for profile {profileToken}"); return new Uri(uri, UriKind.Absolute); @@ -194,7 +227,8 @@ public async Task SetPresetAsync(OnvifEndpoint endpoint, string profileT $"{Escape(profileToken)}" + $"{Escape(name)}"; var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetPreset", reqBody, ct).ConfigureAwait(false); - return Value(body, "PresetToken") ?? string.Empty; + // Nested the same way on some firmwares, for the same reason. + return Descendant(body, "PresetToken")?.Value ?? string.Empty; } public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct) @@ -282,28 +316,21 @@ private async Task GetTimeShiftAsync(Uri deviceService, CancellationTo private async Task CallAsync(Uri service, string action, string body, CameraCredentials? credentials, TimeSpan shift, CancellationToken ct) { - var header = SecurityHeader(credentials, shift); - var envelope = - "" + - $"{header}{body}"; + // SOAP 1.2 first — the version ONVIF specifies. A camera that answers + // it with nothing usable gets one retry as SOAP 1.1, which several + // firmwares are built for and which costs one request to find out. + var (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: true, ct) + .ConfigureAwait(false); - using var req = new HttpRequestMessage(HttpMethod.Post, service); - req.Headers.ConnectionClose = true; - if (credentials is { } c && !string.IsNullOrEmpty(c.Username)) + if (!IsUsable(text)) { - var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{c.Username}:{c.Password}")); - req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); + _logger.LogDebug("ONVIF {Action}: SOAP 1.2 gave HTTP {Status} and {Length} bytes; retrying as SOAP 1.1", + action, (int)status, text.Length); + (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: false, ct) + .ConfigureAwait(false); } - var content = new StringContent(envelope, Encoding.UTF8); - content.Headers.ContentType = new MediaTypeHeaderValue("application/soap+xml") { CharSet = "utf-8" }; - content.Headers.ContentType.Parameters.Add(new NameValueHeaderValue("action", $"\"{action}\"")); - req.Content = content; - - using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); - var text = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(text)) - throw new InvalidOperationException($"ONVIF {action}: empty response (HTTP {(int)resp.StatusCode})"); + if (string.IsNullOrWhiteSpace(text)) throw EmptyBody(action, status); XElement root; try { root = XDocument.Parse(text).Root!; } @@ -311,7 +338,11 @@ private async Task CallAsync(Uri service, string action, string body, var bodyEl = Child(Child(root, "Body"), null); if (bodyEl is null) - throw new InvalidOperationException($"ONVIF {action}: empty SOAP body"); + { + _logger.LogDebug("ONVIF {Action}: HTTP {Status}, body: {Body}", + action, (int)status, text.Length > 400 ? text[..400] : text); + throw EmptyBody(action, status); + } if (bodyEl.Name.LocalName == "Fault") { var reason = Descendant(bodyEl, "Text")?.Value @@ -322,6 +353,66 @@ private async Task CallAsync(Uri service, string action, string body, return bodyEl; } + private async Task<(HttpStatusCode Status, string Text)> SendAsync( + Uri service, string action, string body, CameraCredentials? credentials, + TimeSpan shift, bool soap12, CancellationToken ct) + { + var header = SecurityHeader(credentials, shift); + var envelope = + "" + + $"{header}{body}"; + + using var req = new HttpRequestMessage(HttpMethod.Post, service); + req.Headers.ConnectionClose = true; + if (credentials is { } c && !string.IsNullOrEmpty(c.Username)) + { + // Preemptive Basic for onvif_simple_server, which enforces it at the + // transport and never challenges. A camera that wants Digest answers + // 401 instead, and the handler's credentials settle that exchange. + var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{c.Username}:{c.Password}")); + req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); + } + + var content = new StringContent(envelope, Encoding.UTF8); + if (soap12) + { + content.Headers.ContentType = new MediaTypeHeaderValue("application/soap+xml") { CharSet = "utf-8" }; + content.Headers.ContentType.Parameters.Add(new NameValueHeaderValue("action", $"\"{action}\"")); + } + else + { + // SOAP 1.1 has no action parameter on the content type; it travels + // in a header of its own. + content.Headers.ContentType = new MediaTypeHeaderValue("text/xml") { CharSet = "utf-8" }; + req.Headers.TryAddWithoutValidation("SOAPAction", $"\"{action}\""); + } + req.Content = content; + + using var resp = await ClientFor(service, credentials) + .SendAsync(req, HttpCompletionOption.ResponseContentRead, ct).ConfigureAwait(false); + return (resp.StatusCode, await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false) ?? string.Empty); + } + + // Worth reading: it parses, and its Body holds something. A firmware built + // for SOAP 1.1 typically answers a 1.2 request with no bytes at all or with + // an envelope whose Body is empty, and both mean "ask again differently". + // A fault is a usable answer — a camera that says why it refused is not + // asked twice. + private static bool IsUsable(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + try { return Child(Child(XDocument.Parse(text).Root!, "Body"), null) is not null; } + catch (Exception) { return false; } + } + + // An empty body is what a camera sends when it will not say why. In + // practice it means ONVIF is switched off in the camera's own settings or + // the account has no ONVIF rights — neither of which arrives as a fault, so + // the message has to name them. The status code is the only other clue. + private static InvalidOperationException EmptyBody(string action, HttpStatusCode status) => + new($"ONVIF {action}: the camera returned an empty SOAP body (HTTP {(int)status}). " + + "Check that ONVIF is enabled on the camera and that this account may use it."); + private static string SecurityHeader(CameraCredentials? credentials, TimeSpan shift) { if (credentials is not { } c || string.IsNullOrEmpty(c.Username)) diff --git a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs new file mode 100644 index 0000000..30a190c --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs @@ -0,0 +1,147 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Devices.Onvif; + +namespace OpenIPC.Viewer.Devices.Tests.Onvif; + +// Interop with firmwares that do not behave like onvif_simple_server: they want +// Digest rather than Basic, or SOAP 1.1 rather than 1.2, or they nest the +// stream URI where the spec says it goes. All three fail the same unhelpful +// way — HTTP 200 with an empty SOAP body — so each is reproduced against a stub +// camera rather than taken on trust. +// +// Every call is preceded by an unauthenticated GetSystemDateAndTime (the clock +// probe the WS-Security digest needs), so assertions count the requests that +// carry the action under test rather than all of them. +public sealed class SoapOnvifClientInteropTests +{ + private static SoapOnvifClient NewClient() => new(NullLogger.Instance); + + [Fact] + public async Task ACameraThatAnswersSoap12WithNothing_IsRetriedAsSoap11() + { + using var camera = StubCamera.Start(req => + req.IsSoap12 ? (string.Empty, 200) : (Envelope11(Capabilities()), 200)); + + var caps = await NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + + Assert.NotNull(caps); + var attempts = camera.Requests.Where(r => r.Is("GetCapabilities")).ToList(); + Assert.Equal(2, attempts.Count); + Assert.True(attempts[0].IsSoap12); + Assert.True(attempts[1].IsSoap11); + } + + // SOAP 1.1 carries the action in a header of its own rather than as a + // parameter on the content type. A camera that reads SOAPAction and finds + // nothing there rejects the call, so the retry would be pointless without it. + [Fact] + public async Task TheSoap11Retry_CarriesTheActionInItsOwnHeader() + { + using var camera = StubCamera.Start(req => + req.IsSoap12 ? (string.Empty, 200) : (Envelope11(Capabilities()), 200)); + + await NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + + var retry = camera.Requests.Last(r => r.Is("GetCapabilities")); + Assert.Contains("GetCapabilities", retry.SoapAction ?? "", StringComparison.Ordinal); + } + + // The Hikvision case. The camera refuses the preemptive Basic header and + // challenges for Digest; answering that is HttpClient's job, but only when + // the handler holds the credentials. + [Fact] + public async Task ADigestChallenge_IsAnswered() + { + using var camera = StubCamera.Start( + req => (req.Authorization ?? "").StartsWith("Digest", StringComparison.OrdinalIgnoreCase) + ? (Envelope12(Capabilities()), 200) + : (string.Empty, 401), + challenge: "Digest realm=\"IP Camera\", qop=\"auth\", nonce=\"4f3a2b1c\", stale=\"FALSE\""); + + var caps = await NewClient().GetCapabilitiesAsync( + camera.Endpoint(new CameraCredentials("admin", "secret")), CancellationToken.None); + + Assert.NotNull(caps); + Assert.Contains(camera.Requests, r => + r.Is("GetCapabilities") + && (r.Authorization ?? "").StartsWith("Digest", StringComparison.OrdinalIgnoreCase)); + } + + // Neither version got anywhere. "Empty SOAP body" is not something a user + // can act on; the two things worth checking on the camera are. + [Fact] + public async Task ACameraThatSaysNothingAtAll_FailsWithSomethingActionable() + { + using var camera = StubCamera.Start(_ => (string.Empty, 200)); + + var ex = await Assert.ThrowsAsync(() => + NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None)); + + Assert.Contains("ONVIF is enabled", ex.Message, StringComparison.OrdinalIgnoreCase); + Assert.Contains("account", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // A fault is an answer. Asking again in another dialect would waste a round + // trip and bury what the camera actually said. + [Fact] + public async Task AFault_IsReportedAsWorded_AndNeverRetriedAsSoap11() + { + using var camera = StubCamera.Start(_ => (Envelope12( + "" + + "Sender not authorized"), 400)); + + // The fault type is private to the client, so the assertion is on what + // reaches the caller: the camera's own wording. + var ex = await Assert.ThrowsAnyAsync(() => + NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None)); + + Assert.Contains("Sender not authorized", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain(camera.Requests, r => r.IsSoap11); + } + + // GetStreamUriResponse/MediaUri/Uri is what the spec defines and what most + // cameras send. Reading Uri as a direct child matched only the flatter + // shape onvif_simple_server returns, so a working camera looked like it had + // no stream at all. + [Fact] + public async Task TheStreamUri_IsReadFromWhereTheSpecPutsIt() + { + StubCamera? camera = null; + camera = StubCamera.Start(req => req.Is("GetCapabilities") + // The media service has to be advertised somewhere the client can + // actually follow — this stub. + ? (Envelope12(Capabilities($"http://127.0.0.1:{camera!.Port}/onvif/media")), 200) + : (Envelope12( + "" + + "rtsp://10.16.33.231:554/Streaming/Channels/101" + + "false" + + ""), 200)); + using var _ = camera; + + var uri = await NewClient().GetStreamUriAsync(camera.Endpoint(null), "Profile_1", CancellationToken.None); + + Assert.Equal("rtsp://10.16.33.231:554/Streaming/Channels/101", uri.ToString()); + } + + // --- helpers ------------------------------------------------------------ + + private static string Capabilities(string mediaXAddr = "http://127.0.0.1:1/onvif/media") => + "" + + $"{mediaXAddr}" + + ""; + + private static string Envelope12(string body) => + "" + + "" + + $"{body}"; + + private static string Envelope11(string body) => + "" + + "" + + $"{body}"; +} diff --git a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs new file mode 100644 index 0000000..61a16ee --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs @@ -0,0 +1,133 @@ +using System.Collections.Concurrent; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Onvif; + +namespace OpenIPC.Viewer.Devices.Tests.Onvif; + +// What the client saw arrive. The body is read once, here, so a test can look +// at it without racing the handler for the request stream. +internal sealed record StubRequest( + string Body, + string? ContentType, + string? SoapAction, + string? Authorization) +{ + public bool IsSoap12 => + (ContentType ?? "").Contains("application/soap+xml", StringComparison.OrdinalIgnoreCase); + + public bool IsSoap11 => + (ContentType ?? "").Contains("text/xml", StringComparison.OrdinalIgnoreCase); + + public bool Is(string action) => Body.Contains(action, StringComparison.Ordinal); +} + +// A camera that answers however a test needs it to, over a real socket, so the +// client's own HTTP stack does the work — content types, SOAPAction, and the +// 401 handshake included. None of that would be exercised by a mocked handler. +internal sealed class StubCamera : IDisposable +{ + private readonly HttpListener _listener; + private readonly ConcurrentQueue _requests = new(); + + private StubCamera(HttpListener listener, int port) + { + _listener = listener; + Port = port; + } + + public int Port { get; } + + public IReadOnlyList Requests => _requests.ToArray(); + + public OnvifEndpoint Endpoint(CameraCredentials? credentials) => + OnvifEndpoint.FromHost("127.0.0.1", Port, credentials); + + // `challenge`, when set, is sent as WWW-Authenticate with any 401 the + // responder returns — that is what makes HttpClient try again with Digest. + public static StubCamera Start( + Func respond, + string? challenge = null) + { + var port = FreePort(); + var listener = new HttpListener(); + listener.Prefixes.Add($"http://127.0.0.1:{port}/"); + listener.Start(); + + var camera = new StubCamera(listener, port); + _ = Task.Run(() => camera.LoopAsync(respond, challenge)); + return camera; + } + + private async Task LoopAsync(Func respond, string? challenge) + { + while (_listener.IsListening) + { + HttpListenerContext ctx; + try { ctx = await _listener.GetContextAsync().ConfigureAwait(false); } + catch (Exception) { return; } // disposed mid-wait + + try + { + string body; + using (var reader = new StreamReader(ctx.Request.InputStream, Encoding.UTF8)) + body = await reader.ReadToEndAsync().ConfigureAwait(false); + + var request = new StubRequest( + body, + ctx.Request.ContentType, + ctx.Request.Headers["SOAPAction"], + ctx.Request.Headers["Authorization"]); + _requests.Enqueue(request); + + var (payload, status) = respond(request); + ctx.Response.StatusCode = status; + if (status == 401 && challenge is not null) + ctx.Response.AddHeader("WWW-Authenticate", challenge); + + if (payload.Length > 0) + { + var bytes = Encoding.UTF8.GetBytes(payload); + ctx.Response.ContentType = "application/soap+xml; charset=utf-8"; + ctx.Response.ContentLength64 = bytes.Length; + await ctx.Response.OutputStream.WriteAsync(bytes).ConfigureAwait(false); + } + else + { + // The failure this suite is about: a status, and no body to + // explain it. + ctx.Response.ContentLength64 = 0; + } + } + catch (Exception) + { + // A test that tore the camera down mid-request is not a failure. + } + finally + { + try { ctx.Response.Close(); } catch (Exception) { /* already gone */ } + } + } + } + + // Ask the OS for a port, then hand it to HttpListener. Racy in principle, + // never in practice on a test host. + private static int FreePort() + { + var probe = new TcpListener(IPAddress.Loopback, 0); + probe.Start(); + var port = ((IPEndPoint)probe.LocalEndpoint).Port; + probe.Stop(); + return port; + } + + public void Dispose() + { + try { _listener.Stop(); } catch (Exception) { /* nothing to stop */ } + try { _listener.Close(); } catch (Exception) { /* already closed */ } + } +} From adf7be83f9f8522e04aa7f79530a3c8bced49e80 Mon Sep 17 00:00:00 2001 From: iBinh Date: Tue, 25 Aug 2026 16:00:51 +0700 Subject: [PATCH 02/10] =?UTF-8?q?fix(onvif):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20dialect=20learned=20once,=20mutations=20never=20re-?= =?UTF-8?q?sent,=20authed=20clients=20recycled?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Onvif/SoapOnvifClient.cs | 98 +++++++++++++++---- .../Onvif/SoapOnvifClientInteropTests.cs | 47 ++++++++- 2 files changed, 120 insertions(+), 25 deletions(-) diff --git a/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs b/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs index 52962e4..aa37465 100644 --- a/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs +++ b/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs @@ -57,10 +57,24 @@ public sealed class SoapOnvifClient : IOnvifClient // first authed call, refreshed on an auth fault. private readonly ConcurrentDictionary _shiftByHost = new(StringComparer.OrdinalIgnoreCase); - // One client per (host, user). A handler that carries credentials is what + // Hosts that turned out to speak SOAP 1.1 only. Learned from the retry the + // first time a host answers 1.2 with nothing usable, then used as the first + // choice — so the discovery costs one extra request per host, ever, and a + // state-changing call is never the one doing the discovering. + private readonly ConcurrentDictionary _soap11Hosts = new(StringComparer.OrdinalIgnoreCase); + + // One client per camera address. A handler that carries credentials is what // lets HttpClient answer a 401 challenge on its own, which is the only way // to satisfy a camera that asks for Digest rather than Basic. - private readonly ConcurrentDictionary _authedClients = new(StringComparer.Ordinal); + // + // Keyed by host:port alone — a camera has one credential at a time — with + // the credential kept beside the client so a password change swaps the + // entry and disposes the superseded one, instead of caching every password + // this process has ever seen. Growth is bounded by the number of camera + // addresses. A plain lock rather than GetOrAdd: it also stops a concurrent + // miss from constructing a second client that nothing would ever dispose. + private readonly object _clientsGate = new(); + private readonly Dictionary _authedClients = new(StringComparer.Ordinal); public SoapOnvifClient(ILogger logger) { @@ -91,9 +105,23 @@ private HttpClient ClientFor(Uri service, CameraCredentials? credentials) { if (credentials is not { } c || string.IsNullOrEmpty(c.Username)) return _http; - var key = $"{service.Host}:{service.Port}\u0000{c.Username}\u0000{c.Password}"; - return _authedClients.GetOrAdd(key, _ => - NewClient(new NetworkCredential(c.Username, c.Password ?? string.Empty))); + var key = $"{service.Host}:{service.Port}"; + var credential = $"{c.Username}\u0000{c.Password}"; + lock (_clientsGate) + { + if (_authedClients.TryGetValue(key, out var entry)) + { + if (entry.Credential == credential) return entry.Client; + // The password changed. A request in flight on the old client + // was sent with the old password and is failing anyway, so + // disposing under it loses nothing. + entry.Client.Dispose(); + } + + var client = NewClient(new NetworkCredential(c.Username, c.Password ?? string.Empty)); + _authedClients[key] = (credential, client); + return client; + } } // --- Device service ----------------------------------------------------- @@ -226,7 +254,9 @@ public async Task SetPresetAsync(OnvifEndpoint endpoint, string profileT $"" + $"{Escape(profileToken)}" + $"{Escape(name)}"; - var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetPreset", reqBody, ct).ConfigureAwait(false); + // retryable: false — if the camera ran the request and answered + // garbage, a resend would create a second preset. + var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetPreset", reqBody, ct, retryable: false).ConfigureAwait(false); // Nested the same way on some firmwares, for the same reason. return Descendant(body, "PresetToken")?.Value ?? string.Empty; } @@ -238,7 +268,10 @@ public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, $"" + $"{Escape(profileToken)}" + $"{Escape(presetToken)}"; - await CallAuthedAsync(ptz, endpoint, $"{Tptz}/RemovePreset", reqBody, ct).ConfigureAwait(false); + // retryable: false — a resend after a successful-but-unreadable remove + // would fault on the now-missing preset and report failure for a + // removal that worked. + await CallAuthedAsync(ptz, endpoint, $"{Tptz}/RemovePreset", reqBody, ct, retryable: false).ConfigureAwait(false); } // --- Transport ---------------------------------------------------------- @@ -265,25 +298,30 @@ private async Task ResolveServiceAsync(OnvifEndpoint endpoint, ServiceKind // Authenticated call with a per-host clock shift; on a fault, refresh the // shift once and retry (covers a stale/absent offset causing digest rejection). - private async Task CallAuthedAsync(Uri service, OnvifEndpoint endpoint, string action, string body, CancellationToken ct) + private async Task CallAuthedAsync(Uri service, OnvifEndpoint endpoint, string action, string body, CancellationToken ct, bool retryable = true) { var host = endpoint.DeviceServiceUri.Host; if (!_shiftByHost.TryGetValue(host, out var shift)) { + // Also where the host's SOAP dialect gets discovered, since this + // probe runs before the first real call — so by the time a mutation + // goes out, the dialect is already known. shift = await GetTimeShiftAsync(endpoint.DeviceServiceUri, ct).ConfigureAwait(false); _shiftByHost[host] = shift; } try { - return await CallAsync(service, action, body, endpoint.Credentials, shift, ct).ConfigureAwait(false); + return await CallAsync(service, action, body, endpoint.Credentials, shift, retryable, ct).ConfigureAwait(false); } catch (OnvifFaultException) { - // Maybe the clock drifted / the first shift was wrong — recompute and retry once. + // Maybe the clock drifted / the first shift was wrong — recompute and + // retry once. Safe for mutations too: a fault means the camera + // refused the request, not that it ran it. var fresh = await GetTimeShiftAsync(endpoint.DeviceServiceUri, ct).ConfigureAwait(false); _shiftByHost[host] = fresh; - return await CallAsync(service, action, body, endpoint.Credentials, fresh, ct).ConfigureAwait(false); + return await CallAsync(service, action, body, endpoint.Credentials, fresh, retryable, ct).ConfigureAwait(false); } } @@ -293,7 +331,7 @@ private async Task GetTimeShiftAsync(Uri deviceService, CancellationTo { var body = await CallAsync(deviceService, $"{Tds}/GetSystemDateAndTime", $"", - credentials: null, shift: TimeSpan.Zero, ct).ConfigureAwait(false); + credentials: null, shift: TimeSpan.Zero, retryable: true, ct).ConfigureAwait(false); var utc = Descendant(body, "UTCDateTime"); var date = Child(utc, "Date"); @@ -314,20 +352,38 @@ private async Task GetTimeShiftAsync(Uri deviceService, CancellationTo } } - private async Task CallAsync(Uri service, string action, string body, CameraCredentials? credentials, TimeSpan shift, CancellationToken ct) + private async Task CallAsync(Uri service, string action, string body, CameraCredentials? credentials, TimeSpan shift, bool retryable, CancellationToken ct) { - // SOAP 1.2 first — the version ONVIF specifies. A camera that answers - // it with nothing usable gets one retry as SOAP 1.1, which several - // firmwares are built for and which costs one request to find out. - var (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: true, ct) + // SOAP 1.2 first — the version ONVIF specifies — unless this host has + // already shown it only answers 1.1. A camera that answers the first + // choice with nothing usable gets one retry in the other dialect, which + // several firmwares need and which costs one request to find out. The + // winner is remembered per host, so the discovery happens once. + // + // Except for mutations (retryable: false). An unusable response does + // not prove the request was not executed — a camera that ran SetPreset + // and then answered garbage would get a duplicate preset from a resend. + // Mutations rely on the dialect already learned from this host's + // earlier read calls (the clock probe at minimum) and fail honestly + // rather than guessing. + var soap12First = !_soap11Hosts.ContainsKey(service.Host); + var (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: soap12First, ct) .ConfigureAwait(false); - if (!IsUsable(text)) + if (!IsUsable(text) && retryable) { - _logger.LogDebug("ONVIF {Action}: SOAP 1.2 gave HTTP {Status} and {Length} bytes; retrying as SOAP 1.1", - action, (int)status, text.Length); - (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: false, ct) + _logger.LogDebug("ONVIF {Action}: SOAP {First} gave HTTP {Status} and {Length} bytes; retrying as SOAP {Second}", + action, soap12First ? "1.2" : "1.1", (int)status, text.Length, soap12First ? "1.1" : "1.2"); + (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: !soap12First, ct) .ConfigureAwait(false); + + if (IsUsable(text)) + { + // The other dialect is the one this host speaks; remember it in + // whichever direction the flip went. + if (soap12First) _soap11Hosts[service.Host] = 1; + else _soap11Hosts.TryRemove(service.Host, out _); + } } if (string.IsNullOrWhiteSpace(text)) throw EmptyBody(action, status); diff --git a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs index 30a190c..b428f0b 100644 --- a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs @@ -28,10 +28,15 @@ public async Task ACameraThatAnswersSoap12WithNothing_IsRetriedAsSoap11() var caps = await NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); Assert.NotNull(caps); - var attempts = camera.Requests.Where(r => r.Is("GetCapabilities")).ToList(); - Assert.Equal(2, attempts.Count); - Assert.True(attempts[0].IsSoap12); - Assert.True(attempts[1].IsSoap11); + // The host's first exchange — the clock probe — is where the flip + // happens: 1.2, nothing usable, one retry as 1.1. Everything after + // leads with what that taught, so GetCapabilities is 1.1 on the first + // try rather than failing 1.2 again. + Assert.True(camera.Requests[0].IsSoap12); + Assert.True(camera.Requests[1].IsSoap11); + var capabilities = camera.Requests.Where(r => r.Is("GetCapabilities")).ToList(); + Assert.Single(capabilities); + Assert.True(capabilities[0].IsSoap11); } // SOAP 1.1 carries the action in a header of its own rather than as a @@ -127,6 +132,40 @@ public async Task TheStreamUri_IsReadFromWhereTheSpecPutsIt() Assert.Equal("rtsp://10.16.33.231:554/Streaming/Channels/101", uri.ToString()); } + // The discovery costs one request per host, ever: once a host has answered + // 1.1 after failing 1.2, later calls lead with 1.1 instead of failing 1.2 + // again first. + [Fact] + public async Task TheWorkingDialectIsRemembered() + { + using var camera = StubCamera.Start(req => + req.IsSoap12 ? (string.Empty, 200) : (Envelope11(Capabilities()), 200)); + + var client = NewClient(); + await client.GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + await client.GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + + // Only the very first request on the host — the clock probe — went out + // as 1.2; everything after used what that probe learned. + Assert.Equal(1, camera.Requests.Count(r => r.IsSoap12)); + } + + // An unusable response does not prove the request was not executed. A + // camera that ran SetPreset and answered garbage must not be asked again — + // the resend would create a second preset — so mutations fail honestly + // instead of retrying in the other dialect. + [Fact] + public async Task AMutation_IsNeverRetriedInAnotherDialect() + { + using var camera = StubCamera.Start(req => + req.Is("SetPreset") ? (string.Empty, 200) : (Envelope12(Capabilities()), 200)); + + await Assert.ThrowsAsync(() => + NewClient().SetPresetAsync(camera.Endpoint(null), "Profile_1", "Gate", CancellationToken.None)); + + Assert.Equal(1, camera.Requests.Count(r => r.Is("SetPreset"))); + } + // --- helpers ------------------------------------------------------------ private static string Capabilities(string mediaXAddr = "http://127.0.0.1:1/onvif/media") => From 24fb0a870ade81d6b2cd40466883674bdf25f761 Mon Sep 17 00:00:00 2001 From: iBinh Date: Tue, 25 Aug 2026 15:30:58 +0700 Subject: [PATCH 03/10] feat(ptz): step keypad, home and speed, driven by what the camera reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 7 +- src/OpenIPC.Viewer.App/Services/Localizer.cs | 14 ++ .../ViewModels/SingleCameraPageViewModel.cs | 97 +++++++++ .../Views/Pages/SingleCameraPage.axaml | 77 ++++++++ src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs | 19 ++ src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs | 64 ++++++ .../Onvif/PtzCapabilities.cs | 48 +++++ .../Onvif/PtzController.cs | 68 +++++++ src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs | 48 +++++ src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs | 26 +++ .../Onvif/OnvifCoreClient.cs | 22 +++ .../Onvif/SoapOnvifClient.cs | 187 +++++++++++++++++- src/OpenIPC.Viewer.Web.Client/src/api.ts | 16 ++ .../src/components/Icon.tsx | 2 + .../src/components/PtzPad.tsx | 59 +++++- src/OpenIPC.Viewer.Web.Client/src/strings.ts | 2 + src/OpenIPC.Viewer.Web/Api/PtzApi.cs | 66 ++++++- .../Onvif/OnvifTextTests.cs | 55 ++++++ .../Onvif/PtzRangeTests.cs | 107 ++++++++++ 19 files changed, 977 insertions(+), 7 deletions(-) create mode 100644 src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs create mode 100644 src/OpenIPC.Viewer.Core/Onvif/PtzCapabilities.cs create mode 100644 src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs create mode 100644 src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs diff --git a/README.md b/README.md index 7d25d5d..65a68b3 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,11 @@ Built with .NET 9 / 10 and Avalonia 12. VideoToolbox / Android MediaCodec), auto-reconnect, auto SD/HD switching. - **Multi-camera grid** — up to 25 streams, tabbed layouts, drag-reorder, fullscreen kiosk mode, low-cost "stills" mode (periodic HTTP snapshots). -- **Single-camera view** — PTZ joystick + presets, telemetry overlay, - digital zoom (pinch / Ctrl+wheel), snapshot to disk + share. +- **Single-camera view** — PTZ joystick for sweeping plus a step keypad for + framing (one nudge per press, home position, move speed), presets, + telemetry overlay, digital zoom (pinch / Ctrl+wheel), snapshot to disk + + share. Which PTZ controls appear is read from the camera, so a device that + cannot step or go home is not offered buttons that would fail. - **AI detection (local)** — ONNX object detection on-device (person / car / animal…), boxes in the grid and single view, auto-record on detection. No cloud: the model runs in-process. diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 0954fe6..312f805 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -447,6 +447,13 @@ private static LangCode DetectSystem() ["CameraPage.NightMode.Auto"] = "Auto", ["CameraPage.Preset.Placeholder"] = "preset name", ["CameraPage.Preset.Save"] = "+ Save", + ["Ptz.Home"] = "Go to home position", + ["Ptz.SetHome"] = "Set home here", + ["Ptz.SetHome.Title"] = "Set home position", + ["Ptz.SetHome.Message"] = "Make the camera's current position its home? The old home position is replaced.", + ["Ptz.Speed"] = "Move speed", + ["Ptz.ZoomIn"] = "Zoom in one step", + ["Ptz.ZoomOut"] = "Zoom out one step", ["CameraPage.Snapshot"] = "Snapshot", ["CameraPage.Stop"] = "■ Stop", ["CameraPage.ApplyingStatus"] = "Applying…", @@ -930,6 +937,13 @@ private static LangCode DetectSystem() ["CameraPage.NightMode.Auto"] = "Авто", ["CameraPage.Preset.Placeholder"] = "название пресета", ["CameraPage.Preset.Save"] = "+ Сохранить", + ["Ptz.Home"] = "Перейти в домашнюю позицию", + ["Ptz.SetHome"] = "Сделать текущую позицию домашней", + ["Ptz.SetHome.Title"] = "Задать домашнюю позицию", + ["Ptz.SetHome.Message"] = "Сделать текущую позицию камеры домашней? Прежняя домашняя позиция будет заменена.", + ["Ptz.Speed"] = "Скорость перемещения", + ["Ptz.ZoomIn"] = "Приблизить на шаг", + ["Ptz.ZoomOut"] = "Отдалить на шаг", ["CameraPage.Snapshot"] = "Снимок", ["CameraPage.Stop"] = "■ Стоп", ["CameraPage.ApplyingStatus"] = "Применение…", diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index f111bc0..d669ad9 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -133,6 +133,25 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis [ObservableProperty] private string? _snapshotPath; [ObservableProperty] private PtzController? _ptz; + + // What this camera's PTZ node can actually do, read once when the page + // opens. Null until then, and the buttons that depend on it stay hidden + // rather than failing when pressed. + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(SupportsHome))] + private PtzCapabilities? _ptzCapabilities; + + // Move speed for steps, presets and home — the same 0..1 the joystick uses. + // 0.6 is brisk without overshooting on a fast dome. + [ObservableProperty] private double _ptzSpeed = 0.6; + + public bool SupportsHome => PtzCapabilities?.SupportsHome ?? false; + + // How far one press of an arrow moves, normalized. On a camera that reports + // its relative space in field-of-view units this is a sixth of the frame — + // small enough to frame a doorway, large enough that a press feels like it + // did something. + private const float PtzStepSize = 0.16f; [ObservableProperty] private string _newPresetName = ""; // Majestic state. IsMajestic gates the whole config panel; MajesticConfig @@ -575,6 +594,80 @@ private async Task ToggleRecordingAsync() [RelayCommand] private void TogglePtzOverlay() => IsPtzOverlayVisible = !IsPtzOverlayVisible; + // One nudge per press. The direction is a string so the eight arrows and + // the two zoom buttons are one command with a parameter in XAML rather than + // ten near-identical commands. + [RelayCommand] + private async Task StepAsync(string? direction) + { + if (Ptz is null || string.IsNullOrEmpty(direction)) return; + + var step = direction switch + { + "up" => new PtzVelocity(0, PtzStepSize, 0), + "down" => new PtzVelocity(0, -PtzStepSize, 0), + "left" => new PtzVelocity(-PtzStepSize, 0, 0), + "right" => new PtzVelocity(PtzStepSize, 0, 0), + "upleft" => new PtzVelocity(-PtzStepSize, PtzStepSize, 0), + "upright" => new PtzVelocity(PtzStepSize, PtzStepSize, 0), + "downleft" => new PtzVelocity(-PtzStepSize, -PtzStepSize, 0), + "downright" => new PtzVelocity(PtzStepSize, -PtzStepSize, 0), + "zoomin" => new PtzVelocity(0, 0, PtzStepSize), + "zoomout" => new PtzVelocity(0, 0, -PtzStepSize), + _ => PtzVelocity.Zero, + }; + if (step.Equals(PtzVelocity.Zero)) return; + + try + { + await Ptz.StepAsync(step, (float)PtzSpeed, CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PTZ step {Direction} failed for {CameraId}", direction, _camera.Id); + } + } + + [RelayCommand] + private async Task GoHomeAsync() + { + if (Ptz is null) return; + try + { + await Ptz.GoHomeAsync((float)PtzSpeed, CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + // Home is optional, and a camera may advertise PTZ and still refuse + // it. Log it rather than hiding the button on every camera because + // some cannot. + _logger.LogWarning(ex, "PTZ home failed for {CameraId}", _camera.Id); + } + } + + // Overwriting the home position is not undoable from here, so it asks. + [RelayCommand] + private async Task SetHomeAsync() + { + if (Ptz is null) return; + + var confirmed = await _dialogs.ConfirmAsync( + Localizer.Instance["Ptz.SetHome.Title"], + Localizer.Instance["Ptz.SetHome.Message"], + confirmLabel: Localizer.Instance["Ptz.SetHome"], + cancelLabel: Localizer.Instance["Common.Cancel"]).ConfigureAwait(true); + if (!confirmed) return; + + try + { + await Ptz.SetHomeAsync(CancellationToken.None).ConfigureAwait(true); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "PTZ set-home failed for {CameraId}", _camera.Id); + } + } + [RelayCommand] private void ResetZoom() => ZoomLevel = MinZoom; @@ -1191,6 +1284,10 @@ private async Task InitPtzAsync(CameraCredentials? creds, CancellationToken ct) var port = _camera.OnvifPort ?? 80; var endpoint = OnvifEndpoint.FromHost(_camera.Host, port, creds); Ptz = new PtzController(_onvif, endpoint, _camera.OnvifProfileToken!); + // Best-effort: a camera that cannot describe itself gets the + // continuous-only profile, which is what every camera was assumed to be + // before this asked. + PtzCapabilities = await Ptz.GetCapabilitiesAsync(ct).ConfigureAwait(true); await ReloadPresetsAsync(ct).ConfigureAwait(true); } diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml index ee4ebb2..dc4028f 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml @@ -7,6 +7,30 @@ x:Class="OpenIPC.Viewer.App.Views.Pages.SingleCameraPage" x:DataType="vm:SingleCameraPageViewModel"> + + + + + + + + + + + - + {caps?.home ? ( + + ) : ( + + )} diff --git a/src/OpenIPC.Viewer.Web.Client/src/strings.ts b/src/OpenIPC.Viewer.Web.Client/src/strings.ts index 9928753..cb56574 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/strings.ts +++ b/src/OpenIPC.Viewer.Web.Client/src/strings.ts @@ -129,6 +129,7 @@ export const EN: Dict = { 'Ptz.DownLeft': 'Down-left', 'Ptz.DownRight': 'Down-right', 'Ptz.Stop': 'Stop', + 'Ptz.Home': 'Home position', 'Ptz.Zoom': 'Zoom', 'Ptz.ZoomIn': 'Zoom in', 'Ptz.ZoomOut': 'Zoom out', @@ -401,6 +402,7 @@ export const RU: Dict = { 'Ptz.DownLeft': 'Вниз-влево', 'Ptz.DownRight': 'Вниз-вправо', 'Ptz.Stop': 'Стоп', + 'Ptz.Home': 'Домашняя позиция', 'Ptz.Zoom': 'Зум', 'Ptz.ZoomIn': 'Приблизить', 'Ptz.ZoomOut': 'Отдалить', diff --git a/src/OpenIPC.Viewer.Web/Api/PtzApi.cs b/src/OpenIPC.Viewer.Web/Api/PtzApi.cs index 0f106d5..b05367e 100644 --- a/src/OpenIPC.Viewer.Web/Api/PtzApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/PtzApi.cs @@ -58,6 +58,65 @@ public static void MapPtzEndpoints(this WebApplication app) target!.Value.Client.StopPtzAsync(target.Value.Endpoint, target.Value.ProfileToken, ct)); }); + // One nudge — the browser equivalent of the desktop step pad. Unlike + // /move this needs no refresh loop and no stop: RelativeMove is a single + // request the camera runs to completion, and PtzController falls back to + // a short self-stopping move on cameras that lack it. + app.MapPost("/api/v1/cameras/{id}/ptz/step", async ( + string id, PtzMoveRequest? body, HttpContext ctx, CancellationToken ct) => + { + var (target, error) = await TryResolveAsync(ctx, id, ct); + if (error is not null) + return error; + + var step = new PtzVelocity(Clamp(body?.PanX), Clamp(body?.TiltY), Clamp(body?.Zoom)); + + return await InvokeAsync(ctx, "step", () => + new PtzController(target!.Value.Client, target.Value.Endpoint, target.Value.ProfileToken) + .StepAsync(step, Speed(body?.Speed), ct)); + }); + + app.MapPost("/api/v1/cameras/{id}/ptz/home", async ( + string id, PtzMoveRequest? body, HttpContext ctx, CancellationToken ct) => + { + var (target, error) = await TryResolveAsync(ctx, id, ct); + if (error is not null) + return error; + + return await InvokeAsync(ctx, "home", () => + target!.Value.Client.GotoHomeAsync( + target.Value.Endpoint, target.Value.ProfileToken, Speed(body?.Speed), ct)); + }); + + // What the camera can do, so the browser hides the buttons it would only + // fail with — the same question the desktop head asks once per camera. + app.MapGet("/api/v1/cameras/{id}/ptz/capabilities", async ( + string id, HttpContext ctx, CancellationToken ct) => + { + var (target, error) = await TryResolveAsync(ctx, id, ct); + if (error is not null) + return error; + + try + { + var caps = await target!.Value.Client.GetPtzCapabilitiesAsync( + target.Value.Endpoint, target.Value.ProfileToken, ct); + return Results.Json(new + { + relative = caps.SupportsRelative, + absolute = caps.SupportsAbsolute, + home = caps.SupportsHome, + fieldOfView = caps.RelativeIsFieldOfView, + }); + } + catch (Exception) + { + // Continuous-only is the safe answer, and the one every PTZ + // camera can honour. + return Results.Json(new { relative = false, absolute = false, home = false, fieldOfView = false }); + } + }); + app.MapGet("/api/v1/cameras/{id}/ptz/presets", async (string id, HttpContext ctx, CancellationToken ct) => { var (target, error) = await TryResolveAsync(ctx, id, ct); @@ -187,6 +246,11 @@ private static async Task InvokeAsync(HttpContext ctx, string operation private static IResult PtzUnavailable() => Results.Json(new { error = "ptz_unavailable" }, statusCode: StatusCodes.Status409Conflict); + // Steps and home carry a speed of their own; a continuous move carries its + // speed inside the velocity instead. + private static float Speed(float? requested) => + requested is { } s && !float.IsNaN(s) ? Math.Clamp(s, 0.1f, 1f) : 0.6f; + private static float Clamp(float? value) => value is not { } v || float.IsNaN(v) ? 0f : Math.Clamp(v, -1f, 1f); @@ -201,6 +265,6 @@ private static TimeSpan ResolveTimeout(int? milliseconds) // Axes are ONVIF-normalized [-1, 1]; TimeoutMs is the self-stop window the camera // applies when the next refresh doesn't arrive. -internal sealed record PtzMoveRequest(float? PanX, float? TiltY, float? Zoom, int? TimeoutMs); +internal sealed record PtzMoveRequest(float? PanX, float? TiltY, float? Zoom, int? TimeoutMs, float? Speed); internal sealed record PtzPresetRequest(string? Name); diff --git a/tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs new file mode 100644 index 0000000..3d0a728 --- /dev/null +++ b/tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs @@ -0,0 +1,55 @@ +using OpenIPC.Viewer.Core.Onvif; + +namespace OpenIPC.Viewer.Core.Tests.Onvif; + +// Preset names arrive mangled from cameras that store UTF-8 but label the +// response Latin-1. The repair has to be certain in both directions: recover a +// mangled name, and never touch one that was already right. +// +// The mangled forms are written as escapes rather than pasted: several of the +// bytes involved land in the C1 control range and would not survive a copy +// through a terminal or an editor. +public sealed class OnvifTextTests +{ + private const string Entrance = "\u0412\u0445\u043E\u0434"; // Вход + private const string EntranceViaLatin1 = "\u00D0\u0092\u00D1\u0085\u00D0\u00BE\u00D0\u00B4"; + private const string Gate = "\u0412\u043E\u0440\u043E\u0442\u0430"; // Ворота + private const string GateViaCp1252 = "\u00D0\u2019\u00D0\u00BE\u00D1\u20AC\u00D0\u00BE\u00D1\u201A\u00D0\u00B0"; + private const string Cjk = "\u5165\u53E3"; + + [Fact] + public void AnAsciiNameIsUntouched() => + Assert.Equal("Gate 1", OnvifText.RepairMojibake("Gate 1")); + + [Fact] + public void EmptyInputIsReturnedAsIs() => + Assert.Equal("", OnvifText.RepairMojibake("")); + + // UTF-8 bytes read back as Latin-1 — the common case. + [Fact] + public void ALatin1MisreadIsRecovered() => + Assert.Equal(Entrance, OnvifText.RepairMojibake(EntranceViaLatin1)); + + // The same damage through Windows-1252, which differs from Latin-1 only in + // 0x80-0x9F and is what a good number of firmwares actually use. This name + // encodes to three bytes in that band, so handling Latin-1 alone leaves it + // unrepaired. + [Fact] + public void ACp1252MisreadIsAlsoRecovered() => + Assert.Equal(Gate, OnvifText.RepairMojibake(GateViaCp1252)); + + // A name that is already correct must survive. Read back as bytes it is not + // valid UTF-8, which is how the repair knows to stand down. + [Fact] + public void AProperlyDecodedNameIsNotMangledFurther() + { + Assert.Equal(Entrance, OnvifText.RepairMojibake(Entrance)); + Assert.Equal(Gate, OnvifText.RepairMojibake(Gate)); + } + + // Characters outside what either decoder can emit cannot have come from + // one, so the name is left alone rather than guessed at. + [Fact] + public void TextThatCouldNotHaveComeFromEitherDecoderIsLeftAlone() => + Assert.Equal(Cjk, OnvifText.RepairMojibake(Cjk)); +} diff --git a/tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs new file mode 100644 index 0000000..ec055e3 --- /dev/null +++ b/tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs @@ -0,0 +1,107 @@ +using OpenIPC.Viewer.Core.Onvif; + +namespace OpenIPC.Viewer.Core.Tests.Onvif; + +// Mapping the UI's normalized input onto whatever range a camera declares. +// Getting this wrong does not throw: the move is clamped, refused, or lands +// somewhere else, and on an asymmetric range an axis the user never touched +// drifts on every step. So the arithmetic is pinned here. +public sealed class PtzRangeTests +{ + [Fact] + public void ASymmetricRangeKeepsTheCentreAtZero() + { + var range = new PtzRange(-1f, 1f); + + Assert.Equal(0f, range.FromNormalized(0f), 5); + Assert.Equal(1f, range.FromNormalized(1f), 5); + Assert.Equal(-1f, range.FromNormalized(-1f), 5); + } + + // Degrees, which is what a good number of domes report. + [Fact] + public void ADegreeRangeScalesTheWholeWay() + { + var range = new PtzRange(-180f, 180f); + + Assert.Equal(180f, range.FromNormalized(1f), 3); + Assert.Equal(-90f, range.FromNormalized(-0.5f), 3); + Assert.Equal(0f, range.FromNormalized(0f), 3); + } + + // The case that makes a camera creep: on [0, 100] the centre is 50, not 0. + // Callers subtract the midpoint to turn this back into a translation, and + // that only works if the midpoint is where the mapping puts zero. + [Fact] + public void AnAsymmetricRangePutsZeroAtItsMidpoint() + { + var range = new PtzRange(0f, 100f); + + Assert.Equal(50f, range.FromNormalized(0f), 3); + Assert.Equal(100f, range.FromNormalized(1f), 3); + Assert.Equal(0f, range.FromNormalized(-1f), 3); + } + + [Theory] + [InlineData(2f, 1f)] + [InlineData(-2f, -1f)] + [InlineData(99f, 1f)] + public void InputBeyondTheUnitIntervalIsClampedBeforeScaling(float input, float expected) => + Assert.Equal(expected, new PtzRange(-1f, 1f).FromNormalized(input), 5); + + // Absolute zoom is [0, 1] at the UI, not [-1, 1]. + [Fact] + public void UnitInputMapsOntoTheRangeFromItsFloor() + { + var range = new PtzRange(0f, 16f); + + Assert.Equal(0f, range.FromUnit(0f), 3); + Assert.Equal(8f, range.FromUnit(0.5f), 3); + Assert.Equal(16f, range.FromUnit(1f), 3); + } + + [Fact] + public void ToUnitIsTheInverseOfFromUnit() + { + var range = new PtzRange(1f, 32f); + + Assert.Equal(0.25f, range.ToUnit(range.FromUnit(0.25f)), 4); + Assert.Equal(0f, range.ToUnit(range.Min), 4); + Assert.Equal(1f, range.ToUnit(range.Max), 4); + } + + [Fact] + public void APositionOutsideTheRangeStillReadsAsZeroToOne() + { + var range = new PtzRange(0f, 10f); + + Assert.Equal(0f, range.ToUnit(-5f), 4); + Assert.Equal(1f, range.ToUnit(50f), 4); + } + + // A camera that reports Min == Max, or reports them backwards, has said + // nothing usable. Scaling by it would collapse every move onto one value, + // so the value passes through untouched instead. + [Theory] + [InlineData(0f, 0f)] + [InlineData(1f, -1f)] + public void ARangeThatSaysNothingLeavesTheValueAlone(float min, float max) + { + var range = new PtzRange(min, max); + + Assert.False(range.IsValid); + Assert.Equal(0.5f, range.FromNormalized(0.5f), 5); + Assert.Equal(0.5f, range.FromUnit(0.5f), 5); + Assert.Equal(0.5f, range.Clamp(0.5f), 5); + } + + [Fact] + public void ClampKeepsAValueInsideTheDeclaredRange() + { + var range = new PtzRange(-0.5f, 0.5f); + + Assert.Equal(0.5f, range.Clamp(2f), 5); + Assert.Equal(-0.5f, range.Clamp(-2f), 5); + Assert.Equal(0.1f, range.Clamp(0.1f), 5); + } +} From 5d0dcc3b825fc00e2cafb45c5fc4a4cb438663f0 Mon Sep 17 00:00:00 2001 From: iBinh Date: Tue, 25 Aug 2026 16:07:14 +0700 Subject: [PATCH 04/10] =?UTF-8?q?fix(ptz):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20per-axis=20capabilities,=20in-range=20translations,=20home?= =?UTF-8?q?=20from=20the=20node,=20raceless=20web=20tap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../ViewModels/SingleCameraPageViewModel.cs | 12 ++ .../Views/Pages/SingleCameraPage.axaml | 6 +- .../Onvif/PtzCapabilities.cs | 26 ++- .../Onvif/PtzController.cs | 58 ++++--- src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs | 15 +- src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs | 9 +- .../Onvif/SoapOnvifClient.cs | 59 ++++++- src/OpenIPC.Viewer.Web.Client/src/api.ts | 6 +- .../src/components/PtzPad.tsx | 67 ++++++-- src/OpenIPC.Viewer.Web/Api/PtzApi.cs | 44 ++++- .../Onvif/PtzControllerStepTests.cs | 152 ++++++++++++++++++ .../Onvif/PtzRangeTests.cs | 37 +++-- .../Onvif/PtzCapabilityProbeTests.cs | 134 +++++++++++++++ 13 files changed, 540 insertions(+), 85 deletions(-) create mode 100644 tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzControllerStepTests.cs create mode 100644 tests/OpenIPC.Viewer.Devices.Tests/Onvif/PtzCapabilityProbeTests.cs diff --git a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs index d669ad9..0e85e4a 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -139,6 +139,9 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis // rather than failing when pressed. [ObservableProperty] [NotifyPropertyChangedFor(nameof(SupportsHome))] + [NotifyPropertyChangedFor(nameof(SupportsSetHome))] + [NotifyPropertyChangedFor(nameof(CanStepPanTilt))] + [NotifyPropertyChangedFor(nameof(CanStepZoom))] private PtzCapabilities? _ptzCapabilities; // Move speed for steps, presets and home — the same 0..1 the joystick uses. @@ -147,6 +150,15 @@ public sealed partial class SingleCameraPageViewModel : ViewModelBase, IAsyncDis public bool SupportsHome => PtzCapabilities?.SupportsHome ?? false; + public bool SupportsSetHome => PtzCapabilities?.SupportsSetHome ?? false; + + // Until the capabilities have loaded the keys stay visible — that is the + // pre-capability behaviour, and hiding them for the split second of the + // probe would make the panel flicker. + public bool CanStepPanTilt => PtzCapabilities is not { } c || c.CanStepPanTilt; + + public bool CanStepZoom => PtzCapabilities is not { } c || c.CanStepZoom; + // How far one press of an arrow moves, normalized. On a camera that reports // its relative space in field-of-view units this is a sixth of the frame — // small enough to frame a doorway, large enough that a press feels like it diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml index dc4028f..1b0984a 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml @@ -163,7 +163,7 @@ every NVR keypad puts it. --> - + @@ -189,13 +223,16 @@ export function PtzPad({ cameraId }: { cameraId: string }) { + )}
+ {showZoom && (
{t('Ptz.Zoom')}
+ )}
)} + {caps?.home && ( + + )} +