diff --git a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs index 9bcb763a..bfb445dd 100644 --- a/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs +++ b/src/Daqifi.Core.Tests/Device/DaqifiStreamingDeviceTests.cs @@ -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(() => 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(() => 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(() => 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(() => 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() { diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index c7ed38b9..9219d4c2 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -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() { diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 4196f66a..bd6ff43f 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -86,10 +86,38 @@ public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkCon /// public bool IsStreaming { get; private set; } + private int _streamingFrequency; + /// - /// 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 + /// () 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). /// - public int StreamingFrequency { get; set; } + /// + /// Thrown when the value is less than 1 or greater than the device's maximum sampling rate. + /// + 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; + } + } /// /// Gets a value indicating whether the device is currently logging data to the SD card. @@ -1315,12 +1343,27 @@ public void SetSdCardMinimumFreeSpace(long bytes) } /// - /// Starts logging data to the SD card. + /// Starts logging data to the SD card. Compatibility overload preserving the original + /// return; use to also learn + /// the effective on-card file name. + /// + /// The log file name, or null/empty to auto-generate a timestamped name. + /// Optional decimal channel bitmask; null/empty uses the current config. + /// 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 operation is canceled. + public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) + => StartSdCardLoggingSessionAsync(fileName, channelMask, format, cancellationToken); + + /// + /// Starts logging data to the SD card and returns the effective session details. /// /// /// 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 - /// (.bin for Protobuf, .json for JSON, .dat for TestData). + /// (.bin for Protobuf, .json for JSON, .csv for CSV). /// /// /// Optional decimal bitmask string to enable specific ADC channels (e.g. "3" enables channels 0 and 1). @@ -1329,10 +1372,13 @@ 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. + /// + /// 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 operation is canceled. - public async Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) + public async Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) { if (!IsConnected) { @@ -1392,6 +1438,8 @@ public async Task StartSdCardLoggingAsync(string? fileName = null, string? chann _isLoggingToSdCard = true; IsStreaming = true; + + return new SdCardLoggingSession(logFileName, format); } /// diff --git a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs index f3b42bc8..55f97822 100644 --- a/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs +++ b/src/Daqifi.Core/Device/SdCard/ISdCardOperations.cs @@ -110,9 +110,38 @@ Task CheckSdCardSpaceAsync( /// 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. + /// + /// To learn the effective on-card file name (especially when it was auto-generated), call + /// instead, which returns an + /// . + /// /// Thrown when the device is not connected. Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default); + /// + /// Starts logging data to the SD card and returns the effective session details. Behaves + /// exactly like but resolves to an + /// 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. + /// + /// + /// 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. + /// + /// + /// 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. + /// + /// The logging format to use. Defaults to . + /// A cancellation token to observe while waiting for the task to complete. + /// + /// A task that resolves to an with the effective on-card + /// file name and logging format. + /// + /// Thrown when the device is not connected. + Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default); + /// /// Stops logging data to the SD card. /// diff --git a/src/Daqifi.Core/Device/SdCard/SdCardLoggingSession.cs b/src/Daqifi.Core/Device/SdCard/SdCardLoggingSession.cs new file mode 100644 index 00000000..8b7c1437 --- /dev/null +++ b/src/Daqifi.Core/Device/SdCard/SdCardLoggingSession.cs @@ -0,0 +1,36 @@ +namespace Daqifi.Core.Device.SdCard +{ + /// + /// 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 + /// + /// so callers can report, download, or later delete the log without re-deriving Core's + /// naming convention. + /// + public sealed class SdCardLoggingSession + { + /// + /// Initializes a new instance of the class. + /// + /// The effective on-card file name. + /// The logging format the file is written in. + public SdCardLoggingSession(string fileName, SdCardLogFormat format) + { + FileName = fileName; + Format = format; + } + + /// + /// Gets the effective on-card file name — the value sent to the device, whether it was + /// supplied by the caller or auto-generated as log_YYYYMMDD_HHMMSS with the + /// format-appropriate extension. + /// + public string FileName { get; } + + /// + /// Gets the logging format the file is written in. + /// + public SdCardLogFormat Format { get; } + } +} diff --git a/src/Daqifi.Mcp/DaqifiAgent.cs b/src/Daqifi.Mcp/DaqifiAgent.cs index 62029c1f..b064d8b5 100644 --- a/src/Daqifi.Mcp/DaqifiAgent.cs +++ b/src/Daqifi.Mcp/DaqifiAgent.cs @@ -20,9 +20,6 @@ namespace Daqifi.Mcp; /// public sealed class DaqifiAgent { - /// The maximum sample rate the Nyquist hardware accepts (SCPI range is 1–1000 Hz). - public const int HardwareMaxSampleRateHz = 1000; - private readonly ServerOptions _options; private readonly ConcurrentDictionary _discovered = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _connected = new(StringComparer.Ordinal); @@ -357,13 +354,15 @@ public async Task 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; @@ -393,17 +392,13 @@ public async Task 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 { @@ -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,