Skip to content
Merged
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
15 changes: 13 additions & 2 deletions src/OpenIPC.Viewer.App/Services/DiscoveryDialogFactory.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using OpenIPC.Viewer.App.ViewModels.Dialogs;
using OpenIPC.Viewer.Core.Discovery;
using OpenIPC.Viewer.Core.Majestic;
using OpenIPC.Viewer.Core.Onvif;

namespace OpenIPC.Viewer.App.Services;
Expand All @@ -9,18 +11,27 @@ public sealed class DiscoveryDialogFactory
{
private readonly IDiscoveryAggregator _aggregator;
private readonly OnvifProbeService _probe;
private readonly IMajesticClient _majestic;
private readonly DiscoverySessionCache _cache;
private readonly ILoggerFactory _loggerFactory;

public DiscoveryDialogFactory(
IDiscoveryAggregator aggregator,
OnvifProbeService probe,
IMajesticClient majestic,
DiscoverySessionCache cache,
ILoggerFactory loggerFactory)
{
_aggregator = aggregator;
_probe = probe;
_majestic = majestic;
_cache = cache;
_loggerFactory = loggerFactory;
}

public DiscoveryDialogViewModel Create() =>
new(_aggregator, _probe, _loggerFactory.CreateLogger<DiscoveryDialogViewModel>());
// knownHosts: hosts of cameras already in the library, so rows can carry
// an "already added" badge during the multi-add loop.
public DiscoveryDialogViewModel Create(IReadOnlySet<string> knownHosts) =>
new(_aggregator, _probe, _majestic, _cache, knownHosts,
_loggerFactory.CreateLogger<DiscoveryDialogViewModel>());
}
44 changes: 44 additions & 0 deletions src/OpenIPC.Viewer.App/Services/DiscoverySessionCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System;
using System.Collections.Generic;
using OpenIPC.Viewer.Core.Discovery;

namespace OpenIPC.Viewer.App.Services;

// Session-scoped memory for the discovery dialog, so the user can add several
// cameras from ONE scan: results, the typed credentials and the deep-scan flag
// all survive closing and reopening the dialog. In-memory only on purpose —
// the credentials must never be persisted to disk from here (they end up in
// the secrets store per-camera once a camera is saved).
public sealed class DiscoverySessionCache
{
private readonly object _sync = new();
private readonly Dictionary<string, DiscoveredDevice> _devices =
new(StringComparer.OrdinalIgnoreCase);

public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool DeepScan { get; set; }

// "Use these credentials for all cameras" — on (default) carries the typed
// login into the next add; off makes every camera start with blank fields
// (mixed-credential parks).
public bool ReuseCredentials { get; set; } = true;

public IReadOnlyList<DiscoveredDevice> Snapshot()
{
lock (_sync)
return new List<DiscoveredDevice>(_devices.Values);
}

public void Put(DiscoveredDevice device)
{
lock (_sync)
_devices[device.Host] = device;
}

public void Clear()
{
lock (_sync)
_devices.Clear();
}
}
4 changes: 4 additions & 0 deletions src/OpenIPC.Viewer.App/Services/Localizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,8 @@ private static LangCode DetectSystem()
["Discovery.Status.ManualAdd"] = "No ONVIF — review the stream URL in the editor.",
["Discovery.DeepScan"] = "Deep scan (slower)",
["Discovery.UnknownModel"] = "(unknown model)",
["Discovery.AlreadyAdded"] = "added",
["Discovery.ReuseCreds"] = "Use these credentials for all cameras",
["Discovery.Confidence.Low"] = "low",
["Discovery.Confidence.Medium"] = "medium",
["Discovery.Confidence.High"] = "high",
Expand Down Expand Up @@ -867,6 +869,8 @@ private static LangCode DetectSystem()
["Discovery.Status.ManualAdd"] = "Без ONVIF — проверьте URL потока в редакторе.",
["Discovery.DeepScan"] = "Глубокое сканирование (медленнее)",
["Discovery.UnknownModel"] = "(неизвестная модель)",
["Discovery.AlreadyAdded"] = "добавлена",
["Discovery.ReuseCreds"] = "Одни учётные данные для всех камер",
["Discovery.Confidence.Low"] = "низкая",
["Discovery.Confidence.Medium"] = "средняя",
["Discovery.Confidence.High"] = "высокая",
Expand Down
78 changes: 46 additions & 32 deletions src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -322,39 +322,53 @@ private async Task AddCameraAsync()
[RelayCommand]
private async Task DiscoverCameraAsync()
{
var discoveryVm = _discoveryFactory.Create();
var found = await _dialogs.ShowDiscoveryDialogAsync(discoveryVm).ConfigureAwait(true);
if (found is null)
return;

// Pre-fill the editor from the probe result so the user sees / can tweak
// everything before saving (RTSP URI especially — phase-04 risks §"ONVIF
// returns wrong RTSP URI behind NAT" applies).
var editor = _editorFactory.CreateForNew();
editor.Name = found.Device.Model ?? found.Device.Name ?? found.Device.Host;
editor.Host = found.Device.Host;
editor.OnvifPortText = (found.Device.OnvifServiceUri?.Port ?? 80).ToString(System.Globalization.CultureInfo.InvariantCulture);
editor.RtspMainText = found.RtspMainUri.ToString();
editor.Username = found.Credentials?.Username ?? "";
editor.Password = found.Credentials?.Password ?? "";

var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true);
if (result?.NewRequest is not { } req)
return;

try
// Multi-add loop: after each camera is saved (or its editor cancelled)
// the discovery dialog reopens with the SAME scan results and creds
// (DiscoverySessionCache), so several cameras go in from one scan.
// Only cancelling the discovery dialog itself exits.
var knownHosts = new System.Collections.Generic.HashSet<string>(
System.Linq.Enumerable.Select(_allCameras, c => c.Host),
StringComparer.OrdinalIgnoreCase);

while (true)
{
var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true);
// Persist HasPtz / ProfileToken / manufacturer info from the probe so
// SingleCameraPage knows whether to show the PTZ joystick (Phase 4c).
// Non-ONVIF devices (sweep/mDNS) have no probe — nothing to persist.
if (found.Probe is { } probe)
await _directory.SaveOnvifMetadataAsync(id, probe, CancellationToken.None).ConfigureAwait(true);
await LoadAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add discovered camera {Host}", req.Host);
var discoveryVm = _discoveryFactory.Create(knownHosts);
var found = await _dialogs.ShowDiscoveryDialogAsync(discoveryVm).ConfigureAwait(true);
// Stop background fingerprints of the instance that just closed.
discoveryVm.Cancel();
if (found is null)
return;

// Pre-fill the editor from the probe result so the user sees / can tweak
// everything before saving (RTSP URI especially — phase-04 risks §"ONVIF
// returns wrong RTSP URI behind NAT" applies).
var editor = _editorFactory.CreateForNew();
editor.Name = found.Device.Model ?? found.Device.Name ?? found.Device.Host;
editor.Host = found.Device.Host;
editor.OnvifPortText = (found.Device.OnvifServiceUri?.Port ?? 80).ToString(System.Globalization.CultureInfo.InvariantCulture);
editor.RtspMainText = found.RtspMainUri.ToString();
editor.Username = found.Credentials?.Username ?? "";
editor.Password = found.Credentials?.Password ?? "";

var result = await _dialogs.ShowCameraEditorAsync(editor).ConfigureAwait(true);
if (result?.NewRequest is not { } req)
continue; // editor cancelled — back to the scan list

try
{
var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true);
// Persist HasPtz / ProfileToken / manufacturer info from the probe so
// SingleCameraPage knows whether to show the PTZ joystick (Phase 4c).
// Non-ONVIF devices (sweep/mDNS) have no probe — nothing to persist.
if (found.Probe is { } probe)
await _directory.SaveOnvifMetadataAsync(id, probe, CancellationToken.None).ConfigureAwait(true);
await LoadAsync(CancellationToken.None).ConfigureAwait(true);
knownHosts.Add(req.Host);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add discovered camera {Host}", req.Host);
}
}
}

Expand Down
101 changes: 100 additions & 1 deletion src/OpenIPC.Viewer.App/ViewModels/Dialogs/DiscoveryDialogViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,19 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase
{
private readonly IDiscoveryAggregator _aggregator;
private readonly OnvifProbeService _probe;
private readonly OpenIPC.Viewer.Core.Majestic.IMajesticClient _majestic;
private readonly DiscoverySessionCache _cache;
private readonly IReadOnlySet<string> _knownHosts;
private readonly ILogger<DiscoveryDialogViewModel> _logger;

private CancellationTokenSource? _scanCts;
// Cancels in-flight Majestic fingerprints when the dialog goes away.
private readonly CancellationTokenSource _lifetimeCts = new();
private readonly Dictionary<string, DiscoveredDeviceRowVm> _rowsByHost =
new(StringComparer.OrdinalIgnoreCase);
// Hosts we already fingerprinted (or are fingerprinting) — one ping per host.
private readonly HashSet<string> _fingerprinted = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _fingerprintGate = new(6);

public ObservableCollection<DiscoveredDeviceRowVm> Cameras { get; } = new();

Expand All @@ -43,6 +51,10 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase
[ObservableProperty] private string _username = "";
[ObservableProperty] private string _password = "";

// "Use these credentials for all cameras" (multi-add). Off → the next
// dialog instance starts with blank login fields.
[ObservableProperty] private bool _reuseCredentials = true;

// 0..1 scan progress (mean across sources). Drives the progress bar; hidden
// when not scanning.
[ObservableProperty] private double _scanProgress;
Expand All @@ -66,18 +78,47 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase
public DiscoveryDialogViewModel(
IDiscoveryAggregator aggregator,
OnvifProbeService probe,
OpenIPC.Viewer.Core.Majestic.IMajesticClient majestic,
DiscoverySessionCache cache,
IReadOnlySet<string> knownHosts,
ILogger<DiscoveryDialogViewModel> logger)
{
_aggregator = aggregator;
_probe = probe;
_majestic = majestic;
_cache = cache;
_knownHosts = knownHosts;
_logger = logger;

// Rehydrate the previous scan so the user can add several cameras
// one-by-one without rescanning between dialog opens. Credentials only
// carry over while "use for all cameras" is on — a mixed-credential
// park starts each camera with blank fields.
_reuseCredentials = cache.ReuseCredentials;
if (_reuseCredentials)
{
_username = cache.Username;
_password = cache.Password;
}
_deepScan = cache.DeepScan;
foreach (var device in cache.Snapshot())
Upsert(device);
if (Cameras.Count > 0)
StatusText = string.Format(Localizer.Instance["Discovery.Status.FoundFormat"], Cameras.Count);
}

partial void OnUsernameChanged(string value) => _cache.Username = value;
partial void OnPasswordChanged(string value) => _cache.Password = value;
partial void OnDeepScanChanged(bool value) => _cache.DeepScan = value;
partial void OnReuseCredentialsChanged(bool value) => _cache.ReuseCredentials = value;

[RelayCommand(CanExecute = nameof(CanScan))]
private async Task ScanAsync()
{
Cameras.Clear();
_rowsByHost.Clear();
_fingerprinted.Clear();
_cache.Clear();
Selected = null;
ScanProgress = 0;
StatusText = Localizer.Instance["Discovery.Status.Scanning"];
Expand Down Expand Up @@ -125,10 +166,63 @@ private void Upsert(DiscoveredDevice device)
}
else
{
row = new DiscoveredDeviceRowVm(device);
row = new DiscoveredDeviceRowVm(device)
{
IsAlreadyAdded = _knownHosts.Contains(device.Host),
};
_rowsByHost[device.Host] = row;
Cameras.Add(row);
}

_cache.Put(row.Device);
ScheduleFingerprint(row.Device);
}

// Newer OpenIPC firmwares always run the Majestic web UI, so an HTTP ping
// identifies them even when they answer neither ONVIF nor mDNS with a
// model. One bounded background ping per host; on a hit the row upgrades
// in place (Majestic protocol + "OpenIPC" label + High confidence).
private void ScheduleFingerprint(DiscoveredDevice device)
{
if (device.Protocols.HasFlag(DiscoveryProtocol.Majestic) && device.Model is not null)
return;
if (!_fingerprinted.Add(device.Host))
return;

var ct = _lifetimeCts.Token;
_ = Task.Run(async () =>
{
await _fingerprintGate.WaitAsync(ct).ConfigureAwait(false);
try
{
var hit = device.Protocols.HasFlag(DiscoveryProtocol.Majestic);
var ports = device.Ports.Where(p => p is 80 or 8080).DefaultIfEmpty(80);
foreach (var port in ports)
{
if (hit) break;
hit = await _majestic.PingAsync(
new OpenIPC.Viewer.Core.Majestic.MajesticEndpoint(device.Host, port, null), ct).ConfigureAwait(false);
}
if (!hit) return;

await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() =>
{
if (!_rowsByHost.TryGetValue(device.Host, out var row)) return;
row.Device = row.Device.MergeWith(new DiscoveredDevice(
device.Host, DiscoveryProtocol.Majestic, Array.Empty<int>(), Model: "OpenIPC"));
_cache.Put(row.Device);
});
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
_logger.LogDebug(ex, "Majestic fingerprint failed for {Host}", device.Host);
}
finally
{
_fingerprintGate.Release();
}
}, ct);
}

private bool CanScan() => !ScanInProgress && !AddInProgress;
Expand Down Expand Up @@ -199,6 +293,7 @@ private static Uri GuessRtspUri(DiscoveredDevice device)
public void Cancel()
{
_scanCts?.Cancel();
_lifetimeCts.Cancel();
}
}

Expand All @@ -212,6 +307,10 @@ public sealed partial class DiscoveredDeviceRowVm : ViewModelBase
[NotifyPropertyChangedFor(nameof(ConfidenceText))]
private DiscoveredDevice _device;

// A camera with this host already exists in the library — shown as a badge
// so the multi-add flow makes it obvious what's left to add.
[ObservableProperty] private bool _isAlreadyAdded;

public DiscoveredDeviceRowVm(DiscoveredDevice device) => _device = device;

public string Host => Device.Host;
Expand Down
Loading
Loading