Skip to content
Merged
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
75 changes: 75 additions & 0 deletions src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,81 @@ public void Constructor_InitializesStreamingFrequency()
Assert.False(device.IsStreaming);
}

[Theory]
[InlineData(1)]
[InlineData(500)]
[InlineData(1000)] // MaxSamplingRate for all Nyquist types
public void StreamingFrequency_WithinRange_IsAccepted(int frequency)
{
// Arrange
var device = new DaqifiStreamingDevice("TestDevice");

// Act
device.StreamingFrequency = frequency;

// Assert
Assert.Equal(frequency, device.StreamingFrequency);
}

[Theory]
[InlineData(0)]
[InlineData(-1)]
[InlineData(1001)] // MaxSamplingRate + 1
[InlineData(50000)]
public void StreamingFrequency_OutOfRange_ThrowsArgumentOutOfRangeException(int frequency)
{
// Arrange
var device = new DaqifiStreamingDevice("TestDevice");

// Act & Assert
var exception = Assert.Throws<System.ArgumentOutOfRangeException>(() => device.StreamingFrequency = frequency);
Assert.Contains("1000", exception.Message); // valid range surfaced from DeviceCapabilities.MaxSamplingRate
}

[Fact]
public void StreamingFrequency_OutOfRange_LeavesPreviousValueUnchanged()
{
// Arrange
var device = new DaqifiStreamingDevice("TestDevice") { StreamingFrequency = 250 };

// Act
Assert.Throws<System.ArgumentOutOfRangeException>(() => device.StreamingFrequency = 5000);

// Assert
Assert.Equal(250, device.StreamingFrequency);
}

[Fact]
public void StreamingFrequency_UsesCapabilitiesMaxSamplingRate_NotAHardcodedConstant()
{
// Arrange: lower the device's advertised max and confirm the guard tracks it.
var device = new DaqifiStreamingDevice("TestDevice");
device.Metadata.Capabilities.MaxSamplingRate = 200;

// Act & Assert: 200 is now the ceiling, 201 is rejected.
device.StreamingFrequency = 200;
Assert.Equal(200, device.StreamingFrequency);
Assert.Throws<System.ArgumentOutOfRangeException>(() => device.StreamingFrequency = 201);
}

[Theory]
[InlineData(0)]
[InlineData(-5)]
public void StreamingFrequency_InvalidCapabilitiesMax_SanitizesCeilingToOne(int invalidMax)
{
// Arrange: MaxSamplingRate is a mutable, unvalidated public property. An invalid value
// must not produce an impossible range that rejects every frequency.
var device = new DaqifiStreamingDevice("TestDevice");
device.Metadata.Capabilities.MaxSamplingRate = invalidMax;

// Act & Assert: the ceiling is sanitized to 1, so 1 is accepted and 2 is rejected.
device.StreamingFrequency = 1;
Assert.Equal(1, device.StreamingFrequency);

var ex = Assert.Throws<System.ArgumentOutOfRangeException>(() => device.StreamingFrequency = 2);
Assert.Contains("1 and 1", ex.Message); // range reported with the sanitized max, not "1..0"
}

[Fact]
public void StartStreaming_WhenConnected_SendsCorrectCommandAndSetsIsStreaming()
{
Expand Down
36 changes: 36 additions & 0 deletions src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,42 @@ public async Task StartSdCardLoggingAsync_WithNullFileName_GeneratesTimestampedN
Assert.Contains(".bin", loggingCommand);
}

[Fact]
public async Task StartSdCardLoggingAsync_WithCustomFileName_ReturnsSessionWithThatName()
{
// Arrange
var device = new TestableSdCardStreamingDevice("TestDevice");
device.Connect();

// Act
var session = await device.StartSdCardLoggingSessionAsync("custom_data.bin", format: SdCardLogFormat.Protobuf);

// Assert: the returned name is exactly what was sent to the device.
var sentCommands = device.SentMessages.Select(m => m.Data).ToList();
Assert.Equal("custom_data.bin", session.FileName);
Assert.Equal(SdCardLogFormat.Protobuf, session.Format);
Assert.Contains($"SYSTem:STORage:SD:FILE \"{session.FileName}\"", sentCommands);
}

[Fact]
public async Task StartSdCardLoggingAsync_WithNullFileName_ReturnsGeneratedNameSentToDevice()
{
// Arrange
var device = new TestableSdCardStreamingDevice("TestDevice");
device.Connect();

// Act
var session = await device.StartSdCardLoggingSessionAsync(format: SdCardLogFormat.Json);

// Assert: the auto-generated name the caller receives is the one that reached the device,
// so consumers no longer have to re-derive Core's naming convention.
var sentCommands = device.SentMessages.Select(m => m.Data).ToList();
Assert.StartsWith("log_", session.FileName);
Assert.EndsWith(".json", session.FileName);
Assert.Equal(SdCardLogFormat.Json, session.Format);
Assert.Contains($"SYSTem:STORage:SD:FILE \"{session.FileName}\"", sentCommands);
}

[Fact]
public async Task StartSdCardLoggingAsync_SetsIsLoggingToTrue()
{
Expand Down
60 changes: 54 additions & 6 deletions src/Daqifi.Core/Device/DaqifiStreamingDevice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,38 @@ public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkCon
/// </summary>
public bool IsStreaming { get; private set; }

private int _streamingFrequency;

/// <summary>
/// Gets or sets the streaming frequency in Hz (samples per second).
/// Gets or sets the streaming frequency in Hz (samples per second). The value is
/// validated against the device's advertised maximum sampling rate
/// (<see cref="DeviceCapabilities.MaxSamplingRate"/>) so a silently-wrong rate never
/// reaches the hardware — consistent with the client-side guards Core already applies
/// to PWM (#306) and channel bounds (#300).
/// </summary>
public int StreamingFrequency { get; set; }
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when the value is less than 1 or greater than the device's maximum sampling rate.
/// </exception>
public int StreamingFrequency
{
get => _streamingFrequency;
set
{
// MaxSamplingRate is a mutable, unvalidated public property; sanitize the ceiling so
// an uninitialized/invalid capabilities value (0 or negative) can't produce an
// impossible range like "1..0" that rejects every valid frequency.
var maxSamplingRate = Math.Max(1, Metadata.Capabilities.MaxSamplingRate);
if (value < 1 || value > maxSamplingRate)
{
throw new ArgumentOutOfRangeException(
nameof(StreamingFrequency),
value,
$"Streaming frequency must be between 1 and {maxSamplingRate} Hz (the device's maximum sampling rate).");
}

_streamingFrequency = value;
}
}

/// <summary>
/// Gets a value indicating whether the device is currently logging data to the SD card.
Expand Down Expand Up @@ -1315,12 +1343,27 @@ public void SetSdCardMinimumFreeSpace(long bytes)
}

/// <summary>
/// Starts logging data to the SD card.
/// Starts logging data to the SD card. Compatibility overload preserving the original
/// <see cref="Task"/> return; use <see cref="StartSdCardLoggingSessionAsync"/> to also learn
/// the effective on-card file name.
/// </summary>
/// <param name="fileName">The log file name, or null/empty to auto-generate a timestamped name.</param>
/// <param name="channelMask">Optional decimal channel bitmask; null/empty uses the current config.</param>
/// <param name="format">The logging format to use. Defaults to <see cref="SdCardLogFormat.Protobuf"/>.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <exception cref="InvalidOperationException">Thrown when the device is not connected.</exception>
/// <exception cref="OperationCanceledException">Thrown when the operation is canceled.</exception>
public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default)
=> StartSdCardLoggingSessionAsync(fileName, channelMask, format, cancellationToken);

/// <summary>
/// Starts logging data to the SD card and returns the effective session details.
/// </summary>
/// <param name="fileName">
/// The name of the log file. If null or empty, a timestamped name is generated automatically
/// using the pattern "log_YYYYMMDD_HHMMSS" with an extension matching <paramref name="format"/>
/// (.bin for Protobuf, .json for JSON, .dat for TestData).
/// (.bin for Protobuf, .json for JSON, .csv for CSV).
/// </param>
/// <param name="channelMask">
/// Optional decimal bitmask string to enable specific ADC channels (e.g. "3" enables channels 0 and 1).
Expand All @@ -1329,10 +1372,13 @@ public void SetSdCardMinimumFreeSpace(long bytes)
/// </param>
/// <param name="format">The logging format to use. Defaults to <see cref="SdCardLogFormat.Protobuf"/>.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <returns>
/// A task that resolves to an <see cref="SdCardLoggingSession"/> carrying the effective on-card
/// file name (supplied or auto-generated) and the logging format.
/// </returns>
/// <exception cref="InvalidOperationException">Thrown when the device is not connected.</exception>
/// <exception cref="OperationCanceledException">Thrown when the operation is canceled.</exception>
public async Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default)
public async Task<SdCardLoggingSession> StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default)
{
if (!IsConnected)
{
Expand Down Expand Up @@ -1392,6 +1438,8 @@ public async Task StartSdCardLoggingAsync(string? fileName = null, string? chann

_isLoggingToSdCard = true;
IsStreaming = true;

return new SdCardLoggingSession(logFileName, format);
}

/// <summary>
Expand Down
29 changes: 29 additions & 0 deletions src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,38 @@ Task<SdCardSpaceCheckResult> CheckSdCardSpaceAsync(
/// <param name="format">The logging format to use. Defaults to <see cref="SdCardLogFormat.Protobuf"/>.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
/// <remarks>
/// To learn the effective on-card file name (especially when it was auto-generated), call
/// <see cref="StartSdCardLoggingSessionAsync"/> instead, which returns an
/// <see cref="SdCardLoggingSession"/>.
/// </remarks>
/// <exception cref="System.InvalidOperationException">Thrown when the device is not connected.</exception>
Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default);

/// <summary>
/// Starts logging data to the SD card and returns the effective session details. Behaves
/// exactly like <see cref="StartSdCardLoggingAsync"/> but resolves to an
/// <see cref="SdCardLoggingSession"/> carrying the effective on-card file name (supplied or
/// auto-generated) and the logging format, so callers can report, download, or later delete
/// the log without re-deriving Core's naming convention.
/// </summary>
/// <param name="fileName">
/// The name of the log file. If null or empty, a timestamped name is generated automatically
/// using the pattern "log_YYYYMMDD_HHMMSS" with a format-appropriate extension.
/// </param>
/// <param name="channelMask">
/// Optional decimal bitmask string to enable specific ADC channels (e.g. "3" enables channels 0 and 1).
/// If null or empty, the current device channel configuration is used.
/// </param>
/// <param name="format">The logging format to use. Defaults to <see cref="SdCardLogFormat.Protobuf"/>.</param>
/// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>
/// A task that resolves to an <see cref="SdCardLoggingSession"/> with the effective on-card
/// file name and logging format.
/// </returns>
/// <exception cref="System.InvalidOperationException">Thrown when the device is not connected.</exception>
Task<SdCardLoggingSession> StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default);

/// <summary>
/// Stops logging data to the SD card.
/// </summary>
Expand Down
36 changes: 36 additions & 0 deletions src/Daqifi.Core/Device/SdCard/SdCardLoggingSession.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace Daqifi.Core.Device.SdCard
{
/// <summary>
/// Describes an SD-card logging session that was just started: the effective on-card file
/// name (whether supplied by the caller or auto-generated by Core) and the format it is
/// being written in. Returned by
/// <see cref="ISdCardOperations.StartSdCardLoggingAsync(string, string, SdCardLogFormat, System.Threading.CancellationToken)"/>
/// so callers can report, download, or later delete the log without re-deriving Core's
/// naming convention.
/// </summary>
public sealed class SdCardLoggingSession
{
/// <summary>
/// Initializes a new instance of the <see cref="SdCardLoggingSession"/> class.
/// </summary>
/// <param name="fileName">The effective on-card file name.</param>
/// <param name="format">The logging format the file is written in.</param>
public SdCardLoggingSession(string fileName, SdCardLogFormat format)
{
FileName = fileName;
Format = format;
}

/// <summary>
/// Gets the effective on-card file name — the value sent to the device, whether it was
/// supplied by the caller or auto-generated as <c>log_YYYYMMDD_HHMMSS</c> with the
/// format-appropriate extension.
/// </summary>
public string FileName { get; }

/// <summary>
/// Gets the logging format the file is written in.
/// </summary>
public SdCardLogFormat Format { get; }
}
}
32 changes: 10 additions & 22 deletions src/Daqifi.Mcp/DaqifiAgent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ namespace Daqifi.Mcp;
/// </remarks>
public sealed class DaqifiAgent
{
/// <summary>The maximum sample rate the Nyquist hardware accepts (SCPI range is 1–1000 Hz).</summary>
public const int HardwareMaxSampleRateHz = 1000;

private readonly ServerOptions _options;
private readonly ConcurrentDictionary<string, IDeviceInfo> _discovered = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, DaqifiDevice> _connected = new(StringComparer.Ordinal);
Expand Down Expand Up @@ -357,13 +354,15 @@ public async Task<SampleRateResult> SetSampleRateAsync(string deviceId, int rate
try
{
RequireControl();
var (_, streaming) = RequireStreaming(deviceId);
var (device, streaming) = RequireStreaming(deviceId);

// The hardware ceiling (1000 Hz) always applies; --max-sample-rate-hz can only lower it.
// The device's advertised hardware ceiling always applies (Core validates
// StreamingFrequency against it too); --max-sample-rate-hz can only lower it.
// Guard against a non-positive cap so the applied rate is always a valid >= 1 value.
var hardwareMax = Math.Max(1, device.Metadata.Capabilities.MaxSamplingRate);
var cap = _options.MaxSampleRateHz is { } max
? Math.Clamp(max, 1, HardwareMaxSampleRateHz)
: HardwareMaxSampleRateHz;
? Math.Clamp(max, 1, hardwareMax)
: hardwareMax;

var applied = Math.Min(rateHz, cap);
streaming.StreamingFrequency = applied;
Expand Down Expand Up @@ -393,17 +392,13 @@ public async Task<StartLoggingResult> StartLoggingAsync(
var (device, streaming) = RequireStreaming(deviceId);
var sd = RequireSdCard(device);

// Generate the name in this layer so the result reports the real on-card filename.
// Core honors a non-empty fileName verbatim; channels use the device's current config.
var effectiveName = string.IsNullOrWhiteSpace(fileName)
? $"log_{DateTime.Now:yyyyMMdd_HHmmss}{ExtensionFor(fmt)}"
: fileName!;

await sd.StartSdCardLoggingAsync(effectiveName, channelMask: null, format: fmt, cancellationToken)
// Core owns the naming convention and reports the effective on-card filename back to
// us, so we no longer duplicate the log_{timestamp} generation here.
var session = await sd.StartSdCardLoggingSessionAsync(fileName, channelMask: null, format: fmt, cancellationToken)
.ConfigureAwait(false);

return new StartLoggingResult(
deviceId, effectiveName, fmt.ToString(), streaming.StreamingFrequency, EnabledAnalog(device));
deviceId, session.FileName, session.Format.ToString(), streaming.StreamingFrequency, EnabledAnalog(device));
}
finally
{
Expand Down Expand Up @@ -545,13 +540,6 @@ private static IChannel RequireDigitalChannel(DaqifiDevice device, int channelNu
_ => throw new InvalidOperationException($"Unknown direction '{direction}'. Use 'input' or 'output'."),
};

private static string ExtensionFor(SdCardLogFormat format) => format switch
{
SdCardLogFormat.Json => ".json",
SdCardLogFormat.Csv => ".csv",
_ => ".bin",
};

private static SdCardLogFormat ParseFormat(string? format) => (format ?? string.Empty).Trim().ToLowerInvariant() switch
{
"" or "protobuf" or "bin" or "binary" => SdCardLogFormat.Protobuf,
Expand Down