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
20 changes: 20 additions & 0 deletions src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1490,6 +1490,26 @@ public async Task CheckSdCardSpaceAsync_WhenNearlyFull_RaisesWarningAndReturnsRe
Assert.Same(result, raised!.Result);
}

[Fact]
public async Task CheckSdCardSpaceAsync_WarningSenderIsTheDevice()
{
// The space check now lives in a collaborator, but the event belongs to the device's
// public surface: subscribers key off the sender to tell devices apart. Nothing
// asserted this before the split — every existing subscriber discards the sender —
// so a collaborator raising the event in its own name would have been a silent,
// compile-clean behavior change (#344).
var device = new TestableSdCardStreamingDevice("TestDevice");
device.CannedTextResponse = new List<string> { "52428800,4294967296" };
device.Connect();

object? sender = null;
device.LowSdSpaceWarning += (s, _) => sender = s;

await device.CheckSdCardSpaceAsync();

Assert.Same(device, sender);
}

[Fact]
public async Task CheckSdCardSpaceAsync_WhenPlentyOfSpace_DoesNotRaiseWarning()
{
Expand Down
2,027 changes: 184 additions & 1,843 deletions src/Daqifi.Core/Device/DaqifiStreamingDevice.cs

Large diffs are not rendered by default.

266 changes: 266 additions & 0 deletions src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsOperations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using Daqifi.Core.Communication.Producers;
using Daqifi.Core.Device.Internal;

#nullable enable

namespace Daqifi.Core.Device.Diagnostics
{
/// <summary>
/// The <see cref="IDeviceDiagnostics"/> implementation, extracted from
/// <see cref="DaqifiStreamingDevice"/> so the device delegates rather than hosts it.
/// </summary>
/// <remarks>
/// Each method issues a single SCPI query/command as a text command (the protobuf consumer is
/// paused for the exchange, same as the SD and LAN-chip queries) and hands the response to a
/// tolerant parser. Unlike the SD operations these do not switch the SPI bus, so there is no
/// PrepareSdInterface / settle delay; and they intentionally do not stop streaming, so callers
/// can sample live counters — though parsing is most reliable when the device is not actively
/// streaming.
/// </remarks>
internal sealed class DeviceDiagnosticsOperations : IDeviceDiagnostics
{
/// <summary>Time allowed for the first diagnostics response line. Generous because
/// <c>SYSTem:LOG?</c> and the stats queries can emit dozens of lines.</summary>
private const int DIAGNOSTICS_RESPONSE_TIMEOUT_MS = 2000;

private readonly IDeviceOperationHost _host;

internal DeviceDiagnosticsOperations(IDeviceOperationHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
}

/// <inheritdoc />
public async Task<IReadOnlyList<SystemLogEntry>> GetSystemLogAsync(CancellationToken cancellationToken = default)
{
if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(ScpiMessageProducer.GetSystemLog),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

var entries = SystemLogParser.Parse(lines);

// The parser drops error/status lines, so an error-only response would
// otherwise be indistinguishable from a genuinely empty log buffer.
// Surface a command failure (e.g. unsupported on below-floor firmware)
// rather than returning a misleading empty list.
ThrowIfErrorOnlyResponse(entries.Count, lines, "read the system log");

return entries;
}

/// <inheritdoc />
public async Task ClearSystemLogAsync(CancellationToken cancellationToken = default)
{
if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(ScpiMessageProducer.ClearSystemLog),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

// On success the device echoes a short ack ("Log cleared"); an error-only
// response means the command failed and must not be swallowed.
ThrowIfErrorOnlyResponse(0, lines, "clear the system log");
}

/// <inheritdoc />
public async Task<LogLevelSetting> SetLogLevelAsync(string module, int level, CancellationToken cancellationToken = default)
{
// Build the command first so argument validation (ArgumentException /
// ArgumentOutOfRangeException) surfaces the same way regardless of
// connection state, matching SetAnalogOutput / SetDioDirection.
var command = ScpiMessageProducer.SetLogLevel(module, level);

if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(command),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

if (ScpiResponseClassifier.ContainsScpiError(lines))
{
throw new DeviceDiagnosticsException(
$"The device rejected log level {level} for module '{module}'.",
lines);
}

if (LogLevelParser.TryParseLines(lines, out var setting))
{
return setting;
}

throw new DeviceDiagnosticsException(
$"Setting the log level for module '{module}' returned an unparseable response.",
lines);
}

/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetCommandHistoryAsync(CancellationToken cancellationToken = default)
{
if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(ScpiMessageProducer.GetCommandHistory),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

var commands = CommandHistoryParser.Parse(lines);

// An empty list is valid ("No command history"), but an error-only
// response is a failure — distinguish the two. The "No command history"
// marker is not an error line, so it never trips this check.
ThrowIfErrorOnlyResponse(commands.Count, lines, "read the command history");

return commands;
}

/// <inheritdoc />
public async Task TestSystemLogAsync(CancellationToken cancellationToken = default)
{
if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(ScpiMessageProducer.TestSystemLog),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

// On success the device echoes "Added test log messages"; an error-only
// response means the command failed and must not be swallowed.
ThrowIfErrorOnlyResponse(0, lines, "run the system-log self-test");
}

/// <inheritdoc />
public async Task<int> GetSystemErrorCountAsync(CancellationToken cancellationToken = default)
{
if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(ScpiMessageProducer.GetSystemErrorCount),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

foreach (var line in lines)
{
if (string.IsNullOrWhiteSpace(line))
{
continue;
}

if (int.TryParse(line.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var count))
{
return count;
}
}

throw new DeviceDiagnosticsException(
"The error-count query returned an unparseable response.",
lines);
}

/// <inheritdoc />
public async Task<StreamStats> GetStreamStatsAsync(CancellationToken cancellationToken = default)
{
if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(ScpiMessageProducer.GetStreamStats),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

if (StreamStatsParser.TryParse(lines, out var stats))
{
return stats;
}

throw new DeviceDiagnosticsException(
"The streaming-stats query returned an unparseable response.",
lines);
}

/// <inheritdoc />
public async Task<MemoryDiagnostics> GetMemoryDiagnosticsAsync(CancellationToken cancellationToken = default)
{
if (!_host.IsConnected)
{
throw new DeviceNotConnectedException();
}

cancellationToken.ThrowIfCancellationRequested();

var lines = await _host.ExecuteTextCommandAsync(
() => _host.Send(ScpiMessageProducer.GetMemoryDiagnostics),
responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS,
cancellationToken: cancellationToken).ConfigureAwait(false);

if (MemoryDiagnosticsParser.TryParse(lines, out var diagnostics))
{
return diagnostics;
}

throw new DeviceDiagnosticsException(
"The memory-diagnostics query returned an unparseable response.",
lines);
}

/// <summary>
/// Throws a <see cref="DeviceDiagnosticsException"/> when a diagnostics command produced no
/// usable result and the device's response consisted solely of SCPI error/status lines —
/// i.e. the command failed (commonly an unsupported header on below-floor firmware) rather
/// than legitimately returning nothing. A truly empty response (no lines) is treated as
/// success so callers can distinguish "empty log" from "command failed".
/// </summary>
private static void ThrowIfErrorOnlyResponse(int parsedResultCount, IReadOnlyList<string> lines, string operation)
{
if (parsedResultCount == 0 && ScpiResponseClassifier.IsErrorOnlyResponse(lines))
{
throw new DeviceDiagnosticsException(
$"The device returned an error while attempting to {operation}.",
lines);
}
}
}
}
103 changes: 103 additions & 0 deletions src/Daqifi.Core/Device/Internal/IDeviceOperationHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Daqifi.Core.Communication.Messages;
using Daqifi.Core.Device.SdCard;

#nullable enable

namespace Daqifi.Core.Device.Internal
{
/// <summary>
/// The slice of a streaming device that its operation collaborators work through: the
/// text-exchange and raw-capture primitives, the transport facts that change how a command must
/// be issued, and the few pieces of device state those operations own.
/// </summary>
/// <remarks>
/// <para>
/// Every member here forwards to a member the device already had, and the ones that are
/// <c>virtual</c> on the device stay virtual through this seam. That matters more than it
/// looks: subclasses — instrumented devices in the field, and the test doubles that stand in
/// for hardware — override <see cref="ExecuteTextCommandAsync"/>,
/// <see cref="ExecuteRawCaptureAsync"/>, <see cref="Send"/> and <see cref="IsUsbConnection"/>
/// to intercept device I/O. Routing the collaborators through the device's own virtual members
/// keeps those overrides in the path; a collaborator that reached for the transport directly
/// would silently step around every one of them.
/// </para>
/// <para>
/// <see cref="DaqifiStreamingDevice"/> implements this explicitly, so none of it widens the
/// public API.
/// </para>
/// </remarks>
internal interface IDeviceOperationHost
{
/// <inheritdoc cref="DaqifiDevice.IsConnected"/>
bool IsConnected { get; }

/// <inheritdoc cref="DaqifiStreamingDevice.IsUsbConnection"/>
bool IsUsbConnection { get; }

/// <inheritdoc cref="DaqifiStreamingDevice.IsStreaming"/>
/// <remarks>
/// Settable here because the SD operations defensively stop streaming before they touch the
/// card and must record that they did (issue #118).
/// </remarks>
bool IsStreaming { get; set; }

/// <inheritdoc cref="DaqifiStreamingDevice.StreamingFrequency"/>
int StreamingFrequency { get; }

/// <inheritdoc cref="DaqifiStreamingDevice.StopStreaming"/>
void StopStreaming();

/// <inheritdoc cref="DaqifiDevice.Send{T}"/>
void Send<T>(IOutboundMessage<T> message);

/// <inheritdoc cref="DaqifiDevice.ExecuteTextCommandAsync(Action, int, int, CancellationToken, Func{CancellationToken, Task}, Func{Task})"/>
#pragma warning disable CA1068 // Matches the seam it forwards to, which orders these for source compatibility.
Task<IReadOnlyList<string>> ExecuteTextCommandAsync(
Action setupAction,
int responseTimeoutMs = 1000,
int completionTimeoutMs = 250,
CancellationToken cancellationToken = default,
Func<CancellationToken, Task>? prepareAsync = null,
Func<Task>? finalizeAsync = null);
#pragma warning restore CA1068

/// <inheritdoc cref="DaqifiDevice.ExecuteRawCaptureAsync"/>
Task ExecuteRawCaptureAsync(
Func<Stream, CancellationToken, Task> rawAction,
CancellationToken cancellationToken = default);

/// <inheritdoc cref="DaqifiDevice.EnsureSupported"/>
void EnsureSupported(DeviceFeature feature);

/// <inheritdoc cref="DaqifiDevice.CreateFeatureNotSupportedException"/>
FeatureNotSupportedException CreateFeatureNotSupportedException(DeviceFeature feature);

/// <summary>
/// Overall wall-clock budget for one SD card download, read through the device so a
/// subclass's override of it still applies.
/// </summary>
TimeSpan SdCardDownloadTimeout { get; }

/// <summary>
/// Inactivity window for an SD card transfer, read through the device so a subclass's
/// override of it still applies.
/// </summary>
TimeSpan SdCardTransferIdleTimeout { get; }

/// <summary>
/// Raises the device's <see cref="DaqifiStreamingDevice.LowSdSpaceWarning"/> event.
/// </summary>
/// <remarks>
/// Deliberately a call back into the device rather than an event the collaborator owns.
/// The event is part of <see cref="ISdCardOperations"/>, so its <c>sender</c> has to remain
/// the device a subscriber attached to — a collaborator raising it in its own name would be
/// a silent, compile-clean behavior change.
/// </remarks>
void RaiseLowSdSpaceWarning(LowSdSpaceWarningEventArgs e);
}
}
Loading