diff --git a/src/OpenIPC.Viewer.App/Converters/ReachabilityColorConverter.cs b/src/OpenIPC.Viewer.App/Converters/ReachabilityColorConverter.cs new file mode 100644 index 0000000..0307b8c --- /dev/null +++ b/src/OpenIPC.Viewer.App/Converters/ReachabilityColorConverter.cs @@ -0,0 +1,30 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using OpenIPC.Viewer.App.ViewModels; + +namespace OpenIPC.Viewer.App.Converters; + +public sealed class ReachabilityColorConverter : IValueConverter +{ + public static readonly ReachabilityColorConverter Instance = new(); + + private static readonly IBrush OnlineBrush = new SolidColorBrush(Color.Parse("#22c55e")); + private static readonly IBrush CheckingBrush = new SolidColorBrush(Color.Parse("#f59e0b")); + private static readonly IBrush OfflineBrush = new SolidColorBrush(Color.Parse("#5e6878")); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + if (value is not CameraReachability state) return OfflineBrush; + return state switch + { + CameraReachability.Online => OnlineBrush, + CameraReachability.Checking => CheckingBrush, + _ => OfflineBrush, + }; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/src/OpenIPC.Viewer.App/Services/Localizer.cs b/src/OpenIPC.Viewer.App/Services/Localizer.cs index 590996b..aa293ff 100644 --- a/src/OpenIPC.Viewer.App/Services/Localizer.cs +++ b/src/OpenIPC.Viewer.App/Services/Localizer.cs @@ -82,6 +82,8 @@ private static LangCode DetectSystem() ["Library.RowDelete"] = "Delete", ["Library.RowShowInGrid"] = "Show in grid", ["Library.Offline"] = "OFFLINE", + ["Library.Online"] = "ONLINE", + ["Library.Checking"] = "CHECKING…", ["Library.AllGroups"] = "All groups", ["Library.ManageGroups"] = "Groups…", ["Library.ScanQr"] = "Scan QR", @@ -280,6 +282,8 @@ private static LangCode DetectSystem() ["Library.RowDelete"] = "Удалить", ["Library.RowShowInGrid"] = "В гриде", ["Library.Offline"] = "ОФЛАЙН", + ["Library.Online"] = "ОНЛАЙН", + ["Library.Checking"] = "ПРОВЕРКА…", ["Library.AllGroups"] = "Все группы", ["Library.ManageGroups"] = "Группы…", ["Library.ScanQr"] = "Сканировать QR", diff --git a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs index f86abf2..89a7242 100644 --- a/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs +++ b/src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs @@ -42,6 +42,7 @@ public sealed partial class CameraLibraryPageViewModel : ViewModelBase private readonly UserSettingsService _userSettings; private readonly IDiscoveryService _discovery; + private readonly IReachabilityProbe _reachability; private readonly ManageGroupsDialogFactory _manageGroupsFactory; private bool _autoScanRanThisSession; private System.Collections.Generic.IReadOnlyList _allCameras = System.Array.Empty(); @@ -60,6 +61,7 @@ public CameraLibraryPageViewModel( ManageGroupsDialogFactory manageGroupsFactory, UserSettingsService userSettings, IDiscoveryService discovery, + IReachabilityProbe reachability, ILogger logger) { _directory = directory; @@ -69,6 +71,7 @@ public CameraLibraryPageViewModel( _manageGroupsFactory = manageGroupsFactory; _userSettings = userSettings; _discovery = discovery; + _reachability = reachability; _logger = logger; Cameras.CollectionChanged += (_, _) => { @@ -199,7 +202,20 @@ private void RefilterCameras() Cameras.Clear(); foreach (var camera in filtered) - Cameras.Add(new CameraRowViewModel(camera, _directory, _logger)); + Cameras.Add(new CameraRowViewModel(camera, _directory, _reachability, _logger)); + + // Kick off reachability probes for the freshly-built rows. Fire-and-forget: + // each row updates its own Status independently, in parallel. + _ = ProbeReachabilityAsync(); + } + + private async Task ProbeReachabilityAsync() + { + var rows = new System.Collections.Generic.List(Cameras); + var tasks = new System.Collections.Generic.List(rows.Count); + foreach (var row in rows) + tasks.Add(row.RefreshReachabilityAsync(CancellationToken.None)); + await Task.WhenAll(tasks).ConfigureAwait(true); } [RelayCommand] @@ -397,9 +413,17 @@ private async Task DeleteCameraAsync(CameraRowViewModel? row) } } +public enum CameraReachability { Checking, Online, Offline } + public sealed partial class CameraRowViewModel : ViewModelBase { + // Probe timeout per camera. Kept short so a screen of offline cameras + // settles quickly — probes run in parallel, so this is the worst-case + // wait for the whole list, not a per-camera sum. + private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(2); + private readonly CameraDirectoryService? _directory; + private readonly IReachabilityProbe? _reachability; private readonly ILogger? _logger; public Camera Camera { get; } @@ -410,16 +434,62 @@ public sealed partial class CameraRowViewModel : ViewModelBase [ObservableProperty] private bool _isIncludedInGrid; - public CameraRowViewModel(Camera camera) : this(camera, null, null) { } + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + private CameraReachability _status = CameraReachability.Checking; + + public string StatusText => Localizer.Instance[Status switch + { + CameraReachability.Online => "Library.Online", + CameraReachability.Checking => "Library.Checking", + _ => "Library.Offline", + }]; + + public CameraRowViewModel(Camera camera) : this(camera, null, null, null) { } public CameraRowViewModel(Camera camera, CameraDirectoryService? directory, ILogger? logger) + : this(camera, directory, null, logger) { } + + public CameraRowViewModel(Camera camera, CameraDirectoryService? directory, IReachabilityProbe? reachability, ILogger? logger) { Camera = camera; _directory = directory; + _reachability = reachability; _logger = logger; _isIncludedInGrid = camera.IncludedInGrid; } + /// + /// TCP-probes the camera's RTSP port and updates . + /// Started from the UI thread; the connect runs off-thread and the status + /// write resumes on the UI thread (ConfigureAwait(true)). + /// + public async Task RefreshReachabilityAsync(CancellationToken ct) + { + if (_reachability is null) + return; + + Status = CameraReachability.Checking; + + // RTSP default port is 554; Uri.Port yields -1 when the scheme has no + // registered default and the URI omits an explicit port. + var port = Camera.RtspMainUri.Port; + if (port <= 0) port = 554; + + try + { + var reachable = await _reachability + .IsReachableAsync(Camera.Host, port, ProbeTimeout, ct) + .ConfigureAwait(true); + Status = reachable ? CameraReachability.Online : CameraReachability.Offline; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Reachability probe failed for {CameraId}", Camera.Id); + Status = CameraReachability.Offline; + } + } + partial void OnIsIncludedInGridChanged(bool value) { if (_directory is null) return; diff --git a/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml b/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml index 25c8b85..9121a6c 100644 --- a/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml +++ b/src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:OpenIPC.Viewer.App.ViewModels" xmlns:svc="using:OpenIPC.Viewer.App.Services" + xmlns:conv="using:OpenIPC.Viewer.App.Converters" x:Class="OpenIPC.Viewer.App.Views.Pages.CameraLibraryPage" x:DataType="vm:CameraLibraryPageViewModel"> @@ -148,10 +149,10 @@ - + Foreground="{Binding Status, Converter={x:Static conv:ReachabilityColorConverter.Instance}}" /> diff --git a/src/OpenIPC.Viewer.Composition/SharedComposition.cs b/src/OpenIPC.Viewer.Composition/SharedComposition.cs index d287433..37c7045 100644 --- a/src/OpenIPC.Viewer.Composition/SharedComposition.cs +++ b/src/OpenIPC.Viewer.Composition/SharedComposition.cs @@ -14,6 +14,7 @@ using OpenIPC.Viewer.Devices.Majestic; using OpenIPC.Viewer.Devices.Onvif; using OpenIPC.Viewer.Devices.Onvif.Discovery; +using OpenIPC.Viewer.Infrastructure.Net; using OpenIPC.Viewer.Infrastructure.Persistence; namespace OpenIPC.Viewer.Composition; @@ -40,6 +41,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi // Domain services services.AddSingleton(); + services.AddSingleton(); // Video services.AddSingleton(); diff --git a/src/OpenIPC.Viewer.Core/Services/IReachabilityProbe.cs b/src/OpenIPC.Viewer.Core/Services/IReachabilityProbe.cs new file mode 100644 index 0000000..7aa07e7 --- /dev/null +++ b/src/OpenIPC.Viewer.Core/Services/IReachabilityProbe.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenIPC.Viewer.Core.Services; + +/// +/// Lightweight TCP reachability check used by the library to show whether a +/// camera answers on its stream port. Connect-only — it does not authenticate +/// or pull a frame; "reachable" means the TCP handshake completed within the +/// timeout. The implementation lives in Infrastructure (it does socket IO) and +/// is wired via DI; Core only owns the contract. +/// +public interface IReachabilityProbe +{ + Task IsReachableAsync(string host, int port, TimeSpan timeout, CancellationToken ct); +} diff --git a/src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs b/src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs new file mode 100644 index 0000000..cf32297 --- /dev/null +++ b/src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs @@ -0,0 +1,39 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using OpenIPC.Viewer.Core.Services; + +namespace OpenIPC.Viewer.Infrastructure.Net; + +/// +/// Reachability via a plain TCP connect. Any non-success (refused, no route, +/// DNS failure, timeout) is reported as unreachable rather than thrown — the +/// caller only wants a reachable/not flag for a status badge. +/// +public sealed class TcpReachabilityProbe : IReachabilityProbe +{ + public async Task IsReachableAsync(string host, int port, TimeSpan timeout, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(host) || port < 1 || port > 65535) + return false; + + using var client = new TcpClient(); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct); + linked.CancelAfter(timeout); + try + { + await client.ConnectAsync(host, port, linked.Token).ConfigureAwait(false); + return client.Connected; + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + return false; // our own timeout, not a caller cancel + } + catch (Exception) + { + // SocketException (refused / unreachable), DNS resolution failure, etc. + return false; + } + } +}