11using System ;
2+ using System . Collections . Generic ;
23using System . Collections . ObjectModel ;
4+ using System . Linq ;
35using System . Threading ;
46using System . Threading . Tasks ;
57using CommunityToolkit . Mvvm . ComponentModel ;
68using CommunityToolkit . Mvvm . Input ;
79using Microsoft . Extensions . Logging ;
810using OpenIPC . Viewer . App . Services ;
11+ using OpenIPC . Viewer . Core . Discovery ;
912using OpenIPC . Viewer . Core . Entities ;
1013using OpenIPC . Viewer . Core . Onvif ;
11- using OpenIPC . Viewer . Core . Onvif . Discovery ;
1214
1315namespace 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.
2022public 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.
156244public sealed record DiscoveryDialogResult (
157- DiscoveredCamera Discovered ,
158- OnvifProbeResult Probe ,
245+ DiscoveredDevice Device ,
246+ Uri RtspMainUri ,
247+ OnvifProbeResult ? Probe ,
159248 CameraCredentials ? Credentials ) ;
0 commit comments