Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
144 changes: 144 additions & 0 deletions src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(
() => device.GetSdCardStorageAsync());
}

[Fact]
public async Task GetSdCardStorageAsync_WhenConnected_SendsCorrectCommands()
{
var device = new TestableSdCardStreamingDevice("TestDevice");
device.CannedTextResponse = new List<string> { "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<string> { "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<string> { "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<string> { "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<string> { "**ERROR: -200, \"Execution error\"" });
device.ResponseSequence.Enqueue(new List<string> { "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<string> { "**ERROR: -200, \"Execution error\"" });
device.ResponseSequence.Enqueue(new List<string> { "**ERROR: -200, \"Execution error\"" });
device.Connect();

var ex = await Assert.ThrowsAsync<SdCardOperationException>(
() => 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<string>
{
"Error !! No SD Card Detected",
"**ERROR: -200, \"Execution error\""
});
device.Connect();

var ex = await Assert.ThrowsAsync<SdCardNotPresentException>(
() => 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<string> { "Error !! No SD Card Detected", "**ERROR: -200" });
device.Connect();

await Assert.ThrowsAsync<SdCardNotPresentException>(
() => 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<InvalidOperationException>(
() => device.GetSdCardStorageAsync());
}

#endregion

#region DownloadSdCardFileAsync Tests

[Fact]
Expand Down
101 changes: 101 additions & 0 deletions src/Daqifi.Core.Tests/Device/SdCard/SdCardSpaceParserTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
11 changes: 11 additions & 0 deletions src/Daqifi.Core/Communication/Producers/ScpiMessageProducer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,17 @@ public static IOutboundMessage<string> SetSdMaxFileSize(long bytes)
/// </remarks>
public static IOutboundMessage<string> GetSdMaxFileSize => new ScpiMessage("SYSTem:STORage:SD:MAXSize?");

/// <summary>
/// Creates a query message to get the free and total byte counts of the SD card.
/// </summary>
/// <remarks>
/// Returns a single line of the form <c>"free,total"</c>, where both values are
/// unsigned byte counts.
/// Command: SYSTem:STORage:SD:SPACe?
/// Example: messageProducer.Send(ScpiMessageProducer.GetSdSpace);
/// </remarks>
public static IOutboundMessage<string> GetSdSpace => new ScpiMessage("SYSTem:STORage:SD:SPACe?");

/// <summary>
/// Creates a command message to run an SD card write speed benchmark.
/// </summary>
Expand Down
101 changes: 101 additions & 0 deletions src/Daqifi.Core/Device/DaqifiStreamingDevice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,107 @@ public async Task<IReadOnlyList<SdCardFileInfo>> GetSdCardFilesAsync(Cancellatio
return files;
}

/// <summary>
/// Retrieves the free and total byte counts of the device's SD card.
/// </summary>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous operation, containing the SD card storage info.</returns>
/// <exception cref="InvalidOperationException">Thrown when the device is not connected or is currently logging to SD card.</exception>
/// <exception cref="OperationCanceledException">Thrown when the operation is canceled.</exception>
/// <exception cref="SdCardNotPresentException">Thrown when no SD card is installed in the device.</exception>
/// <exception cref="SdCardOperationException">Thrown when the device returned a SCPI error or an unparseable response.</exception>
public async Task<SdCardStorageInfo> 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;

Comment thread
qodo-code-review[bot] marked this conversation as resolved.
IReadOnlyList<string> 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<string> lines)
{
return lines.Any(l => l.IndexOf("No SD Card Detected", StringComparison.OrdinalIgnoreCase) >= 0);
}

/// <summary>
/// Starts logging data to the SD card.
/// </summary>
Expand Down
Loading