diff --git a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs index 5882f1e4..8dd969dd 100644 --- a/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs +++ b/src/Daqifi.Core.Tests/Device/SdCard/SdCardOperationsTests.cs @@ -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 { "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() { diff --git a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs index 95c5ebc3..4166eed2 100644 --- a/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs +++ b/src/Daqifi.Core/Device/DaqifiStreamingDevice.cs @@ -4,6 +4,7 @@ using Daqifi.Core.Communication.Producers; using Daqifi.Core.Communication.Transport; using Daqifi.Core.Device.Diagnostics; +using Daqifi.Core.Device.Internal; using Microsoft.Extensions.Logging; using Daqifi.Core.Device.Network; using Daqifi.Core.Device.SdCard; @@ -28,39 +29,8 @@ namespace Daqifi.Core.Device /// Represents a DAQiFi device that supports data streaming functionality. /// Extends the base DaqifiDevice with streaming-specific operations. /// - public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkConfigurable, ISdCardOperations, ILanChipInfoProvider, IDeviceDiagnostics + public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkConfigurable, ISdCardOperations, ILanChipInfoProvider, IDeviceDiagnostics, IDeviceOperationHost { - /// - /// The delay in milliseconds to wait for the WiFi module to restart after applying configuration. - /// - private const int WIFI_MODULE_RESTART_DELAY_MS = 2000; - - /// - /// The delay in milliseconds to wait after switching between LAN and SD card interfaces. - /// The SD card and LAN share the SPI bus, so a settle period is needed for the device - /// firmware to complete the interface switch before sending further commands. - /// - private const int SD_INTERFACE_SETTLE_DELAY_MS = 100; - - /// - /// Maximum number of retry attempts for SD card list operations that receive transient - /// SCPI errors (e.g., -200 Execution error) due to interface-switch timing. - /// - private const int SD_LIST_MAX_RETRIES = 1; - - /// - /// Inactivity window that ends the SD listing text exchange, in milliseconds. - /// - /// - /// Deliberately longer than the 250ms default. The listing is only accepted once its - /// end-of-listing terminator has been seen (see ), and the - /// terminator can trail the last listing line by more than the default window — the firmware - /// walks the directory tree between chunks, and a congested WiFi link adds its own gaps. With - /// the default, a merely-slow terminator would read as a missing one and fail a listing that - /// was about to complete. - /// - private const int SD_LIST_COMPLETION_TIMEOUT_MS = 1000; - /// /// Maximum number of retry attempts for the USB stream-interface command sent during /// when the device returns a transient SCPI error @@ -74,34 +44,6 @@ public class DaqifiStreamingDevice : DaqifiDevice, IStreamingDevice, INetworkCon /// private const int UsbStreamInterfaceRetryDelayMs = 150; - /// - /// libscpi's SCPI_ERROR_UNDEFINED_HEADER — the code the firmware returns for a - /// command it doesn't recognize (e.g. a command that postdates the connected firmware). - /// This is the wire-level signal behind the - /// backstop (ADR 0001, docs/adr/0001-firmware-feature-gating.md). - /// - private const int ScpiErrorCodeUndefinedHeader = -113; - - private bool _isLoggingToSdCard; - private IReadOnlyList _sdCardFiles = Array.Empty(); - - /// - /// Admits one SD download at a time. A download that hits its deadline is ABANDONED, not - /// stopped — its worker can still be parked in native I/O holding the transport stream — - /// so the gate is released only when that worker actually finishes, however long that - /// takes. Without it, a caller retrying against a device that stays wedged (an "import - /// all" loop, say) would start a second reader on the same stream, which is the framing - /// corruption already refuses to risk when restarting the - /// protobuf consumer, and would stack another permanently blocked thread each time (#399). - /// - /// - /// Deliberately not disposed: we only ever call and - /// , never , - /// so there is no handle to release — and an abandoned worker may release this long after - /// the device is disposed, which would otherwise fault a continuation nobody observes. - /// - private readonly SemaphoreSlim _sdDownloadGate = new(1, 1); - /// /// Reconstructs host timestamps from the device's rolling 32-bit tick counter during a /// streaming session. Scoped to this device instance, so a single fixed key suffices. @@ -212,7 +154,7 @@ public int StreamingFrequency /// /// Gets a value indicating whether the device is currently logging data to the SD card. /// - public bool IsLoggingToSdCard => _isLoggingToSdCard; + public bool IsLoggingToSdCard => _sdCardOperations.IsLoggingToSdCard; /// /// Gets a value indicating whether the device is connected over USB (serial transport). @@ -225,7 +167,7 @@ public int StreamingFrequency /// /// Gets the most recently retrieved list of files on the SD card. /// - public IReadOnlyList SdCardFiles => _sdCardFiles; + public IReadOnlyList SdCardFiles => _sdCardOperations.SdCardFiles; /// public event EventHandler? LowSdSpaceWarning; @@ -238,17 +180,6 @@ public int StreamingFrequency /// public event EventHandler? GapDetected; - private readonly NetworkConfiguration _networkConfiguration = new NetworkConfiguration(); - - /// - /// Gets a copy of the current network configuration. - /// - /// - /// Returns a clone to prevent external modification. Use - /// to change the device's network configuration. - /// - public NetworkConfiguration NetworkConfiguration => _networkConfiguration.Clone(); - /// /// Initializes a new instance of the class. /// @@ -275,6 +206,14 @@ public DaqifiStreamingDevice(string name, IStreamTransport transport, ILogger? l private void InitializeStreamingDevice() { + // Built here rather than in field initializers because each needs `this` as its host, + // which a field initializer cannot reference. Every constructor routes through this + // method, so they are always in place before the device is handed to a caller. + _networkOperations = new NetworkConfigurationOperations(this); + _sdCardOperations = new SdCardOperations(this); + _lanChipInfoOperations = new LanChipInfoOperations(this); + _diagnosticsOperations = new DeviceDiagnosticsOperations(this); + StreamingFrequency = 100; // Clear the "already-sent" PWM frequency cache on any transition away from Connected — @@ -365,13 +304,13 @@ protected override async Task OnDeviceInitializingAsync( responseTimeoutMs: 500, cancellationToken: cancellationToken).ConfigureAwait(false); - if (!ContainsScpiError(lines)) + if (!ScpiResponseClassifier.ContainsScpiError(lines)) { return; } } - var lastScpiError = lines.LastOrDefault(IsScpiErrorLine)?.Trim(); + var lastScpiError = lines.LastOrDefault(ScpiResponseClassifier.IsScpiErrorLine)?.Trim(); throw new ScpiInitializationErrorException( "Device returned a SCPI error while setting stream interface to USB.", lines, @@ -1797,1836 +1736,238 @@ private void EnsureChannelBelongs(IChannel channel) } } - /// - /// Updates the device network configuration with the specified settings. - /// - /// - /// - /// Supported over any transport, including WiFi/TCP. The settings are staged, persisted to - /// NVM, and only then applied, so the configuration is durable before the applying restart - /// can disturb the control connection (#352). - /// - /// - /// Over a WiFi/TCP control connection this method is expected to drop the connection. - /// Applying the settings restarts the WiFi module, and when the new configuration points at - /// a different network the device necessarily leaves the one carrying the control link. - /// That is normal: the configuration has already been saved at that point. Callers should - /// treat the device as disconnected once this method returns over WiFi and rediscover or - /// reconnect on the new network — typically at a new address. - /// - /// - /// The new network configuration to apply. - /// 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 is null. - /// Thrown when an unsupported WiFi mode or security type is specified. - /// - /// Thrown when the operation is canceled before the configuration is committed to the - /// device. Once the save and apply have been dispatched the operation always completes - /// successfully: cancelling during the restart wait ends the wait early instead of failing, - /// because the device has already persisted and applied the new settings. - /// - public async Task UpdateNetworkConfigurationAsync(NetworkConfiguration configuration, CancellationToken cancellationToken = default) - { - if (configuration == null) - { - throw new ArgumentNullException(nameof(configuration)); - } + // ----------------------------------------------------------------- + // Delegation to the operation collaborators. + // + // Each block below was lifted out of this class wholesale (#344); what + // remains is the public surface, unchanged, forwarding to the object + // that now owns the implementation. The collaborators reach back + // through IDeviceOperationHost, implemented explicitly at the bottom of + // this file, so every call still passes through this device's own + // virtual members and any subclass override of them. + // ----------------------------------------------------------------- - cancellationToken.ThrowIfCancellationRequested(); + /// WiFi/LAN configuration (). + private NetworkConfigurationOperations _networkOperations = null!; - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } + /// SD card operations () and the shared-SPI handover. + private SdCardOperations _sdCardOperations = null!; - // Stop streaming if active - if (IsStreaming) - { - StopStreaming(); - } + /// WiFi module chip info (). + private LanChipInfoOperations _lanChipInfoOperations = null!; - // Set WiFi mode - switch (configuration.Mode) - { - case WifiMode.ExistingNetwork: - Send(ScpiMessageProducer.SetNetworkWifiModeExisting); - break; - case WifiMode.SelfHosted: - Send(ScpiMessageProducer.SetNetworkWifiModeSelfHosted); - break; - default: - throw new ArgumentOutOfRangeException(nameof(configuration), configuration.Mode, "Unsupported WiFi mode."); - } + /// Device diagnostics (). + private DeviceDiagnosticsOperations _diagnosticsOperations = null!; - // Set SSID - Send(ScpiMessageProducer.SetNetworkWifiSsid(configuration.Ssid)); + #region INetworkConfigurable - // Set security type and password - switch (configuration.SecurityType) - { - case WifiSecurityType.None: - Send(ScpiMessageProducer.SetNetworkWifiSecurityOpen); - break; - case WifiSecurityType.WpaPskPhrase: - Send(ScpiMessageProducer.SetNetworkWifiSecurityWpa); - Send(ScpiMessageProducer.SetNetworkWifiPassword(configuration.Password)); - break; - default: - throw new ArgumentOutOfRangeException(nameof(configuration), configuration.SecurityType, "Unsupported WiFi security type."); - } + /// + public NetworkConfiguration NetworkConfiguration => _networkOperations.NetworkConfiguration; - // Stage static IP fields (firmware writes these into the runtime - // WiFi settings that ApplyNetworkLan consumes). Skip any field the - // caller left null so DHCP-only callers see no behavior change. - if (configuration.StaticIP != null) - { - Send(ScpiMessageProducer.SetLanAddress(configuration.StaticIP)); - } - if (configuration.SubnetMask != null) - { - Send(ScpiMessageProducer.SetLanMask(configuration.SubnetMask)); - } - if (configuration.Gateway != null) - { - Send(ScpiMessageProducer.SetLanGateway(configuration.Gateway)); - } + /// + public Task UpdateNetworkConfigurationAsync(NetworkConfiguration configuration, CancellationToken cancellationToken = default) + => _networkOperations.UpdateNetworkConfigurationAsync(configuration, cancellationToken); - // Stage the LAN interface state alongside the credentials above. LAN:ENAbled writes - // isEnabled into the same runtime settings struct the SET commands populate and does - // not restart anything itself, so it belongs before the save (to be persisted) and - // before the apply (the firmware only fires a module REINIT when isEnabled is set). - // This deliberately does NOT call PrepareLanInterface() — that is the transport-aware - // SD-operation restore, which leaves the LAN alone over WiFi (where #598/#599 keep it - // up). Here the LAN enable is unconditional: reconfiguration owns the LAN state. - Send(ScpiMessageProducer.DisableStorageSd); - Send(ScpiMessageProducer.EnableNetworkLan); + /// + public Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = default) + => _networkOperations.LoadNetworkConfigurationAsync(cancellationToken); - // Cancellation boundary. This is the last point where abandoning still avoids the two - // things that matter: nothing has been persisted (no LAN:SAVE) and no module restart - // has been triggered (no LAN:APPLY), so the device keeps serving the network - // configuration it already had. Past the save below it has committed, and cancellation - // stops being a way out. - // - // This is deliberately NOT a side-effect-free point. The staged credentials, the LAN - // enable flag and the SD disable above have all reached the device's runtime state, and - // a later LAN:APPLY from any caller would pick up those staged values. No side-effect- - // free abort exists once the sequence has begun — only the check at the top of this - // method precedes every Send. - cancellationToken.ThrowIfCancellationRequested(); + /// + public Task FactoryResetNetworkAsync(CancellationToken cancellationToken = default) + => _networkOperations.FactoryResetNetworkAsync(cancellationToken); - // Persist BEFORE applying (#352). LAN:SAVE copies the staged runtime settings straight - // to NVM; it does NOT require them to have been applied first. Sending it here — while - // the control link is still guaranteed alive — is what makes the reconfiguration - // durable regardless of what the apply below does to the connection. - Send(ScpiMessageProducer.SaveNetworkLan); - - // Apply last: this restarts the WiFi module. Over a WiFi/TCP control connection that - // restart necessarily tears down the link — inherent to moving the device onto a - // different network, not a fault to be avoided. Because the save above already - // committed the configuration to NVM, losing the link here costs nothing: the device - // comes back on the new network with the settings intact. Nothing is sent after this - // command, so there is no tail left to drop. - Send(ScpiMessageProducer.ApplyNetworkLan); - - // Hold for the module restart window before returning, so the apply is flushed to the - // transport rather than left buffered in a connection that is about to go away. - // Cancelling here ends the wait but does NOT fail the operation: the device has already - // persisted and applied the new configuration, so reporting "canceled" — and skipping - // the local-state update below — would leave the caller believing nothing happened - // while the device is sitting on a different network. - try - { - await Task.Delay(WIFI_MODULE_RESTART_DELAY_MS, cancellationToken); - } - catch (OperationCanceledException) - { - // Already committed on the device; stop waiting early and complete normally. - } + /// + public void PrepareSdInterface() => _sdCardOperations.PrepareSdInterface(); - // Update local configuration. Static IP fields use null = "leave - // unchanged" semantics, so only overwrite when the caller provided - // a value — otherwise we'd clobber the previously known static IP. - _networkConfiguration.Mode = configuration.Mode; - _networkConfiguration.SecurityType = configuration.SecurityType; - _networkConfiguration.Ssid = configuration.Ssid; - _networkConfiguration.Password = configuration.Password; - if (configuration.StaticIP != null) - { - _networkConfiguration.StaticIP = configuration.StaticIP; - } - if (configuration.SubnetMask != null) - { - _networkConfiguration.SubnetMask = configuration.SubnetMask; - } - if (configuration.Gateway != null) - { - _networkConfiguration.Gateway = configuration.Gateway; - } - } + /// + public void PrepareLanInterface() => _sdCardOperations.PrepareLanInterface(); - /// - /// Loads the persisted LAN configuration from the device's NVM back into its runtime settings. - /// - /// 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 LoadNetworkConfigurationAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); + #endregion - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } + #region ISdCardOperations - // Re-check right before the state-changing send so a cancellation requested after the - // entry guard still short-circuits the command (matches the pattern accepted in #324). - cancellationToken.ThrowIfCancellationRequested(); - Send(ScpiMessageProducer.LoadNetworkLan); - return Task.CompletedTask; - } + /// + public Task> GetSdCardFilesAsync(CancellationToken cancellationToken = default) + => _sdCardOperations.GetSdCardFilesAsync(cancellationToken); - /// - /// Resets the device's LAN configuration to firmware factory defaults. - /// - /// 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 FactoryResetNetworkAsync(CancellationToken cancellationToken = default) - { - cancellationToken.ThrowIfCancellationRequested(); + /// + public Task GetSdCardStorageAsync(CancellationToken cancellationToken = default) + => _sdCardOperations.GetSdCardStorageAsync(cancellationToken); - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } + /// + public Task CheckSdCardSpaceAsync( + SdCardCaptureEstimate? plannedCapture = null, + long minimumFreeBytes = SdCardSpaceCheck.DefaultMinimumFreeBytes, + CancellationToken cancellationToken = default) + => _sdCardOperations.CheckSdCardSpaceAsync(plannedCapture, minimumFreeBytes, cancellationToken); - // Re-check right before the state-changing send so a cancellation requested after the - // entry guard still short-circuits the command (matches the pattern accepted in #324). - cancellationToken.ThrowIfCancellationRequested(); - Send(ScpiMessageProducer.FactoryResetNetworkLan); - return Task.CompletedTask; - } + /// + public void SetSdCardMinimumFreeSpace(long bytes) => _sdCardOperations.SetSdCardMinimumFreeSpace(bytes); - /// - /// Prepares the SD-card interface for a file operation. Over USB the LAN interface is - /// disabled first to free the shared SPI bus for the SD card. Over WiFi/TCP (firmware - /// >= v3.7.0, #598/#599) the LAN interface MUST stay enabled — the Harmony SPI driver - /// arbitrates SD/WiFi transactions on the shared bus, and the SD reply routes back over the - /// very TCP channel that requested it, so disabling LAN would drop the control channel - /// mid-operation. Only the SD subsystem is enabled in that case. - /// - /// Thrown when the device is not connected. - public void PrepareSdInterface() - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } + /// + public Task StartSdCardLoggingAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) + => _sdCardOperations.StartSdCardLoggingAsync(fileName, channelMask, format, cancellationToken); - if (IsUsbConnection) - { - Send(ScpiMessageProducer.DisableNetworkLan); - } + /// + public Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) + => _sdCardOperations.StartSdCardLoggingSessionAsync(fileName, channelMask, format, cancellationToken); - Send(ScpiMessageProducer.EnableStorageSd); - } + /// + public Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default) + => _sdCardOperations.StopSdCardLoggingAsync(cancellationToken); - /// - /// Restores the interface after an SD-card file operation. The SD subsystem is disabled in - /// both cases. Over USB the LAN interface is re-enabled (it was disabled by - /// ). Over WiFi/TCP the LAN was never disabled, so it is - /// left alone — re-enabling it would re-initialize the WiFi module and drop the connection. - /// - /// Thrown when the device is not connected. - public void PrepareLanInterface() - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } + /// + public Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default) + => _sdCardOperations.DeleteSdCardFileAsync(fileName, cancellationToken); - Send(ScpiMessageProducer.DisableStorageSd); + /// + public Task FormatSdCardAsync(CancellationToken cancellationToken = default) + => _sdCardOperations.FormatSdCardAsync(cancellationToken); - if (IsUsbConnection) - { - Send(ScpiMessageProducer.EnableNetworkLan); - } - } + /// + public Task DownloadSdCardFileAsync( + string fileName, + Stream destinationStream, + IProgress? progress = null, + CancellationToken cancellationToken = default) + => _sdCardOperations.DownloadSdCardFileAsync(fileName, destinationStream, progress, cancellationToken); + + /// + public Task DownloadSdCardFileAsync( + string fileName, + IProgress? progress = null, + CancellationToken cancellationToken = default) + => _sdCardOperations.DownloadSdCardFileAsync(fileName, progress, cancellationToken); /// - /// Applies the transport predicate for an SD-card operation that drives the card while the - /// link is active (LIST / GET / DELETE and the storage-space query). Over USB (serial) these - /// are available on all SD-capable firmware and are not gated. Over WiFi/TCP they are gated - /// on , which - /// resolves against the requirement table - /// (ADR 0001) — pre-empting a command the firmware cannot service over WiFi, which would - /// otherwise stall on the shared SPI bus. + /// Raises the event. /// + /// The warning event arguments. /// - /// This is only the transport half of the gate: which feature applies depends on the active - /// transport, but whether the device has that feature is the seam's answer, not this - /// method's. + /// Stays on the device rather than moving with the space check that triggers it: the event + /// is part of this device's public surface, so subscribers must keep seeing this device as + /// the sender, and a subclass override must keep intercepting it. /// - /// - /// Thrown when the active transport is not USB and the device does not support - /// . - /// - private void EnsureSdFileTransferSupportedOnTransport() + protected virtual void OnLowSdSpaceWarning(LowSdSpaceWarningEventArgs e) { - if (IsUsbConnection) - { - return; - } - - EnsureSupported(DeviceFeature.SdFileTransferOverWifi); + LowSdSpaceWarning?.Invoke(this, e); } /// - /// Retrieves the list of files stored on the device's SD card. + /// Overall wall-clock budget for one + /// + /// call, covering every GET attempt. This is the per-transfer limit the receiver has + /// always applied; what #399 changed is that the download now enforces it itself instead + /// of trusting whatever the transfer is parked in to notice a cancellation token. /// - /// A cancellation token to observe while waiting for the task to complete. - /// A task that represents the asynchronous operation, containing the list of files. - /// Thrown when the device is not connected. - /// Thrown when the operation is canceled. - /// Thrown when no SD card is installed in the device. - /// Thrown when the SD card filesystem cannot satisfy the request (corrupt card, unreadable directory). - /// Thrown when the device returned an SCPI error that did not match a more specific condition. Empty directories return an empty list rather than throwing. - /// - /// Thrown when the listing did not arrive in full — the device never answered, or stopped - /// answering part-way through. Distinguishing this from a genuinely empty card is the whole - /// point of the terminator probe described in the remarks (closes #396). - /// - /// - /// - /// The firmware emits no end-of-listing marker, and for an empty directory it writes nothing - /// at all, so a lost or truncated reply is byte-for-byte indistinguishable from a healthy - /// empty card. Core closes that gap by appending a SYSTem:ERRor? query to the same - /// text exchange: the transport delivers in order and the firmware does not process the - /// next command until the listing has been handed to the output, so receiving the reply - /// proves both that the device is answering and that the listing ahead of it is complete. - /// Its absence means the response is incomplete, and the caller gets an exception instead of - /// a plausible-looking empty list. - /// - /// - /// The terminator is only meaningful if it cannot be confused with a late reply to an - /// earlier command, so two things guard that boundary: the text exchange discards whatever - /// was already in flight when it opened, and this method's SPI-bus switch and settle delay - /// run as the exchange's prepare phase, ahead of that boundary, leaving the exchange with no - /// internal gap for a stale reply to slip into. - /// - /// - /// The terminator's error code is used only as a liveness marker, never for classification: - /// the queue it pops can hold entries left by earlier commands, so attributing the code to - /// this listing would misreport stale failures. SD errors continue to be classified from the - /// listing lines themselves. Note the side effect this implies — each listing consumes one - /// entry from the device's SCPI error queue, so a - /// run afterwards will not see the entry - /// this listing generated. - /// - /// - public async Task> GetSdCardFilesAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } + /// Virtual only as a test seam — a 30-minute budget is not unit-testable. + internal virtual TimeSpan SdCardDownloadTimeout => TimeSpan.FromMinutes(30); + + /// + /// How long a download may go without receiving a single byte before it is declared + /// stalled. Bounds a WiFi/TCP transfer whose device stopped answering, which the socket + /// itself never reports (see ). + /// + /// Virtual only as a test seam — a 20-second window is not unit-testable. + internal virtual TimeSpan SdCardTransferIdleTimeout => SdCardFileReceiver.DefaultIdleTimeout; - EnsureSdFileTransferSupportedOnTransport(); + #endregion - cancellationToken.ThrowIfCancellationRequested(); + #region ILanChipInfoProvider - // Defensive: always send stop command even if IsStreaming is stale (see issue #118) - Send(ScpiMessageProducer.StopStreaming); - IsStreaming = false; + /// + public Task GetLanChipInfoAsync(CancellationToken cancellationToken = default) + => _lanChipInfoOperations.GetLanChipInfoAsync(cancellationToken); - IReadOnlyList lines = Array.Empty(); - IReadOnlyList listing = Array.Empty(); - var isComplete = false; + #endregion - // Attempt 0 plus SD_LIST_MAX_RETRIES retries. A SCPI error here is often a transient - // timing issue, and an unterminated response can be a one-off stall, so both are - // retried once after an additional settle delay before being surfaced. - for (var attempt = 0; attempt <= SD_LIST_MAX_RETRIES; attempt++) - { - if (attempt > 0) - { - cancellationToken.ThrowIfCancellationRequested(); + #region IDeviceDiagnostics - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); - } + /// + public Task> GetSystemLogAsync(CancellationToken cancellationToken = default) + => _diagnosticsOperations.GetSystemLogAsync(cancellationToken); - // The SPI bus switch and its settle wait run as the exchange's prepare phase, and - // the restore as its finalize phase: both inside the exchange lock, so a competing - // text exchange can neither restore the LAN interface between the switch and the - // LIST nor slip in between the LIST and the restore. The prepare also sits ahead of - // the stale-line boundary, so its settle wait does not become a window in which a - // late reply to an earlier command could pass for this listing's terminator. - // Querying the card too soon after the switch makes the device answer -200 - // (Execution error), so the wait itself is not optional. - // - // Each attempt therefore leaves the bus back on LAN, including across the retry - // delay above — the pairing is per exchange rather than per call so that gap, which - // is outside the lock, is not one in which the device sits switched to the card. - lines = await ExecuteTextCommandAsync( - () => - { - Send(ScpiMessageProducer.GetSdFileList); + /// + public Task ClearSystemLogAsync(CancellationToken cancellationToken = default) + => _diagnosticsOperations.ClearSystemLogAsync(cancellationToken); + + /// + public Task SetLogLevelAsync(string module, int level, CancellationToken cancellationToken = default) + => _diagnosticsOperations.SetLogLevelAsync(module, level, cancellationToken); - // End-of-listing terminator — see this method's remarks. Sent inside - // the same text exchange so the ordering guarantee holds. - Send(ScpiMessageProducer.GetSystemError); - }, - responseTimeoutMs: 3000, - completionTimeoutMs: SD_LIST_COMPLETION_TIMEOUT_MS, - cancellationToken: cancellationToken, - prepareAsync: PrepareSdInterfaceAndSettleAsync, - finalizeAsync: RestoreLanInterfaceAsync); + /// + public Task> GetCommandHistoryAsync(CancellationToken cancellationToken = default) + => _diagnosticsOperations.GetCommandHistoryAsync(cancellationToken); - isComplete = TrySplitAtSdListTerminator(lines, out listing); + /// + public Task TestSystemLogAsync(CancellationToken cancellationToken = default) + => _diagnosticsOperations.TestSystemLogAsync(cancellationToken); - if (isComplete && !ContainsScpiError(listing)) - { - break; - } - } + /// + public Task GetSystemErrorCountAsync(CancellationToken cancellationToken = default) + => _diagnosticsOperations.GetSystemErrorCountAsync(cancellationToken); - if (!isComplete) - { - throw new SdCardListIncompleteException(lines); - } + /// + public Task GetStreamStatsAsync(CancellationToken cancellationToken = default) + => _diagnosticsOperations.GetStreamStatsAsync(cancellationToken); - ThrowIfSdCardListError(listing); + /// + public Task GetMemoryDiagnosticsAsync(CancellationToken cancellationToken = default) + => _diagnosticsOperations.GetMemoryDiagnosticsAsync(cancellationToken); - var files = SdCardFileListParser.ParseFileList(listing); - _sdCardFiles = files; - return files; - } + #endregion - /// - /// Prepare phase shared by the SD card text exchanges: switches the shared SPI bus over to - /// the card and waits for the firmware to complete the switch. - /// - /// - /// Passed as the prepareAsync phase of - /// - /// rather than run - /// inline, so it executes inside the text-exchange lock — a competing exchange restoring the - /// LAN interface between the switch and the commands that depend on it would leave them - /// running against the wrong interface — and ahead of the exchange's stale-line boundary, so - /// the settle wait cannot be mistaken for a window in which the device was answering. - /// - private async Task PrepareSdInterfaceAndSettleAsync(CancellationToken cancellationToken) - { - PrepareSdInterface(); + #region IDeviceOperationHost - // Querying the card too soon after the switch makes the device answer -200 - // (Execution error), so this wait is not optional. - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); - } + // Explicit implementation: this is how the collaborators reach the device, and none of it + // belongs on the public surface. Every member forwards to the member it names, so the + // virtual ones stay virtual and a subclass that overrides them still intercepts every + // operation that was moved out of this class. - /// - /// Finalize phase shared by the SD card text exchanges: hands the shared SPI bus back to the - /// LAN interface. The mirror of . - /// - /// - /// Passed as the finalizeAsync phase of - /// - /// rather than run from the caller's own finally, so it holds the same lock - /// acquisition the matching prepare phase does. Restoring from outside the lock leaves a - /// window in which a competing exchange runs between this operation's commands and its - /// restore — the switch serialized, the restore not (#407). - /// - /// The connection check keeps a restore off a device that dropped mid-operation, where the - /// sends would only throw over the top of whatever - /// actually failed. Nothing to restore in that case: the link is gone. - /// - /// - private Task RestoreLanInterfaceAsync() - { - if (IsConnected) - { - PrepareLanInterface(); - } + bool IDeviceOperationHost.IsConnected => IsConnected; - return Task.CompletedTask; - } + bool IDeviceOperationHost.IsUsbConnection => IsUsbConnection; - /// - /// Splits a raw SD listing response at the SYSTem:ERRor? terminator reply that - /// appends to the exchange. - /// - /// The raw response lines captured from the device. - /// - /// The lines that precede the terminator — the directory listing proper — when the method - /// returns true; otherwise the unmodified input. - /// - /// - /// true when the terminator was present, meaning the response is complete; - /// false when it never arrived, meaning the response is missing or truncated. - /// - private static bool TrySplitAtSdListTerminator( - IReadOnlyList lines, - out IReadOnlyList listingLines) + bool IDeviceOperationHost.IsStreaming { - // Scan from the end. A terminator reply from a PREVIOUS, timed-out exchange can still - // be sitting in the transport buffer and lead this response; splitting at the first - // match would then discard the listing that follows it and report an empty card — - // exactly the failure this terminator exists to prevent. - var terminatorIndex = -1; - for (var i = lines.Count - 1; i >= 0; i--) - { - if (ScpiResponseClassifier.IsSystemErrorReplyLine(lines[i])) - { - terminatorIndex = i; - break; - } - } + get => IsStreaming; + set => IsStreaming = value; + } - if (terminatorIndex < 0) - { - listingLines = lines; - return false; - } + int IDeviceOperationHost.StreamingFrequency => StreamingFrequency; - var listing = new List(terminatorIndex); - for (var j = 0; j < terminatorIndex; j++) - { - // Any other terminator-shaped line is a stale reply of the same kind, not - // directory content — no firmware listing entry can match that shape, since - // entries are always " ". - if (ScpiResponseClassifier.IsSystemErrorReplyLine(lines[j])) - { - continue; - } - - listing.Add(lines[j]); - } - - listingLines = listing; - return true; - } - - /// - /// Retrieves the free and total byte counts of the device's SD card. - /// - /// A cancellation token to observe while waiting for the task to complete. - /// A task that represents the asynchronous operation, containing the SD card storage info. - /// Thrown when the device is not connected. - /// Thrown when the device is currently logging to SD card. - /// Thrown when the operation is canceled. - /// Thrown when no SD card is installed in the device. - /// - /// Thrown when the device's firmware does not recognize the storage query (SCPI -113 - /// "Undefined header"), typically because it predates ; - /// or, over a WiFi/TCP transport, when the firmware predates SD-over-WiFi support - /// () — the storage query drives the SD - /// card through the same transport gate as the file operations. - /// - /// Thrown when the device returned a SCPI error or an unparseable response. - public async Task GetSdCardStorageAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - if (_isLoggingToSdCard) - { - throw new InvalidOperationException("Cannot query SD card storage while logging to SD card."); - } - - // The storage-space query drives the SD card through the same transport-aware - // PrepareSdInterface() as LIST/GET/DELETE, so it carries the identical SD-over-WiFi - // requirement: over WiFi it needs firmware >= v3.7.0 (#598/#599 SPI arbitration) — else - // it would access the SD card with the LAN still enabled on firmware that never learned - // to arbitrate the shared bus. Gate it up front for the same reason as its siblings. - EnsureSdFileTransferSupportedOnTransport(); - - cancellationToken.ThrowIfCancellationRequested(); - - // Defensive: always send stop command even if IsStreaming is stale (see issue #118) - Send(ScpiMessageProducer.StopStreaming); - IsStreaming = false; - - // Same prepare/finalize pairing as GetSdCardFilesAsync: the SPI bus switch and its - // settle wait are the exchange's prepare phase and the LAN restore is its finalize - // phase, so both halves are held by the one lock acquisition rather than only the - // switch (#407). The settle wait also moves ahead of the exchange's stale-line - // boundary instead of blocking a thread inside it. - var lines = await ExecuteTextCommandAsync( - () => Send(ScpiMessageProducer.GetSdSpace), - responseTimeoutMs: 3000, - cancellationToken: cancellationToken, - prepareAsync: PrepareSdInterfaceAndSettleAsync, - finalizeAsync: RestoreLanInterfaceAsync); - - // 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).ConfigureAwait(false); - - lines = await ExecuteTextCommandAsync( - () => Send(ScpiMessageProducer.GetSdSpace), - responseTimeoutMs: 3000, - cancellationToken: cancellationToken, - prepareAsync: PrepareSdInterfaceAndSettleAsync, - finalizeAsync: RestoreLanInterfaceAsync); - - if (!ContainsScpiError(lines) || ContainsNoSdCardMarker(lines)) - { - break; - } - } - } - - 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); - } - - // A -113 "Undefined header" reply means the firmware doesn't recognize the storage - // query at all — typically because it predates the version that introduced it — so - // it gets the typed feature-gating exception instead of a generic operation error. - // The device's answer is authoritative here, so this throws on the wire response - // rather than on Supports(); the seam only supplies the required version and board. - if (lastScpiError != null - && ScpiResponseClassifier.TryExtractErrorCode(lastScpiError, out var scpiErrorCode) - && scpiErrorCode == ScpiErrorCodeUndefinedHeader) - { - throw CreateFeatureNotSupportedException(DeviceFeature.SdStorageQuery); - } - - 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 lines) - { - return lines.Any(l => l.IndexOf("No SD Card Detected", StringComparison.OrdinalIgnoreCase) >= 0); - } - - /// - public async Task CheckSdCardSpaceAsync( - SdCardCaptureEstimate? plannedCapture = null, - long minimumFreeBytes = SdCardSpaceCheck.DefaultMinimumFreeBytes, - CancellationToken cancellationToken = default) - { - // Delegates connection / logging-state validation and the typed SD exceptions - // (no card, old firmware, unparseable response) to GetSdCardStorageAsync. - var storage = await GetSdCardStorageAsync(cancellationToken).ConfigureAwait(false); - - var result = SdCardSpaceCheck.Evaluate(storage, plannedCapture, minimumFreeBytes); - - // Advisory only — raise the warning but never block the caller from starting logging. - if (result.ShouldWarn) - { - OnLowSdSpaceWarning(new LowSdSpaceWarningEventArgs(result)); - } - - return result; - } - - /// - /// Raises the event. - /// - /// The warning event arguments. - protected virtual void OnLowSdSpaceWarning(LowSdSpaceWarningEventArgs e) - { - LowSdSpaceWarning?.Invoke(this, e); - } - - /// - public void SetSdCardMinimumFreeSpace(long bytes) - { - // Argument validation precedes the connection (state) check so misuse surfaces the same - // exception type regardless of connection state (matches SetAnalogOutput / SetDioDirection). - if (bytes < 0) - { - throw new ArgumentOutOfRangeException(nameof(bytes), bytes, "Minimum free space cannot be negative."); - } - - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - Send(ScpiMessageProducer.SetSdMinFreeSpace(bytes)); - } - - /// - /// 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, .csv for CSV). - /// - /// - /// Optional decimal bitmask string to enable specific ADC channels (e.g. "3" enables channels 0 and 1). - /// The firmware parses this as a decimal integer where each bit enables a channel. - /// 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 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 StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - if (!IsUsbConnection) - { - throw new InvalidOperationException( - "SD card logging requires a USB/serial connection. Starting a logging session " + - "disables the LAN interface to give the SD card the shared SPI bus, which over " + - "a network connection would drop the very link the command arrived on. " + - "Listing, downloading and deleting SD files do work over WiFi on firmware " + - "v3.7.0 and later."); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var extension = format switch - { - SdCardLogFormat.Json => ".json", - SdCardLogFormat.Csv => ".csv", - _ => ".bin", - }; - - var logFileName = !string.IsNullOrWhiteSpace(fileName) - ? fileName! - : $"log_{DateTime.Now:yyyyMMdd_HHmmss}{extension}"; - - ValidateSdCardFileName(logFileName); - - // SdCardLogFormat integer values map 1:1 to SYSTem:STReam:FORmat SCPI arguments - var formatCommand = new ScpiMessage($"SYSTem:STReam:FORmat {(int)format}"); - - // SD card and LAN share the SPI bus on the hardware, so LAN must be - // disabled before the SD card can be used. - Send(ScpiMessageProducer.DisableNetworkLan); - await Task.Delay(100, cancellationToken); - - Send(ScpiMessageProducer.EnableStorageSd); - await Task.Delay(100, cancellationToken); - - // Route the data stream to the SD card interface. - Send(ScpiMessageProducer.SetStreamInterface(StreamInterface.SdCard)); - await Task.Delay(100, cancellationToken); - - Send(ScpiMessageProducer.SetSdLoggingFileName(logFileName)); - await Task.Delay(100, cancellationToken); - - Send(formatCommand); - await Task.Delay(100, cancellationToken); - - if (!string.IsNullOrWhiteSpace(channelMask)) - { - Send(ScpiMessageProducer.EnableAdcChannels(channelMask)); - await Task.Delay(100, cancellationToken); - } - - Send(ScpiMessageProducer.StartStreaming(StreamingFrequency)); - - _isLoggingToSdCard = true; - IsStreaming = true; - - return new SdCardLoggingSession(logFileName, format); - } - - /// - /// Stops logging data to the SD card. - /// - /// 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 StopSdCardLoggingAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - // Defensive: always send stop command even if IsStreaming is stale (see issue #118) - Send(ScpiMessageProducer.StopStreaming); - IsStreaming = false; - - Send(ScpiMessageProducer.DisableStorageSd); - - // Restore stream interface to USB so subsequent non-SD operations work. - if (IsUsbConnection) - { - Send(ScpiMessageProducer.SetStreamInterface(StreamInterface.Usb)); - - // Re-enable LAN interface. StartSdCardLoggingAsync disables LAN because - // the SD card and WiFi/LAN share the SPI bus on the hardware. - // - // Only over USB, and for the same reason PrepareLanInterface() is transport-aware: - // over WiFi/TCP the LAN was never disabled (nothing can disable it from the other - // end of the connection it carries), and LAN:ENAbled 1 re-initializes the WiFi - // module — which would drop the very link this command arrived on. A session that - // logged over USB, disconnected, and came back over WiFi could otherwise call this - // and cut itself off (#327). - Send(ScpiMessageProducer.EnableNetworkLan); - } - - _isLoggingToSdCard = false; - - return Task.CompletedTask; - } - - /// - /// Deletes a file from the SD card. - /// - /// The name of the file to delete. - /// 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 device is currently logging to SD card. - /// Thrown when the filename is null, empty, or contains invalid characters. - /// Thrown when the operation is canceled. - public async Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - if (_isLoggingToSdCard) - { - throw new InvalidOperationException("Cannot delete files while logging to SD card."); - } - - EnsureSdFileTransferSupportedOnTransport(); - - cancellationToken.ThrowIfCancellationRequested(); - - if (string.IsNullOrWhiteSpace(fileName)) - { - throw new ArgumentException("Filename cannot be null or empty.", nameof(fileName)); - } - - ValidateSdCardFileName(fileName); - - // Defensive: always send stop command even if IsStreaming is stale (see issue #118) - Send(ScpiMessageProducer.StopStreaming); - IsStreaming = false; - - // Same prepare/finalize treatment as GetSdCardFilesAsync, for the same reasons — the - // SPI switch stays serialized against competing text exchanges, its settle wait stays - // outside the stale-line boundary, and the restore stays under the same lock as the - // switch instead of running from a finally after the lock has been dropped. The - // consequence of a stale line is milder here (delete keys off ContainsScpiError, so it - // would mean a pointless delete-and-relist retry rather than a bad listing) but it is - // the same defect. - var lines = await ExecuteTextCommandAsync( - () => - { - Send(ScpiMessageProducer.DeleteSdFile(fileName)); - Send(ScpiMessageProducer.GetSdFileList); - }, - responseTimeoutMs: 3000, - cancellationToken: cancellationToken, - prepareAsync: PrepareSdInterfaceAndSettleAsync, - finalizeAsync: RestoreLanInterfaceAsync); - - if (ContainsScpiError(lines)) - { - for (var retry = 0; retry < SD_LIST_MAX_RETRIES; retry++) - { - cancellationToken.ThrowIfCancellationRequested(); - - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); - - lines = await ExecuteTextCommandAsync( - () => - { - Send(ScpiMessageProducer.DeleteSdFile(fileName)); - Send(ScpiMessageProducer.GetSdFileList); - }, - responseTimeoutMs: 3000, - cancellationToken: cancellationToken, - prepareAsync: PrepareSdInterfaceAndSettleAsync, - finalizeAsync: RestoreLanInterfaceAsync); - - if (!ContainsScpiError(lines)) - { - break; - } - } - } - - _sdCardFiles = SdCardFileListParser.ParseFileList(lines); - } - - /// - /// Formats the entire SD card, erasing all data. - /// - /// 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 device is currently logging to SD card. - /// Thrown when the operation is canceled. - public Task FormatSdCardAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - if (_isLoggingToSdCard) - { - throw new InvalidOperationException("Cannot format SD card while logging."); - } - - cancellationToken.ThrowIfCancellationRequested(); - - // Defensive: always send stop command even if IsStreaming is stale (see issue #118) - Send(ScpiMessageProducer.StopStreaming); - IsStreaming = false; - - Send(ScpiMessageProducer.EnableStorageSd); - Send(ScpiMessageProducer.FormatSdCard); - - return Task.CompletedTask; - } - - /// - /// Downloads a file from the device's SD card, over USB or over WiFi/TCP. - /// - /// The name of the file to download. - /// The stream to write file contents to. - /// Optional progress reporting. - /// Cancellation token. - /// Metadata about the downloaded file. - /// Thrown when the device is not connected. - /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. - /// Thrown when the filename is null, empty, or contains invalid characters. - /// - /// Thrown when the device serves a marker-only (0-byte) transfer across all retry attempts - /// for a file the last listing reported as non-empty (or - /// whose listed size is unknown), indicating its SD subsystem is not ready. A file the - /// listing reports as 0 bytes downloads successfully as a legitimate empty file. - /// - /// - /// Thrown when the transfer stops making progress before the end-of-file marker arrives: - /// the transport returned an empty read, closed, or — the only signal a socket gives — - /// went quiet for longer than . - /// - /// - /// Thrown when the download does not finish within . - /// The deadline is enforced by this method itself, so it still applies when the transfer - /// is parked in a call that cannot observe a cancellation token (#399). - /// - /// Thrown when is canceled. - /// - /// On a timeout — or a cancellation the parked transfer cannot itself observe — the - /// in-flight transfer is abandoned rather than awaited: it may be blocked in native - /// serial I/O that no token can interrupt, and waiting for it is the hang this method - /// exists to bound. The abandoned transfer's token is cancelled first, so it unwinds at - /// its next token check — but that check is only reached once whatever it is blocked in - /// returns, which may be never. Two consequences for callers: it can still write to - /// after this method has thrown, so the stream must - /// not be reused for anything else; and the device is left mid-SD:GET with the - /// protobuf consumer stopped, so reconnecting (or power-cycling, if its SD subsystem is - /// genuinely wedged) is the reliable way to resume normal operation. - /// - /// The LAN interface is deliberately not restored in that case: the abandoned - /// transfer still owns the transport, and putting the restore commands onto a link it is - /// still reading would only add traffic to a device that has already stopped answering. The - /// reconnect the caller needs anyway re-establishes the interface. On every other outcome — - /// success, a stall, a cancellation the transfer did observe — the restore runs as before. - /// - /// - /// Until an abandoned transfer unwinds it still owns the transport, so a further download - /// on the same device fails fast with rather than - /// putting a second reader on the same stream. A caller looping over many files against a - /// wedged card therefore gets one timeout and then immediate, cheap failures — not a - /// growing pile of blocked threads. - /// - /// - public async Task DownloadSdCardFileAsync( - string fileName, - Stream destinationStream, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - // Over WiFi/TCP this requires firmware >= v3.7.0 (#598/#599); over USB it is always - // available on SD-capable firmware. Older firmware over WiFi gets a typed - // FeatureNotSupportedException instead of the old blanket USB-only rejection (ADR 0001). - EnsureSdFileTransferSupportedOnTransport(); - - if (string.IsNullOrWhiteSpace(fileName)) - { - throw new ArgumentException("Filename cannot be null or empty.", nameof(fileName)); - } - - ValidateSdCardFileName(fileName); - ArgumentNullException.ThrowIfNull(destinationStream); - - cancellationToken.ThrowIfCancellationRequested(); - - if (_isLoggingToSdCard) - { - throw new InvalidOperationException("Cannot download files while logging to SD card."); - } - - // Defensive: always send stop command even if IsStreaming is stale (see issue #118) - Send(ScpiMessageProducer.StopStreaming); - IsStreaming = false; - - var stopwatch = Stopwatch.StartNew(); - long fileSize = 0; - var budget = SdCardDownloadTimeout; - - // Set when the transfer was given up on and left running (#399/#401). Read only by the - // restore below, on this same async flow. - var workerAbandoned = false; - - try - { - await RunWithHardDeadlineAsync(async token => - { - await ExecuteRawCaptureAsync(async (stream, ct) => - { - // Prepare SD card interface. Transport-aware: over USB it hands the shared - // SPI bus to the card, over WiFi/TCP it leaves the LAN up because the reply - // comes back over that very connection (#598/#599). - PrepareSdInterface(); - - // Let the interface switch settle before the card is asked for anything — - // the same wait the LIST/DELETE/space exchanges take for the same reason. - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, ct).ConfigureAwait(false); - - // Send the SCPI command to request the file - Send(ScpiMessageProducer.GetSdFile(fileName)); - - // Receive the file data. A marker-only (0-byte) transfer for a file the - // listing reports as non-empty means the device's SD subsystem wasn't ready - // when it opened the file - the same kind of transient condition - // GetSdCardFilesAsync's LIST retry already absorbs - so retry the GET a - // bounded number of times before giving up (see #264). Passing the listed - // size keeps that retry off a genuinely 0-byte file, which is a legitimate - // empty download rather than a wedged subsystem (#398 gap 2). - // The receiver is told what its transport's silence means. Over USB serial a - // zero-length read is the per-read ReadTimeout firing on a device that is - // merely quiet; over TCP it is the peer's FIN and nothing else, and the - // socket keeps reporting itself readable either way. Over TCP the socket - // also never surfaces silence at all — ReadAsync ignores the receive - // timeout — so the inactivity window is the only thing standing between a - // device that stopped answering and the full 30-minute budget (#327). - var receiver = new SdCardFileReceiver( - stream, - zeroLengthReadMeansClosed: !IsUsbConnection, - idleTimeout: SdCardTransferIdleTimeout); - var listedFileSizeBytes = TryGetListedFileSize(fileName); - long bytesReceived; - var attempt = 0; - while (true) - { - try - { - // Each attempt gets what is left of the overall budget, never a - // fresh full one: retries must not be able to push the total past - // the deadline the caller was promised. - bytesReceived = await receiver.ReceiveAsync( - destinationStream, - fileName, - progress, - timeout: RemainingBudget(budget, stopwatch), - cancellationToken: ct, - listedFileSizeBytes: listedFileSizeBytes).ConfigureAwait(false); - break; - } - catch (SdCardEmptyTransferException) when (attempt < SD_LIST_MAX_RETRIES) - { - attempt++; - await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, ct).ConfigureAwait(false); - Send(ScpiMessageProducer.GetSdFile(fileName)); - } - } - - fileSize = bytesReceived; - }, token).ConfigureAwait(false); - }, - budget, - fileName, - cancellationToken, - onWorkerAbandoned: () => workerAbandoned = true).ConfigureAwait(false); - } - finally - { - // Restore the LAN interface — but NOT when the transfer was abandoned. An abandoned - // worker is still alive and still owns the transport (that is why the download gate - // is not released until it finally unwinds), so sending the restore now would put - // SCPI commands onto a link a transfer is still reading, on top of a device that has - // already stopped answering. There is nothing to gain: the caller is told to - // reconnect or power-cycle, and both re-establish the interface anyway (#399/#401). - if (!workerAbandoned && IsConnected) - { - try - { - PrepareLanInterface(); - } - catch - { - // Best-effort restoration; the device may have disconnected - } - } - } - - stopwatch.Stop(); - return new SdCardDownloadResult(fileName, fileSize, stopwatch.Elapsed); - } - - /// - /// Looks up the size the most recent directory listing reported for a file. Returns null - /// ("unknown", which the receiver treats conservatively) when no listing has been fetched, - /// when the listing did not include this file or a size for it, or when more than one - /// listed entry shares the name. - /// - private long? TryGetListedFileSize(string fileName) - { - // Snapshot the field: GetSdCardFilesAsync replaces the list wholesale, so a - // concurrent refresh swaps the reference rather than mutating what we enumerate. - var listedFiles = _sdCardFiles; - - long? matchedSize = null; - var matched = false; - - foreach (var file in listedFiles) - { - // FAT names are case-insensitive. The listing keeps only the leaf name, so the - // same name can appear twice from different directories; that is ambiguous and - // an over-confident size here would wave through the very failure (a wedged - // subsystem serving nothing) the empty-transfer guard exists to catch. - if (!string.Equals(file.FileName, fileName, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - if (matched) - { - return null; - } - - matched = true; - matchedSize = file.SizeInBytes; - } - - return matchedSize; - } - - /// - /// Downloads a file from the device's SD card, over USB or over WiFi/TCP, to a temporary file. - /// - /// The name of the file to download. - /// Optional progress reporting. - /// Cancellation token. - /// Metadata about the downloaded file, including the local file path. - /// Thrown when the device is not connected. - /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. - /// Thrown when the filename is null, empty, or contains invalid characters. - public async Task DownloadSdCardFileAsync( - string fileName, - IProgress? progress = null, - CancellationToken cancellationToken = default) - { - var ext = Path.GetExtension(fileName); - if (string.IsNullOrEmpty(ext)) ext = ".bin"; - var tempPath = Path.Combine(Path.GetTempPath(), $"daqifi_{Guid.NewGuid():N}{ext}"); - try - { - await using var fileStream = new FileStream( - tempPath, - FileMode.Create, - FileAccess.Write, - FileShare.None, - bufferSize: 65536, - useAsync: true); - - var result = await DownloadSdCardFileAsync(fileName, fileStream, progress, cancellationToken) - .ConfigureAwait(false); - - return result with { FilePath = tempPath }; - } - catch - { - try { File.Delete(tempPath); } catch { /* ignore cleanup failures */ } - throw; - } - } - - /// - /// Overall wall-clock budget for one - /// - /// call, covering every GET attempt. This is the per-transfer limit the receiver has - /// always applied; what #399 changed is that the download now enforces it itself instead - /// of trusting whatever the transfer is parked in to notice a cancellation token. - /// - /// Virtual only as a test seam — a 30-minute budget is not unit-testable. - internal virtual TimeSpan SdCardDownloadTimeout => TimeSpan.FromMinutes(30); + void IDeviceOperationHost.StopStreaming() => StopStreaming(); - /// - /// How long a download may go without receiving a single byte before it is declared - /// stalled. Bounds a WiFi/TCP transfer whose device stopped answering, which the socket - /// itself never reports (see ). - /// - /// Virtual only as a test seam — a 20-second window is not unit-testable. - internal virtual TimeSpan SdCardTransferIdleTimeout => SdCardFileReceiver.DefaultIdleTimeout; - - /// - /// The part of not yet consumed, floored at zero (a negative - /// timeout is not a legal delay). - /// - private static TimeSpan RemainingBudget(TimeSpan budget, Stopwatch stopwatch) - { - var remaining = budget - stopwatch.Elapsed; - return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; - } - - /// - /// The instant the download is given up on regardless of what it is doing. It sits just - /// past the cooperative so that a transfer which IS observing - /// its token still fails through the receiver's own timeout — which reports how many - /// bytes arrived — and the hard deadline only decides the case where it is not. - /// - private static TimeSpan HardDeadlineFor(TimeSpan budget) - { - var graceMs = Math.Clamp(budget.TotalMilliseconds * 0.1, 100, 5000); - return budget + TimeSpan.FromMilliseconds(graceMs); - } + void IDeviceOperationHost.Send(IOutboundMessage message) => Send(message); - /// - /// Runs an SD download on a worker task and races it against a hard deadline, so neither - /// the deadline nor the caller's cancellation depends on the transfer being somewhere it - /// can observe a token (#399). On expiry the worker is abandoned rather than awaited. - /// - /// The transfer. Receives a token cancelled by caller cancellation or the deadline, whichever comes first. - /// The cooperative budget; the hard deadline is of it. - /// Used only in the message. - /// The caller's token, observed by the race itself and not only by the worker. - /// - /// Invoked, before this method throws, when the worker is given up on while still running. - /// Lets the caller skip any cleanup that would touch the transport the abandoned worker - /// still owns. - /// - /// - /// Thrown when a previous download still owns — it is either - /// genuinely in flight or was abandoned and is still parked on the transport. - /// - private async Task RunWithHardDeadlineAsync( - Func operation, - TimeSpan budget, - string fileName, +#pragma warning disable CA1068 // Matches the seam it forwards to. + Task> IDeviceOperationHost.ExecuteTextCommandAsync( + Action setupAction, + int responseTimeoutMs, + int completionTimeoutMs, CancellationToken cancellationToken, - Action? onWorkerAbandoned = null) - { - // Checked before taking the gate so a cancelled caller neither acquires it nor gets an - // answer about some other transfer. - cancellationToken.ThrowIfCancellationRequested(); - - // Fail fast rather than becoming a second reader on a stream an abandoned transfer - // still holds. Wait(0) never blocks: this either takes the gate or reports the state. - if (!_sdDownloadGate.Wait(0)) - { - // Cancellation wins when it raced the gate check — the same precedence the abandon - // path below applies. The caller asked to stop; that is a truer answer than a - // report about a different download. - cancellationToken.ThrowIfCancellationRequested(); - - throw new InvalidOperationException( - "A previous SD card download is still in flight, or was abandoned after timing out and " + - "is still parked on the transport. Reconnect the device before retrying."); - } - - // Released exactly once, by whichever path is last to be done with the worker: the - // finally below in the normal case, or the abandon-path continuation when the worker - // finally unwinds. Interlocked because a worker that completes right at the deadline - // boundary can reach both. - var gateReleased = 0; - void ReleaseGate() - { - if (Interlocked.Exchange(ref gateReleased, 1) != 0) - { - return; - } - - try - { - _sdDownloadGate.Release(); - } - catch (ObjectDisposedException) - { - // The device was disposed while a transfer was still abandoned. Benign - // teardown, and this can run from a discarded continuation — never throw. - } - } - - var hardDeadline = HardDeadlineFor(budget); - - // hardDeadlineCts runs on its own timer, independent of the Task.Delay race below, so - // it still reaches the worker if the worker only returns long after the race was - // decided. linkedCts is what the worker observes: caller cancellation OR the deadline. - var hardDeadlineCts = new CancellationTokenSource(hardDeadline); - var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, hardDeadlineCts.Token); - - // Stops the racing delay the moment the outcome is decided — without it, a download - // that finishes in a second would leave a 30-minute timer registered behind it. - var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - - // LongRunning (a dedicated thread, not a pooled one): the transfer's synchronous - // prefix — the consumer stop-and-join, PrepareSdInterface's blocking writes — otherwise - // runs on the CALLING thread up to the first await, which on a UI thread means a - // wedged device freezes the window, and which would put that prefix outside the very - // deadline it needs to be inside. A pooled Task.Run would also tie up a worker for the - // transfer's full blocking duration. Pass CancellationToken.None to StartNew itself: - // the worker's own token still cancels its waits, and "cancelled before start" must - // not surface as an operation fault. (Mirrors WifiBridgeActivator, #294/#295/#326.) - var workerTask = Task.Factory.StartNew( - () => operation(linkedCts.Token), - CancellationToken.None, - TaskCreationOptions.LongRunning, - TaskScheduler.Default).Unwrap(); - - try - { - var winner = await Task.WhenAny( - workerTask, - Task.Delay(hardDeadline, raceCts.Token)).ConfigureAwait(false); - - // Only abandon when the worker is genuinely still running: WhenAny can hand back - // the delay even though the worker completed at that same boundary, and awaiting - // it below honors that result instead of discarding it. - if (winner != workerTask && !workerTask.IsCompleted) - { - // Tell the caller before unwinding: the worker keeps running and keeps the - // transport, so any cleanup that would write to it has to be skipped. - onWorkerAbandoned?.Invoke(); - - // Cancel explicitly instead of relying on the deadline timer having fired: the - // delay above and hardDeadlineCts are two separate timers of the same duration, - // so the delay can win by a hair and leave a late-returning worker running one - // more state-changing step after the caller already threw. Idempotent. - hardDeadlineCts.Cancel(); - - // The worker may be parked in native serial I/O that no token can interrupt, so - // it is ABANDONED, not awaited — waiting for it is the hang being bounded here. - // Observe its eventual fault so it cannot resurface as an UnobservedTaskException, - // and dispose the sources only once it is done with them (disposing early would - // turn its pending waits into ObjectDisposedException instead of cancellation). - _ = workerTask.ContinueWith( - t => - { - _ = t.Exception; - linkedCts.Dispose(); - hardDeadlineCts.Dispose(); - - // Only now is the transport genuinely free for another download. - ReleaseGate(); - }, - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - - // Prefer surfacing caller cancellation over a generic timeout when both raced. - cancellationToken.ThrowIfCancellationRequested(); - - throw new TimeoutException( - $"SD card download of '{fileName}' did not complete within " + - $"{hardDeadline.TotalSeconds:0.#}s and was abandoned. The device's SD " + - "subsystem is not responding; reconnect (or power-cycle) before retrying."); - } - - // Propagate success or the transfer's own exception unchanged. - await workerTask.ConfigureAwait(false); - } - finally - { - raceCts.Cancel(); - raceCts.Dispose(); - - // The abandon path hands disposal and the gate to its continuation instead; do it - // here only when the worker actually finished (the common, non-hung case). - if (workerTask.IsCompleted) - { - linkedCts.Dispose(); - hardDeadlineCts.Dispose(); - ReleaseGate(); - } - } - } - - /// - /// Checks whether any line in the response contains a SCPI error indicator. - /// These errors (e.g., "**ERROR: -200") can occur transiently when the device - /// firmware has not finished switching the SPI bus interface. - /// - /// The response lines to check. - /// True if any line contains a SCPI error, false otherwise. - private static bool ContainsScpiError(IReadOnlyList lines) - { - return lines.Any(IsScpiErrorLine); - } - - // Strict SCPI error format: "**ERROR" or bare "ERROR" followed by a SCPI delimiter - // (":", space, tab, or end-of-line). Distinguishes a true SCPI error from firmware - // status text like "Error !! No SD Card Detected", which should not be surfaced as - // SdCardOperationException.LastScpiError. Shared with ScpiInitializationErrorException - // classification in DaqifiDevice.InitializeAsync so both sites recognize the same set - // of delimiter-separated error formats (closes a gap where "ERROR -200,..." or - // "ERROR\t-200,..." without a colon went undetected). - private static bool IsScpiErrorLine(string line) - { - return ScpiResponseClassifier.IsScpiErrorLine(line); - } - - // Permissive: any line that looks like a device error or status message, - // including firmware text such as "Error !! ...". Used to recognize that - // the parser would yield no result, without polluting LastScpiError with - // non-SCPI text. Shared classifier so the SD-response rule (closes #190 - // — filenames starting with "error_" must NOT match) stays in lockstep - // across both call sites. - private static bool IsNonResultLine(string line) - { - return ScpiResponseClassifier.IsErrorResponseLine(line); - } - - /// - /// Inspects the final response from a SYSTem:STORage:SD:LISt? exchange - /// and throws a typed when the device - /// reported a real failure (no SD card, filesystem error, generic SCPI error). - /// If any non-error/non-empty line is present, callers proceed to parse — even - /// if SCPI error lines are interleaved — so a successful directory listing is - /// never masked by stray transient errors. - /// - private static void ThrowIfSdCardListError(IReadOnlyList lines) - { - // LastScpiError must only carry a real SCPI-formatted error so callers - // can rely on its shape. Firmware status text ("Error !! ...") is - // surfaced via the exception's Message and RawDeviceResponse instead. - var lastScpiError = lines.LastOrDefault(IsScpiErrorLine)?.Trim(); - - // Specific firmware-emitted error markers take precedence over generic - // content/error checks. They're plain text (not SCPI-shaped), so a - // simple "is there any content line?" check would otherwise miss them - // and pass garbage to the parser. - if (lines.Any(l => l.IndexOf("No SD Card Detected", StringComparison.OrdinalIgnoreCase) >= 0)) - { - throw new SdCardNotPresentException(lines, lastScpiError); - } - - var filesystemErrorLine = lines.FirstOrDefault(l => - l.IndexOf("Failed to open directory", StringComparison.OrdinalIgnoreCase) >= 0); - if (filesystemErrorLine != null) - { - throw new SdCardFilesystemException(lines, lastScpiError, filesystemErrorLine.Trim()); - } - - // If any line looks like a real result (non-empty, not an error or - // firmware status line), hand off to the parser. Stray interleaved - // error lines are still parsed away by SdCardFileListParser. - var hasContentLine = lines.Any(line => - !string.IsNullOrWhiteSpace(line) && !IsNonResultLine(line)); - if (hasContentLine) - { - return; - } - - if (lastScpiError != null) - { - throw new SdCardOperationException( - "The SD card list operation failed: " + lastScpiError, - lines, - lastScpiError); - } - - // Defensive fallback: firmware status text ("Error !! ...") with no - // SCPI error and no recognized marker. Shouldn't happen for known - // firmware paths, but surfacing it as a typed exception is far - // better than silently returning an empty list. - var nonResultLine = lines.FirstOrDefault(l => - !string.IsNullOrWhiteSpace(l) && IsNonResultLine(l))?.Trim(); - if (nonResultLine != null) - { - throw new SdCardOperationException( - "The SD card list operation failed: " + nonResultLine, - lines, - lastScpiError: null); - } - - // No error lines and no content lines — empty directory. Caller continues. - // Safe to treat as empty rather than as a lost reply: GetSdCardFilesAsync only reaches - // this point once the device has answered the end-of-listing terminator (#396). - } - - /// - /// Validates an SD card filename to prevent SCPI command injection. - /// - /// The filename to validate. - /// Thrown when the filename contains invalid characters. - private static void ValidateSdCardFileName(string fileName) - { - if (fileName.IndexOfAny(new[] { '"', '\n', '\r', ';' }) >= 0) - { - throw new ArgumentException( - "Filename contains invalid characters. Quotes, newlines, and semicolons are not allowed.", - nameof(fileName)); - } - } - - /// - public async Task GetLanChipInfoAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - var lines = await ExecuteTextCommandAsync( - () => Send(ScpiMessageProducer.GetLanChipInfo), - responseTimeoutMs: 2000, - cancellationToken: cancellationToken).ConfigureAwait(false); - - if (LanChipInfoParser.TryParseLines(lines, out var info)) - { - return info; - } - - // Closes #203: LAN:ENAbled=1 in saved settings but the WINC1500 state - // machine hasn't reached INITIALIZED yet (steady-state, not the - // post-reboot transient #144 already retries for) makes GETChipInfo? - // return this specific SCPI error instead of JSON. Surface it distinctly - // so the caller's retry loop can react (kick LAN:APPLY) instead of just - // waiting out a blind delay. - var errorLine = lines.LastOrDefault(IsScpiErrorLine); - if (errorLine != null && ScpiResponseClassifier.TryExtractErrorCode(errorLine, out var errorCode) && errorCode == -200) - { - throw new LanNotInitializedException(errorLine.Trim()); - } - - return null; - } - - // ----------------------------------------------------------------- - // IDeviceDiagnostics - // - // 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. - // ----------------------------------------------------------------- - - /// Time allowed for the first diagnostics response line. Generous because - /// SYSTem:LOG? and the stats queries can emit dozens of lines. - private const int DIAGNOSTICS_RESPONSE_TIMEOUT_MS = 2000; - - /// - /// Throws a 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". - /// - private static void ThrowIfErrorOnlyResponse(int parsedResultCount, IReadOnlyList lines, string operation) - { - if (parsedResultCount == 0 && IsErrorOnlyResponse(lines)) - { - throw new DeviceDiagnosticsException( - $"The device returned an error while attempting to {operation}.", - lines); - } - } - - /// - /// Returns true when the response contains at least one non-empty line and every non-empty - /// line is a SCPI error/status line (per ). - /// - private static bool IsErrorOnlyResponse(IReadOnlyList lines) - { - var sawContent = false; - foreach (var line in lines) - { - if (string.IsNullOrWhiteSpace(line)) - { - continue; - } - - sawContent = true; - if (!IsNonResultLine(line)) - { - return false; - } - } - - return sawContent; - } - - /// - public async Task> GetSystemLogAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var lines = await ExecuteTextCommandAsync( - () => 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; - } - - /// - public async Task ClearSystemLogAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var lines = await ExecuteTextCommandAsync( - () => 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"); - } - - /// - public async Task 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 (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var lines = await ExecuteTextCommandAsync( - () => Send(command), - responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, - cancellationToken: cancellationToken).ConfigureAwait(false); - - if (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); - } - - /// - public async Task> GetCommandHistoryAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var lines = await ExecuteTextCommandAsync( - () => 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; - } - - /// - public async Task TestSystemLogAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var lines = await ExecuteTextCommandAsync( - () => 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"); - } - - /// - public async Task GetSystemErrorCountAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var lines = await ExecuteTextCommandAsync( - () => 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); - } - - /// - public async Task GetStreamStatsAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } - - cancellationToken.ThrowIfCancellationRequested(); - - var lines = await ExecuteTextCommandAsync( - () => Send(ScpiMessageProducer.GetStreamStats), - responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, - cancellationToken: cancellationToken).ConfigureAwait(false); - - if (StreamStatsParser.TryParse(lines, out var stats)) - { - return stats; - } + Func? prepareAsync, + Func? finalizeAsync) + => ExecuteTextCommandAsync( + setupAction, responseTimeoutMs, completionTimeoutMs, cancellationToken, prepareAsync, finalizeAsync); +#pragma warning restore CA1068 + + Task IDeviceOperationHost.ExecuteRawCaptureAsync( + Func rawAction, + CancellationToken cancellationToken) + => ExecuteRawCaptureAsync(rawAction, cancellationToken); - throw new DeviceDiagnosticsException( - "The streaming-stats query returned an unparseable response.", - lines); - } + void IDeviceOperationHost.EnsureSupported(DeviceFeature feature) => EnsureSupported(feature); - /// - public async Task GetMemoryDiagnosticsAsync(CancellationToken cancellationToken = default) - { - if (!IsConnected) - { - throw new DeviceNotConnectedException(); - } + FeatureNotSupportedException IDeviceOperationHost.CreateFeatureNotSupportedException(DeviceFeature feature) + => CreateFeatureNotSupportedException(feature); - cancellationToken.ThrowIfCancellationRequested(); + TimeSpan IDeviceOperationHost.SdCardDownloadTimeout => SdCardDownloadTimeout; - var lines = await ExecuteTextCommandAsync( - () => Send(ScpiMessageProducer.GetMemoryDiagnostics), - responseTimeoutMs: DIAGNOSTICS_RESPONSE_TIMEOUT_MS, - cancellationToken: cancellationToken).ConfigureAwait(false); + TimeSpan IDeviceOperationHost.SdCardTransferIdleTimeout => SdCardTransferIdleTimeout; - if (MemoryDiagnosticsParser.TryParse(lines, out var diagnostics)) - { - return diagnostics; - } + void IDeviceOperationHost.RaiseLowSdSpaceWarning(LowSdSpaceWarningEventArgs e) => OnLowSdSpaceWarning(e); - throw new DeviceDiagnosticsException( - "The memory-diagnostics query returned an unparseable response.", - lines); - } + #endregion } } diff --git a/src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsOperations.cs b/src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsOperations.cs new file mode 100644 index 00000000..9119dcc0 --- /dev/null +++ b/src/Daqifi.Core/Device/Diagnostics/DeviceDiagnosticsOperations.cs @@ -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 +{ + /// + /// The implementation, extracted from + /// so the device delegates rather than hosts it. + /// + /// + /// 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. + /// + internal sealed class DeviceDiagnosticsOperations : IDeviceDiagnostics + { + /// Time allowed for the first diagnostics response line. Generous because + /// SYSTem:LOG? and the stats queries can emit dozens of lines. + private const int DIAGNOSTICS_RESPONSE_TIMEOUT_MS = 2000; + + private readonly IDeviceOperationHost _host; + + internal DeviceDiagnosticsOperations(IDeviceOperationHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + /// + public async Task> 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; + } + + /// + 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"); + } + + /// + public async Task 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); + } + + /// + public async Task> 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; + } + + /// + 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"); + } + + /// + public async Task 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); + } + + /// + public async Task 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); + } + + /// + public async Task 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); + } + + /// + /// Throws a 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". + /// + private static void ThrowIfErrorOnlyResponse(int parsedResultCount, IReadOnlyList lines, string operation) + { + if (parsedResultCount == 0 && ScpiResponseClassifier.IsErrorOnlyResponse(lines)) + { + throw new DeviceDiagnosticsException( + $"The device returned an error while attempting to {operation}.", + lines); + } + } + } +} diff --git a/src/Daqifi.Core/Device/Internal/IDeviceOperationHost.cs b/src/Daqifi.Core/Device/Internal/IDeviceOperationHost.cs new file mode 100644 index 00000000..2569621c --- /dev/null +++ b/src/Daqifi.Core/Device/Internal/IDeviceOperationHost.cs @@ -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 +{ + /// + /// 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. + /// + /// + /// + /// Every member here forwards to a member the device already had, and the ones that are + /// virtual 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 , + /// , and + /// 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. + /// + /// + /// implements this explicitly, so none of it widens the + /// public API. + /// + /// + internal interface IDeviceOperationHost + { + /// + bool IsConnected { get; } + + /// + bool IsUsbConnection { get; } + + /// + /// + /// Settable here because the SD operations defensively stop streaming before they touch the + /// card and must record that they did (issue #118). + /// + bool IsStreaming { get; set; } + + /// + int StreamingFrequency { get; } + + /// + void StopStreaming(); + + /// + void Send(IOutboundMessage message); + + /// +#pragma warning disable CA1068 // Matches the seam it forwards to, which orders these for source compatibility. + Task> ExecuteTextCommandAsync( + Action setupAction, + int responseTimeoutMs = 1000, + int completionTimeoutMs = 250, + CancellationToken cancellationToken = default, + Func? prepareAsync = null, + Func? finalizeAsync = null); +#pragma warning restore CA1068 + + /// + Task ExecuteRawCaptureAsync( + Func rawAction, + CancellationToken cancellationToken = default); + + /// + void EnsureSupported(DeviceFeature feature); + + /// + FeatureNotSupportedException CreateFeatureNotSupportedException(DeviceFeature feature); + + /// + /// Overall wall-clock budget for one SD card download, read through the device so a + /// subclass's override of it still applies. + /// + TimeSpan SdCardDownloadTimeout { get; } + + /// + /// Inactivity window for an SD card transfer, read through the device so a subclass's + /// override of it still applies. + /// + TimeSpan SdCardTransferIdleTimeout { get; } + + /// + /// Raises the device's event. + /// + /// + /// Deliberately a call back into the device rather than an event the collaborator owns. + /// The event is part of , so its sender 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. + /// + void RaiseLowSdSpaceWarning(LowSdSpaceWarningEventArgs e); + } +} diff --git a/src/Daqifi.Core/Device/LanChipInfoOperations.cs b/src/Daqifi.Core/Device/LanChipInfoOperations.cs new file mode 100644 index 00000000..6f323039 --- /dev/null +++ b/src/Daqifi.Core/Device/LanChipInfoOperations.cs @@ -0,0 +1,59 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Daqifi.Core.Communication.Producers; +using Daqifi.Core.Device.Internal; +using Daqifi.Core.Firmware; + +#nullable enable + +namespace Daqifi.Core.Device +{ + /// + /// The implementation, extracted from + /// so the device delegates rather than hosts it. + /// + internal sealed class LanChipInfoOperations : ILanChipInfoProvider + { + private readonly IDeviceOperationHost _host; + + internal LanChipInfoOperations(IDeviceOperationHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + /// + public async Task GetLanChipInfoAsync(CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + var lines = await _host.ExecuteTextCommandAsync( + () => _host.Send(ScpiMessageProducer.GetLanChipInfo), + responseTimeoutMs: 2000, + cancellationToken: cancellationToken).ConfigureAwait(false); + + if (LanChipInfoParser.TryParseLines(lines, out var info)) + { + return info; + } + + // Closes #203: LAN:ENAbled=1 in saved settings but the WINC1500 state + // machine hasn't reached INITIALIZED yet (steady-state, not the + // post-reboot transient #144 already retries for) makes GETChipInfo? + // return this specific SCPI error instead of JSON. Surface it distinctly + // so the caller's retry loop can react (kick LAN:APPLY) instead of just + // waiting out a blind delay. + var errorLine = lines.LastOrDefault(ScpiResponseClassifier.IsScpiErrorLine); + if (errorLine != null && ScpiResponseClassifier.TryExtractErrorCode(errorLine, out var errorCode) && errorCode == -200) + { + throw new LanNotInitializedException(errorLine.Trim()); + } + + return null; + } + } +} diff --git a/src/Daqifi.Core/Device/Network/NetworkConfigurationOperations.cs b/src/Daqifi.Core/Device/Network/NetworkConfigurationOperations.cs new file mode 100644 index 00000000..eabdb70c --- /dev/null +++ b/src/Daqifi.Core/Device/Network/NetworkConfigurationOperations.cs @@ -0,0 +1,214 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Daqifi.Core.Communication.Producers; +using Daqifi.Core.Device.Internal; + +#nullable enable + +namespace Daqifi.Core.Device.Network +{ + /// + /// The WiFi/LAN configuration half of , extracted from + /// so the device delegates rather than hosts it. Owns the + /// last-known configuration the device reports. + /// + /// + /// Deliberately not an itself: that interface also carries + /// PrepareSdInterface / PrepareLanInterface, which are the shared-SPI-bus handover + /// every SD operation depends on and therefore belong with the SD operations. The device + /// implements the interface and routes each member to whichever collaborator owns it. + /// + internal sealed class NetworkConfigurationOperations + { + /// + /// Delay after applying WiFi settings, in milliseconds, to allow the module to restart. + /// + private const int WIFI_MODULE_RESTART_DELAY_MS = 2000; + + private readonly IDeviceOperationHost _host; + private readonly NetworkConfiguration _networkConfiguration = new NetworkConfiguration(); + + internal NetworkConfigurationOperations(IDeviceOperationHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + /// + internal NetworkConfiguration NetworkConfiguration => _networkConfiguration.Clone(); + + /// + internal async Task UpdateNetworkConfigurationAsync(NetworkConfiguration configuration, CancellationToken cancellationToken = default) + { + if (configuration == null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + cancellationToken.ThrowIfCancellationRequested(); + + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + // Stop streaming if active + if (_host.IsStreaming) + { + _host.StopStreaming(); + } + + // Set WiFi mode + switch (configuration.Mode) + { + case WifiMode.ExistingNetwork: + _host.Send(ScpiMessageProducer.SetNetworkWifiModeExisting); + break; + case WifiMode.SelfHosted: + _host.Send(ScpiMessageProducer.SetNetworkWifiModeSelfHosted); + break; + default: + throw new ArgumentOutOfRangeException(nameof(configuration), configuration.Mode, "Unsupported WiFi mode."); + } + + // Set SSID + _host.Send(ScpiMessageProducer.SetNetworkWifiSsid(configuration.Ssid)); + + // Set security type and password + switch (configuration.SecurityType) + { + case WifiSecurityType.None: + _host.Send(ScpiMessageProducer.SetNetworkWifiSecurityOpen); + break; + case WifiSecurityType.WpaPskPhrase: + _host.Send(ScpiMessageProducer.SetNetworkWifiSecurityWpa); + _host.Send(ScpiMessageProducer.SetNetworkWifiPassword(configuration.Password)); + break; + default: + throw new ArgumentOutOfRangeException(nameof(configuration), configuration.SecurityType, "Unsupported WiFi security type."); + } + + // Stage static IP fields (firmware writes these into the runtime + // WiFi settings that ApplyNetworkLan consumes). Skip any field the + // caller left null so DHCP-only callers see no behavior change. + if (configuration.StaticIP != null) + { + _host.Send(ScpiMessageProducer.SetLanAddress(configuration.StaticIP)); + } + if (configuration.SubnetMask != null) + { + _host.Send(ScpiMessageProducer.SetLanMask(configuration.SubnetMask)); + } + if (configuration.Gateway != null) + { + _host.Send(ScpiMessageProducer.SetLanGateway(configuration.Gateway)); + } + + // Stage the LAN interface state alongside the credentials above. LAN:ENAbled writes + // isEnabled into the same runtime settings struct the SET commands populate and does + // not restart anything itself, so it belongs before the save (to be persisted) and + // before the apply (the firmware only fires a module REINIT when isEnabled is set). + // This deliberately does NOT call PrepareLanInterface() — that is the transport-aware + // SD-operation restore, which leaves the LAN alone over WiFi (where #598/#599 keep it + // up). Here the LAN enable is unconditional: reconfiguration owns the LAN state. + _host.Send(ScpiMessageProducer.DisableStorageSd); + _host.Send(ScpiMessageProducer.EnableNetworkLan); + + // Cancellation boundary. This is the last point where abandoning still avoids the two + // things that matter: nothing has been persisted (no LAN:SAVE) and no module restart + // has been triggered (no LAN:APPLY), so the device keeps serving the network + // configuration it already had. Past the save below it has committed, and cancellation + // stops being a way out. + // + // This is deliberately NOT a side-effect-free point. The staged credentials, the LAN + // enable flag and the SD disable above have all reached the device's runtime state, and + // a later LAN:APPLY from any caller would pick up those staged values. No side-effect- + // free abort exists once the sequence has begun — only the check at the top of this + // method precedes every Send. + cancellationToken.ThrowIfCancellationRequested(); + + // Persist BEFORE applying (#352). LAN:SAVE copies the staged runtime settings straight + // to NVM; it does NOT require them to have been applied first. Sending it here — while + // the control link is still guaranteed alive — is what makes the reconfiguration + // durable regardless of what the apply below does to the connection. + _host.Send(ScpiMessageProducer.SaveNetworkLan); + + // Apply last: this restarts the WiFi module. Over a WiFi/TCP control connection that + // restart necessarily tears down the link — inherent to moving the device onto a + // different network, not a fault to be avoided. Because the save above already + // committed the configuration to NVM, losing the link here costs nothing: the device + // comes back on the new network with the settings intact. Nothing is sent after this + // command, so there is no tail left to drop. + _host.Send(ScpiMessageProducer.ApplyNetworkLan); + + // Hold for the module restart window before returning, so the apply is flushed to the + // transport rather than left buffered in a connection that is about to go away. + // Cancelling here ends the wait but does NOT fail the operation: the device has already + // persisted and applied the new configuration, so reporting "canceled" — and skipping + // the local-state update below — would leave the caller believing nothing happened + // while the device is sitting on a different network. + try + { + await Task.Delay(WIFI_MODULE_RESTART_DELAY_MS, cancellationToken); + } + catch (OperationCanceledException) + { + // Already committed on the device; stop waiting early and complete normally. + } + + // Update local configuration. Static IP fields use null = "leave + // unchanged" semantics, so only overwrite when the caller provided + // a value — otherwise we'd clobber the previously known static IP. + _networkConfiguration.Mode = configuration.Mode; + _networkConfiguration.SecurityType = configuration.SecurityType; + _networkConfiguration.Ssid = configuration.Ssid; + _networkConfiguration.Password = configuration.Password; + if (configuration.StaticIP != null) + { + _networkConfiguration.StaticIP = configuration.StaticIP; + } + if (configuration.SubnetMask != null) + { + _networkConfiguration.SubnetMask = configuration.SubnetMask; + } + if (configuration.Gateway != null) + { + _networkConfiguration.Gateway = configuration.Gateway; + } + } + + /// + internal Task LoadNetworkConfigurationAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + // Re-check right before the state-changing send so a cancellation requested after the + // entry guard still short-circuits the command (matches the pattern accepted in #324). + cancellationToken.ThrowIfCancellationRequested(); + _host.Send(ScpiMessageProducer.LoadNetworkLan); + return Task.CompletedTask; + } + + /// + internal Task FactoryResetNetworkAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + // Re-check right before the state-changing send so a cancellation requested after the + // entry guard still short-circuits the command (matches the pattern accepted in #324). + cancellationToken.ThrowIfCancellationRequested(); + _host.Send(ScpiMessageProducer.FactoryResetNetworkLan); + return Task.CompletedTask; + } + } +} diff --git a/src/Daqifi.Core/Device/ScpiResponseClassifier.cs b/src/Daqifi.Core/Device/ScpiResponseClassifier.cs index edb503c6..e8df5632 100644 --- a/src/Daqifi.Core/Device/ScpiResponseClassifier.cs +++ b/src/Daqifi.Core/Device/ScpiResponseClassifier.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Globalization; +using System.Linq; #nullable enable @@ -51,6 +53,42 @@ internal static bool IsScpiErrorLine(string line) || MatchesStrictScpiErrorPrefix(trimmed, "ERROR"); } + /// + /// Returns true when any line in the response is a genuine SCPI error line. These can + /// appear transiently — e.g. while the firmware is still switching the shared SPI bus — + /// which is why several callers retry on this rather than failing outright. + /// + internal static bool ContainsScpiError(IReadOnlyList lines) + { + return lines.Any(IsScpiErrorLine); + } + + /// + /// Returns true when the response contains at least one non-empty line and every non-empty + /// line is an error/status line per — i.e. the device + /// answered, and had nothing but a complaint to say. A response with no lines at all is not + /// error-only, so callers can still tell "the log is empty" from "the command failed". + /// + internal static bool IsErrorOnlyResponse(IReadOnlyList lines) + { + var sawContent = false; + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + sawContent = true; + if (!IsErrorResponseLine(line)) + { + return false; + } + } + + return sawContent; + } + /// /// Returns true if the line is the reply to a SYSTem:ERRor? query — the IEEE 488.2 /// error-queue format <code>,"<message>", e.g. 0,"No error" or diff --git a/src/Daqifi.Core/Device/SdCard/SdCardOperations.cs b/src/Daqifi.Core/Device/SdCard/SdCardOperations.cs new file mode 100644 index 00000000..1fe54294 --- /dev/null +++ b/src/Daqifi.Core/Device/SdCard/SdCardOperations.cs @@ -0,0 +1,1357 @@ +using Daqifi.Core.Communication; +using Daqifi.Core.Communication.Messages; +using Daqifi.Core.Communication.Producers; +using Daqifi.Core.Device.Internal; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace Daqifi.Core.Device.SdCard +{ + /// + /// The implementation, extracted from + /// so the device delegates rather than hosts it. Also owns + /// the shared-SPI-bus handover ( / + /// ) that exposes, + /// because every operation here depends on it. + /// + /// + /// Holds the SD-scoped device state — whether a logging session is running, the most recent + /// directory listing, and the single-download gate — so that state lives next to the only code + /// that reads it. Everything that touches the wire goes through + /// , which keeps the device's virtual members (and therefore + /// any subclass override of them) in the path. + /// + internal sealed class SdCardOperations + { + /// + /// The delay in milliseconds to wait after switching between LAN and SD card interfaces. + /// The SD card and LAN share the SPI bus, so a settle period is needed for the device + /// firmware to complete the interface switch before sending further commands. + /// + private const int SD_INTERFACE_SETTLE_DELAY_MS = 100; + + /// + /// Maximum number of retry attempts for SD card list operations that receive transient + /// SCPI errors (e.g., -200 Execution error) due to interface-switch timing. + /// + private const int SD_LIST_MAX_RETRIES = 1; + + /// + /// Inactivity window that ends the SD listing text exchange, in milliseconds. + /// + /// + /// Deliberately longer than the 250ms default. The listing is only accepted once its + /// end-of-listing terminator has been seen (see ), and the + /// terminator can trail the last listing line by more than the default window — the firmware + /// walks the directory tree between chunks, and a congested WiFi link adds its own gaps. With + /// the default, a merely-slow terminator would read as a missing one and fail a listing that + /// was about to complete. + /// + private const int SD_LIST_COMPLETION_TIMEOUT_MS = 1000; + + /// + /// libscpi's SCPI_ERROR_UNDEFINED_HEADER — the code the firmware returns for a + /// command it doesn't recognize (e.g. a command that postdates the connected firmware). + /// This is the wire-level signal behind the + /// backstop (ADR 0001, docs/adr/0001-firmware-feature-gating.md). + /// + private const int ScpiErrorCodeUndefinedHeader = -113; + + private readonly IDeviceOperationHost _host; + + private bool _isLoggingToSdCard; + private IReadOnlyList _sdCardFiles = Array.Empty(); + + /// + /// Admits one SD download at a time. A download that hits its deadline is ABANDONED, not + /// stopped — its worker can still be parked in native I/O holding the transport stream — + /// so the gate is released only when that worker actually finishes, however long that + /// takes. Without it, a caller retrying against a device that stays wedged (an "import + /// all" loop, say) would start a second reader on the same stream, which is the framing + /// corruption already refuses to risk when restarting the + /// protobuf consumer, and would stack another permanently blocked thread each time (#399). + /// + /// + /// Deliberately not disposed: we only ever call and + /// , never , + /// so there is no handle to release — and an abandoned worker may release this long after + /// the device is disposed, which would otherwise fault a continuation nobody observes. + /// + private readonly SemaphoreSlim _sdDownloadGate = new(1, 1); + + internal SdCardOperations(IDeviceOperationHost host) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + } + + /// + internal bool IsLoggingToSdCard => _isLoggingToSdCard; + + /// + internal IReadOnlyList SdCardFiles => _sdCardFiles; + + /// + /// Prepares the SD-card interface for a file operation. Over USB the LAN interface is + /// disabled first to free the shared SPI bus for the SD card. Over WiFi/TCP (firmware + /// >= v3.7.0, #598/#599) the LAN interface MUST stay enabled — the Harmony SPI driver + /// arbitrates SD/WiFi transactions on the shared bus, and the SD reply routes back over the + /// very TCP channel that requested it, so disabling LAN would drop the control channel + /// mid-operation. Only the SD subsystem is enabled in that case. + /// + /// Thrown when the device is not connected. + internal void PrepareSdInterface() + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + if (_host.IsUsbConnection) + { + _host.Send(ScpiMessageProducer.DisableNetworkLan); + } + + _host.Send(ScpiMessageProducer.EnableStorageSd); + } + + /// + /// Restores the interface after an SD-card file operation. The SD subsystem is disabled in + /// both cases. Over USB the LAN interface is re-enabled (it was disabled by + /// ). Over WiFi/TCP the LAN was never disabled, so it is + /// left alone — re-enabling it would re-initialize the WiFi module and drop the connection. + /// + /// Thrown when the device is not connected. + internal void PrepareLanInterface() + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + _host.Send(ScpiMessageProducer.DisableStorageSd); + + if (_host.IsUsbConnection) + { + _host.Send(ScpiMessageProducer.EnableNetworkLan); + } + } + + /// + /// Applies the transport predicate for an SD-card operation that drives the card while the + /// link is active (LIST / GET / DELETE and the storage-space query). Over USB (serial) these + /// are available on all SD-capable firmware and are not gated. Over WiFi/TCP they are gated + /// on , which + /// resolves against the requirement table + /// (ADR 0001) — pre-empting a command the firmware cannot service over WiFi, which would + /// otherwise stall on the shared SPI bus. + /// + /// + /// This is only the transport half of the gate: which feature applies depends on the active + /// transport, but whether the device has that feature is the seam's answer, not this + /// method's. + /// + /// + /// Thrown when the active transport is not USB and the device does not support + /// . + /// + private void EnsureSdFileTransferSupportedOnTransport() + { + if (_host.IsUsbConnection) + { + return; + } + + _host.EnsureSupported(DeviceFeature.SdFileTransferOverWifi); + } + + /// + /// Retrieves the list of files stored on the device's SD card. + /// + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation, containing the list of files. + /// Thrown when the device is not connected. + /// Thrown when the operation is canceled. + /// Thrown when no SD card is installed in the device. + /// Thrown when the SD card filesystem cannot satisfy the request (corrupt card, unreadable directory). + /// Thrown when the device returned an SCPI error that did not match a more specific condition. Empty directories return an empty list rather than throwing. + /// + /// Thrown when the listing did not arrive in full — the device never answered, or stopped + /// answering part-way through. Distinguishing this from a genuinely empty card is the whole + /// point of the terminator probe described in the remarks (closes #396). + /// + /// + /// + /// The firmware emits no end-of-listing marker, and for an empty directory it writes nothing + /// at all, so a lost or truncated reply is byte-for-byte indistinguishable from a healthy + /// empty card. Core closes that gap by appending a SYSTem:ERRor? query to the same + /// text exchange: the transport delivers in order and the firmware does not process the + /// next command until the listing has been handed to the output, so receiving the reply + /// proves both that the device is answering and that the listing ahead of it is complete. + /// Its absence means the response is incomplete, and the caller gets an exception instead of + /// a plausible-looking empty list. + /// + /// + /// The terminator is only meaningful if it cannot be confused with a late reply to an + /// earlier command, so two things guard that boundary: the text exchange discards whatever + /// was already in flight when it opened, and this method's SPI-bus switch and settle delay + /// run as the exchange's prepare phase, ahead of that boundary, leaving the exchange with no + /// internal gap for a stale reply to slip into. + /// + /// + /// The terminator's error code is used only as a liveness marker, never for classification: + /// the queue it pops can hold entries left by earlier commands, so attributing the code to + /// this listing would misreport stale failures. SD errors continue to be classified from the + /// listing lines themselves. Note the side effect this implies — each listing consumes one + /// entry from the device's SCPI error queue, so a + /// run afterwards will not see the entry + /// this listing generated. + /// + /// + internal async Task> GetSdCardFilesAsync(CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + EnsureSdFileTransferSupportedOnTransport(); + + cancellationToken.ThrowIfCancellationRequested(); + + // Defensive: always send stop command even if IsStreaming is stale (see issue #118) + _host.Send(ScpiMessageProducer.StopStreaming); + _host.IsStreaming = false; + + IReadOnlyList lines = Array.Empty(); + IReadOnlyList listing = Array.Empty(); + var isComplete = false; + + // Attempt 0 plus SD_LIST_MAX_RETRIES retries. A SCPI error here is often a transient + // timing issue, and an unterminated response can be a one-off stall, so both are + // retried once after an additional settle delay before being surfaced. + for (var attempt = 0; attempt <= SD_LIST_MAX_RETRIES; attempt++) + { + if (attempt > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); + } + + // The SPI bus switch and its settle wait run as the exchange's prepare phase, and + // the restore as its finalize phase: both inside the exchange lock, so a competing + // text exchange can neither restore the LAN interface between the switch and the + // LIST nor slip in between the LIST and the restore. The prepare also sits ahead of + // the stale-line boundary, so its settle wait does not become a window in which a + // late reply to an earlier command could pass for this listing's terminator. + // Querying the card too soon after the switch makes the device answer -200 + // (Execution error), so the wait itself is not optional. + // + // Each attempt therefore leaves the bus back on LAN, including across the retry + // delay above — the pairing is per exchange rather than per call so that gap, which + // is outside the lock, is not one in which the device sits switched to the card. + lines = await _host.ExecuteTextCommandAsync( + () => + { + _host.Send(ScpiMessageProducer.GetSdFileList); + + // End-of-listing terminator — see this method's remarks. Sent inside + // the same text exchange so the ordering guarantee holds. + _host.Send(ScpiMessageProducer.GetSystemError); + }, + responseTimeoutMs: 3000, + completionTimeoutMs: SD_LIST_COMPLETION_TIMEOUT_MS, + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync, + finalizeAsync: RestoreLanInterfaceAsync); + + isComplete = TrySplitAtSdListTerminator(lines, out listing); + + if (isComplete && !ScpiResponseClassifier.ContainsScpiError(listing)) + { + break; + } + } + + if (!isComplete) + { + throw new SdCardListIncompleteException(lines); + } + + ThrowIfSdCardListError(listing); + + var files = SdCardFileListParser.ParseFileList(listing); + _sdCardFiles = files; + return files; + } + + /// + /// Prepare phase shared by the SD card text exchanges: switches the shared SPI bus over to + /// the card and waits for the firmware to complete the switch. + /// + /// + /// Passed as the prepareAsync phase of + /// + /// rather than run + /// inline, so it executes inside the text-exchange lock — a competing exchange restoring the + /// LAN interface between the switch and the commands that depend on it would leave them + /// running against the wrong interface — and ahead of the exchange's stale-line boundary, so + /// the settle wait cannot be mistaken for a window in which the device was answering. + /// + private async Task PrepareSdInterfaceAndSettleAsync(CancellationToken cancellationToken) + { + PrepareSdInterface(); + + // Querying the card too soon after the switch makes the device answer -200 + // (Execution error), so this wait is not optional. + await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); + } + + /// + /// Finalize phase shared by the SD card text exchanges: hands the shared SPI bus back to the + /// LAN interface. The mirror of . + /// + /// + /// Passed as the finalizeAsync phase of + /// + /// rather than run from the caller's own finally, so it holds the same lock + /// acquisition the matching prepare phase does. Restoring from outside the lock leaves a + /// window in which a competing exchange runs between this operation's commands and its + /// restore — the switch serialized, the restore not (#407). + /// + /// The connection check keeps a restore off a device that dropped mid-operation, where the + /// sends would only throw over the top of whatever + /// actually failed. Nothing to restore in that case: the link is gone. + /// + /// + private Task RestoreLanInterfaceAsync() + { + if (_host.IsConnected) + { + PrepareLanInterface(); + } + + return Task.CompletedTask; + } + + /// + /// Splits a raw SD listing response at the SYSTem:ERRor? terminator reply that + /// appends to the exchange. + /// + /// The raw response lines captured from the device. + /// + /// The lines that precede the terminator — the directory listing proper — when the method + /// returns true; otherwise the unmodified input. + /// + /// + /// true when the terminator was present, meaning the response is complete; + /// false when it never arrived, meaning the response is missing or truncated. + /// + private static bool TrySplitAtSdListTerminator( + IReadOnlyList lines, + out IReadOnlyList listingLines) + { + // Scan from the end. A terminator reply from a PREVIOUS, timed-out exchange can still + // be sitting in the transport buffer and lead this response; splitting at the first + // match would then discard the listing that follows it and report an empty card — + // exactly the failure this terminator exists to prevent. + var terminatorIndex = -1; + for (var i = lines.Count - 1; i >= 0; i--) + { + if (ScpiResponseClassifier.IsSystemErrorReplyLine(lines[i])) + { + terminatorIndex = i; + break; + } + } + + if (terminatorIndex < 0) + { + listingLines = lines; + return false; + } + + var listing = new List(terminatorIndex); + for (var j = 0; j < terminatorIndex; j++) + { + // Any other terminator-shaped line is a stale reply of the same kind, not + // directory content — no firmware listing entry can match that shape, since + // entries are always " ". + if (ScpiResponseClassifier.IsSystemErrorReplyLine(lines[j])) + { + continue; + } + + listing.Add(lines[j]); + } + + listingLines = listing; + return true; + } + + /// + /// Retrieves the free and total byte counts of the device's SD card. + /// + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation, containing the SD card storage info. + /// Thrown when the device is not connected. + /// Thrown when the device is currently logging to SD card. + /// Thrown when the operation is canceled. + /// Thrown when no SD card is installed in the device. + /// + /// Thrown when the device's firmware does not recognize the storage query (SCPI -113 + /// "Undefined header"), typically because it predates ; + /// or, over a WiFi/TCP transport, when the firmware predates SD-over-WiFi support + /// () — the storage query drives the SD + /// card through the same transport gate as the file operations. + /// + /// Thrown when the device returned a SCPI error or an unparseable response. + internal async Task GetSdCardStorageAsync(CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + if (_isLoggingToSdCard) + { + throw new InvalidOperationException("Cannot query SD card storage while logging to SD card."); + } + + // The storage-space query drives the SD card through the same transport-aware + // PrepareSdInterface() as LIST/GET/DELETE, so it carries the identical SD-over-WiFi + // requirement: over WiFi it needs firmware >= v3.7.0 (#598/#599 SPI arbitration) — else + // it would access the SD card with the LAN still enabled on firmware that never learned + // to arbitrate the shared bus. Gate it up front for the same reason as its siblings. + EnsureSdFileTransferSupportedOnTransport(); + + cancellationToken.ThrowIfCancellationRequested(); + + // Defensive: always send stop command even if IsStreaming is stale (see issue #118) + _host.Send(ScpiMessageProducer.StopStreaming); + _host.IsStreaming = false; + + // Same prepare/finalize pairing as GetSdCardFilesAsync: the SPI bus switch and its + // settle wait are the exchange's prepare phase and the LAN restore is its finalize + // phase, so both halves are held by the one lock acquisition rather than only the + // switch (#407). The settle wait also moves ahead of the exchange's stale-line + // boundary instead of blocking a thread inside it. + var lines = await _host.ExecuteTextCommandAsync( + () => _host.Send(ScpiMessageProducer.GetSdSpace), + responseTimeoutMs: 3000, + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync, + finalizeAsync: RestoreLanInterfaceAsync); + + // 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 (ScpiResponseClassifier.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).ConfigureAwait(false); + + lines = await _host.ExecuteTextCommandAsync( + () => _host.Send(ScpiMessageProducer.GetSdSpace), + responseTimeoutMs: 3000, + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync, + finalizeAsync: RestoreLanInterfaceAsync); + + if (!ScpiResponseClassifier.ContainsScpiError(lines) || ContainsNoSdCardMarker(lines)) + { + break; + } + } + } + + if (SdCardSpaceParser.TryParseLines(lines, out var storage)) + { + return storage; + } + + // Parser failed — translate the firmware response into a typed exception. + var lastScpiError = lines.LastOrDefault(ScpiResponseClassifier.IsScpiErrorLine)?.Trim(); + + if (ContainsNoSdCardMarker(lines)) + { + throw new SdCardNotPresentException(lines, lastScpiError); + } + + // A -113 "Undefined header" reply means the firmware doesn't recognize the storage + // query at all — typically because it predates the version that introduced it — so + // it gets the typed feature-gating exception instead of a generic operation error. + // The device's answer is authoritative here, so this throws on the wire response + // rather than on Supports(); the seam only supplies the required version and board. + if (lastScpiError != null + && ScpiResponseClassifier.TryExtractErrorCode(lastScpiError, out var scpiErrorCode) + && scpiErrorCode == ScpiErrorCodeUndefinedHeader) + { + throw _host.CreateFeatureNotSupportedException(DeviceFeature.SdStorageQuery); + } + + 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 lines) + { + return lines.Any(l => l.IndexOf("No SD Card Detected", StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// + internal async Task CheckSdCardSpaceAsync( + SdCardCaptureEstimate? plannedCapture = null, + long minimumFreeBytes = SdCardSpaceCheck.DefaultMinimumFreeBytes, + CancellationToken cancellationToken = default) + { + // Delegates connection / logging-state validation and the typed SD exceptions + // (no card, old firmware, unparseable response) to GetSdCardStorageAsync. + var storage = await GetSdCardStorageAsync(cancellationToken).ConfigureAwait(false); + + var result = SdCardSpaceCheck.Evaluate(storage, plannedCapture, minimumFreeBytes); + + // Advisory only — raise the warning but never block the caller from starting logging. + if (result.ShouldWarn) + { + _host.RaiseLowSdSpaceWarning(new LowSdSpaceWarningEventArgs(result)); + } + + return result; + } + + + /// + internal void SetSdCardMinimumFreeSpace(long bytes) + { + // Argument validation precedes the connection (state) check so misuse surfaces the same + // exception type regardless of connection state (matches SetAnalogOutput / SetDioDirection). + if (bytes < 0) + { + throw new ArgumentOutOfRangeException(nameof(bytes), bytes, "Minimum free space cannot be negative."); + } + + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + _host.Send(ScpiMessageProducer.SetSdMinFreeSpace(bytes)); + } + + /// + /// 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. + internal 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, .csv for CSV). + /// + /// + /// Optional decimal bitmask string to enable specific ADC channels (e.g. "3" enables channels 0 and 1). + /// The firmware parses this as a decimal integer where each bit enables a channel. + /// 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 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. + internal async Task StartSdCardLoggingSessionAsync(string? fileName = null, string? channelMask = null, SdCardLogFormat format = SdCardLogFormat.Protobuf, CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + if (!_host.IsUsbConnection) + { + throw new InvalidOperationException( + "SD card logging requires a USB/serial connection. Starting a logging session " + + "disables the LAN interface to give the SD card the shared SPI bus, which over " + + "a network connection would drop the very link the command arrived on. " + + "Listing, downloading and deleting SD files do work over WiFi on firmware " + + "v3.7.0 and later."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + var extension = format switch + { + SdCardLogFormat.Json => ".json", + SdCardLogFormat.Csv => ".csv", + _ => ".bin", + }; + + var logFileName = !string.IsNullOrWhiteSpace(fileName) + ? fileName! + : $"log_{DateTime.Now:yyyyMMdd_HHmmss}{extension}"; + + ValidateSdCardFileName(logFileName); + + // SdCardLogFormat integer values map 1:1 to SYSTem:STReam:FORmat SCPI arguments + var formatCommand = new ScpiMessage($"SYSTem:STReam:FORmat {(int)format}"); + + // SD card and LAN share the SPI bus on the hardware, so LAN must be + // disabled before the SD card can be used. + _host.Send(ScpiMessageProducer.DisableNetworkLan); + await Task.Delay(100, cancellationToken); + + _host.Send(ScpiMessageProducer.EnableStorageSd); + await Task.Delay(100, cancellationToken); + + // Route the data stream to the SD card interface. + _host.Send(ScpiMessageProducer.SetStreamInterface(StreamInterface.SdCard)); + await Task.Delay(100, cancellationToken); + + _host.Send(ScpiMessageProducer.SetSdLoggingFileName(logFileName)); + await Task.Delay(100, cancellationToken); + + _host.Send(formatCommand); + await Task.Delay(100, cancellationToken); + + if (!string.IsNullOrWhiteSpace(channelMask)) + { + _host.Send(ScpiMessageProducer.EnableAdcChannels(channelMask)); + await Task.Delay(100, cancellationToken); + } + + _host.Send(ScpiMessageProducer.StartStreaming(_host.StreamingFrequency)); + + _isLoggingToSdCard = true; + _host.IsStreaming = true; + + return new SdCardLoggingSession(logFileName, format); + } + + /// + /// Stops logging data to the SD card. + /// + /// 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. + internal Task StopSdCardLoggingAsync(CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Defensive: always send stop command even if IsStreaming is stale (see issue #118) + _host.Send(ScpiMessageProducer.StopStreaming); + _host.IsStreaming = false; + + _host.Send(ScpiMessageProducer.DisableStorageSd); + + // Restore stream interface to USB so subsequent non-SD operations work. + if (_host.IsUsbConnection) + { + _host.Send(ScpiMessageProducer.SetStreamInterface(StreamInterface.Usb)); + + // Re-enable LAN interface. StartSdCardLoggingAsync disables LAN because + // the SD card and WiFi/LAN share the SPI bus on the hardware. + // + // Only over USB, and for the same reason PrepareLanInterface() is transport-aware: + // over WiFi/TCP the LAN was never disabled (nothing can disable it from the other + // end of the connection it carries), and LAN:ENAbled 1 re-initializes the WiFi + // module — which would drop the very link this command arrived on. A session that + // logged over USB, disconnected, and came back over WiFi could otherwise call this + // and cut itself off (#327). + _host.Send(ScpiMessageProducer.EnableNetworkLan); + } + + _isLoggingToSdCard = false; + + return Task.CompletedTask; + } + + /// + /// Deletes a file from the SD card. + /// + /// The name of the file to delete. + /// 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 device is currently logging to SD card. + /// Thrown when the filename is null, empty, or contains invalid characters. + /// Thrown when the operation is canceled. + internal async Task DeleteSdCardFileAsync(string fileName, CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + if (_isLoggingToSdCard) + { + throw new InvalidOperationException("Cannot delete files while logging to SD card."); + } + + EnsureSdFileTransferSupportedOnTransport(); + + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("Filename cannot be null or empty.", nameof(fileName)); + } + + ValidateSdCardFileName(fileName); + + // Defensive: always send stop command even if IsStreaming is stale (see issue #118) + _host.Send(ScpiMessageProducer.StopStreaming); + _host.IsStreaming = false; + + // Same prepare/finalize treatment as GetSdCardFilesAsync, for the same reasons — the + // SPI switch stays serialized against competing text exchanges, its settle wait stays + // outside the stale-line boundary, and the restore stays under the same lock as the + // switch instead of running from a finally after the lock has been dropped. The + // consequence of a stale line is milder here (delete keys off ContainsScpiError, so it + // would mean a pointless delete-and-relist retry rather than a bad listing) but it is + // the same defect. + var lines = await _host.ExecuteTextCommandAsync( + () => + { + _host.Send(ScpiMessageProducer.DeleteSdFile(fileName)); + _host.Send(ScpiMessageProducer.GetSdFileList); + }, + responseTimeoutMs: 3000, + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync, + finalizeAsync: RestoreLanInterfaceAsync); + + if (ScpiResponseClassifier.ContainsScpiError(lines)) + { + for (var retry = 0; retry < SD_LIST_MAX_RETRIES; retry++) + { + cancellationToken.ThrowIfCancellationRequested(); + + await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, cancellationToken).ConfigureAwait(false); + + lines = await _host.ExecuteTextCommandAsync( + () => + { + _host.Send(ScpiMessageProducer.DeleteSdFile(fileName)); + _host.Send(ScpiMessageProducer.GetSdFileList); + }, + responseTimeoutMs: 3000, + cancellationToken: cancellationToken, + prepareAsync: PrepareSdInterfaceAndSettleAsync, + finalizeAsync: RestoreLanInterfaceAsync); + + if (!ScpiResponseClassifier.ContainsScpiError(lines)) + { + break; + } + } + } + + _sdCardFiles = SdCardFileListParser.ParseFileList(lines); + } + + /// + /// Formats the entire SD card, erasing all data. + /// + /// 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 device is currently logging to SD card. + /// Thrown when the operation is canceled. + internal Task FormatSdCardAsync(CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + if (_isLoggingToSdCard) + { + throw new InvalidOperationException("Cannot format SD card while logging."); + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Defensive: always send stop command even if IsStreaming is stale (see issue #118) + _host.Send(ScpiMessageProducer.StopStreaming); + _host.IsStreaming = false; + + _host.Send(ScpiMessageProducer.EnableStorageSd); + _host.Send(ScpiMessageProducer.FormatSdCard); + + return Task.CompletedTask; + } + + /// + /// Downloads a file from the device's SD card, over USB or over WiFi/TCP. + /// + /// The name of the file to download. + /// The stream to write file contents to. + /// Optional progress reporting. + /// Cancellation token. + /// Metadata about the downloaded file. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. + /// Thrown when the filename is null, empty, or contains invalid characters. + /// + /// Thrown when the device serves a marker-only (0-byte) transfer across all retry attempts + /// for a file the last listing reported as non-empty (or + /// whose listed size is unknown), indicating its SD subsystem is not ready. A file the + /// listing reports as 0 bytes downloads successfully as a legitimate empty file. + /// + /// + /// Thrown when the transfer stops making progress before the end-of-file marker arrives: + /// the transport returned an empty read, closed, or — the only signal a socket gives — + /// went quiet for longer than . + /// + /// + /// Thrown when the download does not finish within . + /// The deadline is enforced by this method itself, so it still applies when the transfer + /// is parked in a call that cannot observe a cancellation token (#399). + /// + /// Thrown when is canceled. + /// + /// On a timeout — or a cancellation the parked transfer cannot itself observe — the + /// in-flight transfer is abandoned rather than awaited: it may be blocked in native + /// serial I/O that no token can interrupt, and waiting for it is the hang this method + /// exists to bound. The abandoned transfer's token is cancelled first, so it unwinds at + /// its next token check — but that check is only reached once whatever it is blocked in + /// returns, which may be never. Two consequences for callers: it can still write to + /// after this method has thrown, so the stream must + /// not be reused for anything else; and the device is left mid-SD:GET with the + /// protobuf consumer stopped, so reconnecting (or power-cycling, if its SD subsystem is + /// genuinely wedged) is the reliable way to resume normal operation. + /// + /// The LAN interface is deliberately not restored in that case: the abandoned + /// transfer still owns the transport, and putting the restore commands onto a link it is + /// still reading would only add traffic to a device that has already stopped answering. The + /// reconnect the caller needs anyway re-establishes the interface. On every other outcome — + /// success, a stall, a cancellation the transfer did observe — the restore runs as before. + /// + /// + /// Until an abandoned transfer unwinds it still owns the transport, so a further download + /// on the same device fails fast with rather than + /// putting a second reader on the same stream. A caller looping over many files against a + /// wedged card therefore gets one timeout and then immediate, cheap failures — not a + /// growing pile of blocked threads. + /// + /// + internal async Task DownloadSdCardFileAsync( + string fileName, + Stream destinationStream, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + if (!_host.IsConnected) + { + throw new DeviceNotConnectedException(); + } + + // Over WiFi/TCP this requires firmware >= v3.7.0 (#598/#599); over USB it is always + // available on SD-capable firmware. Older firmware over WiFi gets a typed + // FeatureNotSupportedException instead of the old blanket USB-only rejection (ADR 0001). + EnsureSdFileTransferSupportedOnTransport(); + + if (string.IsNullOrWhiteSpace(fileName)) + { + throw new ArgumentException("Filename cannot be null or empty.", nameof(fileName)); + } + + ValidateSdCardFileName(fileName); + ArgumentNullException.ThrowIfNull(destinationStream); + + cancellationToken.ThrowIfCancellationRequested(); + + if (_isLoggingToSdCard) + { + throw new InvalidOperationException("Cannot download files while logging to SD card."); + } + + // Defensive: always send stop command even if IsStreaming is stale (see issue #118) + _host.Send(ScpiMessageProducer.StopStreaming); + _host.IsStreaming = false; + + var stopwatch = Stopwatch.StartNew(); + long fileSize = 0; + var budget = _host.SdCardDownloadTimeout; + + // Set when the transfer was given up on and left running (#399/#401). Read only by the + // restore below, on this same async flow. + var workerAbandoned = false; + + try + { + await RunWithHardDeadlineAsync(async token => + { + await _host.ExecuteRawCaptureAsync(async (stream, ct) => + { + // Prepare SD card interface. Transport-aware: over USB it hands the shared + // SPI bus to the card, over WiFi/TCP it leaves the LAN up because the reply + // comes back over that very connection (#598/#599). + PrepareSdInterface(); + + // Let the interface switch settle before the card is asked for anything — + // the same wait the LIST/DELETE/space exchanges take for the same reason. + await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, ct).ConfigureAwait(false); + + // Send the SCPI command to request the file + _host.Send(ScpiMessageProducer.GetSdFile(fileName)); + + // Receive the file data. A marker-only (0-byte) transfer for a file the + // listing reports as non-empty means the device's SD subsystem wasn't ready + // when it opened the file - the same kind of transient condition + // GetSdCardFilesAsync's LIST retry already absorbs - so retry the GET a + // bounded number of times before giving up (see #264). Passing the listed + // size keeps that retry off a genuinely 0-byte file, which is a legitimate + // empty download rather than a wedged subsystem (#398 gap 2). + // The receiver is told what its transport's silence means. Over USB serial a + // zero-length read is the per-read ReadTimeout firing on a device that is + // merely quiet; over TCP it is the peer's FIN and nothing else, and the + // socket keeps reporting itself readable either way. Over TCP the socket + // also never surfaces silence at all — ReadAsync ignores the receive + // timeout — so the inactivity window is the only thing standing between a + // device that stopped answering and the full 30-minute budget (#327). + var receiver = new SdCardFileReceiver( + stream, + zeroLengthReadMeansClosed: !_host.IsUsbConnection, + idleTimeout: _host.SdCardTransferIdleTimeout); + var listedFileSizeBytes = TryGetListedFileSize(fileName); + long bytesReceived; + var attempt = 0; + while (true) + { + try + { + // Each attempt gets what is left of the overall budget, never a + // fresh full one: retries must not be able to push the total past + // the deadline the caller was promised. + bytesReceived = await receiver.ReceiveAsync( + destinationStream, + fileName, + progress, + timeout: RemainingBudget(budget, stopwatch), + cancellationToken: ct, + listedFileSizeBytes: listedFileSizeBytes).ConfigureAwait(false); + break; + } + catch (SdCardEmptyTransferException) when (attempt < SD_LIST_MAX_RETRIES) + { + attempt++; + await Task.Delay(SD_INTERFACE_SETTLE_DELAY_MS, ct).ConfigureAwait(false); + _host.Send(ScpiMessageProducer.GetSdFile(fileName)); + } + } + + fileSize = bytesReceived; + }, token).ConfigureAwait(false); + }, + budget, + fileName, + cancellationToken, + onWorkerAbandoned: () => workerAbandoned = true).ConfigureAwait(false); + } + finally + { + // Restore the LAN interface — but NOT when the transfer was abandoned. An abandoned + // worker is still alive and still owns the transport (that is why the download gate + // is not released until it finally unwinds), so sending the restore now would put + // SCPI commands onto a link a transfer is still reading, on top of a device that has + // already stopped answering. There is nothing to gain: the caller is told to + // reconnect or power-cycle, and both re-establish the interface anyway (#399/#401). + if (!workerAbandoned && _host.IsConnected) + { + try + { + PrepareLanInterface(); + } + catch + { + // Best-effort restoration; the device may have disconnected + } + } + } + + stopwatch.Stop(); + return new SdCardDownloadResult(fileName, fileSize, stopwatch.Elapsed); + } + + /// + /// Looks up the size the most recent directory listing reported for a file. Returns null + /// ("unknown", which the receiver treats conservatively) when no listing has been fetched, + /// when the listing did not include this file or a size for it, or when more than one + /// listed entry shares the name. + /// + private long? TryGetListedFileSize(string fileName) + { + // Snapshot the field: GetSdCardFilesAsync replaces the list wholesale, so a + // concurrent refresh swaps the reference rather than mutating what we enumerate. + var listedFiles = _sdCardFiles; + + long? matchedSize = null; + var matched = false; + + foreach (var file in listedFiles) + { + // FAT names are case-insensitive. The listing keeps only the leaf name, so the + // same name can appear twice from different directories; that is ambiguous and + // an over-confident size here would wave through the very failure (a wedged + // subsystem serving nothing) the empty-transfer guard exists to catch. + if (!string.Equals(file.FileName, fileName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (matched) + { + return null; + } + + matched = true; + matchedSize = file.SizeInBytes; + } + + return matchedSize; + } + + /// + /// Downloads a file from the device's SD card, over USB or over WiFi/TCP, to a temporary file. + /// + /// The name of the file to download. + /// Optional progress reporting. + /// Cancellation token. + /// Metadata about the downloaded file, including the local file path. + /// Thrown when the device is not connected. + /// Thrown over a WiFi/TCP transport when the firmware predates SD-over-WiFi file transfer. + /// Thrown when the filename is null, empty, or contains invalid characters. + internal async Task DownloadSdCardFileAsync( + string fileName, + IProgress? progress = null, + CancellationToken cancellationToken = default) + { + var ext = Path.GetExtension(fileName); + if (string.IsNullOrEmpty(ext)) ext = ".bin"; + var tempPath = Path.Combine(Path.GetTempPath(), $"daqifi_{Guid.NewGuid():N}{ext}"); + try + { + await using var fileStream = new FileStream( + tempPath, + FileMode.Create, + FileAccess.Write, + FileShare.None, + bufferSize: 65536, + useAsync: true); + + var result = await DownloadSdCardFileAsync(fileName, fileStream, progress, cancellationToken) + .ConfigureAwait(false); + + return result with { FilePath = tempPath }; + } + catch + { + try { File.Delete(tempPath); } catch { /* ignore cleanup failures */ } + throw; + } + } + + + /// + /// The part of not yet consumed, floored at zero (a negative + /// timeout is not a legal delay). + /// + private static TimeSpan RemainingBudget(TimeSpan budget, Stopwatch stopwatch) + { + var remaining = budget - stopwatch.Elapsed; + return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero; + } + + /// + /// The instant the download is given up on regardless of what it is doing. It sits just + /// past the cooperative so that a transfer which IS observing + /// its token still fails through the receiver's own timeout — which reports how many + /// bytes arrived — and the hard deadline only decides the case where it is not. + /// + private static TimeSpan HardDeadlineFor(TimeSpan budget) + { + var graceMs = Math.Clamp(budget.TotalMilliseconds * 0.1, 100, 5000); + return budget + TimeSpan.FromMilliseconds(graceMs); + } + + /// + /// Runs an SD download on a worker task and races it against a hard deadline, so neither + /// the deadline nor the caller's cancellation depends on the transfer being somewhere it + /// can observe a token (#399). On expiry the worker is abandoned rather than awaited. + /// + /// The transfer. Receives a token cancelled by caller cancellation or the deadline, whichever comes first. + /// The cooperative budget; the hard deadline is of it. + /// Used only in the message. + /// The caller's token, observed by the race itself and not only by the worker. + /// + /// Invoked, before this method throws, when the worker is given up on while still running. + /// Lets the caller skip any cleanup that would touch the transport the abandoned worker + /// still owns. + /// + /// + /// Thrown when a previous download still owns — it is either + /// genuinely in flight or was abandoned and is still parked on the transport. + /// + private async Task RunWithHardDeadlineAsync( + Func operation, + TimeSpan budget, + string fileName, + CancellationToken cancellationToken, + Action? onWorkerAbandoned = null) + { + // Checked before taking the gate so a cancelled caller neither acquires it nor gets an + // answer about some other transfer. + cancellationToken.ThrowIfCancellationRequested(); + + // Fail fast rather than becoming a second reader on a stream an abandoned transfer + // still holds. Wait(0) never blocks: this either takes the gate or reports the state. + if (!_sdDownloadGate.Wait(0)) + { + // Cancellation wins when it raced the gate check — the same precedence the abandon + // path below applies. The caller asked to stop; that is a truer answer than a + // report about a different download. + cancellationToken.ThrowIfCancellationRequested(); + + throw new InvalidOperationException( + "A previous SD card download is still in flight, or was abandoned after timing out and " + + "is still parked on the transport. Reconnect the device before retrying."); + } + + // Released exactly once, by whichever path is last to be done with the worker: the + // finally below in the normal case, or the abandon-path continuation when the worker + // finally unwinds. Interlocked because a worker that completes right at the deadline + // boundary can reach both. + var gateReleased = 0; + void ReleaseGate() + { + if (Interlocked.Exchange(ref gateReleased, 1) != 0) + { + return; + } + + try + { + _sdDownloadGate.Release(); + } + catch (ObjectDisposedException) + { + // The device was disposed while a transfer was still abandoned. Benign + // teardown, and this can run from a discarded continuation — never throw. + } + } + + var hardDeadline = HardDeadlineFor(budget); + + // hardDeadlineCts runs on its own timer, independent of the Task.Delay race below, so + // it still reaches the worker if the worker only returns long after the race was + // decided. linkedCts is what the worker observes: caller cancellation OR the deadline. + var hardDeadlineCts = new CancellationTokenSource(hardDeadline); + var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, hardDeadlineCts.Token); + + // Stops the racing delay the moment the outcome is decided — without it, a download + // that finishes in a second would leave a 30-minute timer registered behind it. + var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + // LongRunning (a dedicated thread, not a pooled one): the transfer's synchronous + // prefix — the consumer stop-and-join, PrepareSdInterface's blocking writes — otherwise + // runs on the CALLING thread up to the first await, which on a UI thread means a + // wedged device freezes the window, and which would put that prefix outside the very + // deadline it needs to be inside. A pooled Task.Run would also tie up a worker for the + // transfer's full blocking duration. Pass CancellationToken.None to StartNew itself: + // the worker's own token still cancels its waits, and "cancelled before start" must + // not surface as an operation fault. (Mirrors WifiBridgeActivator, #294/#295/#326.) + var workerTask = Task.Factory.StartNew( + () => operation(linkedCts.Token), + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default).Unwrap(); + + try + { + var winner = await Task.WhenAny( + workerTask, + Task.Delay(hardDeadline, raceCts.Token)).ConfigureAwait(false); + + // Only abandon when the worker is genuinely still running: WhenAny can hand back + // the delay even though the worker completed at that same boundary, and awaiting + // it below honors that result instead of discarding it. + if (winner != workerTask && !workerTask.IsCompleted) + { + // Tell the caller before unwinding: the worker keeps running and keeps the + // transport, so any cleanup that would write to it has to be skipped. + onWorkerAbandoned?.Invoke(); + + // Cancel explicitly instead of relying on the deadline timer having fired: the + // delay above and hardDeadlineCts are two separate timers of the same duration, + // so the delay can win by a hair and leave a late-returning worker running one + // more state-changing step after the caller already threw. Idempotent. + hardDeadlineCts.Cancel(); + + // The worker may be parked in native serial I/O that no token can interrupt, so + // it is ABANDONED, not awaited — waiting for it is the hang being bounded here. + // Observe its eventual fault so it cannot resurface as an UnobservedTaskException, + // and dispose the sources only once it is done with them (disposing early would + // turn its pending waits into ObjectDisposedException instead of cancellation). + _ = workerTask.ContinueWith( + t => + { + _ = t.Exception; + linkedCts.Dispose(); + hardDeadlineCts.Dispose(); + + // Only now is the transport genuinely free for another download. + ReleaseGate(); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + + // Prefer surfacing caller cancellation over a generic timeout when both raced. + cancellationToken.ThrowIfCancellationRequested(); + + throw new TimeoutException( + $"SD card download of '{fileName}' did not complete within " + + $"{hardDeadline.TotalSeconds:0.#}s and was abandoned. The device's SD " + + "subsystem is not responding; reconnect (or power-cycle) before retrying."); + } + + // Propagate success or the transfer's own exception unchanged. + await workerTask.ConfigureAwait(false); + } + finally + { + raceCts.Cancel(); + raceCts.Dispose(); + + // The abandon path hands disposal and the gate to its continuation instead; do it + // here only when the worker actually finished (the common, non-hung case). + if (workerTask.IsCompleted) + { + linkedCts.Dispose(); + hardDeadlineCts.Dispose(); + ReleaseGate(); + } + } + } + + + /// + /// Inspects the final response from a SYSTem:STORage:SD:LISt? exchange + /// and throws a typed when the device + /// reported a real failure (no SD card, filesystem error, generic SCPI error). + /// If any non-error/non-empty line is present, callers proceed to parse — even + /// if SCPI error lines are interleaved — so a successful directory listing is + /// never masked by stray transient errors. + /// + private static void ThrowIfSdCardListError(IReadOnlyList lines) + { + // LastScpiError must only carry a real SCPI-formatted error so callers + // can rely on its shape. Firmware status text ("Error !! ...") is + // surfaced via the exception's Message and RawDeviceResponse instead. + var lastScpiError = lines.LastOrDefault(ScpiResponseClassifier.IsScpiErrorLine)?.Trim(); + + // Specific firmware-emitted error markers take precedence over generic + // content/error checks. They're plain text (not SCPI-shaped), so a + // simple "is there any content line?" check would otherwise miss them + // and pass garbage to the parser. + if (lines.Any(l => l.IndexOf("No SD Card Detected", StringComparison.OrdinalIgnoreCase) >= 0)) + { + throw new SdCardNotPresentException(lines, lastScpiError); + } + + var filesystemErrorLine = lines.FirstOrDefault(l => + l.IndexOf("Failed to open directory", StringComparison.OrdinalIgnoreCase) >= 0); + if (filesystemErrorLine != null) + { + throw new SdCardFilesystemException(lines, lastScpiError, filesystemErrorLine.Trim()); + } + + // If any line looks like a real result (non-empty, not an error or + // firmware status line), hand off to the parser. Stray interleaved + // error lines are still parsed away by SdCardFileListParser. + var hasContentLine = lines.Any(line => + !string.IsNullOrWhiteSpace(line) && !ScpiResponseClassifier.IsErrorResponseLine(line)); + if (hasContentLine) + { + return; + } + + if (lastScpiError != null) + { + throw new SdCardOperationException( + "The SD card list operation failed: " + lastScpiError, + lines, + lastScpiError); + } + + // Defensive fallback: firmware status text ("Error !! ...") with no + // SCPI error and no recognized marker. Shouldn't happen for known + // firmware paths, but surfacing it as a typed exception is far + // better than silently returning an empty list. + var nonResultLine = lines.FirstOrDefault(l => + !string.IsNullOrWhiteSpace(l) && ScpiResponseClassifier.IsErrorResponseLine(l))?.Trim(); + if (nonResultLine != null) + { + throw new SdCardOperationException( + "The SD card list operation failed: " + nonResultLine, + lines, + lastScpiError: null); + } + + // No error lines and no content lines — empty directory. Caller continues. + // Safe to treat as empty rather than as a lost reply: GetSdCardFilesAsync only reaches + // this point once the device has answered the end-of-listing terminator (#396). + } + + /// + /// Validates an SD card filename to prevent SCPI command injection. + /// + /// The filename to validate. + /// Thrown when the filename contains invalid characters. + private static void ValidateSdCardFileName(string fileName) + { + if (fileName.IndexOfAny(new[] { '"', '\n', '\r', ';' }) >= 0) + { + throw new ArgumentException( + "Filename contains invalid characters. Quotes, newlines, and semicolons are not allowed.", + nameof(fileName)); + } + } + } +}