Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 14 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,13 @@ private static LangCode DetectSystem()
["CameraPage.NightMode.Auto"] = "Auto",
["CameraPage.Preset.Placeholder"] = "preset name",
["CameraPage.Preset.Save"] = "+ Save",
["Ptz.Home"] = "Go to home position",
["Ptz.SetHome"] = "Set home here",
["Ptz.SetHome.Title"] = "Set home position",
["Ptz.SetHome.Message"] = "Make the camera's current position its home? The old home position is replaced.",
["Ptz.Speed"] = "Move speed",
["Ptz.ZoomIn"] = "Zoom in one step",
["Ptz.ZoomOut"] = "Zoom out one step",
["CameraPage.Snapshot"] = "Snapshot",
["CameraPage.Stop"] = "■ Stop",
["CameraPage.ApplyingStatus"] = "Applying…",
Expand Down Expand Up @@ -930,6 +937,13 @@ private static LangCode DetectSystem()
["CameraPage.NightMode.Auto"] = "Авто",
["CameraPage.Preset.Placeholder"] = "название пресета",
["CameraPage.Preset.Save"] = "+ Сохранить",
["Ptz.Home"] = "Перейти в домашнюю позицию",
["Ptz.SetHome"] = "Сделать текущую позицию домашней",
["Ptz.SetHome.Title"] = "Задать домашнюю позицию",
["Ptz.SetHome.Message"] = "Сделать текущую позицию камеры домашней? Прежняя домашняя позиция будет заменена.",
["Ptz.Speed"] = "Скорость перемещения",
["Ptz.ZoomIn"] = "Приблизить на шаг",
["Ptz.ZoomOut"] = "Отдалить на шаг",
["CameraPage.Snapshot"] = "Снимок",
["CameraPage.Stop"] = "■ Стоп",
["CameraPage.ApplyingStatus"] = "Применение…",
Expand Down
109 changes: 109 additions & 0 deletions src/OpenIPC.Viewer.App/ViewModels/SingleCameraPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -575,6 +606,80 @@ private async Task ToggleRecordingAsync()
[RelayCommand]
private void TogglePtzOverlay() => IsPtzOverlayVisible = !IsPtzOverlayVisible;

// One nudge per press. The direction is a string so the eight arrows and
// the two zoom buttons are one command with a parameter in XAML rather than
// ten near-identical commands.
[RelayCommand]
private async Task StepAsync(string? direction)
{
if (Ptz is null || string.IsNullOrEmpty(direction)) return;

var step = direction switch
{
"up" => new PtzVelocity(0, PtzStepSize, 0),
"down" => new PtzVelocity(0, -PtzStepSize, 0),
"left" => new PtzVelocity(-PtzStepSize, 0, 0),
"right" => new PtzVelocity(PtzStepSize, 0, 0),
"upleft" => new PtzVelocity(-PtzStepSize, PtzStepSize, 0),
"upright" => new PtzVelocity(PtzStepSize, PtzStepSize, 0),
"downleft" => new PtzVelocity(-PtzStepSize, -PtzStepSize, 0),
"downright" => new PtzVelocity(PtzStepSize, -PtzStepSize, 0),
"zoomin" => new PtzVelocity(0, 0, PtzStepSize),
"zoomout" => new PtzVelocity(0, 0, -PtzStepSize),
_ => PtzVelocity.Zero,
};
if (step.Equals(PtzVelocity.Zero)) return;

try
{
await Ptz.StepAsync(step, (float)PtzSpeed, CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "PTZ step {Direction} failed for {CameraId}", direction, _camera.Id);
}
}

[RelayCommand]
private async Task GoHomeAsync()
{
if (Ptz is null) return;
try
{
await Ptz.GoHomeAsync((float)PtzSpeed, CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
// Home is optional, and a camera may advertise PTZ and still refuse
// it. Log it rather than hiding the button on every camera because
// some cannot.
_logger.LogWarning(ex, "PTZ home failed for {CameraId}", _camera.Id);
}
}

// Overwriting the home position is not undoable from here, so it asks.
[RelayCommand]
private async Task SetHomeAsync()
{
if (Ptz is null) return;

var confirmed = await _dialogs.ConfirmAsync(
Localizer.Instance["Ptz.SetHome.Title"],
Localizer.Instance["Ptz.SetHome.Message"],
confirmLabel: Localizer.Instance["Ptz.SetHome"],
cancelLabel: Localizer.Instance["Common.Cancel"]).ConfigureAwait(true);
if (!confirmed) return;

try
{
await Ptz.SetHomeAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "PTZ set-home failed for {CameraId}", _camera.Id);
}
}

[RelayCommand]
private void ResetZoom() => ZoomLevel = MinZoom;

Expand Down Expand Up @@ -1191,6 +1296,10 @@ private async Task InitPtzAsync(CameraCredentials? creds, CancellationToken ct)
var port = _camera.OnvifPort ?? 80;
var endpoint = OnvifEndpoint.FromHost(_camera.Host, port, creds);
Ptz = new PtzController(_onvif, endpoint, _camera.OnvifProfileToken!);
// Best-effort: a camera that cannot describe itself gets the
// continuous-only profile, which is what every camera was assumed to be
// before this asked.
PtzCapabilities = await Ptz.GetCapabilitiesAsync(ct).ConfigureAwait(true);
await ReloadPresetsAsync(ct).ConfigureAwait(true);
}

Expand Down
77 changes: 77 additions & 0 deletions src/OpenIPC.Viewer.App/Views/Pages/SingleCameraPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@
x:Class="OpenIPC.Viewer.App.Views.Pages.SingleCameraPage"
x:DataType="vm:SingleCameraPageViewModel">

<UserControl.Styles>
<!-- PTZ step keys: square and quiet until hovered, so nine of them read as
one keypad rather than nine competing buttons. -->
<Style Selector="Button.ptz-step">
<Setter Property="Width" Value="42" />
<Setter Property="Height" Value="34" />
<Setter Property="Margin" Value="2" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="FontSize" Value="16" />
<Setter Property="Foreground" Value="White" />
<Setter Property="Background" Value="#33FFFFFF" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.ptz-step:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
<Setter Property="TextElement.Foreground" Value="#101010" />
</Style>
<Style Selector="Button.ptz-home">
<Setter Property="Background" Value="#4DFFFFFF" />
</Style>
</UserControl.Styles>

<!-- PageScroll: Disabled by default so on desktop/wide the Grid fills the
viewport and the video row (*) stretches — identical to a plain Grid.
On phone-width viewports the code-behind switches the video row to a
Expand Down Expand Up @@ -133,6 +157,59 @@
IsVisible="{Binding IsPtzPanelVisible}">
<controls:PtzJoystick Controller="{Binding Ptz}" HorizontalAlignment="Right" />

<!-- Step pad. The joystick above is for sweeping a scene; this is for
framing one — a fixed nudge per press, which is the only way to
land on a doorway at full zoom. Home sits in the middle, where
every NVR keypad puts it. -->
<Border Background="#90000000" CornerRadius="6" Padding="8" HorizontalAlignment="Right">
<StackPanel Spacing="8">
<UniformGrid Rows="3" Columns="3" IsVisible="{Binding CanStepPanTilt}">
<Button Classes="ptz-step" Content="↖" CommandParameter="upleft"
Command="{Binding StepCommand}" />
<Button Classes="ptz-step" Content="↑" CommandParameter="up"
Command="{Binding StepCommand}" />
<Button Classes="ptz-step" Content="↗" CommandParameter="upright"
Command="{Binding StepCommand}" />
<Button Classes="ptz-step" Content="←" CommandParameter="left"
Command="{Binding StepCommand}" />
<Button Classes="ptz-step ptz-home" Content="⌂"
Command="{Binding GoHomeCommand}"
IsVisible="{Binding SupportsHome}"
ToolTip.Tip="{Binding [Ptz.Home], Source={x:Static svc:Localizer.Instance}}" />
<Button Classes="ptz-step" Content="→" CommandParameter="right"
Command="{Binding StepCommand}" />
<Button Classes="ptz-step" Content="↙" CommandParameter="downleft"
Command="{Binding StepCommand}" />
<Button Classes="ptz-step" Content="↓" CommandParameter="down"
Command="{Binding StepCommand}" />
<Button Classes="ptz-step" Content="↘" CommandParameter="downright"
Command="{Binding StepCommand}" />
</UniformGrid>

<Grid ColumnDefinitions="*,*" IsVisible="{Binding CanStepZoom}">
<Button Grid.Column="0" Classes="ptz-step" Content="−" CommandParameter="zoomout"
Command="{Binding StepCommand}" HorizontalAlignment="Stretch"
ToolTip.Tip="{Binding [Ptz.ZoomOut], Source={x:Static svc:Localizer.Instance}}" />
<Button Grid.Column="1" Classes="ptz-step" Content="+" CommandParameter="zoomin"
Command="{Binding StepCommand}" HorizontalAlignment="Stretch"
ToolTip.Tip="{Binding [Ptz.ZoomIn], Source={x:Static svc:Localizer.Instance}}" />
</Grid>

<StackPanel Spacing="2">
<TextBlock Text="{Binding [Ptz.Speed], Source={x:Static svc:Localizer.Instance}}"
FontSize="{StaticResource FontSizeXs}" Foreground="#c8c8c8" />
<Slider Minimum="0.1" Maximum="1.0" Value="{Binding PtzSpeed, Mode=TwoWay}" Width="132" />
</StackPanel>

<Button Classes="outlined"
Content="{Binding [Ptz.SetHome], Source={x:Static svc:Localizer.Instance}}"
Command="{Binding SetHomeCommand}"
IsVisible="{Binding SupportsSetHome}"
FontSize="{StaticResource FontSizeXs}"
HorizontalAlignment="Stretch" />
</StackPanel>
</Border>

<Border Background="#90000000" CornerRadius="6" Padding="8" MaxWidth="280">
<StackPanel Spacing="6">
<ItemsControl ItemsSource="{Binding Presets}">
Expand Down
19 changes: 19 additions & 0 deletions src/OpenIPC.Viewer.Core/Onvif/IOnvifClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,23 @@ public interface IOnvifClient
Task GotoPresetAsync(OnvifEndpoint endpoint, string profileToken, string presetToken, CancellationToken ct);
Task<string> 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<PtzCapabilities> 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<PtzStatus> 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);
}
82 changes: 82 additions & 0 deletions src/OpenIPC.Viewer.Core/Onvif/OnvifText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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 (©, °, ±) decide nothing.
if (c > '\u00FF') return decoded;
if (c is >= '\u00C0' and <= '\u00FF') 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,
};
}
Loading