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..784d769 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -447,6 +447,14 @@ private static LangCode DetectSystem() ["CameraPage.NightMode.Auto"] = "Auto", ["CameraPage.Preset.Placeholder"] = "preset name", ["CameraPage.Preset.Save"] = "+ Save", + ["Ptz.Home"] = "Go to home position", + ["Ptz.Stop"] = "Stop", + ["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 +938,14 @@ private static LangCode DetectSystem() ["CameraPage.NightMode.Auto"] = "Авто", ["CameraPage.Preset.Placeholder"] = "название пресета", ["CameraPage.Preset.Save"] = "+ Сохранить", + ["Ptz.Home"] = "Перейти в домашнюю позицию", + ["Ptz.Stop"] = "Стоп", + ["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..477e1a2 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs @@ -133,6 +133,37 @@ 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))] + [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. + // 0.6 is brisk without overshooting on a fast dome. + [ObservableProperty] private double _ptzSpeed = 0.6; + + 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 + // did something. + private const float PtzStepSize = 0.16f; [ObservableProperty] private string _newPresetName = ""; // Majestic state. IsMajestic gates the whole config panel; MajesticConfig @@ -575,6 +606,90 @@ private async Task ToggleRecordingAsync() [RelayCommand] private void TogglePtzOverlay() => IsPtzOverlayVisible = !IsPtzOverlayVisible; + // The keypad's middle key: stops whatever is moving, joystick sweep or a + // step whose camera ignored its timeout. + [RelayCommand] + private async Task StopPtzAsync() + { + if (Ptz is null) return; + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await Ptz.StopAsync(cts.Token).ConfigureAwait(true); + } + + // 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,9 +1306,30 @@ 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!); + // The capability probe is several sequential SOAP calls; a camera that + // half-answers must not stall the presets (or the Majestic setup queued + // behind this), so it runs in the background. Until it lands the pad + // shows every key, as it did before capabilities existed. + _ = LoadPtzCapabilitiesAsync(Ptz, ct); await ReloadPresetsAsync(ct).ConfigureAwait(true); } + // Best-effort: a camera that cannot describe itself gets the continuous-only + // profile. The only thing that throws is cancellation — leaving the page. + private async Task LoadPtzCapabilitiesAsync(PtzController ptz, CancellationToken ct) + { + try + { + var caps = await ptz.GetCapabilitiesAsync(ct).ConfigureAwait(true); + if (ReferenceEquals(Ptz, ptz)) + PtzCapabilities = caps; + } + catch (OperationCanceledException) + { + // Page deactivated mid-probe; the next activation asks again. + } + } + private async Task ReloadPresetsAsync(CancellationToken ct) { if (Ptz is null) return; diff --git a/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml index ee4ebb2..280ef14 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"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs b/src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs index 9e089e8..a7f6f8c 100644 --- a/src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs +++ b/src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs @@ -22,4 +22,23 @@ public interface IOnvifClient Task GotoPresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct); Task SetPresetAsync(OnvifEndpoint endpoint, string profileToken, string name, CancellationToken ct); Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct); + + // Read once per camera: which move modes this node implements and the + // ranges it declares. Sending a value outside a declared range is the usual + // reason a move is silently clamped or refused. + Task GetPtzCapabilitiesAsync( + OnvifEndpoint endpoint, string profileToken, CancellationToken ct); + + // A step, in normalized [-1, 1] per axis, relative to where the camera is + // now — what the arrow buttons send. + Task RelativeMoveAsync( + OnvifEndpoint endpoint, string profileToken, PtzVelocity step, float speed, CancellationToken ct); + + // Where the camera is pointing, and whether it is still moving. + Task GetPtzStatusAsync( + OnvifEndpoint endpoint, string profileToken, CancellationToken ct); + + Task GotoHomeAsync(OnvifEndpoint endpoint, string profileToken, float speed, CancellationToken ct); + + Task SetHomeAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct); } diff --git a/src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs b/src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs new file mode 100644 index 0000000..4d57e30 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs @@ -0,0 +1,83 @@ +using System; +using System.Text; + +namespace OpenIPC.Viewer.Core.Onvif; + +// Repairs preset names that come back as mojibake. +// +// Cameras routinely store preset names as UTF-8 bytes but declare the SOAP +// response as Latin-1, so a name like "Вход" arrives as "Ð'ход". The bytes are +// intact — only the label was wrong — so recovering the byte behind each +// character and decoding it as UTF-8 gives the original back. +// +// The repair runs only on evidence, in three tiers. A decoded result that +// leaves Latin-1 (Cyrillic, Greek, the tonal half of Vietnamese) is proof +// outright. One that stays inside Latin-1 but contains a *letter* — "Entrée" +// decoding to "Entrée", "Sân" to "Sân" — is proof too, because the mangled +// form ("Ã" chased by a currency sign) is not plausible as intentional text. +// Only a result made purely of Latin-1 symbols is ambiguous: "©" decodes to +// "©" and both readings are plausible, so the original stands — rewriting a +// name the camera sent correctly is worse than leaving one broken. +public static class OnvifText +{ + public static string RepairMojibake(string value) + { + if (string.IsNullOrEmpty(value)) return value; + + // Two decoders produce this damage in the wild: Latin-1, where every + // byte maps straight to the same code point, and Windows-1252, which + // differs from it only in 0x80–0x9F — the range it renders as + // typographic characters (• – " ™ …). Handling Latin-1 alone repairs + // barely half the fleet. + var bytes = new byte[value.Length]; + for (var i = 0; i < value.Length; i++) + { + var c = value[i]; + if (c <= 0xFF) { bytes[i] = (byte)c; continue; } + var cp1252 = Cp1252Byte(c); + if (cp1252 is null) return value; // never went through either decoder + bytes[i] = cp1252.Value; + } + + string decoded; + try + { + decoded = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true) + .GetString(bytes); + } + catch (ArgumentException) + { + // Not valid UTF-8 underneath — the name really was Latin-1 text. + return value; + } + + // A replacement char means the guess was wrong outright. + if (decoded.Contains('�')) return value; + + foreach (var c in decoded) + { + // Outside Latin-1: proof. A Latin-1 letter (0xC0–0xFF): also + // proof — its mangled form is a pair no one types on purpose. + // Symbols (©, °, ±, and the × ÷ that sit among the letters) + // decide nothing. + if (c > '\u00FF') return decoded; + if (c >= '\u00C0' && char.IsLetter(c)) return decoded; + } + + return value; + } + + // The 0x80–0x9F block of Windows-1252, the only place it differs from + // Latin-1. Everything else in that decoder maps identically. + private static byte? Cp1252Byte(char c) => c switch + { + '€' => 0x80, '‚' => 0x82, 'ƒ' => 0x83, '„' => 0x84, + '…' => 0x85, '†' => 0x86, '‡' => 0x87, 'ˆ' => 0x88, + '‰' => 0x89, 'Š' => 0x8A, '‹' => 0x8B, 'Œ' => 0x8C, + 'Ž' => 0x8E, '‘' => 0x91, '’' => 0x92, '“' => 0x93, + '”' => 0x94, '•' => 0x95, '–' => 0x96, '—' => 0x97, + '˜' => 0x98, '™' => 0x99, 'š' => 0x9A, '›' => 0x9B, + 'œ' => 0x9C, 'ž' => 0x9E, 'Ÿ' => 0x9F, + _ => null, + }; +} diff --git a/src/OpenIPC.Viewer.Core/Onvif/PtzCapabilities.cs b/src/OpenIPC.Viewer.Core/Onvif/PtzCapabilities.cs new file mode 100644 index 0000000..2e9c0b8 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Onvif/PtzCapabilities.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; + +namespace OpenIPC.Viewer.Core.Onvif; + +// What a specific camera's PTZ node can actually do, read once from +// GetConfigurationOptions and GetServiceCapabilities. +// +// PTZ devices differ far more than the spec suggests: some implement only +// continuous velocity moves, some only relative steps; ranges are per-device; +// MoveStatus is optional and several popular brands report it but never update +// it. The UI asks this record what to show rather than offering buttons that +// quietly do nothing. +public sealed record PtzCapabilities( + // Continuous and relative support, per axis pair: the spaces are declared + // separately, and plenty of cameras have one without the other — a + // pan/tilt-only dome, or a zoom-only box camera. + bool SupportsContinuousPanTilt, + bool SupportsContinuousZoom, + bool SupportsRelativePanTilt, + bool SupportsRelativeZoom, + bool SupportsAbsolute, + // Home, as the PTZ node itself reports it (GetNode/HomeSupported) — never + // assumed. SetHome additionally requires the home position not to be + // fixed-by-hardware. + bool SupportsHome, + bool SupportsSetHome, + // GetStatus returns a MoveStatus this camera actually maintains. When + // false, "has the move finished" can only be answered by waiting. + bool SupportsMoveStatus, + // Relative pan/tilt expressed as a fraction of the current field of view + // (TranslationSpaceFov). This is what makes a fixed step feel the same at + // wide and at full zoom — without it a step is in device units and gets + // wilder the further in you are. + bool RelativeIsFieldOfView, + PtzRange RelativePan, + PtzRange RelativeTilt, + PtzRange RelativeZoom, + PtzRange AbsoluteZoom, + IReadOnlyList AuxiliaryCommands) +{ + // What a camera that answered nothing useful gets: continuous only, the one + // operation nearly every PTZ device implements, and what this app assumed + // of every camera before it started asking. + public static PtzCapabilities ContinuousOnly { get; } = new( + SupportsContinuousPanTilt: true, + SupportsContinuousZoom: true, + SupportsRelativePanTilt: false, + SupportsRelativeZoom: false, + SupportsAbsolute: false, + SupportsHome: false, + SupportsSetHome: false, + SupportsMoveStatus: false, + RelativeIsFieldOfView: false, + RelativePan: PtzRange.Normalized, + RelativeTilt: PtzRange.Normalized, + RelativeZoom: PtzRange.Normalized, + AbsoluteZoom: PtzRange.Normalized, + AuxiliaryCommands: Array.Empty()); + + // Whether a step on the axis pair can be served at all — by RelativeMove, + // or by the timed continuous fallback. What the UI gates its keys on. + public bool CanStepPanTilt => SupportsRelativePanTilt || SupportsContinuousPanTilt; + + public bool CanStepZoom => SupportsRelativeZoom || SupportsContinuousZoom; +} diff --git a/src/OpenIPC.Viewer.Core/Onvif/PtzController.cs b/src/OpenIPC.Viewer.Core/Onvif/PtzController.cs index 30041c7..fc90993 100644 --- a/src/OpenIPC.Viewer.Core/Onvif/PtzController.cs +++ b/src/OpenIPC.Viewer.Core/Onvif/PtzController.cs @@ -15,6 +15,11 @@ public sealed class PtzController : IAsyncDisposable private static readonly TimeSpan MoveTick = TimeSpan.FromMilliseconds(80); private static readonly TimeSpan MoveTimeout = TimeSpan.FromMilliseconds(160); + // How long a timed continuous move stands in for one step on a camera with + // no RelativeMove. Long enough to see, short enough that a lost stop costs + // nothing. + private static readonly TimeSpan StepFallbackDuration = TimeSpan.FromMilliseconds(350); + private readonly IOnvifClient _client; private readonly OnvifEndpoint _endpoint; private readonly string _profileToken; @@ -23,12 +28,18 @@ public sealed class PtzController : IAsyncDisposable private PtzVelocity _current = PtzVelocity.Zero; private CancellationTokenSource? _pumpCts; private Task? _pumpTask; + private PtzCapabilities? _capabilities; - public PtzController(IOnvifClient client, OnvifEndpoint endpoint, string profileToken) + // knownCapabilities lets a caller that already holds the answer (the web + // API caches it per camera) skip the discovery round trips; null means + // fetch on first need. + public PtzController(IOnvifClient client, OnvifEndpoint endpoint, string profileToken, + PtzCapabilities? knownCapabilities = null) { _client = client; _endpoint = endpoint; _profileToken = profileToken; + _capabilities = knownCapabilities; } // Called by the joystick on PointerMoved while captured. The velocity must @@ -72,6 +83,94 @@ public async Task StopAsync(CancellationToken ct) catch { /* camera may already be idle */ } } + // Read once and kept: the answer is a property of the camera, and the UI + // asks it on every render to decide which buttons exist. A camera that + // cannot answer gets the continuous-only profile; a cancelled probe throws + // and caches nothing, so it cannot strip controls from later calls. + public async Task GetCapabilitiesAsync(CancellationToken ct) + { + if (_capabilities is { } cached) return cached; + + PtzCapabilities caps; + try + { + caps = await _client.GetPtzCapabilitiesAsync(_endpoint, _profileToken, ct).ConfigureAwait(false); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + caps = PtzCapabilities.ContinuousOnly; + } + + _capabilities = caps; + return caps; + } + + // One nudge, in normalized [-1, 1] per axis. + // + // RelativeMove is the right operation for a step: a single request the + // camera executes and finishes, where a continuous move has to be started + // and stopped and leaves the camera drifting if the stop is lost. Each axis + // pair is decided on its own — pan/tilt and zoom are declared as separate + // spaces and plenty of cameras have one without the other. An axis with no + // relative space falls back to a short timed continuous move, but only + // where a continuous space is declared; an axis the camera can serve + // neither way is dropped rather than sent an operation that must fault. + public async Task StepAsync(PtzVelocity step, float speed, CancellationToken ct) + { + var caps = await GetCapabilitiesAsync(ct).ConfigureAwait(false); + + var wantsPanTilt = step.PanX != 0f || step.TiltY != 0f; + var wantsZoom = step.Zoom != 0f; + + var relativePanTilt = wantsPanTilt && caps.SupportsRelativePanTilt; + var relativeZoom = wantsZoom && caps.SupportsRelativeZoom; + if (relativePanTilt || relativeZoom) + { + // Translations scaled into each axis's declared range: zero stays + // zero (an untouched axis must not move), and a range like [0, 100] + // clamps rather than being sent a value below its own minimum. + var scaled = new PtzVelocity( + relativePanTilt ? caps.RelativePan.ScaleTranslation(step.PanX) : 0f, + relativePanTilt ? caps.RelativeTilt.ScaleTranslation(step.TiltY) : 0f, + relativeZoom ? caps.RelativeZoom.ScaleTranslation(step.Zoom) : 0f); + await _client.RelativeMoveAsync(_endpoint, _profileToken, scaled, speed, ct).ConfigureAwait(false); + } + + var fallbackPanTilt = wantsPanTilt && !relativePanTilt && caps.SupportsContinuousPanTilt; + var fallbackZoom = wantsZoom && !relativeZoom && caps.SupportsContinuousZoom; + if (fallbackPanTilt || fallbackZoom) + { + // The timeout is the step length, so the camera stops itself even + // if the app dies mid-move. + await _client.ContinuousMoveAsync( + _endpoint, _profileToken, + new PtzVelocity( + fallbackPanTilt ? step.PanX * speed : 0f, + fallbackPanTilt ? step.TiltY * speed : 0f, + fallbackZoom ? step.Zoom * speed : 0f), + StepFallbackDuration, ct).ConfigureAwait(false); + + // And an explicit Stop once the step is up: plenty of firmware + // ignores Timeout on ContinuousMove, and cameras without RelativeMove + // are the ones most likely to have the sloppier stack. + try { await Task.Delay(StepFallbackDuration, ct).ConfigureAwait(false); } + finally + { + try { await _client.StopPtzAsync(_endpoint, _profileToken, CancellationToken.None).ConfigureAwait(false); } + catch { /* camera may already be idle — some firmwares drop the socket on Stop */ } + } + } + } + + public Task GoHomeAsync(float speed, CancellationToken ct) => + _client.GotoHomeAsync(_endpoint, _profileToken, speed, ct); + + public Task SetHomeAsync(CancellationToken ct) => + _client.SetHomeAsync(_endpoint, _profileToken, ct); + + public Task GetStatusAsync(CancellationToken ct) => + _client.GetPtzStatusAsync(_endpoint, _profileToken, ct); + public Task> GetPresetsAsync(CancellationToken ct) => _client.GetPresetsAsync(_endpoint, _profileToken, ct); diff --git a/src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs b/src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs new file mode 100644 index 0000000..fe8f2a2 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Onvif/PtzRange.cs @@ -0,0 +1,47 @@ +namespace OpenIPC.Viewer.Core.Onvif; + +// One axis of a camera's PTZ coordinate space, as reported by +// GetConfigurationOptions. +// +// The UI thinks in normalized [-1, 1] (or [0, 1] for absolute zoom); the camera +// thinks in whatever range it declares — commonly [-1, 1], but plenty of +// devices report degrees, [0, 360], or something asymmetric. Sending a value +// outside the declared range is the classic way to have a move silently clamped +// or refused, so every value crossing the wire goes through here. +public readonly record struct PtzRange(float Min, float Max) +{ + public static readonly PtzRange Normalized = new(-1f, 1f); + + public bool IsValid => Max > Min; + + // A normalized [-1, 1] translation scaled onto this range's span: zero stays + // zero — a step that never touched an axis must not move it — and the result + // is clamped into the declared bounds, so a range like [0, 100] simply + // refuses to go negative rather than being sent a value below its minimum. + public float ScaleTranslation(float value) + { + var clamped = value < -1f ? -1f : value > 1f ? 1f : value; + if (!IsValid) return clamped; + return Clamp(clamped * (Max - Min) / 2f); + } + + // Maps normalized [0, 1] onto this range — absolute zoom, where 0 is fully + // wide and 1 fully tele. + public float FromUnit(float value) + { + if (!IsValid) return value; + var clamped = value < 0f ? 0f : value > 1f ? 1f : value; + return Min + clamped * (Max - Min); + } + + // The inverse, for reporting a camera position back to the UI. + public float ToUnit(float value) + { + if (!IsValid) return value; + var unit = (value - Min) / (Max - Min); + return unit < 0f ? 0f : unit > 1f ? 1f : unit; + } + + public float Clamp(float value) => + !IsValid ? value : value < Min ? Min : value > Max ? Max : value; +} diff --git a/src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs b/src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs new file mode 100644 index 0000000..4be6cf9 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Onvif/PtzStatus.cs @@ -0,0 +1,31 @@ +using System; + +namespace OpenIPC.Viewer.Core.Onvif; + +public enum PtzMoveState +{ + // The camera does not report a usable MoveStatus — it is optional in the + // spec, and several brands return a value that never changes. + Unknown = 0, + Idle, + Moving, +} + +// A camera's answer to GetStatus: where it is pointing, and whether it is still +// on its way there. +// +// Positions are in the camera's own declared units — degrees, [-1, 1], or +// whatever its absolute spaces say — exactly as the response carried them. A +// consumer that wants them normalized maps them through the ranges the +// capability probe read (PtzRange.ToUnit); the client does not, because it +// would have to re-fetch those ranges on every poll to do it. +public sealed record PtzStatus( + float Pan, + float Tilt, + float Zoom, + PtzMoveState PanTilt, + PtzMoveState ZoomState, + DateTime? UtcTime) +{ + public bool IsMoving => PanTilt == PtzMoveState.Moving || ZoomState == PtzMoveState.Moving; +} diff --git a/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs b/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs index 77310c1..3d92889 100644 --- a/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs +++ b/src/OpenIPC.Viewer.Devices/Onvif/OnvifCoreClient.cs @@ -222,4 +222,26 @@ private void CloseQuietly(ICommunicationObject co) try { co.Abort(); } catch { /* swallow */ } } } + + // The PTZ operations added for capability-driven stepping are implemented + // only by SoapOnvifClient, which is what DI resolves; this WCF path is the + // superseded one kept for reference. Throwing beats a silent no-op, which + // would look like a camera that ignores its buttons. + public Task GetPtzCapabilitiesAsync( + OnvifEndpoint endpoint, string profileToken, CancellationToken ct) => + throw new NotSupportedException("PTZ capabilities require SoapOnvifClient."); + + public Task RelativeMoveAsync( + OnvifEndpoint endpoint, string profileToken, PtzVelocity step, float speed, CancellationToken ct) => + throw new NotSupportedException("RelativeMove requires SoapOnvifClient."); + + public Task GetPtzStatusAsync( + OnvifEndpoint endpoint, string profileToken, CancellationToken ct) => + throw new NotSupportedException("GetStatus requires SoapOnvifClient."); + + public Task GotoHomeAsync(OnvifEndpoint endpoint, string profileToken, float speed, CancellationToken ct) => + throw new NotSupportedException("GotoHomePosition requires SoapOnvifClient."); + + public Task SetHomeAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) => + throw new NotSupportedException("SetHomePosition requires SoapOnvifClient."); } diff --git a/src/OpenIPC.Viewer.Devices/Onvif/OnvifServiceCandidates.cs b/src/OpenIPC.Viewer.Devices/Onvif/OnvifServiceCandidates.cs new file mode 100644 index 0000000..6c42b84 --- /dev/null +++ b/src/OpenIPC.Viewer.Devices/Onvif/OnvifServiceCandidates.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace OpenIPC.Viewer.Devices.Onvif; + +/// +/// Where a camera's Media or PTZ service might actually live, most trusted first. +/// GetCapabilities is supposed to say, but firmwares get it wrong: YooSee-family +/// cameras (A'Gold CAM-10, issue #67) shift every XAddr one section down, so +/// Media points at ptz_service and PTZ at deviceio_service; others advertise an +/// address the camera no longer has. Nothing here is trusted on its own — +/// proves each candidate with a read-only call. +/// +public static class OnvifServiceCandidates +{ + public static IReadOnlyList Media(Uri deviceService, Uri? advertised, IEnumerable allAdvertised) => + Build(deviceService, advertised, allAdvertised, "media", "/onvif/media_service", "/onvif/Media"); + + public static IReadOnlyList Ptz(Uri deviceService, Uri? advertised, IEnumerable allAdvertised) => + Build(deviceService, advertised, allAdvertised, "ptz", "/onvif/ptz_service", "/onvif/PTZ"); + + // 1. the XAddr advertised for the service, as given; + // 2. the same path at the address we actually reach the camera on; + // 3. any other advertised XAddr whose path names the service (a shifted + // table still lists the right path, just under the wrong section); + // 4. the conventional paths — gSOAP/Dahua-style first, then Hikvision-style; + // 5. the device service itself, which some servers answer everything on. + private static IReadOnlyList Build(Uri deviceService, Uri? advertised, IEnumerable allAdvertised, + string pathHint, params string[] conventionalPaths) + { + var list = new List(); + void Add(Uri uri) + { + if (!list.Contains(uri)) + list.Add(uri); + } + + if (advertised is not null) + { + Add(advertised); + Add(OnDevice(deviceService, advertised.AbsolutePath, advertised.Query)); + } + foreach (var uri in allAdvertised) + { + if (uri.AbsolutePath.Contains(pathHint, StringComparison.OrdinalIgnoreCase)) + Add(OnDevice(deviceService, uri.AbsolutePath, uri.Query)); + } + foreach (var path in conventionalPaths) + Add(OnDevice(deviceService, path, string.Empty)); + Add(deviceService); + return list; + } + + private static Uri OnDevice(Uri deviceService, string path, string query) => + new UriBuilder(deviceService) { Path = path, Query = query.TrimStart('?') }.Uri; +} diff --git a/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs b/src/OpenIPC.Viewer.Devices/Onvif/SoapOnvifClient.cs index 1b408f7..d682896 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,34 +57,94 @@ public sealed class SoapOnvifClient : IOnvifClient // first authed call, refreshed on an auth fault. private readonly ConcurrentDictionary _shiftByHost = new(StringComparer.OrdinalIgnoreCase); + // Media/PTZ service URI per device endpoint, cached only once it has + // answered a read-only call of its own service (see ResolveServiceAsync). + private readonly ConcurrentDictionary _serviceByDevice = new(StringComparer.OrdinalIgnoreCase); + + // Addresses (host:port) 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. + // + // 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) { _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}"; + 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 ----------------------------------------------------- public async Task GetCapabilitiesAsync(OnvifEndpoint endpoint, CancellationToken ct) + { + var caps = await GetCapabilitiesElementAsync(endpoint, ct).ConfigureAwait(false); + return new OnvifCapabilities( + MediaServiceUri: TryUri(XAddrOf(caps, "Media")), + PtzServiceUri: TryUri(XAddrOf(caps, "PTZ"))); + } + + private async Task GetCapabilitiesElementAsync(OnvifEndpoint endpoint, CancellationToken ct) { var body = await CallAuthedAsync(endpoint.DeviceServiceUri, endpoint, $"{Tds}/GetCapabilities", $"All", ct).ConfigureAwait(false); - - var caps = Child(body, "Capabilities"); - return new OnvifCapabilities( - MediaServiceUri: TryUri(XAddrOf(caps, "Media")), - PtzServiceUri: TryUri(XAddrOf(caps, "PTZ"))); + return Child(body, "Capabilities"); } public async Task GetDeviceInformationAsync(OnvifEndpoint endpoint, CancellationToken ct) @@ -126,7 +191,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); @@ -172,7 +241,11 @@ public async Task> GetPresetsAsync(OnvifEndpoint endpoi return Children(body, "Preset") .Where(p => !string.IsNullOrEmpty(Attr(p, "token"))) - .Select(p => new PtzPreset(Token: Attr(p, "token"), Name: Value(p, "Name") ?? Attr(p, "token"))) + // Names come back mangled from cameras that store UTF-8 and label + // the response Latin-1; the bytes survive, only the label was wrong. + .Select(p => new PtzPreset( + Token: Attr(p, "token"), + Name: OnvifText.RepairMojibake(Value(p, "Name") ?? Attr(p, "token")))) .ToList(); } @@ -193,8 +266,11 @@ 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; + // 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; } public async Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct) @@ -204,52 +280,348 @@ 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); + } + + // --- PTZ capability, steps and home ------------------------------------ + + public async Task GetPtzCapabilitiesAsync( + OnvifEndpoint endpoint, string profileToken, CancellationToken ct) + { + var ptz = await ResolveServiceAsync(endpoint, ServiceKind.Ptz, ct).ConfigureAwait(false); + + var configToken = await ResolvePtzConfigurationTokenAsync(endpoint, profileToken, ct).ConfigureAwait(false); + if (configToken is null) return PtzCapabilities.ContinuousOnly; + + XElement options; + try + { + var reqBody = + $"" + + $"{Escape(configToken)}" + + ""; + options = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/GetConfigurationOptions", reqBody, ct) + .ConfigureAwait(false); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + // Optional operation. A camera that will not describe itself gets + // the profile this app assumed of every camera before it asked. + // A cancelled probe is not an answer and propagates instead. + return PtzCapabilities.ContinuousOnly; + } + + var spaces = Descendant(options, "Spaces"); + // Cameras often declare both the generic translation space and the FOV + // one; the FOV one is what makes a step feel the same at any zoom. + var relativeSpaces = spaces?.Descendants() + .Where(e => e.Name.LocalName == "RelativePanTiltTranslationSpace").ToList(); + var relativePanTilt = relativeSpaces?.FirstOrDefault(IsFovSpace) ?? relativeSpaces?.FirstOrDefault(); + var relativeZoom = spaces is null ? null : Descendant(spaces, "RelativeZoomTranslationSpace"); + var absoluteZoom = spaces is null ? null : Descendant(spaces, "AbsoluteZoomPositionSpace"); + var continuousPanTilt = spaces is null ? null : Descendant(spaces, "ContinuousPanTiltVelocitySpace"); + var continuousZoom = spaces is null ? null : Descendant(spaces, "ContinuousZoomVelocitySpace"); + + // A space URI ending in TranslationSpaceFov means a step is a fraction + // of the current field of view, so one press covers the same part of + // the picture at any zoom. + var fov = relativePanTilt is not null && IsFovSpace(relativePanTilt); + + // Home is not advertised among the spaces; the node knows. A camera + // that cannot answer gets no home button rather than one that fails. + var (homeSupported, homeFixed) = await ReadHomeSupportAsync(ptz, endpoint, configToken, ct).ConfigureAwait(false); + + return new PtzCapabilities( + SupportsContinuousPanTilt: continuousPanTilt is not null, + SupportsContinuousZoom: continuousZoom is not null, + SupportsRelativePanTilt: relativePanTilt is not null, + SupportsRelativeZoom: relativeZoom is not null, + SupportsAbsolute: absoluteZoom is not null, + SupportsHome: homeSupported, + // A fixed home position exists but cannot be overwritten. + SupportsSetHome: homeSupported && !homeFixed, + SupportsMoveStatus: await SupportsMoveStatusAsync(ptz, endpoint, ct).ConfigureAwait(false), + RelativeIsFieldOfView: fov, + RelativePan: RangeOf(relativePanTilt, "XRange"), + RelativeTilt: RangeOf(relativePanTilt, "YRange"), + RelativeZoom: RangeOf(relativeZoom, "XRange"), + AbsoluteZoom: RangeOf(absoluteZoom, "XRange"), + AuxiliaryCommands: Array.Empty()); + } + + // Whether the node supports home at all, and whether its home position is + // fixed by hardware: GetConfiguration names the node, GetNode describes it. + // Both are reads a camera may refuse; refusing means no home controls, the + // same as before the buttons existed. + private async Task<(bool Supported, bool Fixed)> ReadHomeSupportAsync( + Uri ptz, OnvifEndpoint endpoint, string configToken, CancellationToken ct) + { + try + { + var conf = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/GetConfiguration", + $"" + + $"{Escape(configToken)}" + + "", ct).ConfigureAwait(false); + var nodeToken = Descendant(conf, "NodeToken")?.Value; + if (string.IsNullOrEmpty(nodeToken)) return (false, false); + + var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/GetNode", + $"" + + $"{Escape(nodeToken)}", ct).ConfigureAwait(false); + var node = Descendant(body, "PTZNode"); + if (node is null) return (false, false); + + return (XmlBool(Descendant(node, "HomeSupported")?.Value), + XmlBool(Attr(node, "FixedHomePosition"))); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + return (false, false); + } + } + + private static bool IsFovSpace(XElement space) => + (Value(space, "URI") ?? "").Contains("TranslationSpaceFov", StringComparison.OrdinalIgnoreCase); + + // xs:boolean allows both spellings. + private static bool XmlBool(string? value) => + string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || value == "1"; + + // Axes the caller left at zero are omitted rather than sent as zero: the + // spec reads an absent element as "leave this axis alone", while an + // explicit zero is a command some cameras act on. + public async Task RelativeMoveAsync( + OnvifEndpoint endpoint, string profileToken, PtzVelocity step, float speed, CancellationToken ct) + { + var ptz = await ResolveServiceAsync(endpoint, ServiceKind.Ptz, ct).ConfigureAwait(false); + + var panTilt = step.PanX != 0f || step.TiltY != 0f + ? $"" + : ""; + var zoom = step.Zoom != 0f ? $"" : ""; + if (panTilt.Length == 0 && zoom.Length == 0) return; + + var speedXml = speed > 0f + ? "" + + (panTilt.Length > 0 ? $"" : "") + + (zoom.Length > 0 ? $"" : "") + + "" + : ""; + + var reqBody = + $"" + + $"{Escape(profileToken)}" + + $"{panTilt}{zoom}" + + speedXml + + ""; + // retryable: false — re-sending a translation the camera may already + // have executed is a double step. + await CallAuthedAsync(ptz, endpoint, $"{Tptz}/RelativeMove", reqBody, ct, retryable: false).ConfigureAwait(false); + } + + // Positions come back exactly as the camera reported them, in its own + // declared units — see PtzStatus for why they are not normalized here. + public async Task GetPtzStatusAsync( + OnvifEndpoint endpoint, string profileToken, CancellationToken ct) + { + var ptz = await ResolveServiceAsync(endpoint, ServiceKind.Ptz, ct).ConfigureAwait(false); + var reqBody = + $"" + + $"{Escape(profileToken)}"; + var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/GetStatus", reqBody, ct).ConfigureAwait(false); + + var position = Descendant(body, "Position"); + var panTilt = position is null ? null : Descendant(position, "PanTilt"); + var zoom = position is null ? null : Descendant(position, "Zoom"); + var move = Descendant(body, "MoveStatus"); + + return new PtzStatus( + Pan: ParseFloat(Attr(panTilt, "x")) ?? 0f, + Tilt: ParseFloat(Attr(panTilt, "y")) ?? 0f, + Zoom: ParseFloat(Attr(zoom, "x")) ?? 0f, + PanTilt: MoveStateOf(move, "PanTilt"), + ZoomState: MoveStateOf(move, "Zoom"), + UtcTime: DateTime.TryParse(Descendant(body, "UtcTime")?.Value, out var utc) ? utc : null); + } + + public async Task GotoHomeAsync( + OnvifEndpoint endpoint, string profileToken, float speed, CancellationToken ct) + { + var ptz = await ResolveServiceAsync(endpoint, ServiceKind.Ptz, ct).ConfigureAwait(false); + var speedXml = speed > 0f + ? $"" + : ""; + var reqBody = + $"" + + $"{Escape(profileToken)}{speedXml}" + + ""; + await CallAuthedAsync(ptz, endpoint, $"{Tptz}/GotoHomePosition", reqBody, ct).ConfigureAwait(false); + } + + public async Task SetHomeAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) + { + var ptz = await ResolveServiceAsync(endpoint, ServiceKind.Ptz, ct).ConfigureAwait(false); + var reqBody = + $"" + + $"{Escape(profileToken)}"; + await CallAuthedAsync(ptz, endpoint, $"{Tptz}/SetHomePosition", reqBody, ct).ConfigureAwait(false); } + // MoveStatus is optional, and several firmwares 400 on the call that + // reports whether they keep it. Not knowing is the same as not having it. + private async Task SupportsMoveStatusAsync(Uri ptz, OnvifEndpoint endpoint, CancellationToken ct) + { + try + { + var body = await CallAuthedAsync(ptz, endpoint, $"{Tptz}/GetServiceCapabilities", + $"", ct).ConfigureAwait(false); + var caps = Descendant(body, "Capabilities"); + return caps is not null && XmlBool(Attr(caps, "MoveStatus")); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + return false; + } + } + + private async Task ResolvePtzConfigurationTokenAsync( + OnvifEndpoint endpoint, string profileToken, CancellationToken ct) + { + var profiles = await GetProfilesAsync(endpoint, ct).ConfigureAwait(false); + foreach (var profile in profiles) + if (profile.Token == profileToken) + return profile.PtzConfigurationToken; + return null; + } + + private static PtzRange RangeOf(XElement? space, string axis) + { + if (space is null) return PtzRange.Normalized; + var range = Descendant(space, axis); + if (range is null) return PtzRange.Normalized; + var min = ParseFloat(Value(range, "Min")); + var max = ParseFloat(Value(range, "Max")); + return min is null || max is null ? PtzRange.Normalized : new PtzRange(min.Value, max.Value); + } + + private static PtzMoveState MoveStateOf(XElement? moveStatus, string axis) => + (moveStatus is null ? null : Value(moveStatus, axis))?.ToUpperInvariant() switch + { + "IDLE" => PtzMoveState.Idle, + "MOVING" => PtzMoveState.Moving, + _ => PtzMoveState.Unknown, + }; + + private static float? ParseFloat(string? text) => + float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) ? v : null; + // --- Transport ---------------------------------------------------------- private enum ServiceKind { Media, Ptz } - // Media/PTZ calls go to the XAddr from GetCapabilities; fall back to the - // device service endpoint (onvif_simple_server often serves all at one URI). + // Media/PTZ calls go to the XAddr from GetCapabilities — but only once it has + // proven itself: some firmwares advertise the wrong service there (issue #67), + // so a candidate must answer a read-only call of its own service first. The + // first that does is cached for the endpoint. If none does, fall back to the + // advertised XAddr, else the device endpoint (onvif_simple_server often + // serves all at one URI), and let the real call report the real error. private async Task ResolveServiceAsync(OnvifEndpoint endpoint, ServiceKind kind, CancellationToken ct) { + var key = $"{kind}\u0000{endpoint.DeviceServiceUri}"; + if (_serviceByDevice.TryGetValue(key, out var known)) + return known; + + XElement? caps; try { - var caps = await GetCapabilitiesAsync(endpoint, ct).ConfigureAwait(false); - var uri = kind == ServiceKind.Media ? caps.MediaServiceUri : caps.PtzServiceUri; - if (uri is not null) - return uri; + caps = await GetCapabilitiesElementAsync(endpoint, ct).ConfigureAwait(false); } - catch (Exception ex) + catch (Exception ex) when (!ct.IsCancellationRequested) { _logger.LogDebug(ex, "ONVIF capability lookup failed; using device endpoint for {Kind}", kind); + return endpoint.DeviceServiceUri; + } + + var advertised = TryUri(XAddrOf(caps, kind == ServiceKind.Media ? "Media" : "PTZ")); + var all = caps?.Descendants().Where(e => e.Name.LocalName == "XAddr") + .Select(e => TryUri(e.Value)).OfType().ToList() ?? new List(); + var candidates = kind == ServiceKind.Media + ? OnvifServiceCandidates.Media(endpoint.DeviceServiceUri, advertised, all) + : OnvifServiceCandidates.Ptz(endpoint.DeviceServiceUri, advertised, all); + + foreach (var candidate in candidates) + { + if (!await AnswersAsync(candidate, endpoint, kind, ct).ConfigureAwait(false)) + continue; + if (candidate != advertised) + _logger.LogInformation("ONVIF {Kind} service answers at {Uri}, not the advertised {Advertised}", + kind, candidate, advertised?.ToString() ?? "(none)"); + _serviceByDevice[key] = candidate; + return candidate; + } + + _logger.LogDebug("No ONVIF {Kind} service candidate answered; using {Uri}", + kind, advertised ?? endpoint.DeviceServiceUri); + return advertised ?? endpoint.DeviceServiceUri; + } + + // One cheap read-only call every implementation of the service must support: + // GetProfiles for Media, GetNodes for PTZ. The response element has to match, + // so a different service that happens to reply doesn't pass for this one. + private async Task AnswersAsync(Uri service, OnvifEndpoint endpoint, ServiceKind kind, CancellationToken ct) + { + var (action, body, expected) = kind == ServiceKind.Media + ? ($"{Trt}/GetProfiles", $"", "GetProfilesResponse") + : ($"{Tptz}/GetNodes", $"", "GetNodesResponse"); + + // CallAuthedAsync ran GetCapabilities just before, so the shift is known; + // a plain CallAsync skips its fault retry, which here would only double + // the cost of every wrong candidate. The dialect retry stays on: a + // service can sit on another port than the device service and speak + // another SOAP version, and this read is where that gets learned — so + // a mutation, which never retries, finds it already known. + _shiftByHost.TryGetValue(endpoint.DeviceServiceUri.Host, out var shift); + try + { + var response = await CallAsync(service, action, body, endpoint.Credentials, shift, retryable: true, ct).ConfigureAwait(false); + return response.Name.LocalName == expected; + } + catch (Exception ex) when (!ct.IsCancellationRequested) + { + _logger.LogDebug(ex, "ONVIF {Kind} candidate {Uri} did not answer", kind, service); + return false; } - return endpoint.DeviceServiceUri; } // 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); } } @@ -259,7 +631,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"); @@ -280,30 +652,47 @@ 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) { - 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)) + // 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 address = $"{service.Host}:{service.Port}"; + var soap12First = !_soap11Hosts.ContainsKey(address); + var (status, text) = await SendAsync(service, action, body, credentials, shift, soap12: soap12First, ct) + .ConfigureAwait(false); + + if (!IsUsable(text) && retryable) { - var basic = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{c.Username}:{c.Password}")); - req.Headers.Authorization = new AuthenticationHeaderValue("Basic", basic); + _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[address] = 1; + else _soap11Hosts.TryRemove(address, out _); + } } - 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})"); + // A refused login usually comes back as an HTML error page or nothing, + // not a fault — say so rather than blaming an "empty body". + if (status is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden && !IsUsable(text)) + throw new InvalidOperationException( + $"ONVIF {action}: the camera refused the login (HTTP {(int)status}). Check the username and password."); + if (string.IsNullOrWhiteSpace(text)) throw EmptyBody(action, status); XElement root; try { root = XDocument.Parse(text).Root!; } @@ -311,7 +700,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 +715,72 @@ 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 — except VersionMismatch, which is exactly how a gSOAP + // firmware built for 1.1 rejects a 1.2 envelope when it doesn't stay silent. + private static bool IsUsable(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + XElement? body; + try { body = Child(Child(XDocument.Parse(text).Root!, "Body"), null); } + catch (Exception) { return false; } + if (body is null) return false; + if (body.Name.LocalName != "Fault") return true; + var code = Descendant(body, "faultcode")?.Value ?? Descendant(body, "Value")?.Value ?? string.Empty; + return !code.Contains("VersionMismatch", StringComparison.Ordinal); + } + + // 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/src/OpenIPC.Viewer.Web.Client/src/api.ts b/src/OpenIPC.Viewer.Web.Client/src/api.ts index d047e67..c7fce98 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/api.ts +++ b/src/OpenIPC.Viewer.Web.Client/src/api.ts @@ -72,6 +72,17 @@ export type CalendarPointDto = { startedAt: string; sizeBytes: number } export type GroupDto = { id: number; name: string; sortOrder: number } +// What this camera's PTZ node can do, so the pad hides buttons it would only +// fail with. +export type PtzCapabilitiesDto = { + relativePanTilt: boolean + relativeZoom: boolean + continuousPanTilt: boolean + continuousZoom: boolean + home: boolean + fieldOfView: boolean +} + export type PtzPresetDto = { token: string; name: string } // A snapshot kept in the shared library (same rows the desktop browser reads). @@ -302,6 +313,13 @@ export const api = { // and send stop on release. A camera that never gets the stop halts on its own. ptzMove: (id: string, v: { panX?: number; tiltY?: number; zoom?: number; timeoutMs?: number }) => req('POST', `/api/v1/cameras/${id}/ptz/move`, v), + // One nudge: no hold loop and no stop, the camera runs it to completion. + ptzStep: (id: string, v: { panX?: number; tiltY?: number; zoom?: number; speed?: number }) => + req('POST', `/api/v1/cameras/${id}/ptz/step`, v), + ptzHome: (id: string, speed: number) => + req('POST', `/api/v1/cameras/${id}/ptz/home`, { speed }), + ptzCapabilities: (id: string) => + req('GET', `/api/v1/cameras/${id}/ptz/capabilities`), ptzStop: (id: string) => req('POST', `/api/v1/cameras/${id}/ptz/stop`), ptzPresets: (id: string) => req('GET', `/api/v1/cameras/${id}/ptz/presets`), ptzSavePreset: (id: string, name: string) => diff --git a/src/OpenIPC.Viewer.Web.Client/src/components/Icon.tsx b/src/OpenIPC.Viewer.Web.Client/src/components/Icon.tsx index dda0fd7..64f8961 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/components/Icon.tsx +++ b/src/OpenIPC.Viewer.Web.Client/src/components/Icon.tsx @@ -20,6 +20,8 @@ const PATHS: Record = { > ), play: , + // House: the PTZ home position, in the middle of the pad where a keypad puts it. + home: , stop: , download: , camera: ( diff --git a/src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx b/src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx index c0571fd..710361e 100644 --- a/src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx +++ b/src/OpenIPC.Viewer.Web.Client/src/components/PtzPad.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react' -import { api, type PtzPresetDto } from '../api' +import { api, type PtzCapabilitiesDto, type PtzPresetDto } from '../api' import { useI18n } from '../i18n' import { Icon } from './Icon' @@ -13,6 +13,16 @@ import { Icon } from './Icon' const REFRESH_MS = 500 const MOVE_TIMEOUT_MS = 1200 +// How far one nudge moves, normalized. On a camera that reports its relative +// space in field-of-view units this is a sixth of the frame. +const STEP = 0.16 + +// On a step-capable axis the sweep does not begin until a press has lasted this +// long. A shorter press was never going to sweep anywhere useful, so it lands +// as exactly one step — and because the sweep never started, there is no stop +// racing the step on release. +const TAP_MS = 220 + type Dir = { panX?: number; tiltY?: number; zoom?: number } export function PtzPad({ cameraId }: { cameraId: string }) { @@ -22,12 +32,25 @@ export function PtzPad({ cameraId }: { cameraId: string }) { const [presetName, setPresetName] = useState('') const [busy, setBusy] = useState(false) const [error, setError] = useState(null) + const [caps, setCaps] = useState(null) // Held in refs so the interval callback always reads the live values without // re-subscribing (a re-render mid-drag must not restart the refresh loop). const timer = useRef(null) const speedRef = useRef(speed) speedRef.current = speed + // One press is one of: 'pending' (step-capable, inside the tap window), + // 'sweeping' (continuous move running), 'idle'. + const press = useRef<'idle' | 'pending' | 'sweeping'>('idle') + const holdTimer = useRef(null) + // The pointer that owns the current press. A second finger on another arrow + // is ignored until the first lets go — otherwise its release would stop, + // cancel or step the first finger's movement. + const pressPointer = useRef(null) + // Steps run one after another. On a camera without RelativeMove each one is + // a timed move followed by a Stop, and overlapping them would let an earlier + // tap's Stop cut a later one short. + const stepQueue = useRef>(Promise.resolve()) const loadPresets = useCallback(async () => { try { @@ -51,6 +74,24 @@ export function PtzPad({ cameraId }: { cameraId: string }) { } }, [cameraId, t]) + // One nudge. Held buttons sweep, a tap frames — the same split the desktop + // keypad makes, and the only way to land on a doorway at full zoom. + const step = useCallback( + (dir: Dir) => { + const send = () => + api + .ptzStep(cameraId, { + panX: (dir.panX ?? 0) * STEP, + tiltY: (dir.tiltY ?? 0) * STEP, + zoom: (dir.zoom ?? 0) * STEP, + speed: speedRef.current, + }) + .catch(() => setError(t('Ptz.Error'))) + stepQueue.current = stepQueue.current.then(send) + }, + [cameraId, t], + ) + const start = useCallback( (dir: Dir) => { setError(null) @@ -75,26 +116,81 @@ export function PtzPad({ cameraId }: { cameraId: string }) { [cameraId, stop, t], ) + useEffect(() => { + let cancelled = false + // Back to "not known yet" first: until this camera answers, the previous + // camera's capabilities must not pick step versus sweep for it. + setCaps(null) + api + .ptzCapabilities(cameraId) + .then((c) => { if (!cancelled) setCaps(c) }) + // A camera that will not describe itself gets no Home button. + .catch(() => { if (!cancelled) setCaps(null) }) + return () => { cancelled = true } + }, [cameraId]) + useEffect(() => { void loadPresets() // Leaving the page (or switching camera) while held must not keep panning. return () => { if (timer.current !== null) window.clearInterval(timer.current) + if (holdTimer.current !== null) window.clearTimeout(holdTimer.current) + holdTimer.current = null + press.current = 'idle' + pressPointer.current = null void api.ptzStop(cameraId).catch(() => undefined) } }, [cameraId, loadPresets]) + // A release inside the tap window sends one step; a longer press swept, so + // it sends one stop. The two never race because the sweep does not start + // until the window has passed. + const endPress = (pointerId: number, dir: Dir | null) => { + if (pointerId !== pressPointer.current) return + pressPointer.current = null + if (holdTimer.current !== null) { + window.clearTimeout(holdTimer.current) + holdTimer.current = null + } + const mode = press.current + press.current = 'idle' + if (mode === 'sweeping') void stop() + // dir is null when the pointer was cancelled or captured away — nothing + // moved yet, and a step the user did not release on would be a surprise. + else if (mode === 'pending' && dir) step(dir) + } + // Pointer capture keeps the release event ours even if the finger slides off // the button — otherwise a drag-away would leave the camera moving. const hold = (dir: Dir) => ({ onPointerDown: (e: React.PointerEvent) => { e.preventDefault() + if (pressPointer.current !== null) return + pressPointer.current = e.pointerId e.currentTarget.setPointerCapture(e.pointerId) - start(dir) + const canStep = dir.zoom ? caps?.relativeZoom : caps?.relativePanTilt + // Unknown capabilities keep the old behaviour: every camera could sweep. + const canSweep = !caps || (dir.zoom ? caps.continuousZoom : caps.continuousPanTilt) + if (!canStep) { + // No step on this axis: the hold-to-sweep behaviour, immediately, as + // before capabilities existed. + press.current = 'sweeping' + start(dir) + return + } + press.current = 'pending' + // A relative-only camera cannot sweep, so a long press is still one + // step on release rather than a continuous move it never offered. + if (!canSweep) return + holdTimer.current = window.setTimeout(() => { + press.current = 'sweeping' + holdTimer.current = null + start(dir) + }, TAP_MS) }, - onPointerUp: () => void stop(), - onPointerCancel: () => void stop(), - onLostPointerCapture: () => void stop(), + onPointerUp: (e: React.PointerEvent) => endPress(e.pointerId, dir), + onPointerCancel: (e: React.PointerEvent) => endPress(e.pointerId, null), + onLostPointerCapture: (e: React.PointerEvent) => endPress(e.pointerId, null), }) const savePreset = async () => { @@ -123,26 +219,49 @@ export function PtzPad({ cameraId }: { cameraId: string }) { } } + // Before capabilities load everything shows, as it always did; once they + // have, an axis the camera can serve neither way loses its keys instead of + // keeping buttons that can only fail. + const showPad = !caps || caps.relativePanTilt || caps.continuousPanTilt + const showZoom = !caps || caps.relativeZoom || caps.continuousZoom + return ( + {showPad && ( + {/* Stop stays in the middle whatever the camera supports: it is the + escape hatch for a move that did not stop when it should have. */} void stop()} title={t('Ptz.Stop')}> + )} + {showZoom && ( {t('Ptz.Zoom')} + )} + + {caps?.home && ( + void api.ptzHome(cameraId, speed).catch(() => setError(t('Ptz.Error')))} + title={t('Ptz.Home')} + > + {t('Ptz.Home')} + + )} {t('Ptz.Speed')} 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..4b42285 100644 --- a/src/OpenIPC.Viewer.Web/Api/PtzApi.cs +++ b/src/OpenIPC.Viewer.Web/Api/PtzApi.cs @@ -58,6 +58,89 @@ 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 declare a continuous space. + 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", async () => + { + // Seed the controller with cached capabilities so a step is one + // SOAP call, not a capability discovery per press; the cache + // fills from whichever request needed the answer first. + var key = CacheKey(id, target!.Value); + CapabilitiesCache.TryGetValue(key, out var known); + var controller = new PtzController( + target.Value.Client, target.Value.Endpoint, target.Value.ProfileToken, known); + await controller.StepAsync(step, Speed(body?.Speed), ct); + if (known is null) + CapabilitiesCache[key] = await controller.GetCapabilitiesAsync(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); + CapabilitiesCache[CacheKey(id, target.Value)] = caps; + return Results.Json(new + { + relativePanTilt = caps.SupportsRelativePanTilt, + relativeZoom = caps.SupportsRelativeZoom, + continuousPanTilt = caps.SupportsContinuousPanTilt, + continuousZoom = caps.SupportsContinuousZoom, + home = caps.SupportsHome, + fieldOfView = caps.RelativeIsFieldOfView, + }); + } + catch (Exception) when (!ct.IsCancellationRequested) + { + // Continuous-only is the safe answer, and the one every PTZ + // camera can honour; the pad keeps its hold-to-sweep keys and + // gains nothing it cannot verify. An aborted request is not an + // answer, so it never reaches the cache. + CapabilitiesCache[CacheKey(id, target!.Value)] = PtzCapabilities.ContinuousOnly; + return Results.Json(new + { + relativePanTilt = false, + relativeZoom = false, + continuousPanTilt = true, + continuousZoom = true, + 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); @@ -121,6 +204,17 @@ public static void MapPtzEndpoints(this WebApplication app) }); } + // Capabilities are a property of the hardware behind an endpoint and + // profile, so that — not the camera id alone — is the key: editing a + // camera's host, port or profile must not let the old device's answer pick + // the movement for the new one. /capabilities also refreshes the entry + // whenever the pad asks again (every mount). + private static readonly System.Collections.Concurrent.ConcurrentDictionary CapabilitiesCache = + new(StringComparer.OrdinalIgnoreCase); + + private static string CacheKey(string id, PtzTarget target) => + $"{id}\u0000{target.Endpoint.DeviceServiceUri}\u0000{target.ProfileToken}"; + // Everything a PTZ call needs: the ONVIF transport plus the camera's endpoint // and media profile. Credentials come from the secrets store, never the API. private readonly record struct PtzTarget(IOnvifClient Client, OnvifEndpoint Endpoint, string ProfileToken); @@ -187,6 +281,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 +300,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..8ca3b8f --- /dev/null +++ b/tests/OpenIPC.Viewer.Core.Tests/Onvif/OnvifTextTests.cs @@ -0,0 +1,76 @@ +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)); + + // The ambiguous class: a decode made purely of Latin-1 *symbols*. + // "\u00C2\u00A9" would decode to "\u00A9" (©), and both readings are + // plausible as intentional text, so the original stands. + [Theory] + [InlineData("\u00C2\u00A9")] + [InlineData("\u00C2\u00B0C")] + [InlineData("\u00C3\u2014")] // Windows-1252 reading of "×" (C3 97): a symbol among the letters + [InlineData("\u00C3\u00B7")] // "÷" (C3 B7) + public void ASymbolOnlyDecode_IsAmbiguousAndLeftAlone(string name) => + Assert.Equal(name, OnvifText.RepairMojibake(name)); + + // A decode containing a Latin-1 *letter* is not ambiguous — "Ã" chased by + // a currency sign is a pair nobody types on purpose. This keeps French, + // German and the â/ê/ô half of Vietnamese repairable even though those + // letters live inside Latin-1. + [Theory] + [InlineData("Entr\u00C3\u00A9e", "Entr\u00E9e")] + [InlineData("S\u00C3\u00A2n", "S\u00E2n")] + public void ADecodeWithLatin1Letters_IsRepaired(string mangled, string expected) => + Assert.Equal(expected, OnvifText.RepairMojibake(mangled)); +} diff --git a/tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzControllerStepTests.cs b/tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzControllerStepTests.cs new file mode 100644 index 0000000..9fa88e8 --- /dev/null +++ b/tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzControllerStepTests.cs @@ -0,0 +1,158 @@ +using System; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Onvif; + +namespace OpenIPC.Viewer.Core.Tests.Onvif; + +// What one press of a step key actually sends, per capability profile. The +// review pass on this feature found three ways for it to be quietly wrong — +// values outside an asymmetric declared range, zoom conflated with pan/tilt, +// and a continuous fallback fired at cameras that declared no continuous +// space — so each is pinned against a fake client that records the calls. +public sealed class PtzControllerStepTests +{ + private static readonly OnvifEndpoint Endpoint = + OnvifEndpoint.FromHost("127.0.0.1", 80, credentials: null); + + private static PtzCapabilities Caps( + bool relPanTilt = false, bool relZoom = false, + bool contPanTilt = false, bool contZoom = false, + PtzRange? pan = null, PtzRange? tilt = null, PtzRange? zoom = null) => new( + SupportsContinuousPanTilt: contPanTilt, + SupportsContinuousZoom: contZoom, + SupportsRelativePanTilt: relPanTilt, + SupportsRelativeZoom: relZoom, + SupportsAbsolute: false, + SupportsHome: false, + SupportsSetHome: false, + SupportsMoveStatus: false, + RelativeIsFieldOfView: false, + RelativePan: pan ?? PtzRange.Normalized, + RelativeTilt: tilt ?? PtzRange.Normalized, + RelativeZoom: zoom ?? PtzRange.Normalized, + AbsoluteZoom: PtzRange.Normalized, + AuxiliaryCommands: Array.Empty()); + + private static PtzController Controller(RecordingClient client, PtzCapabilities caps) => + new(client, Endpoint, "Profile_1", caps); + + [Fact] + public async Task AnArrowUsesRelativeMove_WhenThePanTiltSpaceIsDeclared() + { + var client = new RecordingClient(); + await Controller(client, Caps(relPanTilt: true)) + .StepAsync(new PtzVelocity(0.16f, 0f, 0f), speed: 0.6f, CancellationToken.None); + + var move = Assert.Single(client.RelativeMoves); + Assert.Equal(0.16f, move.PanX, 4); + Assert.Empty(client.ContinuousMoves); + } + + // The review case: on a declared range of [0, 100] a negative step must + // clamp to the range's own floor, never be sent below it — and an axis the + // press never touched must stay exactly zero, not drift to a midpoint. + [Fact] + public async Task AsymmetricRanges_NeverProduceOutOfRangeValues_AndUntouchedAxesStayZero() + { + var client = new RecordingClient(); + var caps = Caps(relPanTilt: true, pan: new PtzRange(0f, 100f), tilt: new PtzRange(0f, 100f)); + + await Controller(client, caps) + .StepAsync(new PtzVelocity(-0.16f, 0f, 0f), speed: 0.6f, CancellationToken.None); + + var move = Assert.Single(client.RelativeMoves); + Assert.True(move.PanX >= 0f, $"pan {move.PanX} sent below the declared minimum"); + Assert.Equal(0f, move.TiltY); + } + + // Zoom is its own space. A camera with relative pan/tilt but only + // continuous zoom steps the arrows via RelativeMove and the zoom keys via + // the timed fallback — neither is sent an operation its axis lacks. + [Fact] + public async Task ZoomFallsBackToContinuous_WhenOnlyItsContinuousSpaceIsDeclared() + { + var client = new RecordingClient(); + var caps = Caps(relPanTilt: true, contZoom: true); + + await Controller(client, caps) + .StepAsync(new PtzVelocity(0f, 0f, 0.16f), speed: 0.5f, CancellationToken.None); + + Assert.Empty(client.RelativeMoves); + var (velocity, timeout) = Assert.Single(client.ContinuousMoves); + Assert.Equal(0.08f, velocity.Zoom, 4); // step × speed + Assert.Equal(0f, velocity.PanX); + Assert.NotNull(timeout); // self-stopping, always + Assert.Equal(1, client.Stops); // ...and stopped explicitly, for firmware that ignores Timeout + } + + // A camera that declared neither a relative nor a continuous space for the + // axis gets nothing — not a continuous move that is guaranteed to fault. + [Fact] + public async Task AnAxisTheCameraCannotServe_SendsNothing() + { + var client = new RecordingClient(); + + await Controller(client, Caps(relZoom: true)) + .StepAsync(new PtzVelocity(0.16f, 0f, 0f), speed: 0.6f, CancellationToken.None); + + Assert.Empty(client.RelativeMoves); + Assert.Empty(client.ContinuousMoves); + } + + // Seeded capabilities are trusted as given: no discovery call is made, so + // the web API's per-camera cache actually saves the round trips. + [Fact] + public async Task SeededCapabilities_SkipDiscovery() + { + var client = new RecordingClient(); + + await Controller(client, Caps(relPanTilt: true)) + .StepAsync(new PtzVelocity(0.16f, 0f, 0f), speed: 0.6f, CancellationToken.None); + + Assert.Equal(0, client.CapabilityReads); + } + + private sealed class RecordingClient : IOnvifClient + { + public List RelativeMoves { get; } = new(); + public List<(PtzVelocity Velocity, TimeSpan? Timeout)> ContinuousMoves { get; } = new(); + public int CapabilityReads; + public int Stops; + + public Task RelativeMoveAsync(OnvifEndpoint endpoint, string profileToken, PtzVelocity step, float speed, CancellationToken ct) + { + RelativeMoves.Add(step); + return Task.CompletedTask; + } + + public Task ContinuousMoveAsync(OnvifEndpoint endpoint, string profileToken, PtzVelocity velocity, TimeSpan? timeout, CancellationToken ct) + { + ContinuousMoves.Add((velocity, timeout)); + return Task.CompletedTask; + } + + public Task GetPtzCapabilitiesAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) + { + CapabilityReads++; + return Task.FromResult(PtzCapabilities.ContinuousOnly); + } + + // The rest of the surface is not part of stepping. + public Task GetCapabilitiesAsync(OnvifEndpoint endpoint, CancellationToken ct) => throw new NotSupportedException(); + public Task GetDeviceInformationAsync(OnvifEndpoint endpoint, CancellationToken ct) => throw new NotSupportedException(); + public Task> GetProfilesAsync(OnvifEndpoint endpoint, CancellationToken ct) => throw new NotSupportedException(); + public Task GetStreamUriAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) => throw new NotSupportedException(); + public Task StopPtzAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) + { + Stops++; + return Task.CompletedTask; + } + public Task> GetPresetsAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) => throw new NotSupportedException(); + public Task GotoPresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct) => throw new NotSupportedException(); + public Task SetPresetAsync(OnvifEndpoint endpoint, string profileToken, string name, CancellationToken ct) => throw new NotSupportedException(); + public Task RemovePresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct) => throw new NotSupportedException(); + public Task GetPtzStatusAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) => throw new NotSupportedException(); + public Task GotoHomeAsync(OnvifEndpoint endpoint, string profileToken, float speed, CancellationToken ct) => throw new NotSupportedException(); + public Task SetHomeAsync(OnvifEndpoint endpoint, string profileToken, CancellationToken ct) => throw new NotSupportedException(); + } +} 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..95f0ce0 --- /dev/null +++ b/tests/OpenIPC.Viewer.Core.Tests/Onvif/PtzRangeTests.cs @@ -0,0 +1,110 @@ +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 OnTheDefaultRangeATranslationPassesThroughUnchanged() + { + var range = new PtzRange(-1f, 1f); + + Assert.Equal(0f, range.ScaleTranslation(0f), 5); + Assert.Equal(0.16f, range.ScaleTranslation(0.16f), 5); + Assert.Equal(-1f, range.ScaleTranslation(-1f), 5); + } + + // Degrees, which is what a good number of domes report. + [Fact] + public void ADegreeRangeScalesByItsHalfSpan() + { + var range = new PtzRange(-180f, 180f); + + Assert.Equal(180f, range.ScaleTranslation(1f), 3); + Assert.Equal(-90f, range.ScaleTranslation(-0.5f), 3); + Assert.Equal(0f, range.ScaleTranslation(0f), 3); + } + + // The two properties an asymmetric range must keep at once: zero stays + // zero (an untouched axis must not creep), and nothing is ever sent + // outside the declared bounds — [0, 100] simply cannot go negative, so a + // negative step clamps to its floor instead of being sent below it. + [Fact] + public void AnAsymmetricRangeKeepsZeroAndNeverLeavesItsBounds() + { + var range = new PtzRange(0f, 100f); + + Assert.Equal(0f, range.ScaleTranslation(0f), 3); + Assert.Equal(50f, range.ScaleTranslation(1f), 3); + Assert.Equal(8f, range.ScaleTranslation(0.16f), 3); + Assert.Equal(0f, range.ScaleTranslation(-0.16f), 3); // clamped, not -8 + Assert.Equal(0f, range.ScaleTranslation(-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).ScaleTranslation(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.ScaleTranslation(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); + } +} diff --git a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/OnvifServiceCandidatesTests.cs b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/OnvifServiceCandidatesTests.cs new file mode 100644 index 0000000..2d620f0 --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/OnvifServiceCandidatesTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using OpenIPC.Viewer.Devices.Onvif; +using Xunit; + +namespace OpenIPC.Viewer.Devices.Tests.Onvif; + +// The candidate order is what decides which URI the client tries first, so it +// is pinned against the GetCapabilities table the A'Gold CAM-10 (YooSee) really +// sends — every XAddr shifted one section down, DeviceIO on a stale address. +public sealed class OnvifServiceCandidatesTests +{ + private static readonly Uri Device = new("http://192.168.1.50:5000/onvif/device_service"); + + private static readonly Uri[] ShiftedTable = + { + new("http://192.168.1.50:5000/onvif/device_service"), // Device + new("http://192.168.1.50:5000/onvif/media_service"), // Events + new("http://192.168.1.50:5000/onvif/ptz_service"), // Media + new("http://10.0.0.7:5000/onvif/deviceio_service"), // PTZ (and a stale IP) + }; + + [Fact] + public void Media_ReachesTheRealMediaServiceAfterTheAdvertisedOne() + { + var list = OnvifServiceCandidates.Media(Device, ShiftedTable[2], ShiftedTable); + + Assert.Equal(ShiftedTable[2], list[0]); + Assert.Equal(new Uri("http://192.168.1.50:5000/onvif/media_service"), list[1]); + } + + [Fact] + public void Ptz_ReachesTheRealPtzServiceAfterTheAdvertisedOne() + { + var list = OnvifServiceCandidates.Ptz(Device, ShiftedTable[3], ShiftedTable); + + Assert.Equal(ShiftedTable[3], list[0]); + Assert.Equal(new Uri("http://192.168.1.50:5000/onvif/deviceio_service"), list[1]); + Assert.Equal(new Uri("http://192.168.1.50:5000/onvif/ptz_service"), list[2]); + } + + [Fact] + public void AdvertisedAddressIsRetriedOnTheDeviceAuthority() + { + var natted = new Uri("http://10.0.0.7/onvif/Media?x=1"); + + var list = OnvifServiceCandidates.Media(Device, natted, new[] { natted }); + + Assert.Equal(natted, list[0]); + Assert.Equal(new Uri("http://192.168.1.50:5000/onvif/Media?x=1"), list[1]); + } + + [Fact] + public void NothingAdvertised_FallsBackToConventionalPathsThenDeviceService() + { + var list = OnvifServiceCandidates.Ptz(Device, advertised: null, Array.Empty()); + + Assert.Equal(new[] + { + new Uri("http://192.168.1.50:5000/onvif/ptz_service"), + new Uri("http://192.168.1.50:5000/onvif/PTZ"), + Device, + }, list); + } + + [Fact] + public void Candidates_AreDistinct() + { + var list = OnvifServiceCandidates.Media(Device, ShiftedTable[1], ShiftedTable); + + Assert.Equal(list.Count, list.Distinct().Count()); + Assert.Equal(Device, list[^1]); + } +} diff --git a/tests/OpenIPC.Viewer.Devices.Tests/Onvif/PtzCapabilityProbeTests.cs b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/PtzCapabilityProbeTests.cs new file mode 100644 index 0000000..beeb7dc --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/PtzCapabilityProbeTests.cs @@ -0,0 +1,134 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using OpenIPC.Viewer.Devices.Onvif; + +namespace OpenIPC.Viewer.Devices.Tests.Onvif; + +// The capability probe against a stub camera: the SOAP chain is GetProfiles → +// GetConfigurationOptions → GetServiceCapabilities → GetConfiguration → +// GetNode, and every flag the UI gates a button on comes out of it. Wrong +// parsing does not throw — it shows buttons that fail or hides ones that work — +// so the answers are pinned against known response bodies. +public sealed class PtzCapabilityProbeTests +{ + private static SoapOnvifClient NewClient() => new(NullLogger.Instance); + + // A camera with relative + continuous pan/tilt (FOV-relative), no zoom + // spaces at all, MoveStatus maintained, and a node that supports home and + // allows overwriting it. + [Fact] + public async Task TheFlagsComeFromWhatTheCameraDeclared() + { + using var camera = StubCamera.Start(Respond); + + var caps = await NewClient().GetPtzCapabilitiesAsync( + camera.Endpoint(null), "Profile_1", CancellationToken.None); + + Assert.True(caps.SupportsRelativePanTilt); + Assert.True(caps.SupportsContinuousPanTilt); + Assert.False(caps.SupportsRelativeZoom); + Assert.False(caps.SupportsContinuousZoom); + Assert.True(caps.RelativeIsFieldOfView); + Assert.True(caps.SupportsMoveStatus); + Assert.True(caps.SupportsHome); + Assert.True(caps.SupportsSetHome); + Assert.Equal(-1f, caps.RelativePan.Min, 4); + Assert.Equal(1f, caps.RelativePan.Max, 4); + } + + // A hardware-fixed home position can be visited but not overwritten. + [Fact] + public async Task AFixedHomePosition_AllowsGotoButNotSetHome() + { + using var camera = StubCamera.Start(req => Respond(req, fixedHome: true)); + + var caps = await NewClient().GetPtzCapabilitiesAsync( + camera.Endpoint(null), "Profile_1", CancellationToken.None); + + Assert.True(caps.SupportsHome); + Assert.False(caps.SupportsSetHome); + } + + // A camera that answers the node questions with nothing gets no home + // controls — never a fabricated yes. + [Fact] + public async Task ACameraThatWillNotDescribeItsNode_GetsNoHome() + { + using var camera = StubCamera.Start(req => + req.Is("GetConfiguration") && !req.Is("GetConfigurationOptions") + ? (string.Empty, 200) + : Respond(req)); + + var caps = await NewClient().GetPtzCapabilitiesAsync( + camera.Endpoint(null), "Profile_1", CancellationToken.None); + + Assert.True(caps.SupportsRelativePanTilt); // the spaces still parsed + Assert.False(caps.SupportsHome); + Assert.False(caps.SupportsSetHome); + } + + private static (string Body, int Status) Respond(StubRequest req) => Respond(req, fixedHome: false); + + private static (string Body, int Status) Respond(StubRequest req, bool fixedHome) + { + const string tt = "http://www.onvif.org/ver10/schema"; + const string tptz = "http://www.onvif.org/ver20/ptz/wsdl"; + + if (req.Is("GetProfiles")) + { + return (Envelope( + $"" + + "main" + + "node0" + + ""), 200); + } + if (req.Is("GetConfigurationOptions")) + { + return (Envelope( + $"" + + "" + + "" + + "http://www.onvif.org/ver10/tptz/PanTiltSpaces/TranslationSpaceFov" + + "-11" + + "-11" + + "" + + "" + + "http://www.onvif.org/ver10/tptz/PanTiltSpaces/VelocityGenericSpace" + + "-11" + + "-11" + + "" + + ""), 200); + } + if (req.Is("GetServiceCapabilities")) + { + return (Envelope( + $"" + + ""), 200); + } + if (req.Is("GetNode")) + { + return (Envelope( + $"" + + $"" + + "node0true" + + ""), 200); + } + if (req.Is("GetConfiguration")) + { + return (Envelope( + $"" + + "node0" + + ""), 200); + } + + // Clock probe, device capabilities (no PTZ XAddr → the client falls + // back to the device endpoint, which is this stub), anything else. + return (Envelope(""), 200); + } + + private static string Envelope(string body) => + "" + + "" + + $"{body}"; +} 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..a14f6f4 --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/SoapOnvifClientInteropTests.cs @@ -0,0 +1,275 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using OpenIPC.Viewer.Core.Entities; +using OpenIPC.Viewer.Core.Onvif; +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); + // 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 + // 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()); + } + + // 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"))); + } + + // gSOAP — what most ONVIF firmware is built on — doesn't always stay silent + // when handed an envelope version it wasn't built for: it says so with a + // VersionMismatch fault. That fault is the one that means "ask again in the + // other dialect", not "the camera refused". + [Fact] + public async Task AVersionMismatchFault_IsRetriedAsSoap11() + { + using var camera = StubCamera.Start(req => req.IsSoap12 + ? (Envelope12( + "" + + "s:VersionMismatch" + + "SOAP version mismatch or invalid SOAP message" + + ""), 500) + : (Envelope11(Capabilities()), 200)); + + var caps = await NewClient().GetCapabilitiesAsync(camera.Endpoint(null), CancellationToken.None); + + Assert.NotNull(caps.MediaServiceUri); + Assert.Contains(camera.Requests, r => r.Is("GetCapabilities") && r.IsSoap11); + } + + // A wrong password is the most common failure there is, and it rarely + // arrives as a fault — an HTML error page with a 401 is typical. It must be + // named as a login problem, not as "ONVIF is switched off". + [Fact] + public async Task ARefusedLogin_IsNamedAsSuch() + { + using var camera = StubCamera.Start(_ => ("401 Unauthorized", 401)); + + var ex = await Assert.ThrowsAsync(() => + NewClient().GetCapabilitiesAsync( + camera.Endpoint(new CameraCredentials("admin", "wrong")), CancellationToken.None)); + + Assert.Contains("username and password", ex.Message, StringComparison.OrdinalIgnoreCase); + } + + // Issue #67, end to end: a YooSee-style firmware that advertises every + // XAddr one section down and only answers each service at its own path. + // The client must find the real PTZ service and send the move there. + [Fact] + public async Task ShiftedXAddrs_AreProvedBeforeUse() + { + StubCamera? camera = null; + camera = StubCamera.Start(req => + { + var at = $"http://127.0.0.1:{camera!.Port}"; + if (req.Is("GetSystemDateAndTime") || req.Is("GetCapabilities")) + return req.Path == "/onvif/device_service" + ? (Envelope12(ShiftedCapabilities(at)), 200) + : (string.Empty, 404); + if (req.Is("GetNodes")) + return req.Path == "/onvif/ptz_service" + ? (Envelope12(""), 200) + : (string.Empty, 404); + if (req.Is("ContinuousMove")) + return req.Path == "/onvif/ptz_service" + ? (Envelope12(""), 200) + : (string.Empty, 404); + return (string.Empty, 404); + }); + using var _ = camera; + + var client = NewClient(); + var endpoint = camera.Endpoint(null); + await client.ContinuousMoveAsync(endpoint, "IPCProfilesToken0", new PtzVelocity(1f, 0f, 0f), null, CancellationToken.None); + await client.ContinuousMoveAsync(endpoint, "IPCProfilesToken0", new PtzVelocity(1f, 0f, 0f), null, CancellationToken.None); + + var moves = camera.Requests.Where(r => r.Is("ContinuousMove")).ToList(); + Assert.Equal(2, moves.Count); + Assert.All(moves, m => Assert.Equal("/onvif/ptz_service", m.Path)); + // The verdict is cached: the second move neither re-reads the + // capabilities nor re-proves the service. + Assert.Equal(1, camera.Requests.Count(r => r.Is("GetCapabilities"))); + } + + // --- helpers ------------------------------------------------------------ + + // Verbatim layout from the A'Gold CAM-10 report: every section's XAddr is + // the next section's path, and DeviceIO carries an address the camera + // no longer has. + private static string ShiftedCapabilities(string at) => + "" + + $"{at}/onvif/device_service" + + $"{at}/onvif/media_service" + + $"{at}/onvif/ptz_service" + + $"{at}/onvif/deviceio_service" + + "http://10.0.0.7:5000/onvif/deviceio_service" + + ""; + + 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..5022135 --- /dev/null +++ b/tests/OpenIPC.Viewer.Devices.Tests/Onvif/StubCamera.cs @@ -0,0 +1,135 @@ +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, + string Path = "/") +{ + 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"], + ctx.Request.Url?.AbsolutePath ?? "/"); + _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 */ } + } +}