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
30 changes: 30 additions & 0 deletions src/OpenIPC.Viewer.App/Converters/ReachabilityColorConverter.cs
Original file line number Diff line number Diff line change
@@ -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();
}
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 @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
74 changes: 72 additions & 2 deletions src/OpenIPC.Viewer.App/ViewModels/CameraLibraryPageViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Camera> _allCameras = System.Array.Empty<Camera>();
Expand All @@ -60,6 +61,7 @@ public CameraLibraryPageViewModel(
ManageGroupsDialogFactory manageGroupsFactory,
UserSettingsService userSettings,
IDiscoveryService discovery,
IReachabilityProbe reachability,
ILogger<CameraLibraryPageViewModel> logger)
{
_directory = directory;
Expand All @@ -69,6 +71,7 @@ public CameraLibraryPageViewModel(
_manageGroupsFactory = manageGroupsFactory;
_userSettings = userSettings;
_discovery = discovery;
_reachability = reachability;
_logger = logger;
Cameras.CollectionChanged += (_, _) =>
{
Expand Down Expand Up @@ -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<CameraRowViewModel>(Cameras);
var tasks = new System.Collections.Generic.List<Task>(rows.Count);
foreach (var row in rows)
tasks.Add(row.RefreshReachabilityAsync(CancellationToken.None));
await Task.WhenAll(tasks).ConfigureAwait(true);
}

[RelayCommand]
Expand Down Expand Up @@ -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; }
Expand All @@ -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;
}

/// <summary>
/// TCP-probes the camera's RTSP port and updates <see cref="Status"/>.
/// Started from the UI thread; the connect runs off-thread and the status
/// write resumes on the UI thread (ConfigureAwait(true)).
/// </summary>
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;
Expand Down
5 changes: 3 additions & 2 deletions src/OpenIPC.Viewer.App/Views/Pages/CameraLibraryPage.axaml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<Grid RowDefinitions="Auto,*">
Expand Down Expand Up @@ -148,10 +149,10 @@
<Border Background="{StaticResource Bg3Brush}"
CornerRadius="3"
Padding="6,2">
<TextBlock Text="{Binding [Library.Offline], Source={x:Static svc:Localizer.Instance}}"
<TextBlock Text="{Binding StatusText}"
FontFamily="{StaticResource FontMono}"
FontSize="10"
Foreground="{StaticResource TextTertiaryBrush}" />
Foreground="{Binding Status, Converter={x:Static conv:ReachabilityColorConverter.Instance}}" />
</Border>
</WrapPanel>
</StackPanel>
Expand Down
2 changes: 2 additions & 0 deletions src/OpenIPC.Viewer.Composition/SharedComposition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -40,6 +41,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection servi

// Domain services
services.AddSingleton<CameraDirectoryService>();
services.AddSingleton<IReachabilityProbe, TcpReachabilityProbe>();

// Video
services.AddSingleton<IVideoEngine, OpenIPC.Viewer.Video.FfmpegVideoEngine>();
Expand Down
17 changes: 17 additions & 0 deletions src/OpenIPC.Viewer.Core/Services/IReachabilityProbe.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using System;
using System.Threading;
using System.Threading.Tasks;

namespace OpenIPC.Viewer.Core.Services;

/// <summary>
/// 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.
/// </summary>
public interface IReachabilityProbe
{
Task<bool> IsReachableAsync(string host, int port, TimeSpan timeout, CancellationToken ct);
}
39 changes: 39 additions & 0 deletions src/OpenIPC.Viewer.Infrastructure/Net/TcpReachabilityProbe.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public sealed class TcpReachabilityProbe : IReachabilityProbe
{
public async Task<bool> 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;
}
}
}
Loading