Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
133 changes: 133 additions & 0 deletions src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,139 @@ 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()
{
var device = new RetryableSdCardStreamingDevice("TestDevice");
var response = new List<string>
{
"Error !! No SD Card Detected",
"**ERROR: -200, \"Execution error\""
};
device.ResponseSequence.Enqueue(response);
device.ResponseSequence.Enqueue(new List<string>(response));
device.Connect();

var ex = await Assert.ThrowsAsync<SdCardNotPresentException>(
() => device.GetSdCardStorageAsync());
Assert.Contains("**ERROR", ex.LastScpiError);
}

[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.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);
}

#endregion

#region DownloadSdCardFileAsync Tests

[Fact]
Expand Down
100 changes: 100 additions & 0 deletions src/Daqifi.Core.Tests/Device/SdCard/SdCardSpaceParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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("**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
88 changes: 88 additions & 0 deletions src/Daqifi.Core/Device/DaqifiStreamingDevice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,94 @@ 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.</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.");
}

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);

if (ContainsScpiError(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))
{
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 (lines.Any(l => l.IndexOf("No SD Card Detected", StringComparison.OrdinalIgnoreCase) >= 0))
{
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);
}

/// <summary>
/// Starts logging data to the SD card.
/// </summary>
Expand Down
10 changes: 10 additions & 0 deletions src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ public interface ISdCardOperations
/// <exception cref="SdCardOperationException">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.</exception>
Task<IReadOnlyList<SdCardFileInfo>> GetSdCardFilesAsync(CancellationToken cancellationToken = default);

/// <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="System.InvalidOperationException">Thrown when the device is not connected.</exception>
/// <exception cref="SdCardNotPresentException">Thrown when no SD card is installed in the device.</exception>
/// <exception cref="SdCardOperationException">Thrown when the device returned an SCPI error or an unparseable response.</exception>
Task<SdCardStorageInfo> GetSdCardStorageAsync(CancellationToken cancellationToken = default);

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