diff --git a/CopperPad.Gui/MainWindow.cs b/CopperPad.Gui/MainWindow.cs index 879bebe..dad9532 100644 --- a/CopperPad.Gui/MainWindow.cs +++ b/CopperPad.Gui/MainWindow.cs @@ -75,7 +75,7 @@ public MainWindow() MinHeight = 640; Content = BuildContent(); - _host.DevicesChanged += (_, args) => Dispatcher.UIThread.Post(() => OnDevicesChanged(args.Devices)); + _host.DevicesChanged += (_, args) => Dispatcher.UIThread.Post(() => OnDevicesChanged(args)); _host.RawReportReceived += (_, args) => Dispatcher.UIThread.Post(() => OnRawReport(args)); _host.StateChanged += (_, args) => Dispatcher.UIThread.Post(() => OnStateChanged(args.State)); Opened += async (_, _) => await InitializeAsync().ConfigureAwait(true); @@ -365,17 +365,30 @@ private async Task InitializeAsync() SetStatus("Profile load failed: " + ex.Message); } - _host.Start(); + StartHost(); } private void RefreshDevices() { - _host.Stop(); - _host.Start(); + try + { + _host.Stop(); + _host.Start(); + } + catch (Exception ex) when (IsRecoverableHidException(ex)) + { + SetStatus("Controller refresh failed: " + ex.Message); + } } - private void OnDevicesChanged(IReadOnlyList devices) + private void OnDevicesChanged(HidDevicesChangedEventArgs args) { + var devices = args.Devices; + if (!string.IsNullOrWhiteSpace(args.Diagnostic)) + { + SetStatus(args.Diagnostic); + } + var selectedId = _selectedDevice?.Id; var items = devices.Select(device => new DeviceListItem(device)).ToArray(); _deviceList.ItemsSource = items; @@ -392,6 +405,18 @@ private void OnDevicesChanged(IReadOnlyList devices) } } + private void StartHost() + { + try + { + _host.Start(); + } + catch (Exception ex) when (IsRecoverableHidException(ex)) + { + SetStatus("Controller scan failed: " + ex.Message); + } + } + private void SelectDevice(HidDeviceInfo? device) { _selectedDevice = device; @@ -932,6 +957,9 @@ private static void AddCell(Grid grid, Control control, int row, int column) private static int DecimalToInt(decimal? value) => (int)(value ?? 0); + private static bool IsRecoverableHidException(Exception ex) + => ex is IOException or InvalidOperationException or TimeoutException or UnauthorizedAccessException or NotSupportedException; + private static string ToHexRows(byte[] bytes) { if (bytes.Length == 0) diff --git a/CopperPad.Tests/ControllerDiagnosticsTests.cs b/CopperPad.Tests/ControllerDiagnosticsTests.cs index ea4d078..f230c53 100644 --- a/CopperPad.Tests/ControllerDiagnosticsTests.cs +++ b/CopperPad.Tests/ControllerDiagnosticsTests.cs @@ -20,6 +20,21 @@ public void DiagnosticsHost_EnumeratesAllHidDevices() Assert.Equal(observed, host.GetDevices()); } + [Fact] + public void DiagnosticsHost_PublishesScanDiagnosticWhenEnumerationFails() + { + var provider = new FakeHidDeviceProvider { GetDevicesException = new NotSupportedException("descriptor unavailable") }; + using var host = new ControllerDiagnosticsHost(provider, new ControllerHostOptions()); + HidDevicesChangedEventArgs? observed = null; + host.DevicesChanged += (_, args) => observed = args; + + host.Start(); + + Assert.Empty(host.GetDevices()); + Assert.NotNull(observed); + Assert.Equal("HID scan failed: descriptor unavailable", observed.Diagnostic); + } + [Fact] public async Task DiagnosticsHost_PublishesRawReportsAndProfileMappedState() { @@ -89,10 +104,19 @@ private sealed class FakeHidDeviceProvider : IHidDeviceProvider private readonly Dictionary _streams = new(StringComparer.Ordinal); private IReadOnlyList _devices = Array.Empty(); + public Exception? GetDevicesException { get; init; } + public event EventHandler? Changed; public IReadOnlyList GetDevices() - => _devices; + { + if (GetDevicesException != null) + { + throw GetDevicesException; + } + + return _devices; + } public IHidInputStream Open(HidDeviceDescriptor device, TimeSpan readTimeout) => _streams.TryGetValue(device.Id, out var stream) diff --git a/CopperPad/ControllerDiagnostics.cs b/CopperPad/ControllerDiagnostics.cs index d1079a3..82a7bd8 100644 --- a/CopperPad/ControllerDiagnostics.cs +++ b/CopperPad/ControllerDiagnostics.cs @@ -12,9 +12,10 @@ public sealed record HidDeviceInfo( bool ReportsUseId, string? Diagnostic); -public sealed class HidDevicesChangedEventArgs(IReadOnlyList devices) : EventArgs +public sealed class HidDevicesChangedEventArgs(IReadOnlyList devices, string? diagnostic = null) : EventArgs { public IReadOnlyList Devices { get; } = devices; + public string? Diagnostic { get; } = diagnostic; } public sealed class ControllerRawReportReceivedEventArgs(HidDeviceInfo device, byte[] report, int length, DateTimeOffset timestamp) : EventArgs @@ -38,6 +39,7 @@ public sealed class ControllerDiagnosticsHost : IDisposable private IReadOnlyList _descriptors = Array.Empty(); private IReadOnlyList _devices = Array.Empty(); private ControllerProfileSet _profiles; + private string? _diagnostic; private string? _selectedDeviceId; private CancellationTokenSource? _readerCancellation; private Task? _readerTask; @@ -168,12 +170,27 @@ private void OnProviderChanged(object? sender, EventArgs args) private void RescanLocked(bool restartReader) { - _descriptors = _provider.GetDevices() - .GroupBy(device => device.Id, StringComparer.Ordinal) - .Select(group => group.First()) - .OrderBy(device => device.ProductName, StringComparer.OrdinalIgnoreCase) - .ThenBy(device => device.Id, StringComparer.Ordinal) - .ToArray(); + try + { + _descriptors = _provider.GetDevices() + .GroupBy(device => device.Id, StringComparer.Ordinal) + .Select(group => group.First()) + .OrderBy(device => device.ProductName, StringComparer.OrdinalIgnoreCase) + .ThenBy(device => device.Id, StringComparer.Ordinal) + .ToArray(); + _diagnostic = null; + } + catch (Exception ex) when (IsRecoverableHidException(ex)) + { + StopReaderLocked(); + _descriptors = Array.Empty(); + _devices = Array.Empty(); + _selectedDeviceId = null; + _diagnostic = "HID scan failed: " + ex.Message; + RaiseDevicesChangedLocked(); + return; + } + _devices = _descriptors.Select(ToInfo).ToArray(); if (_selectedDeviceId != null && !_descriptors.Any(device => string.Equals(device.Id, _selectedDeviceId, StringComparison.Ordinal))) { @@ -260,7 +277,7 @@ private async Task ReadLoopAsync(HidDeviceDescriptor device, IControllerMapper m catch (OperationCanceledException) { } - catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException or UnauthorizedAccessException) + catch (Exception ex) when (IsRecoverableHidException(ex)) { var failed = info with { Diagnostic = "HID read failed: " + ex.Message }; var controller = new ControllerInfo( @@ -276,7 +293,10 @@ private async Task ReadLoopAsync(HidDeviceDescriptor device, IControllerMapper m } private void RaiseDevicesChangedLocked() - => DevicesChanged?.Invoke(this, new HidDevicesChangedEventArgs(_devices)); + => DevicesChanged?.Invoke(this, new HidDevicesChangedEventArgs(_devices, _diagnostic)); + + private static bool IsRecoverableHidException(Exception ex) + => ex is IOException or InvalidOperationException or TimeoutException or UnauthorizedAccessException or NotSupportedException; private void ThrowIfDisposed() { diff --git a/CopperPad/ControllerHost.cs b/CopperPad/ControllerHost.cs index e8ed2d7..d842ba7 100644 --- a/CopperPad/ControllerHost.cs +++ b/CopperPad/ControllerHost.cs @@ -226,7 +226,7 @@ private async Task ReadLoopAsync() catch (OperationCanceledException) { } - catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException or UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or InvalidOperationException or TimeoutException or UnauthorizedAccessException or NotSupportedException) { var diagnostic = "HID read failed: " + ex.Message; Info = Info with { IsConnected = false, Diagnostic = diagnostic }; diff --git a/CopperPad/HidSharpDeviceProvider.cs b/CopperPad/HidSharpDeviceProvider.cs index cb6ff8a..495116c 100644 --- a/CopperPad/HidSharpDeviceProvider.cs +++ b/CopperPad/HidSharpDeviceProvider.cs @@ -55,7 +55,7 @@ private static HidDeviceDescriptor CreateDescriptor(HidDevice device) .SelectMany(report => report.GetAllUsages()) .Any(IsGameControllerUsage); } - catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentException) + catch (Exception ex) when (ex is IOException or InvalidOperationException or ArgumentException or NotSupportedException) { diagnostic = "Unable to parse HID report descriptor: " + ex.Message; } @@ -99,7 +99,7 @@ private static string SafeString(Func getter, string? fallback, string d var value = getter(); return string.IsNullOrWhiteSpace(value) ? fallback ?? defaultValue : value; } - catch (Exception ex) when (ex is IOException or InvalidOperationException) + catch (Exception ex) when (ex is IOException or InvalidOperationException or NotSupportedException or UnauthorizedAccessException) { return string.IsNullOrWhiteSpace(fallback) ? defaultValue : fallback; }