diff --git a/docs/DEVICE_INTERFACES.md b/docs/DEVICE_INTERFACES.md index e19eb67e..80f814fc 100644 --- a/docs/DEVICE_INTERFACES.md +++ b/docs/DEVICE_INTERFACES.md @@ -369,6 +369,53 @@ catch (OperationCanceledException) } ``` +#### Telling "the device went away" apart from a bug + +Every device operation opens with a connectivity guard. When it fails, it throws +`DeviceNotConnectedException` — a typed exception, so a disconnect can be classified without +matching on the exception message: + +```csharp +using Daqifi.Core.Device; +using Daqifi.Core.Device.SdCard; + +var sdCard = (ISdCardOperations)device; + +try +{ + IReadOnlyList files = await sdCard.GetSdCardFilesAsync(); +} +catch (DeviceNotConnectedException ex) +{ + // Ordinary and expected: the user pressed Disconnect mid-refresh, or WiFi dropped. + // Log at warning with reconnect guidance — do NOT raise an error-tracker issue. + logger.LogWarning(ex, ex.IsShuttingDown + ? "Device is disconnecting; skipping refresh." + : "Device is not connected. Reconnect and try again."); +} +catch (InvalidOperationException ex) +{ + // Everything else on this path really is a defect — e.g. calling a text command + // re-entrantly, or using a device constructed without a transport. + logger.LogError(ex, "Bug: invalid SD card operation."); +} +``` + +Notes: + +- `DeviceNotConnectedException` derives from `InvalidOperationException`, which is what these + guards threw before, so existing `catch (InvalidOperationException)` blocks keep working. Order + the `catch` clauses most-specific-first, as above. +- `IsShuttingDown` is `true` when the guard fired because a `Disconnect()` or `Dispose()` is in + flight (or already finished) rather than because the device was never connected. Both mean "the + device is unavailable"; the flag is there for callers that want to suppress a retry prompt when + the user initiated the disconnect themselves. +- `TransportNotConnectedException` is its sibling, not its base: it reports that the underlying + stream is gone (a serial unplug, a dropped TCP socket) while the device still believed it was + connected. Catch `DeviceNotConnectedException` for the device-state case and + `TransportNotConnectedException` for the transport case — or both, since they classify the same + way for reporting purposes. + ### Connection Status Monitoring ```csharp diff --git a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs index 5d46a7d5..918f9644 100644 --- a/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs +++ b/src/Daqifi.Core.Tests/Device/Capabilities/DaqifiDeviceCapabilityDocumentTests.cs @@ -35,7 +35,9 @@ public async Task ReadCapabilityDocumentAsync_WhenDisconnected_Throws() { var device = new TestableCapabilityDevice("BenchNq1"); - await Assert.ThrowsAsync( + // Typed since #395; still an InvalidOperationException by inheritance, so a consumer's + // existing catch keeps working. ThrowsAsync matches exactly, hence the derived type here. + await Assert.ThrowsAsync( () => device.ReadCapabilityDocumentAsync()); } diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs index a29a0deb..01192596 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceInitializeTests.cs @@ -87,7 +87,7 @@ public async Task InitializeAsync_WhenDisconnected_Throws() // Not connected // Act & Assert - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => device.InitializeAsync()); Assert.Equal("Device must be connected before initialization.", ex.Message); } diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs index 7a7c7fa9..82dcd264 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTests.cs @@ -71,13 +71,13 @@ public void Disconnect_ChangesStatusAndRaisesEvent() } [Fact] - public void Send_WhenDisconnected_ThrowsInvalidOperationException() + public void Send_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiDevice("TestDevice"); // Act & Assert - Assert.Throws(() => device.Send(new Daqifi.Core.Communication.Messages.ScpiMessage(""))); + Assert.Throws(() => device.Send(new Daqifi.Core.Communication.Messages.ScpiMessage(""))); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs index 56736799..5fb9deb3 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceTextCommandLockTests.cs @@ -40,23 +40,23 @@ public async Task ExecuteTextCommandAsync_WhenAlreadyInsideAsyncFlow_ThrowsInval } [Fact] - public async Task ExecuteTextCommandAsync_WhenDisposing_ThrowsInvalidOperation() + public async Task ExecuteTextCommandAsync_WhenDisposing_ThrowsDeviceNotConnected() { var device = new TextCommandTestableDevice("TestDevice"); SetIsDisconnecting(device, true); - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); Assert.Contains("disposing or disconnecting", ex.Message); } [Fact] - public async Task ExecuteTextCommandAsync_WhenDisposed_ThrowsInvalidOperation() + public async Task ExecuteTextCommandAsync_WhenDisposed_ThrowsDeviceNotConnected() { var device = new TextCommandTestableDevice("TestDevice"); SetDisposed(device, true); - var ex = await Assert.ThrowsAsync( + var ex = await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); Assert.Contains("disposing or disconnecting", ex.Message); } @@ -70,12 +70,12 @@ public async Task ExecuteTextCommandAsync_ReleasesLockAfterValidationFailure() // not block on WaitAsync. var device = new TextCommandTestableDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); // Second call: also throws, but ONLY if the lock was released. // If the lock leaked, this would deadlock and xunit's per-test // budget would time it out instead. - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); } @@ -87,7 +87,7 @@ public async Task ExecuteTextCommandAsync_AsyncLocalClearedAfterReturn() // the same flow doesn't false-positive the re-entrancy check. var device = new TextCommandTestableDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CallExecuteTextCommandAsync(() => { })); Assert.False(GetIsInsideTextExchange(device).Value); diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs index 925d9586..c6c51902 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithMessageProducerTests.cs @@ -77,7 +77,7 @@ public void DaqifiDevice_SendMessage_WhenDisconnected_ShouldThrowException() using var device = new DaqifiDevice("Test Device", stream); // Act & Assert - Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); + Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs index aa3882bb..6e0b405d 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiDeviceWithTransportTests.cs @@ -83,7 +83,7 @@ public void DaqifiDevice_SendMessage_WithoutConnection_ShouldThrowException() using var device = new DaqifiDevice("Test Device", transport); // Act & Assert - Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); + Assert.Throws(() => device.Send(ScpiMessageProducer.GetDeviceInfo)); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs index e4b0b5a0..d155768a 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceChannelManagementTests.cs @@ -571,13 +571,13 @@ public void SetPwmFrequency_OutOfRange_ThrowsArgumentOutOfRangeException(int fre } [Fact] - public void SetPwmEnabled_WhenDisconnected_ThrowsInvalidOperationException() + public void SetPwmEnabled_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = CreateConnectedDevice(digitalChannels: 8); var channel = DigitalChannelAt(device, 4); device.Disconnect(); - Assert.Throws(() => device.SetPwmEnabled(channel, true)); + Assert.Throws(() => device.SetPwmEnabled(channel, true)); } [Fact] @@ -688,7 +688,7 @@ public void DisableChannel_WithNullChannel_ThrowsArgumentNullException() } [Fact] - public void ChannelManagement_WhenDisconnected_ThrowsInvalidOperationException() + public void ChannelManagement_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Populate channels but do not connect. var device = new TestableDaqifiStreamingDevice("TestDevice"); @@ -701,14 +701,14 @@ public void ChannelManagement_WhenDisconnected_ThrowsInvalidOperationException() var analog = AnalogChannelAt(device, 0); var digital = DigitalChannelAt(device, 0); - Assert.Throws(() => device.EnableChannel(analog)); - Assert.Throws(() => device.EnableChannels(new[] { analog })); - Assert.Throws(() => device.DisableChannel(analog)); - Assert.Throws(() => device.DisableAllChannels()); - Assert.Throws(() => device.SetDioDirection(digital, ChannelDirection.Output)); - Assert.Throws(() => device.SetDioValue(digital, true)); - Assert.Throws(() => device.SetAnalogOutput(0, 1.0)); - Assert.Throws(() => device.Reboot()); + Assert.Throws(() => device.EnableChannel(analog)); + Assert.Throws(() => device.EnableChannels(new[] { analog })); + Assert.Throws(() => device.DisableChannel(analog)); + Assert.Throws(() => device.DisableAllChannels()); + Assert.Throws(() => device.SetDioDirection(digital, ChannelDirection.Output)); + Assert.Throws(() => device.SetDioValue(digital, true)); + Assert.Throws(() => device.SetAnalogOutput(0, 1.0)); + Assert.Throws(() => device.Reboot()); } #endregion diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs index a9488b9d..79227663 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceFriendlyNameTests.cs @@ -36,7 +36,7 @@ public async Task SetFriendlyNameAsync_InvalidName_Throws(string name) public async Task SetFriendlyNameAsync_NotConnected_Throws() { var device = new CapturingStreamingDevice(); // not connected - await Assert.ThrowsAsync(() => device.SetFriendlyNameAsync("Lab Nq1")); + await Assert.ThrowsAsync(() => device.SetFriendlyNameAsync("Lab Nq1")); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs index 573de3ad..2b9613f3 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDevicePwmFrequencyTests.cs @@ -75,7 +75,7 @@ public void SetPwmFrequency_AfterReconnect_SendsAgainEvenIfUnchanged() public void SetPwmFrequency_NotConnected_ThrowsAndSendsNothing() { var device = new CapturingStreamingDevice(); // not connected - Assert.Throws(() => device.SetPwmFrequency(2000)); + Assert.Throws(() => device.SetPwmFrequency(2000)); Assert.Empty(device.PwmFrequencySends); } diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs index e034efc8..9c27dd6d 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs @@ -133,24 +133,24 @@ public void StopStreaming_WhenConnected_SendsCorrectCommandAndSetsIsStreaming() } [Fact] - public void StartStreaming_WhenDisconnected_ThrowsInvalidOperationException() + public void StartStreaming_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.StartStreaming()); + var exception = Assert.Throws(() => device.StartStreaming()); Assert.Equal("Device is not connected.", exception.Message); } [Fact] - public void StopStreaming_WhenDisconnected_ThrowsInvalidOperationException() + public void StopStreaming_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.StopStreaming()); + var exception = Assert.Throws(() => device.StopStreaming()); Assert.Equal("Device is not connected.", exception.Message); } @@ -201,14 +201,14 @@ public void NvmPersistence_WhenConnected_SendsCorrectCommand(string methodName, [Theory] [MemberData(nameof(NvmPersistenceCommands))] - public void NvmPersistence_WhenDisconnected_ThrowsInvalidOperationException(string methodName, string expectedCommand) + public void NvmPersistence_WhenDisconnected_ThrowsDeviceNotConnectedException(string methodName, string expectedCommand) { // Arrange _ = expectedCommand; var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => InvokeNvmMethod(device, methodName)); + var exception = Assert.Throws(() => InvokeNvmMethod(device, methodName)); Assert.Equal("Device is not connected.", exception.Message); } @@ -264,26 +264,26 @@ public void UseAdcCalibration_WhenConnected_SendsCorrectCommand(int bank) } [Fact] - public void SetAdcCalibrationSlope_WhenDisconnected_ThrowsInvalidOperationException() + public void SetAdcCalibrationSlope_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = Assert.Throws(() => device.SetAdcCalibrationSlope(0, 1.0)); + var exception = Assert.Throws(() => device.SetAdcCalibrationSlope(0, 1.0)); Assert.Equal("Device is not connected.", exception.Message); } [Fact] - public void SetAdcCalibrationOffset_WhenDisconnected_ThrowsInvalidOperationException() + public void SetAdcCalibrationOffset_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = Assert.Throws(() => device.SetAdcCalibrationOffset(0, 1.0)); + var exception = Assert.Throws(() => device.SetAdcCalibrationOffset(0, 1.0)); Assert.Equal("Device is not connected.", exception.Message); } [Fact] - public void UseAdcCalibration_WhenDisconnected_ThrowsInvalidOperationException() + public void UseAdcCalibration_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = Assert.Throws(() => device.UseAdcCalibration(1)); + var exception = Assert.Throws(() => device.UseAdcCalibration(1)); Assert.Equal("Device is not connected.", exception.Message); } diff --git a/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs new file mode 100644 index 00000000..c83cc0e9 --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/DeviceNotConnectedExceptionTests.cs @@ -0,0 +1,335 @@ +using Daqifi.Core.Communication.Messages; +using Daqifi.Core.Communication.Transport; +using Daqifi.Core.Device; +using Daqifi.Core.Device.Network; +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Daqifi.Core.Tests.Device +{ + /// + /// Tests for issue #395 — the up-front connectivity guards must throw the typed + /// so clients can classify "the device went away" + /// (ordinary and expected) apart from a genuine application defect, without matching on the + /// exception message. + /// + public class DeviceNotConnectedExceptionTests + { + // ── Type contract ─────────────────────────────────────────────────── + + [Fact] + public void DeviceNotConnectedException_DerivesFromInvalidOperationException() + { + // The guards previously threw a plain InvalidOperationException. Deriving keeps + // existing catch (InvalidOperationException) sites working unchanged. + Assert.IsAssignableFrom(new DeviceNotConnectedException()); + } + + [Fact] + public void DeviceNotConnectedException_IsNotATransportNotConnectedException() + { + // The two typed connectivity exceptions are deliberate siblings, not a hierarchy: a + // device can fail its guard while its transport is healthy (mid-Disconnect), and a + // transport can drop while the device still reports Connected. + Assert.IsNotAssignableFrom(new DeviceNotConnectedException()); + Assert.IsNotAssignableFrom(new TransportNotConnectedException()); + } + + [Fact] + public void DeviceNotConnectedException_DefaultMessage_MatchesThePreviousGuardWording() + { + // Kept byte-for-byte so downstream code still matching on the message keeps working + // while it migrates to the type. + Assert.Equal("Device is not connected.", new DeviceNotConnectedException().Message); + } + + [Fact] + public void DeviceNotConnectedException_IsShuttingDown_DefaultsToFalse() + { + Assert.False(new DeviceNotConnectedException().IsShuttingDown); + Assert.False(new DeviceNotConnectedException("custom").IsShuttingDown); + Assert.False(new DeviceNotConnectedException("custom", new Exception("inner")).IsShuttingDown); + } + + [Fact] + public void DeviceNotConnectedException_PreservesMessageAndInnerException() + { + var inner = new Exception("inner"); + var ex = new DeviceNotConnectedException("custom", inner); + + Assert.Equal("custom", ex.Message); + Assert.Same(inner, ex.InnerException); + } + + [Fact] + public void DeviceNotConnectedException_ShuttingDownConstructor_SetsTheFlag() + { + var ex = new DeviceNotConnectedException("tearing down", isShuttingDown: true); + + Assert.True(ex.IsShuttingDown); + Assert.Equal("tearing down", ex.Message); + } + + // ── Guard sites: synchronous API ──────────────────────────────────── + + public static IEnumerable SynchronousGuardSites() + { + yield return Site("StartStreaming", d => d.StartStreaming()); + yield return Site("StopStreaming", d => d.StopStreaming()); + yield return Site("Reboot", d => d.Reboot()); + yield return Site("DisableAllChannels", d => d.DisableAllChannels()); + yield return Site("SetAnalogOutput", d => d.SetAnalogOutput(0, 1.0)); + yield return Site("SetPwmFrequency", d => d.SetPwmFrequency(1000)); + yield return Site("SaveAdcCalibration", d => d.SaveAdcCalibration()); + yield return Site("LoadAdcCalibration", d => d.LoadAdcCalibration()); + yield return Site("SaveFactoryAdcCalibration", d => d.SaveFactoryAdcCalibration()); + yield return Site("LoadFactoryAdcCalibration", d => d.LoadFactoryAdcCalibration()); + yield return Site("UseAdcCalibration", d => d.UseAdcCalibration(1)); + yield return Site("SetAdcCalibrationSlope", d => d.SetAdcCalibrationSlope(0, 1.0)); + yield return Site("SetAdcCalibrationOffset", d => d.SetAdcCalibrationOffset(0, 0.0)); + yield return Site("SaveVoltagePrecision", d => d.SaveVoltagePrecision()); + yield return Site("LoadVoltagePrecision", d => d.LoadVoltagePrecision()); + yield return Site("PrepareSdInterface", d => d.PrepareSdInterface()); + yield return Site("PrepareLanInterface", d => d.PrepareLanInterface()); + yield return Site("SetSdCardMinimumFreeSpace", d => d.SetSdCardMinimumFreeSpace(52_428_800)); + yield return Site("Send", d => d.Send(new ScpiMessage("*IDN?"))); + + static object[] Site(string name, Action call) => [name, call]; + } + + [Theory] + [MemberData(nameof(SynchronousGuardSites))] + public void SynchronousGuard_WhenDisconnected_ThrowsDeviceNotConnected( + string siteName, + Action call) + { + _ = siteName; + var device = new DaqifiStreamingDevice("TestDevice"); + + var ex = Assert.Throws(() => call(device)); + + Assert.Equal("Device is not connected.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + // ── Guard sites: asynchronous API (SD, network, diagnostics) ──────── + + public static IEnumerable AsynchronousGuardSites() + { + yield return Site("GetSdCardFilesAsync", d => d.GetSdCardFilesAsync()); + yield return Site("GetSdCardStorageAsync", d => d.GetSdCardStorageAsync()); + yield return Site("CheckSdCardSpaceAsync", d => d.CheckSdCardSpaceAsync()); + yield return Site("StartSdCardLoggingAsync", d => d.StartSdCardLoggingAsync()); + yield return Site("StartSdCardLoggingSessionAsync", d => d.StartSdCardLoggingSessionAsync()); + yield return Site("StopSdCardLoggingAsync", d => d.StopSdCardLoggingAsync()); + yield return Site("DeleteSdCardFileAsync", d => d.DeleteSdCardFileAsync("test.bin")); + yield return Site("FormatSdCardAsync", d => d.FormatSdCardAsync()); + yield return Site("DownloadSdCardFileAsync", d => d.DownloadSdCardFileAsync("test.bin", new MemoryStream())); + yield return Site("UpdateNetworkConfigurationAsync", d => d.UpdateNetworkConfigurationAsync( + new NetworkConfiguration(WifiMode.ExistingNetwork, WifiSecurityType.WpaPskPhrase, "ssid", "pw"))); + yield return Site("LoadNetworkConfigurationAsync", d => d.LoadNetworkConfigurationAsync()); + yield return Site("FactoryResetNetworkAsync", d => d.FactoryResetNetworkAsync()); + yield return Site("GetLanChipInfoAsync", d => d.GetLanChipInfoAsync()); + yield return Site("GetSystemLogAsync", d => d.GetSystemLogAsync()); + yield return Site("ClearSystemLogAsync", d => d.ClearSystemLogAsync()); + yield return Site("SetLogLevelAsync", d => d.SetLogLevelAsync("STREAM", 2)); + yield return Site("GetCommandHistoryAsync", d => d.GetCommandHistoryAsync()); + yield return Site("TestSystemLogAsync", d => d.TestSystemLogAsync()); + yield return Site("GetSystemErrorCountAsync", d => d.GetSystemErrorCountAsync()); + yield return Site("GetStreamStatsAsync", d => d.GetStreamStatsAsync()); + yield return Site("GetMemoryDiagnosticsAsync", d => d.GetMemoryDiagnosticsAsync()); + yield return Site("SetFriendlyNameAsync", d => d.SetFriendlyNameAsync("Lab Nq1")); + + static object[] Site(string name, Func call) => [name, call]; + } + + [Theory] + [MemberData(nameof(AsynchronousGuardSites))] + public async Task AsynchronousGuard_WhenDisconnected_ThrowsDeviceNotConnected( + string siteName, + Func call) + { + _ = siteName; + var device = new DaqifiStreamingDevice("TestDevice"); + + var ex = await Assert.ThrowsAsync(() => call(device)); + + Assert.Equal("Device is not connected.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + [Fact] + public async Task InitializeAsync_WhenDisconnected_ThrowsDeviceNotConnected() + { + // This guard keeps its own wording, so it is worth pinning separately: the type is + // what callers classify on, not the message. + var device = new DaqifiStreamingDevice("TestDevice"); + + var ex = await Assert.ThrowsAsync(() => device.InitializeAsync()); + + Assert.Equal("Device must be connected before initialization.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + // ── The disposing / disconnecting distinction ─────────────────────── + + [Fact] + public async Task TextCommand_WhenNotConnected_ThrowsWithIsShuttingDownFalse() + { + var device = new TextCommandTestableDevice("TestDevice"); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.Equal("Device is not connected.", ex.Message); + Assert.False(ex.IsShuttingDown); + } + + [Fact] + public async Task TextCommand_WhenDisconnecting_ThrowsWithIsShuttingDownTrue() + { + // The real disconnect race the issue describes: Disconnect() sets _isDisconnecting + // before the transport check further down is ever reached, so this guard is what an + // in-flight caller actually sees. + var device = new TextCommandTestableDevice("TestDevice"); + SetPrivateField(device, "_isDisconnecting", true); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.True(ex.IsShuttingDown); + Assert.Contains("disposing or disconnecting", ex.Message); + } + + [Fact] + public async Task TextCommand_WhenDisposed_ThrowsWithIsShuttingDownTrue() + { + var device = new TextCommandTestableDevice("TestDevice"); + SetPrivateField(device, "_disposed", true); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.True(ex.IsShuttingDown); + Assert.Contains("disposing or disconnecting", ex.Message); + } + + [Fact] + public async Task TextCommand_WhenLockAlreadyDisposedByRacingDispose_ThrowsWithIsShuttingDownTrue() + { + // Dispose() disposed the text-exchange semaphore while this caller was about to wait + // on it. That path is also "the device went away", so it carries the same flag rather + // than leaking a low-level ObjectDisposedException. + var device = new TextCommandTestableDevice("TestDevice"); + var semaphore = (SemaphoreSlim)typeof(DaqifiDevice) + .GetField("_textExchangeLock", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(device)!; + semaphore.Dispose(); + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.True(ex.IsShuttingDown); + Assert.Contains("disposed", ex.Message); + + // The translation must not discard the cause: the original + // ObjectDisposedException survives so this rare race stays diagnosable. + Assert.IsType(ex.InnerException); + } + + [Fact] + public void DeviceNotConnectedException_CanCarryBothAnInnerExceptionAndTheShutdownFlag() + { + var inner = new ObjectDisposedException("SemaphoreSlim"); + var ex = new DeviceNotConnectedException("tearing down", inner, isShuttingDown: true); + + Assert.Equal("tearing down", ex.Message); + Assert.Same(inner, ex.InnerException); + Assert.True(ex.IsShuttingDown); + } + + [Fact] + public async Task TextCommand_ReEntrancyGuard_StaysAPlainInvalidOperationException() + { + // Re-entering ExecuteTextCommandAsync from a setupAction is an application defect, + // not a connectivity condition — it must NOT be classified as "the device went away". + var device = new TextCommandTestableDevice("TestDevice"); + var flag = (AsyncLocal)typeof(DaqifiDevice) + .GetField("_isInsideTextExchange", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(device)!; + flag.Value = true; + + var ex = await Assert.ThrowsAsync( + () => device.CallExecuteTextCommandAsync()); + + Assert.IsNotAssignableFrom(ex); + Assert.Contains("not re-entrant", ex.Message); + } + + // ── Source compatibility for existing consumers ───────────────────── + + [Fact] + public void ExistingCatchOfInvalidOperationException_StillCatchesTheGuard() + { + var device = new DaqifiStreamingDevice("TestDevice"); + var caught = false; + + try + { + device.StartStreaming(); + } + catch (InvalidOperationException) + { + caught = true; + } + + Assert.True(caught); + } + + [Fact] + public void GuardException_CanBeClassifiedApartFromAnUnrelatedInvalidOperation() + { + // The whole point of the issue: a disconnect is separable from a real defect without + // reading either exception's message. + var device = new DaqifiStreamingDevice("TestDevice"); + + var disconnect = Record.Exception(() => device.StartStreaming()); + Exception defect = new InvalidOperationException("a genuine bug"); + + Assert.IsType(disconnect); + Assert.IsNotAssignableFrom(defect); + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private static void SetPrivateField(DaqifiDevice device, string fieldName, object value) + { + typeof(DaqifiDevice) + .GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(device, value); + } + + /// + /// Exposes the protected ExecuteTextCommandAsync so the text-path guards can be + /// exercised directly. The real method runs, guards included. + /// + private sealed class TextCommandTestableDevice : DaqifiDevice + { + public TextCommandTestableDevice(string name, IPAddress? ipAddress = null) + : base(name, ipAddress) + { + } + + public Task> CallExecuteTextCommandAsync() + { + return ExecuteTextCommandAsync(() => { }, responseTimeoutMs: 100, completionTimeoutMs: 50); + } + } + } +} diff --git a/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs index 83533900..40f72899 100644 --- a/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs +++ b/src/Daqifi.Core.Tests/Device/Diagnostics/DeviceDiagnosticsTests.cs @@ -17,7 +17,7 @@ public async Task GetSystemLogAsync_WhenDisconnected_Throws() { var device = new TestableDiagnosticsDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.GetSystemLogAsync()); + await Assert.ThrowsAsync(() => device.GetSystemLogAsync()); } [Fact] @@ -91,7 +91,7 @@ public async Task ClearSystemLogAsync_WhenDisconnected_Throws() { var device = new TestableDiagnosticsDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.ClearSystemLogAsync()); + await Assert.ThrowsAsync(() => device.ClearSystemLogAsync()); } [Fact] @@ -276,7 +276,7 @@ public async Task GetMemoryDiagnosticsAsync_WhenDisconnected_Throws() { var device = new TestableDiagnosticsDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.GetMemoryDiagnosticsAsync()); + await Assert.ThrowsAsync(() => device.GetMemoryDiagnosticsAsync()); } /// diff --git a/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs b/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs index 7fc05269..d99c980f 100644 --- a/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs +++ b/src/Daqifi.Core.Tests/Device/GetLanChipInfoAsyncTests.cs @@ -17,7 +17,7 @@ public async Task GetLanChipInfoAsync_WhenDisconnected_Throws() { var device = new TestableLanChipInfoDevice("TestDevice"); - await Assert.ThrowsAsync(() => device.GetLanChipInfoAsync()); + await Assert.ThrowsAsync(() => device.GetLanChipInfoAsync()); } [Fact] diff --git a/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs b/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs index 7b65038b..ff1fca7c 100644 --- a/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs +++ b/src/Daqifi.Core.Tests/Device/Network/NetworkConfigurableTests.cs @@ -27,7 +27,7 @@ public void NetworkConfiguration_InitializedOnConstruction() } [Fact] - public async Task UpdateNetworkConfigurationAsync_WhenDisconnected_ThrowsInvalidOperationException() + public async Task UpdateNetworkConfigurationAsync_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); @@ -38,7 +38,7 @@ public async Task UpdateNetworkConfigurationAsync_WhenDisconnected_ThrowsInvalid "TestPassword"); // Act & Assert - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => device.UpdateNetworkConfigurationAsync(config)); Assert.Equal("Device is not connected.", exception.Message); } @@ -477,13 +477,13 @@ public async Task UpdateNetworkConfigurationAsync_CanceledDuringRestartWait_Comp } [Fact] - public void PrepareSdInterface_WhenDisconnected_ThrowsInvalidOperationException() + public void PrepareSdInterface_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.PrepareSdInterface()); + var exception = Assert.Throws(() => device.PrepareSdInterface()); Assert.Equal("Device is not connected.", exception.Message); } @@ -506,13 +506,13 @@ public void PrepareSdInterface_WhenConnected_SendsCorrectCommands() } [Fact] - public void PrepareLanInterface_WhenDisconnected_ThrowsInvalidOperationException() + public void PrepareLanInterface_WhenDisconnected_ThrowsDeviceNotConnectedException() { // Arrange var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - var exception = Assert.Throws(() => device.PrepareLanInterface()); + var exception = Assert.Throws(() => device.PrepareLanInterface()); Assert.Equal("Device is not connected.", exception.Message); } @@ -641,11 +641,11 @@ public async Task UpdateNetworkConfigurationAsync_WithUnsupportedSecurityType_Th } [Fact] - public async Task LoadNetworkConfigurationAsync_WhenDisconnected_ThrowsInvalidOperationException() + public async Task LoadNetworkConfigurationAsync_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => device.LoadNetworkConfigurationAsync()); Assert.Equal("Device is not connected.", exception.Message); } @@ -676,11 +676,11 @@ await Assert.ThrowsAsync( } [Fact] - public async Task FactoryResetNetworkAsync_WhenDisconnected_ThrowsInvalidOperationException() + public async Task FactoryResetNetworkAsync_WhenDisconnected_ThrowsDeviceNotConnectedException() { var device = new DaqifiStreamingDevice("TestDevice"); - var exception = await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => device.FactoryResetNetworkAsync()); Assert.Equal("Device is not connected.", exception.Message); } diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index c307cef0..974d205f 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -24,7 +24,7 @@ public async Task GetSdCardFilesAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.GetSdCardFilesAsync()); } @@ -616,7 +616,7 @@ public async Task StartSdCardLoggingAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.StartSdCardLoggingAsync()); } @@ -627,7 +627,7 @@ public async Task StopSdCardLoggingAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.StopSdCardLoggingAsync()); } @@ -690,7 +690,7 @@ public async Task DeleteSdCardFileAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.DeleteSdCardFileAsync("data.bin")); } @@ -1156,7 +1156,7 @@ public async Task FormatSdCardAsync_WhenDisconnected_Throws() var device = new DaqifiStreamingDevice("TestDevice"); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.FormatSdCardAsync()); } @@ -1253,7 +1253,7 @@ public async Task GetSdCardStorageAsync_WhenDisconnected_Throws() { var device = new DaqifiStreamingDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.GetSdCardStorageAsync()); } @@ -1509,7 +1509,7 @@ public async Task CheckSdCardSpaceAsync_WhenDisconnected_Throws() { var device = new TestableSdCardStreamingDevice("TestDevice"); - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.CheckSdCardSpaceAsync()); } @@ -1547,7 +1547,7 @@ public void SetSdCardMinimumFreeSpace_WhenDisconnected_Throws() { var device = new DaqifiStreamingDevice("TestDevice"); - Assert.Throws(() => device.SetSdCardMinimumFreeSpace(52428800)); + Assert.Throws(() => device.SetSdCardMinimumFreeSpace(52428800)); } [Fact] @@ -1571,7 +1571,7 @@ public async Task DownloadSdCardFileAsync_WhenDisconnected_Throws() using var stream = new MemoryStream(); // Act & Assert - await Assert.ThrowsAsync( + await Assert.ThrowsAsync( () => device.DownloadSdCardFileAsync("test.bin", stream)); } diff --git a/src/Daqifi.Core/Device/DaqifiDevice.cs b/src/Daqifi.Core/Device/DaqifiDevice.cs index 72e583fd..64a347cf 100644 --- a/src/Daqifi.Core/Device/DaqifiDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiDevice.cs @@ -599,16 +599,16 @@ public void Disconnect() /// /// The type of the message data payload. /// The message to send to the device. + /// Thrown when the device is not connected. /// - /// Thrown when the device is not connected, or when connected but has no transport or - /// stream to send on (e.g. the producer-less - /// constructor). + /// Thrown when the device is connected but has no transport or stream to send on + /// (e.g. the producer-less constructor). /// public virtual void Send(IOutboundMessage message) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Use the queued message producer when available and the message is string-based; @@ -644,14 +644,15 @@ public virtual void Send(IOutboundMessage message) /// /// A cancellation token to observe. /// A task representing the asynchronous operation. - /// Thrown when the device is not connected or has no transport. + /// Thrown when the device is not connected. + /// Thrown when the device has no transport-based connection. protected virtual async Task ExecuteRawCaptureAsync( Func rawAction, CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_transport == null) @@ -763,7 +764,13 @@ private void RestartMessageConsumerAfterSwap() /// exchange opened — late replies to earlier commands — are excluded: only what arrived once /// had begun sending is returned. /// - /// Thrown when the device is not connected or has no transport. + /// + /// Thrown when the device is not connected, or — with + /// set — when the device is + /// disposed, disposing, or disconnecting. + /// + /// Thrown when the device has no transport-based connection. + /// Thrown when the underlying transport has dropped. /// Thrown when the operation is canceled. // prepareAsync is added AFTER cancellationToken (technically violating CA1068 // "CancellationToken should be last") to keep existing positional callers working, matching @@ -801,7 +808,13 @@ protected virtual Task> ExecuteTextCommandAsync( /// The time in milliseconds of inactivity after the first response before considering the response complete. Defaults to 250ms. /// A cancellation token to observe while waiting for the task to complete. /// A list of text lines received from the device. - /// Thrown when the device is not connected or has no transport. + /// + /// Thrown when the device is not connected, or — with + /// set — when the device is + /// disposed, disposing, or disconnecting. + /// + /// Thrown when the device has no transport-based connection. + /// Thrown when the underlying transport has dropped. /// Thrown when the operation is canceled. protected virtual Task> ExecuteTextCommandAsync( Func setupActionAsync, @@ -849,14 +862,17 @@ private async Task> ExecuteTextCommandCoreAsync( { await _textExchangeLock.WaitAsync(cancellationToken).ConfigureAwait(false); } - catch (ObjectDisposedException) + catch (ObjectDisposedException ex) { // Dispose() raced ahead of us and disposed the semaphore. // Surface the same clean failure as the post-acquisition // _disposed check below, instead of leaking a low-level - // teardown exception to callers. - throw new InvalidOperationException( - "ExecuteTextCommandAsync cannot run because the device is disposed."); + // teardown exception to callers. The original is kept as + // InnerException so this rare race stays diagnosable. + throw new DeviceNotConnectedException( + "ExecuteTextCommandAsync cannot run because the device is disposed.", + ex, + isShuttingDown: true); } _isInsideTextExchange.Value = true; @@ -869,14 +885,15 @@ private async Task> ExecuteTextCommandCoreAsync( // documented in #186). if (_disposed || _isDisconnecting) { - throw new InvalidOperationException( + throw new DeviceNotConnectedException( "ExecuteTextCommandAsync cannot run while the device is " - + "disposing or disconnecting."); + + "disposing or disconnecting.", + isShuttingDown: true); } if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_transport == null) @@ -1118,7 +1135,8 @@ private async Task> ExecuteTextCommandCoreAsync( /// A cancellation token to observe while waiting for the task to complete. /// The list of error strings popped from the queue (empty if the queue was already clean). /// Thrown when is not positive. - /// Thrown when the device is not connected or has no transport. + /// Thrown when the device is not connected. + /// Thrown when the device has no transport-based connection. /// Thrown when the operation is canceled. public virtual async Task> DrainErrorQueueAsync( int maxIterations = 256, @@ -1236,14 +1254,14 @@ public virtual async Task> DrainErrorQueueAsync( /// The parsed document, which has already been applied to ; or /// null when the device did not supply one this parser can trust. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public virtual async Task ReadCapabilityDocumentAsync( CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (!Supports(DeviceFeature.CapabilityDocument)) @@ -1386,7 +1404,7 @@ public void Dispose() /// surfaces a ). /// /// Thrown when is not positive. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returns a SCPI error during initialization that persists after an internal retry. /// Thrown when the device does not report its channel configuration within . /// Thrown when the operation is canceled. @@ -1396,7 +1414,7 @@ public virtual async Task InitializeAsync( { if (!IsConnected) { - throw new InvalidOperationException("Device must be connected before initialization."); + throw new DeviceNotConnectedException("Device must be connected before initialization."); } if (_isInitialized) diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index b7312e2a..b60b25c2 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -315,12 +315,12 @@ protected override async Task OnDeviceInitializingAsync(CancellationToken cancel /// /// Starts streaming data from the device at the configured frequency. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void StartStreaming() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (IsStreaming) return; @@ -349,12 +349,12 @@ public void StartStreaming() /// /// Stops streaming data from the device. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void StopStreaming() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (!IsStreaming) return; @@ -711,7 +711,7 @@ public void DisableAllChannels() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } foreach (var channel in SnapshotChannels()) @@ -743,7 +743,7 @@ public void SetDioDirection(IChannel channel, ChannelDirection direction) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -769,7 +769,7 @@ public void SetDioValue(IChannel channel, bool value) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -838,7 +838,7 @@ public void SetPwmEnabled(IChannel channel, bool enabled) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -889,7 +889,7 @@ public void SetPwmDutyCycle(IChannel channel, int dutyCyclePercent) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureChannelBelongs(channel); @@ -914,7 +914,7 @@ public void SetPwmFrequency(int frequencyHz) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Skip the redundant round-trip when the device already has this frequency (from a @@ -958,7 +958,7 @@ public void SetPwmFrequency(int frequencyHz) /// A task that completes once both commands have been sent. /// Thrown when is null. /// Thrown when fails validation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is cancelled. public Task SetFriendlyNameAsync(string name, CancellationToken cancellationToken = default) { @@ -976,7 +976,7 @@ public Task SetFriendlyNameAsync(string name, CancellationToken cancellationToke if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -1019,7 +1019,7 @@ public void SetAnalogOutput(int channelNumber, double voltage) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Analog-output (DAC) channels are addressed by number; they are not part of the @@ -1034,7 +1034,7 @@ public void Reboot() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.RebootDevice); @@ -1049,7 +1049,7 @@ public void SaveAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SaveAdcCalibration); @@ -1060,7 +1060,7 @@ public void LoadAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.LoadAdcCalibration); @@ -1076,7 +1076,7 @@ public void SetAdcCalibrationSlope(int channelNumber, double calM) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SetAdcCalibrationSlope(channelNumber, calM)); @@ -1092,7 +1092,7 @@ public void SetAdcCalibrationOffset(int channelNumber, double calB) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SetAdcCalibrationOffset(channelNumber, calB)); @@ -1103,7 +1103,7 @@ public void SaveFactoryAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SaveFactoryAdcCalibration); @@ -1114,7 +1114,7 @@ public void LoadFactoryAdcCalibration() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.LoadFactoryAdcCalibration); @@ -1130,7 +1130,7 @@ public void UseAdcCalibration(int bank) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.UseAdcCalibration(bank)); @@ -1141,7 +1141,7 @@ public void SaveVoltagePrecision() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SaveVoltagePrecision); @@ -1152,7 +1152,7 @@ public void LoadVoltagePrecision() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.LoadVoltagePrecision); @@ -1167,7 +1167,7 @@ private void SetChannelsEnabled(IReadOnlyList channels, bool enabled) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Validate everything up front so a bad entry can't leave a partially-applied state. @@ -1318,7 +1318,7 @@ private void EnsureChannelBelongs(IChannel channel) /// The new network configuration to apply. /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when is null. /// Thrown when an unsupported WiFi mode or security type is specified. /// @@ -1338,7 +1338,7 @@ public async Task UpdateNetworkConfigurationAsync(NetworkConfiguration configura if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Stop streaming if active @@ -1471,7 +1471,7 @@ public async Task UpdateNetworkConfigurationAsync(NetworkConfiguration configura /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = default) { @@ -1479,7 +1479,7 @@ public Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Re-check right before the state-changing send so a cancellation requested after the @@ -1494,7 +1494,7 @@ public Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task FactoryResetNetworkAsync(CancellationToken cancellationToken = default) { @@ -1502,7 +1502,7 @@ public Task FactoryResetNetworkAsync(CancellationToken cancellationToken = defau if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Re-check right before the state-changing send so a cancellation requested after the @@ -1520,12 +1520,12 @@ public Task FactoryResetNetworkAsync(CancellationToken cancellationToken = defau /// very TCP channel that requested it, so disabling LAN would drop the control channel /// mid-operation. Only the SD subsystem is enabled in that case. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void PrepareSdInterface() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (IsUsbConnection) @@ -1542,12 +1542,12 @@ public void PrepareSdInterface() /// ). Over WiFi/TCP the LAN was never disabled, so it is /// left alone — re-enabling it would re-initialize the WiFi module and drop the connection. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. public void PrepareLanInterface() { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.DisableStorageSd); @@ -1591,7 +1591,7 @@ private void EnsureSdFileTransferSupportedOnTransport() /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the list of files. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. /// Thrown when no SD card is installed in the device. /// Thrown when the SD card filesystem cannot satisfy the request (corrupt card, unreadable directory). @@ -1633,7 +1633,7 @@ public async Task> GetSdCardFilesAsync(Cancellatio { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } EnsureSdFileTransferSupportedOnTransport(); @@ -1793,7 +1793,8 @@ private static bool TrySplitAtSdListTerminator( /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the SD card storage info. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the operation is canceled. /// Thrown when no SD card is installed in the device. /// @@ -1808,7 +1809,7 @@ public async Task GetSdCardStorageAsync(CancellationToken can { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_isLoggingToSdCard) @@ -1957,7 +1958,7 @@ public void SetSdCardMinimumFreeSpace(long bytes) if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } Send(ScpiMessageProducer.SetSdMinFreeSpace(bytes)); @@ -1973,7 +1974,7 @@ public void SetSdCardMinimumFreeSpace(long bytes) /// The logging format to use. Defaults to . /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) => StartSdCardLoggingSessionAsync(fileName, channelMask, format, cancellationToken); @@ -1997,13 +1998,13 @@ public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask /// A task that resolves to an carrying the effective on-card /// file name (supplied or auto-generated) and the logging format. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public async Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (!IsUsbConnection) @@ -2068,13 +2069,13 @@ public async Task StartSdCardLoggingSessionAsync(string? f /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the operation is canceled. public Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2106,14 +2107,15 @@ public Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default /// The name of the file to delete. /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the filename is null, empty, or contains invalid characters. /// Thrown when the operation is canceled. public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_isLoggingToSdCard) @@ -2195,13 +2197,14 @@ public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cance /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the operation is canceled. public Task FormatSdCardAsync(CancellationToken cancellationToken = default) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } if (_isLoggingToSdCard) @@ -2229,7 +2232,8 @@ public Task FormatSdCardAsync(CancellationToken cancellationToken = default) /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file. - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. /// /// Thrown when the device serves a marker-only (0-byte) transfer for the file across all @@ -2244,7 +2248,7 @@ public async Task DownloadSdCardFileAsync( { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } // Over WiFi/TCP this requires firmware >= v3.7.0 (#598/#599); over USB it is always @@ -2344,7 +2348,8 @@ await ExecuteRawCaptureAsync(async (stream, ct) => /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file, including the local file path. - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. public async Task DownloadSdCardFileAsync( string fileName, @@ -2499,7 +2504,7 @@ private static void ValidateSdCardFileName(string fileName) { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } var lines = await ExecuteTextCommandAsync( @@ -2589,7 +2594,7 @@ public async Task> GetSystemLogAsync(CancellationT { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2615,7 +2620,7 @@ public async Task ClearSystemLogAsync(CancellationToken cancellationToken = defa { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2640,7 +2645,7 @@ public async Task SetLogLevelAsync(string module, int level, Ca if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2672,7 +2677,7 @@ public async Task> GetCommandHistoryAsync(CancellationToke { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2697,7 +2702,7 @@ public async Task TestSystemLogAsync(CancellationToken cancellationToken = defau { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2717,7 +2722,7 @@ public async Task GetSystemErrorCountAsync(CancellationToken cancellationTo { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2750,7 +2755,7 @@ public async Task GetStreamStatsAsync(CancellationToken cancellatio { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); @@ -2775,7 +2780,7 @@ public async Task GetMemoryDiagnosticsAsync(CancellationToken { if (!IsConnected) { - throw new InvalidOperationException("Device is not connected."); + throw new DeviceNotConnectedException(); } cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Daqifi.Core/Device/DeviceNotConnectedException.cs b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs new file mode 100644 index 00000000..254bc497 --- /dev/null +++ b/src/Daqifi.Core/Device/DeviceNotConnectedException.cs @@ -0,0 +1,126 @@ +using System; + +#nullable enable + +namespace Daqifi.Core.Device +{ + /// + /// Thrown by a device operation's up-front connectivity guard when the device is not in a state + /// that can carry the request — it was never connected, it has already disconnected, or a + /// / is in flight on + /// another thread. + /// + /// + /// + /// The distinction this type exists to give callers is between an ordinary, expected + /// condition — the device went away, the user pressed Disconnect while a refresh was in flight, + /// WiFi dropped mid-transfer — and a genuine application defect. Clients that classify failures + /// for user-facing reporting or error tracking should log this at warning level with reconnect + /// guidance, and reserve error level (and alerting) for the exceptions that really do indicate + /// a bug. Before this type existed the only way to tell them apart was to match on the + /// exception message, which broke silently on any wording change. + /// + /// + /// Derives from — the type these guards threw before — + /// so existing catch (InvalidOperationException) sites keep working unchanged, while new + /// code can catch this specific type. + /// + /// + /// , which reports that the + /// underlying transport's stream is gone, made the same trade one layer down. The two are + /// deliberately siblings rather than one deriving from the other: a device can fail this guard + /// while holding a perfectly healthy transport (for instance, mid-), + /// and a transport can drop while the device still reports . + /// + /// + public class DeviceNotConnectedException : InvalidOperationException + { + /// + /// The message used when no explicit message is supplied. Kept byte-for-byte identical to + /// the message these guards threw before this type existed, so any downstream code still + /// matching on it continues to work during migration. + /// + private const string DefaultMessage = "Device is not connected."; + + /// + /// Gets a value indicating whether the guard fired because the device is tearing down — + /// a or is in + /// flight, or has already completed — rather than because the device was simply not + /// connected in the first place. + /// + /// + /// Both cases are "the device is unavailable" and most callers can treat them the same way. + /// The flag is here for callers that want to say something more specific, such as + /// suppressing a retry prompt when the user themselves initiated the disconnect. + /// + public bool IsShuttingDown { get; } + + /// + /// Initializes a new instance of the class with + /// the default message. + /// + public DeviceNotConnectedException() + : this(DefaultMessage, isShuttingDown: false) + { + } + + /// + /// Initializes a new instance of the class with a + /// specified error message. + /// + /// The message that describes the device's connectivity state. + public DeviceNotConnectedException(string message) + : this(message, isShuttingDown: false) + { + } + + /// + /// Initializes a new instance of the class with a + /// specified error message and teardown state. + /// + /// The message that describes the device's connectivity state. + /// + /// true when the guard fired because the device is disconnecting or disposing; + /// otherwise false. See . + /// + public DeviceNotConnectedException(string message, bool isShuttingDown) + : base(message) + { + IsShuttingDown = isShuttingDown; + } + + /// + /// Initializes a new instance of the class with a + /// specified error message and a reference to the inner exception that is the cause of this + /// exception. + /// + /// The message that describes the device's connectivity state. + /// The exception that caused the current exception, or null. + public DeviceNotConnectedException(string message, Exception? innerException) + : this(message, innerException, isShuttingDown: false) + { + } + + /// + /// Initializes a new instance of the class with a + /// specified error message, the inner exception that caused it, and the teardown state. + /// + /// + /// Use this when a guard translates a lower-level teardown exception — such as the + /// from a racing + /// ahead of an in-flight call — so the original type and stack survive on + /// rather than being discarded. + /// + /// The message that describes the device's connectivity state. + /// The exception that caused the current exception, or null. + /// + /// true when the guard fired because the device is disconnecting or disposing; + /// otherwise false. See . + /// + public DeviceNotConnectedException(string message, Exception? innerException, bool isShuttingDown) + : base(message, innerException) + { + IsShuttingDown = isShuttingDown; + } + } +} diff --git a/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs b/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs index faa3c851..a52beb73 100644 --- a/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs +++ b/src/Daqifi.Core/Device/Diagnostics/IDeviceDiagnostics.cs @@ -24,7 +24,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The buffered log entries, oldest first; empty when the buffer was empty. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task> GetSystemLogAsync(CancellationToken cancellationToken = default); /// @@ -32,7 +32,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task ClearSystemLogAsync(CancellationToken cancellationToken = default); /// @@ -42,7 +42,7 @@ public interface IDeviceDiagnostics /// The log level: 0 = None, 1 = Error, 2 = Info, 3 = Debug. /// A cancellation token to observe while waiting for the task to complete. /// The level actually applied, as echoed by the device (may be capped by the module's ceiling). - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when is null, empty, or contains invalid characters. /// Thrown when is outside 0–3. /// Thrown when the device rejected the request or returned an unparseable response. @@ -53,7 +53,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The remembered commands, newest first; empty when there is no history. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task> GetCommandHistoryAsync(CancellationToken cancellationToken = default); /// @@ -61,7 +61,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task TestSystemLogAsync(CancellationToken cancellationToken = default); /// @@ -70,7 +70,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The current error-queue depth. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returned an unparseable response. Task GetSystemErrorCountAsync(CancellationToken cancellationToken = default); @@ -79,7 +79,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The parsed streaming statistics. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returned an unparseable response. Task GetStreamStatsAsync(CancellationToken cancellationToken = default); @@ -88,7 +88,7 @@ public interface IDeviceDiagnostics /// /// A cancellation token to observe while waiting for the task to complete. /// The parsed memory diagnostics. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when the device returned an unparseable response. Task GetMemoryDiagnosticsAsync(CancellationToken cancellationToken = default); } diff --git a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs index 884a04a0..f01c1a62 100644 --- a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs +++ b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs @@ -36,7 +36,7 @@ public interface ISdCardOperations /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the list of files. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when no SD card is installed in the device. /// Thrown when the SD card filesystem cannot satisfy the request (e.g. corrupt card, unreadable directory). /// Thrown when the device returned an SCPI error that did not match a more specific condition. An empty directory returns an empty list rather than throwing. @@ -52,7 +52,8 @@ public interface ISdCardOperations /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation, containing the SD card storage info. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when no SD card is installed in the device. /// Thrown when the device's firmware does not recognize the storage query (SCPI -113 "Undefined header"), typically because it predates the minimum supported firmware. /// Thrown when the device returned an SCPI error or an unparseable response. @@ -75,7 +76,8 @@ public interface ISdCardOperations /// A task that resolves to the evaluated . The check never blocks /// logging; callers decide whether to proceed based on . /// - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when no SD card is installed in the device. /// Thrown when the device returned an SCPI error or an unparseable response. Task CheckSdCardSpaceAsync( @@ -90,7 +92,7 @@ Task CheckSdCardSpaceAsync( /// firmware gate; the client-side remains the primary UX surface. /// /// The minimum free space to keep available, in bytes. Use 0 to disable the gate. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. /// Thrown when is negative. void SetSdCardMinimumFreeSpace(long bytes); @@ -120,7 +122,7 @@ Task CheckSdCardSpaceAsync( /// instead, which returns an /// . /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default); /// @@ -144,7 +146,7 @@ Task CheckSdCardSpaceAsync( /// A task that resolves to an with the effective on-card /// file name and logging format. /// - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default); /// @@ -152,7 +154,7 @@ Task CheckSdCardSpaceAsync( /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected. + /// Thrown when the device is not connected. Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default); /// @@ -161,7 +163,8 @@ Task CheckSdCardSpaceAsync( /// The name of the file to delete. /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. /// Thrown when the filename is null, empty, or contains invalid characters. Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default); @@ -170,7 +173,8 @@ Task CheckSdCardSpaceAsync( /// /// A cancellation token to observe while waiting for the task to complete. /// A task that represents the asynchronous operation. - /// Thrown when the device is not connected or is currently logging to SD card. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. Task FormatSdCardAsync(CancellationToken cancellationToken = default); /// @@ -181,7 +185,8 @@ Task CheckSdCardSpaceAsync( /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file. - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. Task DownloadSdCardFileAsync( string fileName, @@ -196,7 +201,8 @@ Task DownloadSdCardFileAsync( /// Optional progress reporting. /// Cancellation token. /// Metadata about the downloaded file, including the local . - /// Thrown when the device is not connected or is not using a USB/serial transport. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. /// Thrown when the filename is null, empty, or contains invalid characters. Task DownloadSdCardFileAsync( string fileName,