Skip to content

Commit f76982e

Browse files
authored
Merge pull request #39 from OpenIPC/feat/discovery-v2
feat: Discovery v2 foundation — model + source abstraction + aggregator
2 parents fd50c15 + c4083ba commit f76982e

17 files changed

Lines changed: 922 additions & 63 deletions

File tree

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,26 @@
11
using Microsoft.Extensions.Logging;
22
using OpenIPC.Viewer.App.ViewModels.Dialogs;
3+
using OpenIPC.Viewer.Core.Discovery;
34
using OpenIPC.Viewer.Core.Onvif;
4-
using OpenIPC.Viewer.Core.Onvif.Discovery;
55

66
namespace OpenIPC.Viewer.App.Services;
77

88
public sealed class DiscoveryDialogFactory
99
{
10-
private readonly IDiscoveryService _discovery;
10+
private readonly IDiscoveryAggregator _aggregator;
1111
private readonly OnvifProbeService _probe;
1212
private readonly ILoggerFactory _loggerFactory;
1313

1414
public DiscoveryDialogFactory(
15-
IDiscoveryService discovery,
15+
IDiscoveryAggregator aggregator,
1616
OnvifProbeService probe,
1717
ILoggerFactory loggerFactory)
1818
{
19-
_discovery = discovery;
19+
_aggregator = aggregator;
2020
_probe = probe;
2121
_loggerFactory = loggerFactory;
2222
}
2323

2424
public DiscoveryDialogViewModel Create() =>
25-
new(_discovery, _probe, _loggerFactory.CreateLogger<DiscoveryDialogViewModel>());
25+
new(_aggregator, _probe, _loggerFactory.CreateLogger<DiscoveryDialogViewModel>());
2626
}

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,7 +338,7 @@ private static LangCode DetectSystem()
338338
["Discovery.Header"] = "Discover ONVIF cameras",
339339
["Discovery.Button.Scan"] = "Scan",
340340
["Discovery.Button.AddSelected"] = "Add selected",
341-
["Discovery.Status.Initial"] = "Click Scan to find ONVIF cameras on the LAN.",
341+
["Discovery.Status.Initial"] = "Click Scan to find cameras on the LAN (ONVIF + mDNS; enable Deep scan for the rest).",
342342
["Discovery.Status.Scanning"] = "Scanning…",
343343
["Discovery.Status.NoResponse"] = "No cameras responded. Check multicast / firewall.",
344344
["Discovery.Status.FoundFormat"] = "Found {0} camera(s).",
@@ -347,7 +347,12 @@ private static LangCode DetectSystem()
347347
["Discovery.Status.ProbingFormat"] = "Probing {0}…",
348348
["Discovery.Status.ProbeOkFormat"] = "OK — {0} {1}",
349349
["Discovery.Status.ProbeFailedFormat"] = "Probe failed: {0}",
350+
["Discovery.Status.ManualAdd"] = "No ONVIF — review the stream URL in the editor.",
351+
["Discovery.DeepScan"] = "Deep scan (slower)",
350352
["Discovery.UnknownModel"] = "(unknown model)",
353+
["Discovery.Confidence.Low"] = "low",
354+
["Discovery.Confidence.Medium"] = "medium",
355+
["Discovery.Confidence.High"] = "high",
351356

352357
["RawConfigEditor.Title"] = "Edit raw Majestic config",
353358
["RawConfigEditor.InvalidJsonFormat"] = "Invalid JSON: {0}",
@@ -742,7 +747,7 @@ private static LangCode DetectSystem()
742747
["Discovery.Header"] = "Поиск ONVIF-камер",
743748
["Discovery.Button.Scan"] = "Сканировать",
744749
["Discovery.Button.AddSelected"] = "Добавить выбранные",
745-
["Discovery.Status.Initial"] = "Нажмите «Сканировать», чтобы найти ONVIF-камеры в локальной сети.",
750+
["Discovery.Status.Initial"] = "Нажмите «Сканировать» для поиска камер в сети (ONVIF + mDNS; «Глубокое сканирование» — для остальных).",
746751
["Discovery.Status.Scanning"] = "Сканирование…",
747752
["Discovery.Status.NoResponse"] = "Камеры не ответили. Проверьте multicast / фаервол.",
748753
["Discovery.Status.FoundFormat"] = "Найдено камер: {0}.",
@@ -751,7 +756,12 @@ private static LangCode DetectSystem()
751756
["Discovery.Status.ProbingFormat"] = "Опрос {0}…",
752757
["Discovery.Status.ProbeOkFormat"] = "OK — {0} {1}",
753758
["Discovery.Status.ProbeFailedFormat"] = "Опрос не удался: {0}",
759+
["Discovery.Status.ManualAdd"] = "Без ONVIF — проверьте URL потока в редакторе.",
760+
["Discovery.DeepScan"] = "Глубокое сканирование (медленнее)",
754761
["Discovery.UnknownModel"] = "(неизвестная модель)",
762+
["Discovery.Confidence.Low"] = "низкая",
763+
["Discovery.Confidence.Medium"] = "средняя",
764+
["Discovery.Confidence.High"] = "высокая",
755765

756766
["RawConfigEditor.Title"] = "Правка raw-конфига Majestic",
757767
["RawConfigEditor.InvalidJsonFormat"] = "Невалидный JSON: {0}",

‎src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,10 @@ private async Task DiscoverCameraAsync()
324324
// everything before saving (RTSP URI especially — phase-04 risks §"ONVIF
325325
// returns wrong RTSP URI behind NAT" applies).
326326
var editor = _editorFactory.CreateForNew();
327-
editor.Name = found.Discovered.Model ?? found.Discovered.Name ?? found.Discovered.Host;
328-
editor.Host = found.Discovered.Host;
329-
editor.OnvifPortText = found.Discovered.OnvifPort.ToString(System.Globalization.CultureInfo.InvariantCulture);
330-
editor.RtspMainText = found.Probe.RtspMainUri.ToString();
327+
editor.Name = found.Device.Model ?? found.Device.Name ?? found.Device.Host;
328+
editor.Host = found.Device.Host;
329+
editor.OnvifPortText = (found.Device.OnvifServiceUri?.Port ?? 80).ToString(System.Globalization.CultureInfo.InvariantCulture);
330+
editor.RtspMainText = found.RtspMainUri.ToString();
331331
editor.Username = found.Credentials?.Username ?? "";
332332
editor.Password = found.Credentials?.Password ?? "";
333333

@@ -340,7 +340,9 @@ private async Task DiscoverCameraAsync()
340340
var id = await _directory.AddAsync(req, CancellationToken.None).ConfigureAwait(true);
341341
// Persist HasPtz / ProfileToken / manufacturer info from the probe so
342342
// SingleCameraPage knows whether to show the PTZ joystick (Phase 4c).
343-
await _directory.SaveOnvifMetadataAsync(id, found.Probe, CancellationToken.None).ConfigureAwait(true);
343+
// Non-ONVIF devices (sweep/mDNS) have no probe — nothing to persist.
344+
if (found.Probe is { } probe)
345+
await _directory.SaveOnvifMetadataAsync(id, probe, CancellationToken.None).ConfigureAwait(true);
344346
await LoadAsync(CancellationToken.None).ConfigureAwait(true);
345347
}
346348
catch (Exception ex)
Lines changed: 120 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,56 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Collections.ObjectModel;
4+
using System.Linq;
35
using System.Threading;
46
using System.Threading.Tasks;
57
using CommunityToolkit.Mvvm.ComponentModel;
68
using CommunityToolkit.Mvvm.Input;
79
using Microsoft.Extensions.Logging;
810
using OpenIPC.Viewer.App.Services;
11+
using OpenIPC.Viewer.Core.Discovery;
912
using OpenIPC.Viewer.Core.Entities;
1013
using OpenIPC.Viewer.Core.Onvif;
11-
using OpenIPC.Viewer.Core.Onvif.Discovery;
1214

1315
namespace OpenIPC.Viewer.App.ViewModels.Dialogs;
1416

15-
// Two-step: (1) Scan multicast WS-Discovery -> list of cameras; (2) user
16-
// picks one, types credentials, we probe ONVIF (capabilities + profiles +
17-
// stream URI) to produce the full result the Library page hands to the
18-
// CameraEditor. Probe is here so failed creds fail fast inside the dialog
17+
// Two-step: (1) scan via the aggregator (ONVIF + later sweep/mDNS) -> merged
18+
// devices, upserted by host as signals arrive; (2) user picks one, types creds,
19+
// we ONVIF-probe (capabilities + profiles + stream URI) to produce the result
20+
// the Library hands to the CameraEditor. Probe fails fast inside the dialog
1921
// instead of pre-filling the editor with bad data.
2022
public sealed partial class DiscoveryDialogViewModel : ViewModelBase
2123
{
22-
private readonly IDiscoveryService _discovery;
24+
private readonly IDiscoveryAggregator _aggregator;
2325
private readonly OnvifProbeService _probe;
2426
private readonly ILogger<DiscoveryDialogViewModel> _logger;
2527

2628
private CancellationTokenSource? _scanCts;
29+
private readonly Dictionary<string, DiscoveredDeviceRowVm> _rowsByHost =
30+
new(StringComparer.OrdinalIgnoreCase);
2731

28-
public ObservableCollection<DiscoveredCameraRowVm> Cameras { get; } = new();
32+
public ObservableCollection<DiscoveredDeviceRowVm> Cameras { get; } = new();
2933

3034
[ObservableProperty] private string _statusText = Localizer.Instance["Discovery.Status.Initial"];
3135

3236
[ObservableProperty]
3337
[NotifyPropertyChangedFor(nameof(IsRowSelected))]
3438
[NotifyPropertyChangedFor(nameof(CanAdd))]
35-
private DiscoveredCameraRowVm? _selected;
39+
private DiscoveredDeviceRowVm? _selected;
3640

3741
public bool IsRowSelected => Selected is not null;
3842

3943
[ObservableProperty] private string _username = "";
4044
[ObservableProperty] private string _password = "";
4145

46+
// 0..1 scan progress (mean across sources). Drives the progress bar; hidden
47+
// when not scanning.
48+
[ObservableProperty] private double _scanProgress;
49+
50+
// Opt-in active /24 sweep — finds OpenIPC cameras that answer neither ONVIF
51+
// nor mDNS, at the cost of knocking on every host. Off by default.
52+
[ObservableProperty] private bool _deepScan;
53+
4254
[ObservableProperty]
4355
[NotifyCanExecuteChangedFor(nameof(ScanCommand))]
4456
[NotifyPropertyChangedFor(nameof(CanAdd))]
@@ -52,11 +64,11 @@ public sealed partial class DiscoveryDialogViewModel : ViewModelBase
5264
public bool CanAdd => Selected is not null && !ScanInProgress && !AddInProgress;
5365

5466
public DiscoveryDialogViewModel(
55-
IDiscoveryService discovery,
67+
IDiscoveryAggregator aggregator,
5668
OnvifProbeService probe,
5769
ILogger<DiscoveryDialogViewModel> logger)
5870
{
59-
_discovery = discovery;
71+
_aggregator = aggregator;
6072
_probe = probe;
6173
_logger = logger;
6274
}
@@ -65,7 +77,9 @@ public DiscoveryDialogViewModel(
6577
private async Task ScanAsync()
6678
{
6779
Cameras.Clear();
80+
_rowsByHost.Clear();
6881
Selected = null;
82+
ScanProgress = 0;
6983
StatusText = Localizer.Instance["Discovery.Status.Scanning"];
7084
ScanInProgress = true;
7185

@@ -75,11 +89,12 @@ private async Task ScanAsync()
7589

7690
try
7791
{
78-
var timeout = TimeSpan.FromSeconds(6);
79-
await foreach (var cam in _discovery.ScanAsync(timeout, ct).ConfigureAwait(true))
80-
{
81-
Cameras.Add(new DiscoveredCameraRowVm(cam));
82-
}
92+
var options = new DiscoveryOptions(TimeSpan.FromSeconds(6), DeepScan);
93+
var progress = new Progress<double>(p => ScanProgress = p);
94+
95+
await foreach (var device in _aggregator.ScanAsync(options, progress, ct).ConfigureAwait(true))
96+
Upsert(device);
97+
8398
StatusText = Cameras.Count == 0
8499
? Localizer.Instance["Discovery.Status.NoResponse"]
85100
: string.Format(Localizer.Instance["Discovery.Status.FoundFormat"], Cameras.Count);
@@ -96,6 +111,23 @@ private async Task ScanAsync()
96111
finally
97112
{
98113
ScanInProgress = false;
114+
ScanProgress = 0;
115+
}
116+
}
117+
118+
// Merge-by-host upsert: a device can be yielded repeatedly as more sources
119+
// confirm it, so update the existing row in place instead of duplicating.
120+
private void Upsert(DiscoveredDevice device)
121+
{
122+
if (_rowsByHost.TryGetValue(device.Host, out var row))
123+
{
124+
row.Device = device;
125+
}
126+
else
127+
{
128+
row = new DiscoveredDeviceRowVm(device);
129+
_rowsByHost[device.Host] = row;
130+
Cameras.Add(row);
99131
}
100132
}
101133

@@ -106,21 +138,31 @@ private async Task ScanAsync()
106138
var row = Selected;
107139
if (row is null) return null;
108140

109-
AddInProgress = true;
110-
StatusText = string.Format(Localizer.Instance["Discovery.Status.ProbingFormat"], row.HostPort);
141+
var creds = string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password)
142+
? null
143+
: new CameraCredentials(Username, Password);
111144

145+
AddInProgress = true;
112146
try
113147
{
114-
var creds = string.IsNullOrEmpty(Username) && string.IsNullOrEmpty(Password)
115-
? null
116-
: new CameraCredentials(Username, Password);
117-
var endpoint = new OnvifEndpoint(row.Camera.DeviceServiceUri, creds);
148+
// ONVIF device → probe for the real stream URI. Non-ONVIF (sweep/mDNS)
149+
// → skip the probe and pre-fill a guessed RTSP URL from the open ports;
150+
// the user reviews / tests it in the editor before saving.
151+
var onvifUri = row.Device.OnvifServiceUri;
152+
if (onvifUri is null)
153+
{
154+
StatusText = Localizer.Instance["Discovery.Status.ManualAdd"];
155+
return new DiscoveryDialogResult(row.Device, GuessRtspUri(row.Device), null, creds);
156+
}
157+
158+
StatusText = string.Format(Localizer.Instance["Discovery.Status.ProbingFormat"], row.HostPort);
159+
var endpoint = new OnvifEndpoint(onvifUri, creds);
118160

119161
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
120162
var probeResult = await _probe.ProbeAsync(endpoint, cts.Token).ConfigureAwait(true);
121163

122164
StatusText = string.Format(Localizer.Instance["Discovery.Status.ProbeOkFormat"], probeResult.Manufacturer ?? "?", probeResult.Model ?? "").TrimEnd();
123-
return new DiscoveryDialogResult(row.Camera, probeResult, creds);
165+
return new DiscoveryDialogResult(row.Device, probeResult.RtspMainUri, probeResult, creds);
124166
}
125167
catch (Exception ex)
126168
{
@@ -134,26 +176,73 @@ private async Task ScanAsync()
134176
}
135177
}
136178

179+
// OpenIPC/Majestic RTSP convention (matches CameraEditor): rtsp://host/ for
180+
// the default 554, an explicit port otherwise. The user can refine it.
181+
private static Uri GuessRtspUri(DiscoveredDevice device)
182+
{
183+
var port = device.Ports.Contains(8554) && !device.Ports.Contains(554) ? 8554 : 554;
184+
return port == 554
185+
? new Uri($"rtsp://{device.Host}/")
186+
: new Uri($"rtsp://{device.Host}:{port}/");
187+
}
188+
137189
public void Cancel()
138190
{
139191
_scanCts?.Cancel();
140192
}
141193
}
142194

143-
public sealed class DiscoveredCameraRowVm
195+
public sealed partial class DiscoveredDeviceRowVm : ViewModelBase
144196
{
145-
public DiscoveredCamera Camera { get; }
146-
public string HostPort => Camera.OnvifPort == 80 ? Camera.Host : $"{Camera.Host}:{Camera.OnvifPort}";
147-
public string DisplayName => Camera.Name ?? Camera.Model ?? Camera.Host;
148-
public string Subtitle => Camera.Model ?? Localizer.Instance["Discovery.UnknownModel"];
197+
[ObservableProperty]
198+
[NotifyPropertyChangedFor(nameof(DisplayName))]
199+
[NotifyPropertyChangedFor(nameof(Subtitle))]
200+
[NotifyPropertyChangedFor(nameof(HostPort))]
201+
[NotifyPropertyChangedFor(nameof(ProtocolsText))]
202+
[NotifyPropertyChangedFor(nameof(ConfidenceText))]
203+
private DiscoveredDevice _device;
204+
205+
public DiscoveredDeviceRowVm(DiscoveredDevice device) => _device = device;
206+
207+
public string Host => Device.Host;
208+
public string DisplayName => Device.Name ?? Device.Model ?? Device.Host;
209+
public string Subtitle => Device.Model ?? Localizer.Instance["Discovery.UnknownModel"];
210+
211+
public string HostPort
212+
{
213+
get
214+
{
215+
var port = Device.OnvifServiceUri?.Port ?? (Device.Ports.Count > 0 ? Device.Ports.First() : 0);
216+
return port is 0 or 80 ? Device.Host : $"{Device.Host}:{port}";
217+
}
218+
}
219+
220+
// e.g. "ONVIF · RTSP" — how the device was detected.
221+
public string ProtocolsText => string.Join(" · ", DescribeProtocols(Device.Protocols));
222+
223+
public string ConfidenceText => Localizer.Instance[Device.Confidence switch
224+
{
225+
DiscoveryConfidence.High => "Discovery.Confidence.High",
226+
DiscoveryConfidence.Medium => "Discovery.Confidence.Medium",
227+
_ => "Discovery.Confidence.Low",
228+
}];
149229

150-
public DiscoveredCameraRowVm(DiscoveredCamera camera)
230+
private static IEnumerable<string> DescribeProtocols(DiscoveryProtocol p)
151231
{
152-
Camera = camera;
232+
if (p.HasFlag(DiscoveryProtocol.Onvif)) yield return "ONVIF";
233+
if (p.HasFlag(DiscoveryProtocol.Mdns)) yield return "mDNS";
234+
if (p.HasFlag(DiscoveryProtocol.Majestic)) yield return "Majestic";
235+
if (p.HasFlag(DiscoveryProtocol.Rtsp)) yield return "RTSP";
236+
if (p.HasFlag(DiscoveryProtocol.Http)) yield return "HTTP";
153237
}
154238
}
155239

240+
// The dialog's output: the picked device, the RTSP URL to pre-fill (from the
241+
// ONVIF probe when available, otherwise a sensible guess for non-ONVIF devices),
242+
// the ONVIF probe result (null for non-ONVIF — no PTZ/profile metadata), and any
243+
// credentials the user typed.
156244
public sealed record DiscoveryDialogResult(
157-
DiscoveredCamera Discovered,
158-
OnvifProbeResult Probe,
245+
DiscoveredDevice Device,
246+
Uri RtspMainUri,
247+
OnvifProbeResult? Probe,
159248
CameraCredentials? Credentials);

0 commit comments

Comments
 (0)