diff --git a/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs b/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs index c386d6e..08e458f 100644 --- a/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs +++ b/src/Daqifi.Core.Tests/Communication/Producers/ScpiMessageProducerTests.cs @@ -475,6 +475,14 @@ public void GetSdBenchmarkResults_ReturnsCorrectCommand() AssertMessageFormat(message); } + [Fact] + public void GetSdSpace_ReturnsCorrectCommand() + { + var message = ScpiMessageProducer.GetSdSpace; + Assert.Equal("SYSTem:STORage:SD:SPACe?", message.Data); + AssertMessageFormat(message); + } + [Theory] [InlineData(StreamInterface.Usb, 0)] [InlineData(StreamInterface.WiFi, 1)] diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index a184061..97b4103 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -1064,6 +1064,150 @@ public async Task FormatSdCardAsync_WhenNotStreaming_StillSendsStopCommand() #endregion + #region GetSdCardStorageAsync Tests + + [Fact] + public async Task GetSdCardStorageAsync_WhenDisconnected_Throws() + { + var device = new DaqifiStreamingDevice("TestDevice"); + + await Assert.ThrowsAsync( + () => device.GetSdCardStorageAsync()); + } + + [Fact] + public async Task GetSdCardStorageAsync_WhenConnected_SendsCorrectCommands() + { + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List { "1024,4096" }; + device.Connect(); + + await device.GetSdCardStorageAsync(); + + var sentCommands = device.SentMessages.Select(m => m.Data).ToList(); + Assert.Contains("SYSTem:COMMunicate:LAN:ENAbled 0", sentCommands); // PrepareSdInterface + Assert.Contains("SYSTem:STORage:SD:ENAble 1", sentCommands); // PrepareSdInterface + Assert.Contains("SYSTem:STORage:SD:SPACe?", sentCommands); // GetSdSpace + } + + [Fact] + public async Task GetSdCardStorageAsync_ParsesResponseCorrectly() + { + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List { "1048576000,2097152000" }; + device.Connect(); + + var storage = await device.GetSdCardStorageAsync(); + + Assert.Equal(1_048_576_000L, storage.FreeBytes); + Assert.Equal(2_097_152_000L, storage.TotalBytes); + Assert.Equal(1_048_576_000L, storage.UsedBytes); + } + + [Fact] + public async Task GetSdCardStorageAsync_RestoresLanInterface() + { + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List { "1024,4096" }; + device.Connect(); + + await device.GetSdCardStorageAsync(); + + var sentCommands = device.SentMessages.Select(m => m.Data).ToList(); + Assert.Contains("SYSTem:STORage:SD:ENAble 0", sentCommands); // PrepareLanInterface + Assert.Contains("SYSTem:COMMunicate:LAN:ENAbled 1", sentCommands); // PrepareLanInterface + } + + [Fact] + public async Task GetSdCardStorageAsync_DefensivelySendsStopStreaming() + { + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.CannedTextResponse = new List { "1024,4096" }; + device.Connect(); + Assert.False(device.IsStreaming); + + await device.GetSdCardStorageAsync(); + + var sentCommands = device.SentMessages.Select(m => m.Data).ToList(); + Assert.Contains("SYSTem:StopStreamData", sentCommands); + } + + [Fact] + public async Task GetSdCardStorageAsync_WithScpiError_RetriesAndReturnsStorage() + { + var device = new RetryableSdCardStreamingDevice("TestDevice"); + device.ResponseSequence.Enqueue(new List { "**ERROR: -200, \"Execution error\"" }); + device.ResponseSequence.Enqueue(new List { "1024,4096" }); + device.Connect(); + + var storage = await device.GetSdCardStorageAsync(); + + Assert.Equal(1024L, storage.FreeBytes); + Assert.Equal(4096L, storage.TotalBytes); + Assert.Equal(2, device.ExecuteTextCommandCallCount); + } + + [Fact] + public async Task GetSdCardStorageAsync_WithPersistentScpiError_ThrowsSdCardOperationException() + { + var device = new RetryableSdCardStreamingDevice("TestDevice"); + device.ResponseSequence.Enqueue(new List { "**ERROR: -200, \"Execution error\"" }); + device.ResponseSequence.Enqueue(new List { "**ERROR: -200, \"Execution error\"" }); + device.Connect(); + + var ex = await Assert.ThrowsAsync( + () => device.GetSdCardStorageAsync()); + Assert.Equal(2, device.ExecuteTextCommandCallCount); + Assert.Contains("**ERROR", ex.LastScpiError); + } + + [Fact] + public async Task GetSdCardStorageAsync_WithNoSdCardDetected_ThrowsSdCardNotPresentException() + { + // The "No SD Card Detected" marker is non-transient, so the method must + // short-circuit on the first attempt instead of retrying. + var device = new RetryableSdCardStreamingDevice("TestDevice"); + device.ResponseSequence.Enqueue(new List + { + "Error !! No SD Card Detected", + "**ERROR: -200, \"Execution error\"" + }); + device.Connect(); + + var ex = await Assert.ThrowsAsync( + () => device.GetSdCardStorageAsync()); + Assert.Contains("**ERROR", ex.LastScpiError); + Assert.Equal(1, device.ExecuteTextCommandCallCount); + } + + [Fact] + public async Task GetSdCardStorageAsync_OnError_StillRestoresLanInterface() + { + var device = new RetryableSdCardStreamingDevice("TestDevice"); + device.ResponseSequence.Enqueue(new List { "Error !! No SD Card Detected", "**ERROR: -200" }); + device.Connect(); + + await Assert.ThrowsAsync( + () => device.GetSdCardStorageAsync()); + + var sentCommands = device.SentMessages.Select(m => m.Data).ToList(); + Assert.Contains("SYSTem:STORage:SD:ENAble 0", sentCommands); + Assert.Contains("SYSTem:COMMunicate:LAN:ENAbled 1", sentCommands); + } + + [Fact] + public async Task GetSdCardStorageAsync_WhenLogging_Throws() + { + var device = new TestableSdCardStreamingDevice("TestDevice"); + device.Connect(); + await device.StartSdCardLoggingAsync("test.bin"); + + await Assert.ThrowsAsync( + () => device.GetSdCardStorageAsync()); + } + + #endregion + #region DownloadSdCardFileAsync Tests [Fact] diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardSpaceParserTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardSpaceParserTests.cs new file mode 100644 index 0000000..87cbd5f --- /dev/null +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardSpaceParserTests.cs @@ -0,0 +1,101 @@ +using Daqifi.Core.Device.SdCard; + +namespace Daqifi.Core.Tests.Device.SdCard; + +public class SdCardSpaceParserTests +{ + [Fact] + public void TryParse_ValidResponse_ReturnsStorageInfo() + { + Assert.True(SdCardSpaceParser.TryParse("1048576000,2097152000", out var result)); + Assert.NotNull(result); + Assert.Equal(1_048_576_000L, result.FreeBytes); + Assert.Equal(2_097_152_000L, result.TotalBytes); + Assert.Equal(1_048_576_000L, result.UsedBytes); + } + + [Fact] + public void TryParse_WithSurroundingWhitespace_ReturnsStorageInfo() + { + Assert.True(SdCardSpaceParser.TryParse(" 100 , 500 ", out var result)); + Assert.NotNull(result); + Assert.Equal(100L, result.FreeBytes); + Assert.Equal(500L, result.TotalBytes); + } + + [Fact] + public void TryParse_FullCard_ReturnsZeroFree() + { + Assert.True(SdCardSpaceParser.TryParse("0,1000000", out var result)); + Assert.NotNull(result); + Assert.Equal(0L, result.FreeBytes); + Assert.Equal(1_000_000L, result.TotalBytes); + Assert.Equal(1_000_000L, result.UsedBytes); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryParse_NullOrWhitespace_ReturnsFalse(string? input) + { + Assert.False(SdCardSpaceParser.TryParse(input, out var result)); + Assert.Null(result); + } + + [Theory] + [InlineData("1048576000")] // no comma + [InlineData(",2097152000")] // missing free + [InlineData("1048576000,")] // missing total + [InlineData("abc,2097152000")] // non-numeric free + [InlineData("1048576000,xyz")] // non-numeric total + [InlineData("-1,2097152000")] // negative free + [InlineData("1048576000,-1")] // negative total + [InlineData("5000,1000")] // free > total (corrupt metadata) + [InlineData("**ERROR: -200, \"Execution error\"")] // SCPI error + public void TryParse_Malformed_ReturnsFalse(string input) + { + Assert.False(SdCardSpaceParser.TryParse(input, out var result)); + Assert.Null(result); + } + + [Fact] + public void TryParseLines_FindsFirstValidLine() + { + var lines = new[] + { + "", + "some preamble", + "1024,4096", + "trailing text" + }; + + Assert.True(SdCardSpaceParser.TryParseLines(lines, out var result)); + Assert.NotNull(result); + Assert.Equal(1024L, result.FreeBytes); + Assert.Equal(4096L, result.TotalBytes); + } + + [Fact] + public void TryParseLines_NoValidLines_ReturnsFalse() + { + var lines = new[] { "", "garbage", "**ERROR: -200" }; + + Assert.False(SdCardSpaceParser.TryParseLines(lines, out var result)); + Assert.Null(result); + } + + [Fact] + public void TryParseLines_EmptySequence_ReturnsFalse() + { + Assert.False(SdCardSpaceParser.TryParseLines([], out var result)); + Assert.Null(result); + } + + [Fact] + public void UsedBytes_ComputesDifference() + { + var info = new SdCardStorageInfo(FreeBytes: 300, TotalBytes: 1000); + Assert.Equal(700L, info.UsedBytes); + } +} diff --git a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs index 1b436a7..958cd58 100644 --- a/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs +++ b/src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs @@ -207,6 +207,17 @@ public static IOutboundMessage SetSdMaxFileSize(long bytes) /// public static IOutboundMessage GetSdMaxFileSize => new ScpiMessage("SYSTem:STORage:SD:MAXSize?"); + /// + /// Creates a query message to get the free and total byte counts of the SD card. + /// + /// + /// Returns a single line of the form "free,total", where both values are + /// unsigned byte counts. + /// Command: SYSTem:STORage:SD:SPACe? + /// Example: messageProducer.Send(ScpiMessageProducer.GetSdSpace); + /// + public static IOutboundMessage GetSdSpace => new ScpiMessage("SYSTem:STORage:SD:SPACe?"); + /// /// Creates a command message to run an SD card write speed benchmark. /// diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index cab157e..e2fa8e0 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -392,6 +392,107 @@ public async Task> GetSdCardFilesAsync(Cancellatio return files; } + /// + /// Retrieves the free and total byte counts of the device's SD card. + /// + /// 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 operation is canceled. + /// Thrown when no SD card is installed in the device. + /// Thrown when the device returned a SCPI error or an unparseable response. + public async Task GetSdCardStorageAsync(CancellationToken cancellationToken = default) + { + if (!IsConnected) + { + throw new InvalidOperationException("Device is not connected."); + } + + if (_isLoggingToSdCard) + { + throw new InvalidOperationException("Cannot query SD card storage while logging to SD card."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Defensive: always send stop command even if IsStreaming is stale (see issue #118) + Send(ScpiMessageProducer.StopStreaming); + IsStreaming = false; + + IReadOnlyList lines; + try + { + lines = await ExecuteTextCommandAsync(() => + { + PrepareSdInterface(); + + // Allow the device firmware to complete the SPI bus switch + // before querying the SD card. Without this delay, the device + // can return SCPI error -200 (Execution error). + Thread.Sleep(SD_INTERFACE_SETTLE_DELAY_MS); + + Send(ScpiMessageProducer.GetSdSpace); + }, responseTimeoutMs: 3000, cancellationToken: cancellationToken); + + // Only retry transient SCPI errors. A "No SD Card Detected" line + // is non-transient — retrying just delays the typed exception and + // risks misclassification if the marker isn't repeated on retry. + if (ContainsScpiError(lines) && !ContainsNoSdCardMarker(lines)) + { + for (var retry = 0; retry < SD_LIST_MAX_RETRIES; retry++) + { + cancellationToken.ThrowIfCancellationRequested(); + + await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken); + + lines = await ExecuteTextCommandAsync(() => + { + PrepareSdInterface(); + Thread.Sleep(SD_INTERFACE_SETTLE_DELAY_MS); + Send(ScpiMessageProducer.GetSdSpace); + }, responseTimeoutMs: 3000, cancellationToken: cancellationToken); + + if (!ContainsScpiError(lines) || ContainsNoSdCardMarker(lines)) + { + break; + } + } + } + } + finally + { + if (IsConnected) + { + PrepareLanInterface(); + } + } + + if (SdCardSpaceParser.TryParseLines(lines, out var storage)) + { + return storage; + } + + // Parser failed — translate the firmware response into a typed exception. + var lastScpiError = lines.LastOrDefault(IsScpiErrorLine)?.Trim(); + + if (ContainsNoSdCardMarker(lines)) + { + throw new SdCardNotPresentException(lines, lastScpiError); + } + + throw new SdCardOperationException( + lastScpiError != null + ? "The SD card storage query failed: " + lastScpiError + : "The SD card storage query returned an unparseable response.", + lines, + lastScpiError); + } + + private static bool ContainsNoSdCardMarker(IReadOnlyList lines) + { + return lines.Any(l => l.IndexOf("No SD Card Detected", StringComparison.OrdinalIgnoreCase) >= 0); + } + /// /// Starts logging data to the SD card. /// diff --git a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs index 9d50bd3..c4372f0 100644 --- a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs +++ b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs @@ -34,6 +34,16 @@ public interface ISdCardOperations /// 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. Task> GetSdCardFilesAsync(CancellationToken cancellationToken = default); + /// + /// Retrieves the free and total byte counts of the device's SD card. + /// + /// 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 no SD card is installed in the device. + /// Thrown when the device returned an SCPI error or an unparseable response. + Task GetSdCardStorageAsync(CancellationToken cancellationToken = default); + /// /// Starts logging data to the SD card. /// diff --git a/src/Daqifi.Core/Device/SdCard/SdCardSpaceParser.cs b/src/Daqifi.Core/Device/SdCard/SdCardSpaceParser.cs new file mode 100644 index 0000000..2d3ea4c --- /dev/null +++ b/src/Daqifi.Core/Device/SdCard/SdCardSpaceParser.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; + +namespace Daqifi.Core.Device.SdCard; + +/// +/// Parses the response from the SYSTem:STORage:SD:SPACe? SCPI command. +/// The firmware returns a single line of the form "free,total" where +/// both values are unsigned byte counts. +/// +public static class SdCardSpaceParser +{ + /// + /// Attempts to parse a single response line into a . + /// + /// A response line, e.g. "1048576000,2097152000". + /// The parsed storage info, or if parsing failed. + /// if parsing succeeded; otherwise . + public static bool TryParse(string? line, [NotNullWhen(true)] out SdCardStorageInfo? result) + { + result = null; + + if (string.IsNullOrWhiteSpace(line)) + { + return false; + } + + var commaIndex = line.IndexOf(','); + if (commaIndex <= 0 || commaIndex == line.Length - 1) + { + return false; + } + + var freeSpan = line.AsSpan(0, commaIndex).Trim(); + var totalSpan = line.AsSpan(commaIndex + 1).Trim(); + + if (!long.TryParse(freeSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var free) || + !long.TryParse(totalSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out var total)) + { + return false; + } + + // Reject negatives and any free > total — both indicate corrupt firmware + // metadata. Surfacing them as a parse failure forces the typed exception + // path rather than producing a negative UsedBytes downstream. + if (free < 0 || total < 0 || free > total) + { + return false; + } + + result = new SdCardStorageInfo(free, total); + return true; + } + + /// + /// Attempts to parse from a sequence of response lines, + /// trying each non-empty line in order until one succeeds. + /// + /// Response lines from the device. + /// The parsed storage info, or if no line could be parsed. + /// if any line was successfully parsed; otherwise . + public static bool TryParseLines(IEnumerable lines, [NotNullWhen(true)] out SdCardStorageInfo? result) + { + foreach (var line in lines) + { + if (TryParse(line, out result)) + { + return true; + } + } + + result = null; + return false; + } +} diff --git a/src/Daqifi.Core/Device/SdCard/SdCardStorageInfo.cs b/src/Daqifi.Core/Device/SdCard/SdCardStorageInfo.cs new file mode 100644 index 0000000..8f550d2 --- /dev/null +++ b/src/Daqifi.Core/Device/SdCard/SdCardStorageInfo.cs @@ -0,0 +1,15 @@ +namespace Daqifi.Core.Device.SdCard; + +/// +/// Free and total byte counts reported by the device's SD card via the +/// SYSTem:STORage:SD:SPACe? SCPI query. +/// +/// Free space remaining on the SD card, in bytes. +/// Total capacity of the SD card, in bytes. +public sealed record SdCardStorageInfo(long FreeBytes, long TotalBytes) +{ + /// + /// Used space on the SD card, in bytes. Computed as TotalBytes - FreeBytes. + /// + public long UsedBytes => TotalBytes - FreeBytes; +}