diff --git a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs index 0abeb970..c00aea82 100644 --- a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs +++ b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs @@ -96,6 +96,112 @@ [new HidDeviceInfo(0x04D8, 0x003C, "path-1", "SN-1", "DAQiFi Bootloader")] Assert.Equal(100, terminalProgress.PercentComplete); } + [Theory] + [InlineData(0)] + [InlineData(-1)] + public async Task ExecuteWithRetryAsync_WithNonPositiveMaxAttempts_ThrowsAndNeverRunsTheAction(int maxAttempts) + { + // The retry helper is shared plumbing for flash-critical steps (erase, program, + // verify). A caller asking for zero attempts must fail loudly rather than return + // a silent success that skipped the operation entirely. + var context = new FirmwareUpdateContext( + new object(), + NullLogger.Instance, + new FirmwareUpdateServiceOptions()); + + var actionRan = false; + + var ex = await Assert.ThrowsAsync( + () => context.ExecuteWithRetryAsync( + "erase flash", + maxAttempts, + TimeSpan.Zero, + _ => + { + actionRan = true; + return Task.CompletedTask; + }, + _ => true, + CancellationToken.None)); + + Assert.Equal("maxAttempts", ex.ParamName); + Assert.False(actionRan); + } + + [Fact] + public async Task ExecuteWithRetryAsync_WithSingleAttempt_RunsTheActionExactlyOnce() + { + // Guards the boundary: 1 is valid and must not be caught by the new check. + var context = new FirmwareUpdateContext( + new object(), + NullLogger.Instance, + new FirmwareUpdateServiceOptions()); + + var runCount = 0; + + await context.ExecuteWithRetryAsync( + "erase flash", + 1, + TimeSpan.Zero, + _ => + { + runCount++; + return Task.CompletedTask; + }, + _ => true, + CancellationToken.None); + + Assert.Equal(1, runCount); + } + + [Fact] + public async Task UpdateFirmwareAsync_RaisesStateChangedWithTheServiceAsSender() + { + // The state machine lives in an internal collaborator, so the event's + // sender is forwarded rather than being an implicit `this`. Subscribers + // that key off sender (e.g. a UI tracking several services) must keep + // seeing the public service instance. + var device = new FakeStreamingDevice("COM3"); + var hidTransport = new FakeHidTransport(); + hidTransport.EnqueueRead([0x01, 0x10]); // version + hidTransport.EnqueueRead([0x01, 0x02]); // erase ack + hidTransport.EnqueueRead([0x01, 0x03]); // program ack 1 + hidTransport.EnqueueRead([0x01, 0x03]); // program ack 2 + hidTransport.EnqueueRead([0xCD, 0xAB]); // READ_CRC response → decodes to 0xABCD (match) + + var enumerator = new FakeHidDeviceEnumerator([ + Array.Empty(), + [new HidDeviceInfo(0x04D8, 0x003C, "path-1", "SN-1", "DAQiFi Bootloader")] + ]); + + var service = new FirmwareUpdateService( + hidTransport, + new FakeFirmwareDownloadService(), + new FakeExternalProcessRunner(), + NullLogger.Instance, + new FakeBootloaderProtocol( + [[0xA1, 0x01], [0xA1, 0x02]], + crcRegions: [new FlashCrcRegion(0x9D000000, 256, 0xABCD)]), + enumerator, + CreateFastOptions()); + + var senders = new List(); + service.StateChanged += (sender, _) => senders.Add(sender); + + var hexPath = CreateTempFile(); + try + { + await service.UpdateFirmwareAsync(device, hexPath); + } + finally + { + File.Delete(hexPath); + } + + Assert.NotEmpty(senders); + Assert.All(senders, sender => Assert.Same(service, sender)); + } + [Fact] public async Task UpdateFirmwareAsync_WhenFlashCrcMatches_VerifiesViaReadCrcAndCompletes() { @@ -1785,7 +1891,7 @@ public async Task UpdateWifiModuleAsync_FiresBridgeActivationCallbackAtWincPromp [Fact] public void WifiFlashProgressParser_IgnoresImageBuildPercentAndAdvancesAcrossDeviceFlashPhases() { - var parser = new FirmwareUpdateService.WifiFlashProgressParser(); + var parser = new WifiFlashProgressParser(); // The local image-build phase reaches 100% per region; those must NOT move the bar, // otherwise the monotonic max latches before the on-device flash even starts. @@ -1818,7 +1924,7 @@ public void WifiFlashProgressParser_IgnoresImageBuildPercentAndAdvancesAcrossDev [Fact] public void WifiFlashProgressParser_MeasuresFromRangeBase_ForNonZeroVerifyRange() { - var parser = new FirmwareUpdateService.WifiFlashProgressParser(); + var parser = new WifiFlashProgressParser(); // Range base 0x40000 (span 0x40000). Absolute block addresses must be measured relative to // the base; otherwise the first block saturates the fraction to ~100% immediately. @@ -1840,7 +1946,7 @@ public void WifiFlashProgressParser_MeasuresFromRangeBase_ForNonZeroVerifyRange( [Fact] public void WifiFlashProgressParser_NeverMovesBackward_WhenAddressesResetBetweenPhases() { - var parser = new FirmwareUpdateService.WifiFlashProgressParser(); + var parser = new WifiFlashProgressParser(); parser.Observe("begin write operation"); var writeEnd = parser.Observe(" 0x060000:[wwwwwwww] 0x068000:[wwwwwwww] 0x070000:[wwwwwwww] 0x078000:[wwwwwwww]"); diff --git a/src/Daqifi.Core/Firmware/FirmwareUpdateContext.cs b/src/Daqifi.Core/Firmware/FirmwareUpdateContext.cs new file mode 100644 index 00000000..4d16118f --- /dev/null +++ b/src/Daqifi.Core/Firmware/FirmwareUpdateContext.cs @@ -0,0 +1,421 @@ +using System.Text; +using Daqifi.Core.Device; +using Microsoft.Extensions.Logging; + +namespace Daqifi.Core.Firmware; + +/// +/// State, progress and retry plumbing shared by the two independent update flows +/// ( and ) behind +/// . Owns the update state machine, the last-reported +/// progress percentage, the per-state timeout / retry wrappers and the exception + recovery +/// guidance construction, so neither flow has to know about the other. +/// +internal sealed class FirmwareUpdateContext +{ + private static readonly IReadOnlyDictionary> AllowedTransitions + = new Dictionary> + { + [FirmwareUpdateState.Idle] = new HashSet + { + FirmwareUpdateState.PreparingDevice, + FirmwareUpdateState.Complete, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.PreparingDevice] = new HashSet + { + FirmwareUpdateState.WaitingForBootloader, + FirmwareUpdateState.Programming, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.WaitingForBootloader] = new HashSet + { + FirmwareUpdateState.Connecting, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.Connecting] = new HashSet + { + FirmwareUpdateState.ErasingFlash, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.ErasingFlash] = new HashSet + { + FirmwareUpdateState.Programming, + FirmwareUpdateState.CleaningUp, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.Programming] = new HashSet + { + FirmwareUpdateState.Verifying, + FirmwareUpdateState.ReconnectingAfterFlash, + FirmwareUpdateState.JumpingToApp, + FirmwareUpdateState.CleaningUp, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.Verifying] = new HashSet + { + FirmwareUpdateState.JumpingToApp, + FirmwareUpdateState.Complete, + FirmwareUpdateState.CleaningUp, + FirmwareUpdateState.Failed + }, + // Terminal leg of the WiFi flow: the WINC image is already flashed and verified, + // so the only outcomes are a completed update or a reconnect failure. No cleanup + // path — there is no half-flashed PIC32 application to re-erase. + [FirmwareUpdateState.ReconnectingAfterFlash] = new HashSet + { + FirmwareUpdateState.Complete, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.JumpingToApp] = new HashSet + { + FirmwareUpdateState.Complete, + FirmwareUpdateState.Failed + }, + // Cleanup (re-erase) runs after a failure in a flash-touching state. + // It either leaves the device in a clean bootloader state (Recovered) + // or, if the re-erase itself fails, falls through to Failed. + [FirmwareUpdateState.CleaningUp] = new HashSet + { + FirmwareUpdateState.Recovered, + FirmwareUpdateState.Failed + }, + [FirmwareUpdateState.Complete] = new HashSet + { + FirmwareUpdateState.Idle + }, + [FirmwareUpdateState.Failed] = new HashSet + { + FirmwareUpdateState.Idle + }, + // Recovered is a terminal failure state (the update did not install + // firmware) but the device is safe; it only resets for the next run. + [FirmwareUpdateState.Recovered] = new HashSet + { + FirmwareUpdateState.Idle + } + }; + + // The service instance reported as the `sender` of StateChanged, so subscribers keep + // seeing the public facade rather than this internal collaborator. + private readonly object _eventSender; + + internal FirmwareUpdateContext( + object eventSender, + ILogger logger, + FirmwareUpdateServiceOptions options) + { + _eventSender = eventSender; + Logger = logger; + Options = options; + } + + internal ILogger Logger { get; } + + internal FirmwareUpdateServiceOptions Options { get; } + + /// + /// Supplies the extra diagnostic detail appended to a + /// timeout message (poll attempts, + /// requested target, last enumeration error). Owned by the PIC32 flow and wired up by + /// ; null yields the bare timeout message. + /// + internal Func? WaitingForBootloaderTimeoutDetailProvider { get; set; } + + internal FirmwareUpdateState CurrentState { get; private set; } = FirmwareUpdateState.Idle; + + internal string CurrentOperation { get; private set; } = "Idle"; + + internal double LastReportedPercent { get; private set; } + + internal event EventHandler? StateChanged; + + internal void ResetProgress() => LastReportedPercent = 0; + + internal void ReportProgress( + IProgress? progress, + FirmwareUpdateState state, + double percentComplete, + string currentOperation, + long bytesWritten, + long totalBytes) + { + var clampedPercent = Math.Clamp(percentComplete, 0, 100); + LastReportedPercent = clampedPercent; + + progress?.Report(new FirmwareUpdateProgress + { + State = state, + PercentComplete = clampedPercent, + CurrentOperation = currentOperation, + BytesWritten = Math.Max(0, bytesWritten), + TotalBytes = Math.Max(0, totalBytes) + }); + } + + internal void TransitionToState(FirmwareUpdateState nextState, string operation) + { + if (CurrentState == nextState) + { + CurrentOperation = operation; + return; + } + + if (!AllowedTransitions.TryGetValue(CurrentState, out var allowedStates) || + !allowedStates.Contains(nextState)) + { + throw new InvalidOperationException( + $"Invalid firmware update transition: {CurrentState} -> {nextState}."); + } + + var previousState = CurrentState; + CurrentState = nextState; + CurrentOperation = operation; + + Logger.LogInformation( + "Firmware update state transition: {PreviousState} -> {CurrentState} ({Operation})", + previousState, + nextState, + operation); + + StateChanged?.Invoke(_eventSender, new FirmwareUpdateStateChangedEventArgs(previousState, nextState, operation)); + } + + internal void ResetIfTerminalState() + { + if (CurrentState is FirmwareUpdateState.Complete + or FirmwareUpdateState.Failed + or FirmwareUpdateState.Recovered) + { + TransitionToState(FirmwareUpdateState.Idle, "Resetting state for next firmware update operation."); + } + } + + /// + /// Runs , retrying up to times while + /// classifies the failure as retryable. + /// + /// + /// is less than 1. Rejected rather than clamped: a caller asking + /// for zero attempts has a bug, and silently returning success would skip a flash-critical step + /// (erase, program, verify) with no failure signal at all. + /// + internal async Task ExecuteWithRetryAsync( + string operation, + int maxAttempts, + TimeSpan retryDelay, + Func action, + Func isTransient, + CancellationToken cancellationToken) + { + if (maxAttempts < 1) + { + throw new ArgumentOutOfRangeException( + nameof(maxAttempts), + maxAttempts, + $"Operation '{operation}' requires at least one attempt."); + } + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await action(cancellationToken).ConfigureAwait(false); + return; + } + catch (Exception ex) when (attempt < maxAttempts && isTransient(ex)) + { + Logger.LogWarning( + ex, + "Operation '{Operation}' failed on attempt {Attempt}/{MaxAttempts}; retrying in {DelayMs} ms.", + operation, + attempt, + maxAttempts, + retryDelay.TotalMilliseconds); + + await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); + } + } + } + + internal async Task ExecuteWithStateTimeoutAsync( + FirmwareUpdateState state, + string operation, + Func action, + CancellationToken cancellationToken) + { + var timeout = Options.GetStateTimeout(state); + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + try + { + await action(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException(BuildStateTimeoutMessage(state, operation, timeout)); + } + } + + internal async Task ExecuteWithStateTimeoutAsync( + FirmwareUpdateState state, + string operation, + Func> action, + CancellationToken cancellationToken) + { + var timeout = Options.GetStateTimeout(state); + using var timeoutCts = new CancellationTokenSource(timeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + try + { + return await action(linkedCts.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException(BuildStateTimeoutMessage(state, operation, timeout)); + } + } + + /// + /// Reconnects the device's serial transport, polling until it reports connected. + /// This loop is bounded by the caller's state timeout via . + /// + internal async Task WaitForSerialReconnectAsync( + IStreamingDevice device, + CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (device.IsConnected) + { + return; + } + + try + { + device.Connect(); + if (device.IsConnected) + { + return; + } + } + catch (Exception ex) + { + Logger.LogDebug(ex, "Serial reconnect attempt failed."); + } + + await Task.Delay(Options.PollInterval, cancellationToken).ConfigureAwait(false); + } + } + + internal static void EnsureDeviceConnected(IStreamingDevice device) + { + if (!device.IsConnected) + { + throw new InvalidOperationException("Device must be connected before starting firmware update."); + } + } + + // failureSubject names the operation that failed, so the message is honest about what + // the caller actually ran. Diagnostics pass their own subject: a health check or soft + // reset must not report "Firmware update failed" to a consumer (e.g. a recovery dialog) + // that deliberately probed the bootloader *instead of* starting an update. + internal FirmwareUpdateException CreateFirmwareUpdateException( + FirmwareUpdateState failedState, + string failedOperation, + Exception exception, + string? recoveryGuidance = null, + string failureSubject = "Firmware update") + { + if (exception is FirmwareUpdateException firmwareUpdateException) + { + // Already a fully-contextualized firmware exception (carries its own + // guidance). No flash-path operation throws one today, so the + // caller-supplied guidance below never has to be merged in here. + return firmwareUpdateException; + } + + var message = $"{failureSubject} failed in state '{failedState}' while {failedOperation}."; + + return new FirmwareUpdateException( + failedState, + failedOperation, + message, + recoveryGuidance ?? BuildRecoveryGuidance(failedState), + exception); + } + + internal static string BuildRecoveryGuidance(FirmwareUpdateState failedState) + { + return failedState switch + { + FirmwareUpdateState.PreparingDevice => + "Ensure the device is connected over USB and not currently busy streaming.", + FirmwareUpdateState.WaitingForBootloader => + "The device did not enter bootloader mode. Try unplugging/replugging USB, then retry.", + FirmwareUpdateState.Connecting => + "Bootloader was found but HID connection failed. Check USB cable stability and retry.", + FirmwareUpdateState.ErasingFlash => + "Flash erase failed. Retry update; if this persists, power-cycle the device and re-enter bootloader mode.", + FirmwareUpdateState.Programming => + "Programming failed. Retry update while keeping USB connected; device may still be recoverable in bootloader mode.", + FirmwareUpdateState.Verifying => + "Flash verification failed — the device's flash CRC did not match the firmware image. " + + "Retry the update and confirm the expected firmware package was selected.", + FirmwareUpdateState.ReconnectingAfterFlash => + "The firmware was flashed and verified successfully; only reconnecting to the device " + + "afterwards timed out. Unplug and replug USB, then reconnect — the update itself does " + + "not need to be re-run.", + FirmwareUpdateState.JumpingToApp => + "The device did not return to application mode. Power-cycle the device and reconnect.", + _ => + "Retry the update. If repeated failures occur, reconnect the device and attempt manual bootloader recovery." + }; + } + + internal static string FormatExceptionSummary(Exception exception) + { + var builder = new StringBuilder(); + var current = exception; + var firstSegment = true; + + while (current != null) + { + if (!firstSegment) + { + builder.Append(" | Inner "); + } + + builder.Append(current.GetType().Name); + builder.Append(": "); + builder.Append(current.Message); + current = current.InnerException; + firstSegment = false; + } + + return builder.ToString(); + } + + private string BuildStateTimeoutMessage( + FirmwareUpdateState state, + string operation, + TimeSpan timeout) + { + var message = + $"State '{state}' timed out while attempting to {operation} after {timeout.TotalSeconds:F1} seconds."; + + if (state != FirmwareUpdateState.WaitingForBootloader || + WaitingForBootloaderTimeoutDetailProvider is not { } detailProvider) + { + return message; + } + + return $"{message} {detailProvider()}"; + } +} diff --git a/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs b/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs index f6905279..0fb97248 100644 --- a/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs +++ b/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs @@ -1,8 +1,3 @@ -using System.IO; -using System.Runtime.ExceptionServices; -using System.Text; -using System.Text.RegularExpressions; -using Daqifi.Core.Communication.Producers; using Daqifi.Core.Communication.Transport; using Daqifi.Core.Device; using Daqifi.Core.Device.Discovery; @@ -14,117 +9,16 @@ namespace Daqifi.Core.Firmware; /// /// Default firmware update orchestration service for PIC32 and WiFi update flows. /// +/// +/// This type is the public facade. The two independent flows live behind it in +/// (bootloader connect/erase/program/verify/jump and the +/// standalone bootloader diagnostics) and (WINC version probe and +/// external flash-tool orchestration), over the shared state machine, progress and retry plumbing +/// in . The facade owns argument validation, the operation lock +/// that serializes all device I/O, and disposal. +/// public sealed class FirmwareUpdateService : IFirmwareUpdateService, IPic32BootloaderDiagnostics, IDisposable { - // WINC flash tool prompt markers (stdin handshake). - private const string WincBootPromptMarker = "Power cycle WINC and set to bootloader mode"; - private const string WincContinuePromptMarker = "Press any key to continue"; - - // WINC flash tool failure markers. The "transient" set is recoverable by re-running the - // tool once the device has settled into bridge mode; the full set forces a failure verdict. - private const string WifiBridgeIdQueryFailureMarker = "failed to read serial bridge ID query response"; - private const string WifiProgrammerInitFailureMarker = "failed to initialise programming firmware"; - private const string WifiProgrammingFailedMarker = "Programming device failed"; - private const string WifiReadXoFailedMarker = "Reading XO (offset) failed"; - private const string WifiBuildImageFailedMarker = "Building programming image failed"; - - private static readonly IReadOnlyDictionary> AllowedTransitions - = new Dictionary> - { - [FirmwareUpdateState.Idle] = new HashSet - { - FirmwareUpdateState.PreparingDevice, - FirmwareUpdateState.Complete, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.PreparingDevice] = new HashSet - { - FirmwareUpdateState.WaitingForBootloader, - FirmwareUpdateState.Programming, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.WaitingForBootloader] = new HashSet - { - FirmwareUpdateState.Connecting, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.Connecting] = new HashSet - { - FirmwareUpdateState.ErasingFlash, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.ErasingFlash] = new HashSet - { - FirmwareUpdateState.Programming, - FirmwareUpdateState.CleaningUp, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.Programming] = new HashSet - { - FirmwareUpdateState.Verifying, - FirmwareUpdateState.ReconnectingAfterFlash, - FirmwareUpdateState.JumpingToApp, - FirmwareUpdateState.CleaningUp, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.Verifying] = new HashSet - { - FirmwareUpdateState.JumpingToApp, - FirmwareUpdateState.Complete, - FirmwareUpdateState.CleaningUp, - FirmwareUpdateState.Failed - }, - // Terminal leg of the WiFi flow: the WINC image is already flashed and verified, - // so the only outcomes are a completed update or a reconnect failure. No cleanup - // path — there is no half-flashed PIC32 application to re-erase. - [FirmwareUpdateState.ReconnectingAfterFlash] = new HashSet - { - FirmwareUpdateState.Complete, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.JumpingToApp] = new HashSet - { - FirmwareUpdateState.Complete, - FirmwareUpdateState.Failed - }, - // Cleanup (re-erase) runs after a failure in a flash-touching state. - // It either leaves the device in a clean bootloader state (Recovered) - // or, if the re-erase itself fails, falls through to Failed. - [FirmwareUpdateState.CleaningUp] = new HashSet - { - FirmwareUpdateState.Recovered, - FirmwareUpdateState.Failed - }, - [FirmwareUpdateState.Complete] = new HashSet - { - FirmwareUpdateState.Idle - }, - [FirmwareUpdateState.Failed] = new HashSet - { - FirmwareUpdateState.Idle - }, - // Recovered is a terminal failure state (the update did not install - // firmware) but the device is safe; it only resets for the next run. - [FirmwareUpdateState.Recovered] = new HashSet - { - FirmwareUpdateState.Idle - } - }; - - // States where a failure may have left the application flash partially - // written AND the HID bootloader is still connected, so re-erasing to a - // clean bootloader state is both necessary and possible. Failures in - // PreparingDevice/WaitingForBootloader/Connecting happen before any flash - // write; a JumpingToApp failure happens after HID has already been - // disconnected — neither is eligible for cleanup. - private static readonly IReadOnlySet CleanupEligibleStates - = new HashSet - { - FirmwareUpdateState.ErasingFlash, - FirmwareUpdateState.Programming, - FirmwareUpdateState.Verifying - }; - private readonly SemaphoreSlim _operationLock = new(1, 1); // Async-context flag set true while the current logical flow holds @@ -136,20 +30,11 @@ private static readonly IReadOnlySet CleanupEligibleStates // holds, since SemaphoreSlim is not re-entrant. AsyncLocal flows // through await resumptions on different threads. private readonly AsyncLocal _isInsideOperation = new(); - private readonly IHidTransport _hidTransport; - private readonly IExternalProcessRunner _externalProcessRunner; - private readonly ILogger _logger; - private readonly IBootloaderProtocol _bootloaderProtocol; - private readonly IHidDeviceEnumerator _hidDeviceEnumerator; - private readonly IUsbLocationProvider _usbLocationProvider; - private readonly FirmwareUpdateServiceOptions _options; + private readonly FirmwareUpdateContext _context; + private readonly Pic32BootloaderSession _bootloaderSession; + private readonly Pic32FirmwareUpdater _pic32Updater; + private readonly WifiModuleUpdater _wifiUpdater; - private string _currentOperation = "Idle"; - private double _lastReportedPercent; - private int _bootloaderPollAttempts; - private Exception? _lastBootloaderEnumerationError; - private string? _targetBootloaderDevicePath; - private string? _targetBootloaderLocationKey; private bool _disposed; /// @@ -170,15 +55,33 @@ public FirmwareUpdateService( FirmwareUpdateServiceOptions? options = null, IUsbLocationProvider? usbLocationProvider = null) { - _hidTransport = hidTransport ?? throw new ArgumentNullException(nameof(hidTransport)); - FirmwareDownloadService = firmwareDownloadService ?? throw new ArgumentNullException(nameof(firmwareDownloadService)); - _externalProcessRunner = externalProcessRunner ?? throw new ArgumentNullException(nameof(externalProcessRunner)); - _logger = logger ?? NullLogger.Instance; - _bootloaderProtocol = bootloaderProtocol ?? new Pic32BootloaderProtocol(); - _hidDeviceEnumerator = hidDeviceEnumerator ?? new HidLibraryDeviceEnumerator(); - _options = options ?? new FirmwareUpdateServiceOptions(); - _options.Validate(); - _usbLocationProvider = usbLocationProvider ?? UsbLocationProviderFactory.CreateForCurrentPlatform(); + ArgumentNullException.ThrowIfNull(hidTransport); + ArgumentNullException.ThrowIfNull(firmwareDownloadService); + ArgumentNullException.ThrowIfNull(externalProcessRunner); + + FirmwareDownloadService = firmwareDownloadService; + + var resolvedOptions = options ?? new FirmwareUpdateServiceOptions(); + resolvedOptions.Validate(); + + _context = new FirmwareUpdateContext( + this, + logger ?? NullLogger.Instance, + resolvedOptions); + + _bootloaderSession = new Pic32BootloaderSession( + _context, + hidTransport, + bootloaderProtocol ?? new Pic32BootloaderProtocol(), + hidDeviceEnumerator ?? new HidLibraryDeviceEnumerator(), + usbLocationProvider ?? UsbLocationProviderFactory.CreateForCurrentPlatform()); + + _pic32Updater = new Pic32FirmwareUpdater(_context, _bootloaderSession); + _wifiUpdater = new WifiModuleUpdater(_context, externalProcessRunner, firmwareDownloadService); + + // Only the PIC32 flow polls for a bootloader, so it supplies the extra detail + // (VID/PID, poll attempts, requested target) appended to a WaitingForBootloader timeout. + _context.WaitingForBootloaderTimeoutDetailProvider = _bootloaderSession.DescribeBootloaderSearch; } /// @@ -188,10 +91,14 @@ public FirmwareUpdateService( public IFirmwareDownloadService FirmwareDownloadService { get; } /// - public FirmwareUpdateState CurrentState { get; private set; } = FirmwareUpdateState.Idle; + public FirmwareUpdateState CurrentState => _context.CurrentState; /// - public event EventHandler? StateChanged; + public event EventHandler? StateChanged + { + add => _context.StateChanged += value; + remove => _context.StateChanged -= value; + } /// public Task UpdateFirmwareAsync( @@ -244,20 +151,10 @@ public async Task UpdateFirmwareAsync( } var hexLines = File.ReadAllLines(hexFilePath); - var hexRecords = _bootloaderProtocol.ParseHexFile(hexLines); - var totalBytes = hexRecords.Sum(record => (long)record.Length); - if (totalBytes <= 0) - { - throw new InvalidDataException("Firmware HEX file did not contain any writable records."); - } - - // Computed up front (alongside parsing) so the post-programming Verifying - // state can checksum exactly the bytes we programmed via the bootloader - // READ_CRC command. See VerifyFlashContentsAsync. - var crcRegions = _bootloaderProtocol.ComputeCrcRegions(hexLines); + var (hexRecords, crcRegions, totalBytes) = _bootloaderSession.PrepareHexImage(hexLines); await RunExclusiveAsync( - ct => RunPic32UpdateAsync( + ct => _pic32Updater.RunUpdateAsync( device, hexRecords, crcRegions, totalBytes, progress, targetDevicePath, targetLocationKey, ct), cancellationToken).ConfigureAwait(false); } @@ -286,7 +183,7 @@ public async Task UpdateWifiModuleAsync( } await RunExclusiveAsync( - ct => RunWifiUpdateAsync(device, firmwarePath, progress, skipVersionCheck, ct), + ct => _wifiUpdater.RunUpdateAsync(device, firmwarePath, progress, skipVersionCheck, ct), cancellationToken).ConfigureAwait(false); } @@ -315,14 +212,14 @@ public async Task CheckWifiFirmwareStatusAsync( // serialized device-I/O context. if (_isInsideOperation.Value) { - return await CheckWifiFirmwareStatusCoreAsync(device, cancellationToken).ConfigureAwait(false); + return await _wifiUpdater.CheckStatusAsync(device, cancellationToken).ConfigureAwait(false); } await _operationLock.WaitAsync(cancellationToken).ConfigureAwait(false); _isInsideOperation.Value = true; try { - return await CheckWifiFirmwareStatusCoreAsync(device, cancellationToken).ConfigureAwait(false); + return await _wifiUpdater.CheckStatusAsync(device, cancellationToken).ConfigureAwait(false); } finally { @@ -337,50 +234,7 @@ public Task CheckBootloaderHealthAsync( CancellationToken cancellationToken = default) => RunBootloaderDiagnosticAsync( targetDevicePath, - async ct => - { - // Track the phase so a failure is reported against the state it - // occurred in, with the matching recovery guidance — mirroring - // RunPic32UpdateAsync's failedState/failedOperation capture. - var failedState = FirmwareUpdateState.WaitingForBootloader; - var failedOperation = "wait for HID bootloader enumeration"; - try - { - var hidDevice = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.WaitingForBootloader, - failedOperation, - innerCt => WaitForBootloaderDeviceAsync(targetDevicePath, null, innerCt), - ct).ConfigureAwait(false); - - failedState = FirmwareUpdateState.Connecting; - failedOperation = "connect HID transport"; - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Connecting, - failedOperation, - innerCt => ConnectToBootloaderWithRetryAsync(hidDevice, targetDevicePath, null, innerCt), - ct).ConfigureAwait(false); - - failedOperation = "request bootloader version"; - var version = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Connecting, - failedOperation, - RequestBootloaderVersionAsync, - ct).ConfigureAwait(false); - - _logger.LogInformation( - "Standalone bootloader health check succeeded; version {BootloaderVersion}.", version); - return version; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - throw CreateFirmwareUpdateException( - failedState, failedOperation, ex, failureSubject: "Bootloader health check"); - } - }, + ct => _pic32Updater.RunHealthCheckAsync(targetDevicePath, ct), cancellationToken); /// @@ -391,46 +245,8 @@ public async Task ResetBootloaderAsync( targetDevicePath, async ct => { - var failedState = FirmwareUpdateState.WaitingForBootloader; - var failedOperation = "wait for HID bootloader enumeration"; - try - { - var hidDevice = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.WaitingForBootloader, - failedOperation, - innerCt => WaitForBootloaderDeviceAsync(targetDevicePath, null, innerCt), - ct).ConfigureAwait(false); - - failedState = FirmwareUpdateState.Connecting; - failedOperation = "connect HID transport"; - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Connecting, - failedOperation, - innerCt => ConnectToBootloaderWithRetryAsync(hidDevice, targetDevicePath, null, innerCt), - ct).ConfigureAwait(false); - - failedState = FirmwareUpdateState.JumpingToApp; - failedOperation = "issue JMP_TO_APP soft reset"; - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.JumpingToApp, - failedOperation, - innerCt => _hidTransport - .WriteAsync(_bootloaderProtocol.CreateJumpToApplicationMessage(), innerCt), - ct).ConfigureAwait(false); - - _logger.LogInformation( - "Standalone JMP_TO_APP soft reset issued to bootloader without touching flash."); - return true; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - throw; - } - catch (Exception ex) - { - throw CreateFirmwareUpdateException( - failedState, failedOperation, ex, failureSubject: "Bootloader soft reset"); - } + await _pic32Updater.RunSoftResetAsync(targetDevicePath, ct).ConfigureAwait(false); + return true; }, cancellationToken).ConfigureAwait(false); @@ -469,18 +285,15 @@ private async Task RunBootloaderDiagnosticAsync( _isInsideOperation.Value = true; try { - ResetIfTerminalState(); + _context.ResetIfTerminalState(); - if (CurrentState != FirmwareUpdateState.Idle) + if (_context.CurrentState != FirmwareUpdateState.Idle) { throw new InvalidOperationException( - $"Cannot run a bootloader diagnostic while service is in state {CurrentState}."); + $"Cannot run a bootloader diagnostic while service is in state {_context.CurrentState}."); } - _bootloaderPollAttempts = 0; - _lastBootloaderEnumerationError = null; - _targetBootloaderDevicePath = targetDevicePath; - _targetBootloaderLocationKey = null; + _bootloaderSession.ResetTargetingState(targetDevicePath); return await operation(cancellationToken).ConfigureAwait(false); } @@ -489,7 +302,7 @@ private async Task RunBootloaderDiagnosticAsync( // A health check leaves a live HID handle; a soft reset re-enumerates the // device out from under it. Either way, release the handle before returning // so a subsequent update (or diagnostic) starts from a clean transport. - await SafeDisconnectHidAsync().ConfigureAwait(false); + await _bootloaderSession.SafeDisconnectAsync().ConfigureAwait(false); _isInsideOperation.Value = false; _operationLock.Release(); } @@ -505,19 +318,16 @@ private async Task RunExclusiveAsync( _isInsideOperation.Value = true; try { - ResetIfTerminalState(); + _context.ResetIfTerminalState(); - if (CurrentState != FirmwareUpdateState.Idle) + if (_context.CurrentState != FirmwareUpdateState.Idle) { throw new InvalidOperationException( - $"Cannot start firmware update while service is in state {CurrentState}."); + $"Cannot start firmware update while service is in state {_context.CurrentState}."); } - _lastReportedPercent = 0; - _bootloaderPollAttempts = 0; - _lastBootloaderEnumerationError = null; - _targetBootloaderDevicePath = null; - _targetBootloaderLocationKey = null; + _context.ResetProgress(); + _bootloaderSession.ResetTargetingState(); await operation(cancellationToken).ConfigureAwait(false); } finally @@ -527,2113 +337,6 @@ private async Task RunExclusiveAsync( } } - private async Task RunPic32UpdateAsync( - IStreamingDevice device, - IReadOnlyList hexRecords, - IReadOnlyList crcRegions, - long totalBytes, - IProgress? progress, - string? targetDevicePath, - string? targetLocationKey, - CancellationToken cancellationToken) - { - // Recorded so a WaitingForBootloader timeout can name the requested path/location in its message. - _targetBootloaderDevicePath = targetDevicePath; - _targetBootloaderLocationKey = targetLocationKey; - - try - { - TransitionToState(FirmwareUpdateState.PreparingDevice, "Preparing device for PIC32 firmware update."); - ReportProgress(progress, FirmwareUpdateState.PreparingDevice, 0, _currentOperation, 0, totalBytes); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.PreparingDevice, - "prepare the device for bootloader mode", - async stateToken => - { - EnsureDeviceConnected(device); - - if (device.IsStreaming) - { - device.StopStreaming(); - } - - device.Send(ScpiMessageProducer.ForceBootloader); - await Task.Delay(_options.PostForceBootDelay, stateToken).ConfigureAwait(false); - device.Disconnect(); - }, - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.WaitingForBootloader, "Waiting for HID bootloader device."); - ReportProgress(progress, FirmwareUpdateState.WaitingForBootloader, 5, _currentOperation, 0, totalBytes); - - var hidDevice = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.WaitingForBootloader, - "wait for HID bootloader enumeration", - ct => WaitForBootloaderDeviceAsync(targetDevicePath, targetLocationKey, ct), - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.Connecting, "Connecting to HID bootloader."); - ReportProgress(progress, FirmwareUpdateState.Connecting, 10, _currentOperation, 0, totalBytes); - - string version; - try - { - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Connecting, - "connect HID transport", - ct => ConnectToBootloaderWithRetryAsync(hidDevice, targetDevicePath, targetLocationKey, ct), - cancellationToken).ConfigureAwait(false); - - version = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Connecting, - "request bootloader version", - RequestBootloaderVersionAsync, - cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // #298: a dirty HID bootloader handle left behind by another - // program (or a previous run) can make the connect or the - // version health check fail even though the device is - // physically present. Nothing has been erased yet, so it's - // safe to attempt one JMP_TO_APP soft reset to force a clean - // re-enumeration before giving up. - version = await RecoverBootloaderHealthWithSoftResetAsync( - ex, - targetDevicePath, - targetLocationKey, - cancellationToken).ConfigureAwait(false); - } - - _logger.LogInformation("Bootloader version response: {BootloaderVersion}", version); - - TransitionToState(FirmwareUpdateState.ErasingFlash, "Erasing PIC32 flash."); - ReportProgress(progress, FirmwareUpdateState.ErasingFlash, 15, _currentOperation, 0, totalBytes); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.ErasingFlash, - "erase flash", - EraseFlashWithRetryAsync, - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.Programming, "Programming flash records."); - ReportProgress(progress, FirmwareUpdateState.Programming, 20, _currentOperation, 0, totalBytes); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Programming, - "program flash records", - ct => ProgramFlashAsync(hexRecords, totalBytes, progress, ct), - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.Verifying, "Verifying flash contents via CRC."); - ReportProgress(progress, FirmwareUpdateState.Verifying, 92, _currentOperation, totalBytes, totalBytes); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Verifying, - "verify flash contents via CRC", - ct => VerifyFlashContentsAsync(crcRegions, progress, totalBytes, ct), - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.JumpingToApp, "Jumping to application firmware."); - ReportProgress(progress, FirmwareUpdateState.JumpingToApp, 95, _currentOperation, totalBytes, totalBytes); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.JumpingToApp, - "jump to application and reconnect serial transport", - ct => JumpToApplicationAndReconnectAsync(device, ct), - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.Complete, "PIC32 firmware update completed."); - ReportProgress(progress, FirmwareUpdateState.Complete, 100, _currentOperation, totalBytes, totalBytes); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - var canceledState = CurrentState; - _logger.LogWarning("PIC32 firmware update canceled in state {State}.", canceledState); - - // A cancel mid-flash still leaves the device half-flashed, so it must - // be cleaned up just like any other failure in a flash-touching state - // (acceptance criterion #208: never leave a half-flashed device). The - // cleanup runs on its own token, so the already-canceled operation - // token does not abort it. We still rethrow the cancellation. - await CleanUpAfterPic32FailureAsync(canceledState, progress, totalBytes, canceled: true) - .ConfigureAwait(false); - throw; - } - catch (Exception ex) - { - // Capture the state/operation at the moment of failure BEFORE any - // cleanup transitions move us off it — these stay the diagnostic - // "where it broke" context on the thrown exception. - var failedState = CurrentState; - var failedOperation = _currentOperation; - _logger.LogError(ex, "PIC32 firmware update failed in state {State}.", failedState); - - var cleanupOutcome = await CleanUpAfterPic32FailureAsync( - failedState, progress, totalBytes).ConfigureAwait(false); - - throw CreateFirmwareUpdateException(failedState, failedOperation, ex, cleanupOutcome); - } - finally - { - await SafeDisconnectHidAsync().ConfigureAwait(false); - } - } - - /// - /// Describes the terminal disposition of a failed PIC32 update after the - /// optional re-erase cleanup pass, used to tailor the recovery guidance and - /// reflected by the service's terminal state. - /// - private enum Pic32CleanupOutcome - { - /// - /// Cleanup did not apply: the failure occurred before flash was written - /// (PreparingDevice/WaitingForBootloader/Connecting) or after the HID - /// bootloader was disconnected (JumpingToApp). Terminal state: Failed. - /// - NotEligible, - - /// - /// The application flash was re-erased successfully; the device is in a - /// clean bootloader state and can be re-flashed. Terminal state: Recovered. - /// - Recovered, - - /// - /// Cleanup was eligible but could not complete (the HID transport had - /// dropped, or the re-erase itself failed), so the device may be in a - /// half-flashed state. Terminal state: Failed. - /// - CleanupFailed - } - - /// - /// After a PIC32 update failure, re-erases the application flash when the - /// failure left the device half-flashed but still reachable over HID, so it - /// is never abandoned in a partially-programmed state. Drives the - /// CleaningUp → Recovered (success) or → Failed (cleanup failed) terminal - /// transitions and reports them via state/progress events. The update has - /// already failed; this only determines how safely it ends. - /// - private async Task CleanUpAfterPic32FailureAsync( - FirmwareUpdateState failedState, - IProgress? progress, - long totalBytes, - bool canceled = false) - { - var frozenPercent = _lastReportedPercent; - var eligible = CleanupEligibleStates.Contains(failedState); - - if (!eligible || !_hidTransport.IsConnected) - { - // No re-erase will run. Either the failure never touched flash / the - // device is past HID (NotEligible — keep the per-state guidance), or - // a flash-touching failure left the HID transport unusable so we - // cannot re-erase (CleanupFailed — warn that it may be half-flashed). - // Both terminate in Failed. On the cancel path the rethrown - // OperationCanceledException carries no recovery guidance, so this - // terminal event text is the only channel observers get. - var outcome = eligible ? Pic32CleanupOutcome.CleanupFailed : Pic32CleanupOutcome.NotEligible; - - string failedOperation; - if (outcome == Pic32CleanupOutcome.CleanupFailed) - { - failedOperation = canceled - ? "PIC32 firmware update canceled; cleanup re-erase skipped because the HID transport " + - "disconnected — device may be in a half-flashed state." - : "Cleanup re-erase skipped: HID transport disconnected; device may be in a half-flashed state."; - _logger.LogWarning( - "Cannot run firmware re-erase cleanup after failure in {State}: HID transport is no longer " + - "connected; device may be in a half-flashed state.", - failedState); - } - else - { - failedOperation = canceled ? "PIC32 firmware update canceled." : _currentOperation; - } - - TransitionToState(FirmwareUpdateState.Failed, failedOperation); - ReportProgress(progress, FirmwareUpdateState.Failed, frozenPercent, failedOperation, 0, totalBytes); - return outcome; - } - - var cleaningOperation = canceled - ? "Update canceled; re-erasing flash to leave the device in a clean bootloader state." - : "Re-erasing flash to leave the device in a clean bootloader state."; - - try - { - // The CleaningUp notification runs inside the try: a throwing - // StateChanged subscriber or progress sink must land in the catch - // below (CleaningUp → Failed) rather than stranding the machine in - // the non-terminal CleaningUp state, which has no reset path. - TransitionToState(FirmwareUpdateState.CleaningUp, cleaningOperation); - ReportProgress(progress, FirmwareUpdateState.CleaningUp, frozenPercent, cleaningOperation, 0, totalBytes); - _logger.LogInformation( - "Attempting firmware re-erase cleanup after failure in {State}.", failedState); - - // Reuse the same retry-wrapped erase path as the main flow, but on a - // fresh timeout token: the cleanup must run on a best-effort basis - // even if the original operation token was already canceled, and it - // is bounded by the same budget as a normal erase. - using var cleanupCts = new CancellationTokenSource( - _options.GetStateTimeout(FirmwareUpdateState.CleaningUp)); - await EraseFlashWithRetryAsync(cleanupCts.Token).ConfigureAwait(false); - - var recoveredOperation = canceled - ? "Update canceled; flash re-erased — device is in a clean bootloader state and can be re-flashed." - : "Flash re-erased; device is in a clean bootloader state and can be re-flashed."; - TransitionToState(FirmwareUpdateState.Recovered, recoveredOperation); - ReportProgress(progress, FirmwareUpdateState.Recovered, frozenPercent, recoveredOperation, 0, totalBytes); - _logger.LogInformation( - "Firmware re-erase cleanup succeeded; device is in a clean bootloader state."); - return Pic32CleanupOutcome.Recovered; - } - catch (Exception cleanupEx) - { - if (CurrentState == FirmwareUpdateState.Recovered) - { - // The re-erase itself succeeded — a StateChanged subscriber or - // progress sink threw after the Recovered transition committed. - // The device is clean; a consumer callback must not turn that - // into a half-flashed verdict (and Recovered → Failed is not a - // legal transition). - _logger.LogWarning( - cleanupEx, - "A state/progress observer threw after the Recovered transition; cleanup itself succeeded."); - return Pic32CleanupOutcome.Recovered; - } - - const string cleanupFailedOperation = - "Cleanup re-erase failed; device may be in a half-flashed state."; - TransitionToState(FirmwareUpdateState.Failed, cleanupFailedOperation); - ReportProgress(progress, FirmwareUpdateState.Failed, frozenPercent, cleanupFailedOperation, 0, totalBytes); - _logger.LogError( - cleanupEx, - "Firmware re-erase cleanup failed after failure in {State}; device may be half-flashed.", - failedState); - return Pic32CleanupOutcome.CleanupFailed; - } - } - - private async Task RunWifiUpdateAsync( - IStreamingDevice device, - string firmwarePath, - IProgress? progress, - bool skipVersionCheck, - CancellationToken cancellationToken) - { - const long totalBytes = 100; - - try - { - if (!skipVersionCheck - && await IsWifiFirmwareUpToDateAsync(device, progress, cancellationToken).ConfigureAwait(false)) - { - return; - } - - TransitionToState(FirmwareUpdateState.PreparingDevice, "Preparing device for WiFi module update."); - ReportProgress(progress, FirmwareUpdateState.PreparingDevice, 0, _currentOperation, 0, totalBytes); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.PreparingDevice, - "prepare device for WiFi update mode", - async stateToken => - { - EnsureDeviceConnected(device); - - if (device.IsStreaming) - { - device.StopStreaming(); - } - - device.Send(ScpiMessageProducer.SetLanFirmwareUpdateMode); - await Task.Delay(_options.PostLanFirmwareModeDelay, stateToken).ConfigureAwait(false); - device.Disconnect(); - - // The OS does not free the USB-CDC COM handle the instant Disconnect returns. - // Wait so the external WINC flash tool can open the port; without this the tool - // fails to open it and exits in ~1s producing no programming output (caught by - // the output-based success verification below as a failure). - if (_options.PostLanDisconnectPortReleaseDelay > TimeSpan.Zero) - { - await Task.Delay(_options.PostLanDisconnectPortReleaseDelay, stateToken).ConfigureAwait(false); - } - }, - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.Programming, "Running WiFi module flash tool."); - ReportProgress(progress, FirmwareUpdateState.Programming, 20, _currentOperation, 0, totalBytes); - - // Build a fresh request per attempt: the stdin prompt responder carries one-shot - // state (it answers the WINC prompt exactly once), so reusing a request across a - // retry would leave the responder already "spent". The factory takes the per-attempt - // linked token so the prompt-delay wait stays cancellable. - var processResult = await RunWifiFlashToolWithRetryAsync( - ct => BuildWifiProcessRequest(device, firmwarePath, progress, ct), - cancellationToken).ConfigureAwait(false); - - if (processResult.TimedOut) - { - throw new TimeoutException( - $"WiFi flashing process timed out after {_options.WifiProcessTimeout.TotalSeconds:F0} seconds " + - $"(exit code {processResult.ExitCode}). " + - BuildProcessLogExcerpt(processResult)); - } - - // Verify success from the tool's OWN output, not from its exit code or run duration. - // A genuine flash ends with "verify passed" then the success marker; when the tool - // cannot reach the WINC — most often because the device never released the serial port, - // so the tool couldn't open it and bailed in ~1s — it produces none of these. The exit - // code is unreliable in both directions (some WINC script/tool combinations emit failure - // markers yet still exit 0), so the success marker is the authority. - if (!ContainsAny(processResult.StandardOutputLines, _options.WifiFlashSuccessMarker)) - { - throw new IOException( - $"WiFi flashing did not complete successfully — the flash tool never reported " + - $"'{_options.WifiFlashSuccessMarker}'. {DescribeWifiFlashFailure(processResult)} " + - BuildProcessLogExcerpt(processResult)); - } - - // Everything past this point runs on an already-flashed, already-verified WINC image, - // so it gets its own state rather than sharing Verifying with the PIC32 CRC check — - // a reconnect timeout here is environmental, not a bad flash (#398 gap 4). - TransitionToState( - FirmwareUpdateState.ReconnectingAfterFlash, - "Reconnecting device and restoring LAN configuration."); - ReportProgress(progress, FirmwareUpdateState.ReconnectingAfterFlash, 92, _currentOperation, 92, totalBytes); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.ReconnectingAfterFlash, - "reconnect serial transport after WiFi flash", - async stateToken => - { - await Task.Delay(_options.PostWifiReconnectDelay, stateToken).ConfigureAwait(false); - await WaitForSerialReconnectAsync(device, stateToken).ConfigureAwait(false); - device.Send(ScpiMessageProducer.EnableNetworkLan); - device.Send(ScpiMessageProducer.ApplyNetworkLan); - device.Send(ScpiMessageProducer.SaveNetworkLan); - }, - cancellationToken).ConfigureAwait(false); - - TransitionToState(FirmwareUpdateState.Complete, "WiFi module update completed."); - ReportProgress(progress, FirmwareUpdateState.Complete, 100, _currentOperation, totalBytes, totalBytes); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - TransitionToState(FirmwareUpdateState.Failed, "WiFi module update canceled."); - ReportProgress(progress, FirmwareUpdateState.Failed, _lastReportedPercent, _currentOperation, 0, totalBytes); - _logger.LogWarning("WiFi module update canceled."); - throw; - } - catch (Exception ex) - { - var failedState = CurrentState; - var failedOperation = _currentOperation; - TransitionToState(FirmwareUpdateState.Failed, failedOperation); - ReportProgress(progress, FirmwareUpdateState.Failed, _lastReportedPercent, failedOperation, 0, totalBytes); - _logger.LogError(ex, "WiFi module update failed in state {State}.", failedState); - - throw CreateFirmwareUpdateException(failedState, failedOperation, ex); - } - } - - private async Task IsWifiFirmwareUpToDateAsync( - IStreamingDevice device, - IProgress? progress, - CancellationToken cancellationToken) - { - // Internal callsite: in addition to deciding the boolean, we must - // transition to Complete + report 100% progress so the caller's - // single UpdateWifiModuleAsync(...) call observes the same end-state - // as a successful flash. CheckWifiFirmwareStatusAsync (the public - // planning API) does not have that side effect — its callers own - // their own logging / UI transitions. - var status = await CheckWifiFirmwareStatusCoreAsync(device, cancellationToken).ConfigureAwait(false); - - switch (status.Reason) - { - case WifiFirmwareStatusReason.UpdateAvailable: - _logger.LogInformation( - "WiFi firmware update available: device has {DeviceVersion}, latest is {LatestVersion}.", - status.CurrentChipInfo!.FwVersion, - status.LatestRelease!.TagName); - return false; - - case WifiFirmwareStatusReason.UpToDate: - var message = $"WiFi firmware is already up to date (device: {status.CurrentChipInfo!.FwVersion}, latest: {status.LatestRelease!.TagName})."; - _logger.LogInformation(message); - TransitionToState(FirmwareUpdateState.Complete, message); - ReportProgress(progress, FirmwareUpdateState.Complete, 100, message, 100, 100); - return true; - - default: - // DeviceDoesNotSupportLanQuery, ChipInfoUnavailable, - // LanNotInitialized, LatestReleaseUnavailable, - // VersionUnparseable — proceed with the flash conservatively. - return false; - } - } - - private async Task CheckWifiFirmwareStatusCoreAsync( - IStreamingDevice device, - CancellationToken cancellationToken) - { - if (device is not ILanChipInfoProvider lanChipInfoProvider) - { - return new WifiFirmwareStatus - { - IsUpToDate = false, - Reason = WifiFirmwareStatusReason.DeviceDoesNotSupportLanQuery, - }; - } - - // Closes #301: right after a PIC32 reflash the WINC module comes back - // powered off, so the first GETChipInfo? probe below would always fail, - // report ChipInfoUnavailable, and send the caller into a needless - // multi-minute WiFi reflash. Powering it on first (mirroring what - // daqifi-desktop's FirmwareUpdateCoordinator does today) closes that gap. - // Skipped when the device isn't connected — Send would throw, and a - // disconnected device will fail the chip-info probe regardless. - if (_options.PowerOnWifiModuleBeforeProbe && device.IsConnected) - { - // Observe cancellation before this state-changing Send: a - // pre-cancelled call must not power on the device before the - // cancellation is surfaced to the caller. - cancellationToken.ThrowIfCancellationRequested(); - - try - { - device.Send(ScpiMessageProducer.TurnDeviceOn); - if (_options.PowerOnWifiModuleSettleDelay > TimeSpan.Zero) - { - await Task.Delay(_options.PowerOnWifiModuleSettleDelay, cancellationToken).ConfigureAwait(false); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - // Best-effort: the chip-info probe below has its own bounded - // retry and gracefully degrades to ChipInfoUnavailable, so a - // failure to send the power-on command must not abort the - // whole status check. Skip the settle delay too — there is - // nothing to settle if the send itself failed. - _logger.LogDebug(ex, "Failed to send WINC power-on command before chip-info probe; continuing without it."); - } - } - - // Bounded retry for the LAN chip-info probe (closes #144). Right - // after a PIC32 firmware update the application is up while WiFi - // is still finishing startup, so the first chip-info query can - // transiently fail; without retry, the WiFi version decision - // would short-circuit to ChipInfoUnavailable and flow on to a - // multi-minute reflash of already-current WiFi firmware. The - // retry budget is bounded (LanChipInfoMaxAttempts × RetryDelay) - // and observes cancellation between attempts. - var (chipInfo, wasLanNotInitialized) = await TryGetLanChipInfoWithRetryAsync( - device, lanChipInfoProvider, cancellationToken).ConfigureAwait(false); - if (chipInfo == null) - { - return new WifiFirmwareStatus - { - IsUpToDate = false, - Reason = wasLanNotInitialized - ? WifiFirmwareStatusReason.LanNotInitialized - : WifiFirmwareStatusReason.ChipInfoUnavailable, - }; - } - - FirmwareReleaseInfo? latestWifi; - try - { - latestWifi = await FirmwareDownloadService - .GetLatestWifiReleaseAsync(cancellationToken) - .ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogDebug(ex, "Failed to query latest WiFi firmware release; reporting status as LatestReleaseUnavailable."); - return new WifiFirmwareStatus - { - CurrentChipInfo = chipInfo, - IsUpToDate = false, - Reason = WifiFirmwareStatusReason.LatestReleaseUnavailable, - }; - } - - if (latestWifi == null) - { - return new WifiFirmwareStatus - { - CurrentChipInfo = chipInfo, - IsUpToDate = false, - Reason = WifiFirmwareStatusReason.LatestReleaseUnavailable, - }; - } - - // Only the device-reported version needs parsing; latestWifi.Version - // is already a strongly-typed FirmwareVersion from FirmwareDownloadService. - // Re-parsing TagName would risk divergence from the canonical Version - // (different tag prefix conventions, etc.) and cost an extra parse. - if (!FirmwareVersion.TryParse(chipInfo.FwVersion, out var deviceVersion)) - { - return new WifiFirmwareStatus - { - CurrentChipInfo = chipInfo, - LatestRelease = latestWifi, - IsUpToDate = false, - Reason = WifiFirmwareStatusReason.VersionUnparseable, - }; - } - - var isCurrent = deviceVersion >= latestWifi.Version; - return new WifiFirmwareStatus - { - CurrentChipInfo = chipInfo, - LatestRelease = latestWifi, - IsUpToDate = isCurrent, - Reason = isCurrent ? WifiFirmwareStatusReason.UpToDate : WifiFirmwareStatusReason.UpdateAvailable, - }; - } - - private async Task<(LanChipInfo? ChipInfo, bool WasLanNotInitialized)> TryGetLanChipInfoWithRetryAsync( - IStreamingDevice device, - ILanChipInfoProvider lanChipInfoProvider, - CancellationToken cancellationToken) - { - var maxAttempts = Math.Max(1, _options.LanChipInfoMaxAttempts); - var retryDelay = _options.LanChipInfoRetryDelay; - var totalTimeout = _options.LanChipInfoTotalTimeout; - - // Tracks the most recent failure's classification (reset on any - // non-LanNotInitialized outcome) so the caller can report the - // specific WifiFirmwareStatusReason.LanNotInitialized only when - // that was genuinely the terminal condition, not stale from an - // earlier attempt. Sent at most once per probe (closes #203) — - // repeatedly kicking APPLY would tear down and re-init the WINC - // on every failed attempt, risking disruption of an already- - // associated WiFi link for no additional benefit. - var lastFailureWasLanNotInitialized = false; - var hasSentLanApply = false; - - // Wall-clock budget guards against the pathological case where - // attempt-count × per-attempt-timeout + retry-delay sum vastly - // exceeds the configured retry budget (e.g., 3 × 2s device timeout - // + 2 × 2s delay = ~10s while _operationLock is held). Linking - // the caller's CT preserves cancellation semantics; the timeout - // CTS just adds a deadline. - using var timeoutCts = new CancellationTokenSource(totalTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, timeoutCts.Token); - var linkedToken = linkedCts.Token; - - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - try - { - linkedToken.ThrowIfCancellationRequested(); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - _logger.LogDebug( - "LAN chip-info probe hit total timeout ({Timeout}) before attempt {Attempt}/{Max}.", - totalTimeout, - attempt, - maxAttempts); - return (null, lastFailureWasLanNotInitialized); - } - - try - { - var chipInfo = await lanChipInfoProvider.GetLanChipInfoAsync(linkedToken).ConfigureAwait(false); - if (chipInfo != null) - { - if (attempt > 1) - { - _logger.LogDebug( - "LAN chip-info query succeeded on attempt {Attempt}/{Max}.", - attempt, - maxAttempts); - } - return (chipInfo, false); - } - lastFailureWasLanNotInitialized = false; - _logger.LogDebug( - "LAN chip-info query returned null on attempt {Attempt}/{Max}.", - attempt, - maxAttempts); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - _logger.LogDebug( - "LAN chip-info probe hit total timeout ({Timeout}) during attempt {Attempt}/{Max}.", - totalTimeout, - attempt, - maxAttempts); - return (null, lastFailureWasLanNotInitialized); - } - catch (LanNotInitializedException ex) - { - lastFailureWasLanNotInitialized = true; - _logger.LogDebug( - ex, - "LAN chip-info query on attempt {Attempt}/{Max} reported the WINC state machine is not initialized.", - attempt, - maxAttempts); - - if (_options.KickLanApplyOnNotInitialized && !hasSentLanApply && device.IsConnected) - { - // Observe cancellation before this state-changing Send, mirroring - // the WINC power-on guard above: a cancelled probe must not still - // kick APPLY on the device. Uses the caller's token (not the - // linked timeout token) so a total-timeout expiry alone doesn't - // suppress a kick the caller never actually asked to cancel. - cancellationToken.ThrowIfCancellationRequested(); - - hasSentLanApply = true; - try - { - device.Send(ScpiMessageProducer.ApplyNetworkLan); - _logger.LogDebug("Sent LAN:APPLY to initialize the WINC state machine after a not-initialized chip-info response."); - } - catch (Exception sendEx) when (sendEx is not OperationCanceledException) - { - // Best-effort: falling through to the normal retry delay/loop - // below still gives the device a chance to recover on its own. - _logger.LogDebug(sendEx, "Failed to send LAN:APPLY after a not-initialized chip-info response; continuing retry loop without it."); - } - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - lastFailureWasLanNotInitialized = false; - _logger.LogDebug( - ex, - "LAN chip-info query failed on attempt {Attempt}/{Max}.", - attempt, - maxAttempts); - } - - if (attempt < maxAttempts) - { - try - { - await Task.Delay(retryDelay, linkedToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - _logger.LogDebug( - "LAN chip-info probe hit total timeout ({Timeout}) during retry delay after attempt {Attempt}/{Max}.", - totalTimeout, - attempt, - maxAttempts); - return (null, lastFailureWasLanNotInitialized); - } - } - } - - _logger.LogDebug( - "LAN chip-info query exhausted {Max} attempts; reporting status as {Reason}.", - maxAttempts, - lastFailureWasLanNotInitialized ? WifiFirmwareStatusReason.LanNotInitialized : WifiFirmwareStatusReason.ChipInfoUnavailable); - return (null, lastFailureWasLanNotInitialized); - } - - private ExternalProcessRequest BuildWifiProcessRequest( - IStreamingDevice device, - string firmwarePath, - IProgress? progress, - CancellationToken cancellationToken) - { - var toolPath = ResolveWifiToolPath(firmwarePath); - var port = ResolveWifiPort(device); - - var toolArguments = _options.WifiFlashToolArgumentsTemplate - .Replace("{port}", QuoteArgument(port), StringComparison.Ordinal) - .Replace("{firmwarePath}", QuoteArgument(firmwarePath), StringComparison.Ordinal); - - var executablePath = toolPath; - var executableArguments = toolArguments; - - var extension = Path.GetExtension(toolPath); - if ((extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase) || - extension.Equals(".bat", StringComparison.OrdinalIgnoreCase)) && - OperatingSystem.IsWindows()) - { - executablePath = "cmd.exe"; - executableArguments = $"/c \"{toolPath}\" {toolArguments}"; - } - - // Tracks the live device-flash phase (write/read/verify) from the tool's block-address - // output so the bar advances across the multi-minute flash, instead of latching to the - // image-build phase's "100%" lines and freezing. See WifiFlashProgressParser. - var progressParser = new WifiFlashProgressParser(); - var progressLock = new object(); - - return new ExternalProcessRequest - { - FileName = executablePath, - Arguments = executableArguments, - WorkingDirectory = Path.GetDirectoryName(toolPath), - Timeout = _options.WifiProcessTimeout, - OnStandardOutputLine = line => - { - _logger.LogInformation("WiFi flash output: {Line}", line); - - double processPercent; - lock (progressLock) - { - var updated = progressParser.Observe(line); - if (!updated.HasValue) - { - return; - } - - processPercent = updated.Value; - } - - // Map the 0-100 device-flash percent into the Programming state's 20-90 overall band. - var overallPercent = 20 + (processPercent * 0.70); - ReportProgress( - progress, - FirmwareUpdateState.Programming, - overallPercent, - line, - (long)Math.Round(processPercent), - 100); - }, - OnStandardErrorLine = line => _logger.LogWarning("WiFi flash stderr: {Line}", line), - StandardInputResponseFactory = BuildWifiPromptResponder(cancellationToken) - }; - } - - /// - /// Builds the stdin responder for the WINC flash tool's interactive prompts. At the - /// "Power cycle WINC" prompt it fires the optional bridge-activation callback, waits - /// so the firmware can - /// finish bridge-mode init, then sends the empty continue line. The returned delegate carries - /// one-shot state, so a fresh responder must be built for each flash attempt. - /// - /// - /// The flash run's linked token (state timeout + caller cancellation). The prompt-response wait - /// observes it so a timeout or cancel unblocks the output-pump thread promptly instead of - /// sleeping out the full delay after the process has been killed. - /// - private Func BuildWifiPromptResponder(CancellationToken cancellationToken) - { - var continueSignalSent = false; - - return line => - { - if (line.Contains(WincBootPromptMarker, StringComparison.OrdinalIgnoreCase)) - { - if (continueSignalSent) - { - return null; - } - - if (_options.WifiBridgeActivationCallback is { } activate) - { - _logger.LogInformation("Activating WiFi bridge mode before WINC programming."); - try - { - activate(); - _logger.LogInformation("Bridge activation callback completed; waiting for firmware bridge init."); - } - catch (Exception ex) - { - // The bridge activation is best-effort — a failure here must not abort the - // flash; the tool may still reach the WINC and the success verification is - // the source of truth for the outcome. - _logger.LogWarning(ex, "WiFi bridge activation callback threw; continuing with the flash."); - } - } - else - { - _logger.LogInformation("WiFi flash tool requested WINC power-cycle; waiting before sending continue signal."); - } - - if (_options.WincBootPromptResponseDelay > TimeSpan.Zero) - { - // The responder runs inline on the process output-pump thread and the tool - // blocks on stdin until we return, so the wait must be synchronous (a fire-and- - // forget Task.Delay would not pause it). Block on a cancellable delay so a run - // timeout / cancel unblocks the pump immediately; if canceled, skip the continue - // signal — the process is being torn down anyway. - try - { - Task.Delay(_options.WincBootPromptResponseDelay, cancellationToken) - .GetAwaiter() - .GetResult(); - } - catch (OperationCanceledException) - { - _logger.LogDebug("WINC prompt-response wait canceled; skipping the continue signal."); - return null; - } - } - - continueSignalSent = true; - _logger.LogInformation("Sending continue signal to WiFi flash tool."); - return string.Empty; - } - - if (!continueSignalSent && - line.Contains(WincContinuePromptMarker, StringComparison.OrdinalIgnoreCase)) - { - continueSignalSent = true; - _logger.LogInformation("Sending continue signal to WiFi flash tool."); - return string.Empty; - } - - return null; - }; - } - - private async Task RunWifiFlashToolWithRetryAsync( - Func requestFactory, - CancellationToken cancellationToken) - { - var attempts = Math.Max(1, _options.WifiFlashAttempts); - ExternalProcessResult result = null!; - - for (var attempt = 1; attempt <= attempts; attempt++) - { - // Build the request inside the state-timeout lambda so the responder closes over the - // linked token (state timeout + caller cancellation) and its prompt-delay wait unblocks - // when the run is canceled or times out. - result = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Programming, - "execute WiFi flash process", - ct => _externalProcessRunner.RunAsync(requestFactory(ct), ct), - cancellationToken).ConfigureAwait(false); - - // A timeout or a verified success ends the loop; so does a non-transient failure, - // since re-running the tool only helps when the device hadn't yet settled into bridge - // mode. Only a transient bridge-init failure with attempts remaining triggers a retry. - if (result.TimedOut || - ContainsAny(result.StandardOutputLines, _options.WifiFlashSuccessMarker) || - attempt >= attempts || - !IsTransientWifiFlashFailure(result)) - { - return result; - } - - _logger.LogWarning( - "WiFi flash tool reported a transient bridge-init failure on attempt {Attempt}/{Attempts}; retrying in {DelayMs} ms.", - attempt, - attempts, - _options.WifiFlashRetryDelay.TotalMilliseconds); - await Task.Delay(_options.WifiFlashRetryDelay, cancellationToken).ConfigureAwait(false); - } - - return result; - } - - /// - /// True when the result shows a transient bridge-init failure — the device hadn't finished - /// entering bridge mode when the tool issued its first query. Re-running the tool once the - /// device has settled typically succeeds, so these (and only these) are retried. - /// - private static bool IsTransientWifiFlashFailure(ExternalProcessResult result) - { - // Retry ONLY on the bridge-init markers — the device hadn't finished entering bridge mode - // when the tool issued its first query, which a re-run fixes. These markers co-occur with - // the generic "Programming device failed" / "Reading XO failed" lines in the real failure - // output, so keying on them alone still catches the transient case without retrying a - // genuine (non-recoverable) programming failure — which would only delay the real error and - // needlessly re-fire the bridge-activation callback. Scan both streams since tool/script - // versions route these lines inconsistently. - return ContainsAny(result.StandardErrorLines, WifiBridgeIdQueryFailureMarker, WifiProgrammerInitFailureMarker) - || ContainsAny(result.StandardOutputLines, WifiBridgeIdQueryFailureMarker, WifiProgrammerInitFailureMarker); - } - - /// - /// Produces a short human-readable reason for a flash that did not report the success marker, - /// distinguishing "the tool never opened the port" from a device-reported programming failure. - /// - private static string DescribeWifiFlashFailure(ExternalProcessResult result) - { - // A "Building programming image failed" is a LOCAL image-build failure that happens before - // any on-device flashing, so it must not be reported as a device-reachability failure. - if (ContainsAny(result.StandardErrorLines, WifiBuildImageFailedMarker) || - ContainsAny(result.StandardOutputLines, WifiBuildImageFailedMarker)) - { - return "The flash tool failed to build the programming image (before contacting the device)."; - } - - // Markers that imply the tool actually reached the device. Scan both streams — tool/script - // versions route these to stdout vs stderr inconsistently. - if (ContainsAny( - result.StandardErrorLines, - WifiBridgeIdQueryFailureMarker, - WifiProgrammerInitFailureMarker, - WifiProgrammingFailedMarker, - WifiReadXoFailedMarker) || - ContainsAny( - result.StandardOutputLines, - WifiBridgeIdQueryFailureMarker, - WifiProgrammerInitFailureMarker, - WifiProgrammingFailedMarker, - WifiReadXoFailedMarker)) - { - return "The flash tool reached the device but reported a programming failure."; - } - - // "No output" must consider BOTH streams — some failure modes (tool/port errors) print - // only to stderr, so checking stdout alone would mislabel them as "no output". - if (result.StandardOutputLines.Count == 0 && result.StandardErrorLines.Count == 0) - { - return "The flash tool produced no output — it likely could not open the serial port " + - "(the device may not have released it)."; - } - - if (result.StandardOutputLines.Count == 0 && result.StandardErrorLines.Count > 0) - { - return $"The flash tool wrote only to stderr and never programmed the device (exit code {result.ExitCode})."; - } - - return $"The flash tool exited with code {result.ExitCode} without completing the program."; - } - - private static bool ContainsAny(IReadOnlyList lines, params string[] markers) - { - foreach (var line in lines) - { - foreach (var marker in markers) - { - if (line.Contains(marker, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - - return false; - } - - private string ResolveWifiToolPath(string firmwarePath) - { - if (File.Exists(firmwarePath)) - { - return firmwarePath; - } - - if (Directory.Exists(firmwarePath)) - { - var matches = Directory.GetFiles( - firmwarePath, - _options.WifiFlashToolFileName, - SearchOption.AllDirectories); - - if (matches.Length == 0) - { - throw new FileNotFoundException( - $"Could not locate '{_options.WifiFlashToolFileName}' under '{firmwarePath}'."); - } - - return matches[0]; - } - - throw new FileNotFoundException("WiFi firmware path was not found.", firmwarePath); - } - - private string ResolveWifiPort(IStreamingDevice device) - { - if (!string.IsNullOrWhiteSpace(_options.WifiPortOverride)) - { - return _options.WifiPortOverride; - } - - if (!string.IsNullOrWhiteSpace(device.Name)) - { - return device.Name; - } - - throw new InvalidOperationException("Unable to resolve a serial port name for WiFi update."); - } - - private static string QuoteArgument(string value) - { - if (string.IsNullOrEmpty(value)) - { - return "\"\""; - } - - var escaped = value.Replace("\"", "\\\"", StringComparison.Ordinal); - return escaped.IndexOfAny([' ', '\t']) >= 0 - ? $"\"{escaped}\"" - : escaped; - } - - private async Task WaitForBootloaderDeviceAsync( - string? targetDevicePath, - string? targetLocationKey, - CancellationToken cancellationToken) - { - // A device path's physical location can't change while it stays enumerated, so caching - // per call (across poll iterations, not across separate update runs) avoids re-issuing a - // WMI query for the same candidate on every poll while targeting by location. - var locationCache = new Dictionary(StringComparer.Ordinal); - - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - - _bootloaderPollAttempts++; - - IReadOnlyList devices; - try - { - devices = await _hidDeviceEnumerator - .EnumerateAsync(_options.BootloaderVendorId, _options.BootloaderProductId, cancellationToken) - .ConfigureAwait(false); - _lastBootloaderEnumerationError = null; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _lastBootloaderEnumerationError = ex; - throw new InvalidOperationException( - $"HID enumeration failed while searching for bootloader device " + - $"VID=0x{_options.BootloaderVendorId:X4}, PID=0x{_options.BootloaderProductId:X4} " + - $"on poll attempt {_bootloaderPollAttempts}.", - ex); - } - - // Multiple identical bootloaders can be enumerated at once; when a specific one was - // requested, match it by path (the rest stay held by the caller). Otherwise, if a - // location key was requested, resolve each candidate's physical-location key and match - // on that — this is what lets a caller target the bootloader a device rebooted INTO, - // before its device path exists, using the location it observed while the device was - // still in serial/app mode. Otherwise take the first match, preserving the - // single-device behavior. - // Ordinal (case-sensitive): a device path is an OS identifier and, in this flow, comes from - // the same in-process HID enumeration (via IHidPlatform) the caller used to obtain targetDevicePath. - var match = targetDevicePath != null - ? devices.FirstOrDefault(d => - string.Equals(d.DevicePath, targetDevicePath, StringComparison.Ordinal)) - : targetLocationKey != null - ? devices.FirstOrDefault(d => - string.Equals( - ResolveLocationCached(d.DevicePath, locationCache), - targetLocationKey, - StringComparison.Ordinal)) - : devices.FirstOrDefault(); - if (match != null) - { - return match; - } - - await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false); - } - } - - private string? ResolveLocationCached(string devicePath, Dictionary cache) - { - if (cache.TryGetValue(devicePath, out var cached)) - { - return cached; - } - - var resolved = _usbLocationProvider.GetLocationKey(devicePath); - cache[devicePath] = resolved; - return resolved; - } - - private async Task ConnectToBootloaderWithRetryAsync( - HidDeviceInfo bootloaderDevice, - string? targetDevicePath, - string? targetLocationKey, - CancellationToken cancellationToken) - { - await ExecuteWithRetryAsync( - "connect HID bootloader", - _options.HidConnectRetryCount, - _options.HidConnectRetryDelay, - async ct => - { - if (_hidTransport.IsConnected) - { - await _hidTransport.DisconnectAsync().ConfigureAwait(false); - } - - // When a specific device was requested (by path or by location, several identical - // bootloaders present), connect to that exact device by path — bootloaderDevice was - // already matched on the requested criterion in WaitForBootloaderDeviceAsync. - // Otherwise fall back to VID/PID(+serial) first-match for the single-device case. - if (targetDevicePath != null || targetLocationKey != null) - { - await _hidTransport - .ConnectByPathAsync(bootloaderDevice.DevicePath, ct) - .ConfigureAwait(false); - } - else - { - await _hidTransport.ConnectAsync( - _options.BootloaderVendorId, - _options.BootloaderProductId, - bootloaderDevice.SerialNumber, - ct).ConfigureAwait(false); - } - }, - ex => ex is IOException or TimeoutException or InvalidOperationException, - cancellationToken).ConfigureAwait(false); - } - - /// - /// Recovers from a failed health - /// check (bad connect or a garbage version response) by issuing one - /// JMP_TO_APP soft reset, waiting for the bootloader to re-enumerate, - /// and retrying the connect + version check exactly once. See #298: the - /// observed failure is a dirty HID bootloader handle left behind by another - /// program, which a clean reset clears without touching flash. - /// - private async Task RecoverBootloaderHealthWithSoftResetAsync( - Exception originalFailure, - string? targetDevicePath, - string? targetLocationKey, - CancellationToken cancellationToken) - { - _logger.LogWarning( - originalFailure, - "Bootloader connect/health-check failed in {State}; attempting a JMP_TO_APP soft-reset recovery before giving up.", - FirmwareUpdateState.Connecting); - - try - { - // Best-effort: the handle may already be unusable (that's often why - // the health check failed in the first place), so a write failure - // here just falls through to the original failure below rather than - // surfacing a new unhandled exception. - if (_hidTransport.IsConnected) - { - await _hidTransport - .WriteAsync(_bootloaderProtocol.CreateJumpToApplicationMessage(), cancellationToken) - .ConfigureAwait(false); - } - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning( - ex, - "JMP_TO_APP soft-reset write failed; the bootloader handle is likely already unusable."); - // Rethrow via ExceptionDispatchInfo (not `throw originalFailure;`) so the - // original exception's stack trace still points at the actual - // connect/health-check failure site, not this recovery method. - ExceptionDispatchInfo.Capture(originalFailure).Throw(); - throw; // unreachable; satisfies flow analysis - } - finally - { - await SafeDisconnectHidAsync().ConfigureAwait(false); - } - - try - { - var recoveredDevice = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.WaitingForBootloader, - "wait for HID bootloader re-enumeration after soft reset", - ct => WaitForBootloaderDeviceAsync(targetDevicePath, targetLocationKey, ct), - cancellationToken).ConfigureAwait(false); - - await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Connecting, - "reconnect HID transport after soft reset", - ct => ConnectToBootloaderWithRetryAsync(recoveredDevice, targetDevicePath, targetLocationKey, ct), - cancellationToken).ConfigureAwait(false); - - var version = await ExecuteWithStateTimeoutAsync( - FirmwareUpdateState.Connecting, - "request bootloader version after soft reset", - RequestBootloaderVersionAsync, - cancellationToken).ConfigureAwait(false); - - _logger.LogInformation("Bootloader health restored after JMP_TO_APP soft reset."); - return version; - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogWarning( - ex, - "Bootloader is still unhealthy after the JMP_TO_APP soft-reset recovery attempt."); - ExceptionDispatchInfo.Capture(originalFailure).Throw(); - throw; // unreachable; satisfies flow analysis - } - } - - private async Task RequestBootloaderVersionAsync(CancellationToken cancellationToken) - { - await _hidTransport - .WriteAsync(_bootloaderProtocol.CreateRequestVersionMessage(), cancellationToken) - .ConfigureAwait(false); - - var response = await _hidTransport - .ReadAsync(_options.BootloaderResponseTimeout, cancellationToken) - .ConfigureAwait(false); - - var version = _bootloaderProtocol.DecodeVersionResponse(response); - if (string.Equals(version, "Error", StringComparison.OrdinalIgnoreCase)) - { - throw new InvalidDataException("Bootloader returned an invalid version response."); - } - - return version; - } - - private async Task EraseFlashWithRetryAsync(CancellationToken cancellationToken) - { - await ExecuteWithRetryAsync( - "erase flash", - _options.FlashWriteRetryCount, - _options.FlashWriteRetryDelay, - async ct => - { - await _hidTransport - .WriteAsync(_bootloaderProtocol.CreateEraseFlashMessage(), ct) - .ConfigureAwait(false); - - var response = await _hidTransport - .ReadAsync(_options.BootloaderResponseTimeout, ct) - .ConfigureAwait(false); - - if (!_bootloaderProtocol.DecodeEraseFlashResponse(response)) - { - throw new InvalidDataException("Bootloader erase acknowledgment was invalid."); - } - }, - ex => ex is IOException or TimeoutException or InvalidDataException, - cancellationToken).ConfigureAwait(false); - } - - private async Task ProgramFlashAsync( - IReadOnlyList hexRecords, - long totalBytes, - IProgress? progress, - CancellationToken cancellationToken) - { - long bytesWritten = 0; - for (var index = 0; index < hexRecords.Count; index++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var record = hexRecords[index]; - await ExecuteWithRetryAsync( - $"program flash record {index + 1}", - _options.FlashWriteRetryCount, - _options.FlashWriteRetryDelay, - async ct => - { - var message = _bootloaderProtocol.CreateProgramFlashMessage(record); - await _hidTransport.WriteAsync(message, ct).ConfigureAwait(false); - - var response = await _hidTransport - .ReadAsync(_options.BootloaderResponseTimeout, ct) - .ConfigureAwait(false); - - if (!_bootloaderProtocol.DecodeProgramFlashResponse(response)) - { - throw new InvalidDataException( - $"Bootloader program acknowledgment was invalid for record {index + 1}."); - } - }, - ex => ex is IOException or TimeoutException or InvalidDataException, - cancellationToken).ConfigureAwait(false); - - bytesWritten += record.Length; - var completion = totalBytes <= 0 ? 90 : 20 + (bytesWritten / (double)totalBytes * 70); - ReportProgress( - progress, - FirmwareUpdateState.Programming, - completion, - $"Programming record {index + 1} of {hexRecords.Count}", - bytesWritten, - totalBytes); - } - } - - private async Task VerifyFlashContentsAsync( - IReadOnlyList crcRegions, - IProgress? progress, - long totalBytes, - CancellationToken cancellationToken) - { - if (crcRegions.Count == 0) - { - // No flashable regions to verify (degenerate/empty image). Fall back - // to confirming the bootloader is still responsive so we never jump - // to an application we couldn't reach over HID. - var version = await RequestBootloaderVersionAsync(cancellationToken).ConfigureAwait(false); - _logger.LogInformation( - "No flash CRC regions to verify; confirmed bootloader liveness: {BootloaderVersion}.", - version); - return; - } - - for (var index = 0; index < crcRegions.Count; index++) - { - cancellationToken.ThrowIfCancellationRequested(); - - var region = crcRegions[index]; - await ExecuteWithRetryAsync( - $"read flash CRC for region {index + 1} at 0x{region.Address:X8}", - _options.FlashWriteRetryCount, - _options.FlashWriteRetryDelay, - async ct => - { - await _hidTransport - .WriteAsync(_bootloaderProtocol.CreateReadCrcMessage(region.Address, region.Length), ct) - .ConfigureAwait(false); - - var response = await _hidTransport - .ReadAsync(_options.BootloaderResponseTimeout, ct) - .ConfigureAwait(false); - - ushort actualCrc; - try - { - actualCrc = _bootloaderProtocol.DecodeReadCrcResponse(response); - } - catch (InvalidDataException ex) - { - // A malformed / framing-corrupt READ_CRC frame is a - // transport-level fault, not evidence of bad flash. Surface - // it as transient (like a HID read error) so a one-off USB - // glitch is retried rather than failing the whole update — - // consistent with how the erase/program steps treat - // InvalidDataException. - throw new IOException( - $"READ_CRC response for region {index + 1} at 0x{region.Address:X8} was malformed; " + - "treating as a transient transport fault.", - ex); - } - - if (actualCrc != region.ExpectedCrc) - { - // A genuine CRC mismatch is deterministic — the flash does - // not match the image. Throw InvalidDataException, which the - // retry predicate excludes, so verification fails fast into - // the failure/cleanup path rather than masking a bad flash - // behind retries. - throw new InvalidDataException( - $"Flash CRC mismatch in region {index + 1} at 0x{region.Address:X8} " + - $"(length {region.Length}): expected 0x{region.ExpectedCrc:X4}, " + - $"device reported 0x{actualCrc:X4}."); - } - }, - // Retry transient transport faults: HID read errors, timeouts, and - // malformed frames (wrapped as IOException above). A CRC mismatch - // throws InvalidDataException, which is intentionally NOT retried — - // it is deterministic and must fail fast into the failure/cleanup path. - ex => ex is IOException or TimeoutException, - cancellationToken).ConfigureAwait(false); - - // Verifying occupies the 92→94% band (JumpingToApp starts at 95%). - var verifyPercent = 92 + ((index + 1) / (double)crcRegions.Count * 2); - ReportProgress( - progress, - FirmwareUpdateState.Verifying, - verifyPercent, - $"Verified flash CRC region {index + 1} of {crcRegions.Count}", - totalBytes, - totalBytes); - } - - _logger.LogInformation( - "Flash CRC verification passed for {RegionCount} region(s).", - crcRegions.Count); - } - - private async Task JumpToApplicationAndReconnectAsync( - IStreamingDevice device, - CancellationToken cancellationToken) - { - await _hidTransport - .WriteAsync(_bootloaderProtocol.CreateJumpToApplicationMessage(), cancellationToken) - .ConfigureAwait(false); - - await SafeDisconnectHidAsync().ConfigureAwait(false); - await WaitForSerialReconnectAsync(device, cancellationToken).ConfigureAwait(false); - - // Discard the race-winning serial handle from the USB CDC re-enumeration - // window. On macOS the first SerialPort.Open() that succeeds after a PIC32 - // reset is typically a "shadow" handle: IsOpen==true, but the kernel - // device-node isn't fully wired yet — writes silently drop and reads see - // zero bytes. A fresh open after a brief settling delay yields a clean - // binding. Symptom without this step: SCPI Sends after reconnect appear - // to succeed but the device never responds (LEDs stay off, readiness - // probe returns null indefinitely until budget expires). - // Opt out by setting PostReconnectStaleHandleDelay = TimeSpan.Zero - // (callers on platforms where the first open is already clean). - if (_options.PostReconnectStaleHandleDelay > TimeSpan.Zero) - { - _logger.LogInformation( - "Discarding race-winning serial handle; closing and re-opening after {Delay} to obtain a clean USB CDC binding.", - _options.PostReconnectStaleHandleDelay); - device.Disconnect(); - await Task.Delay(_options.PostReconnectStaleHandleDelay, cancellationToken).ConfigureAwait(false); - await WaitForSerialReconnectAsync(device, cancellationToken).ConfigureAwait(false); - } - - // Wake the post-reset device. PIC32 application firmware boots - // dormant (LEDs off, WiFi subsystem unpowered, won't answer LAN - // queries) until SYSTem:POWer:STATe 1 is sent. InitializeAsync - // handles that plus the rest of the standard init sequence - // (echo off, stream format, etc.). Without this, callers writing - // a "natural" probe like GetLanChipInfoAsync would silently fail - // for tens of seconds because the device is still dormant. - // Skipped for non-DaqifiDevice transports (e.g. test fakes); they - // are responsible for their own readiness if needed. - if (device is DaqifiDevice initializableDevice) - { - _logger.LogInformation("Waking post-reset device via InitializeAsync."); - try - { - // Pass the update's token so a cancel during the post-reset wake isn't ignored - // while InitializeAsync waits (up to ChannelPopulationTimeout) for channels. - await initializableDevice.InitializeAsync(cancellationToken: cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - // Don't fail the firmware update outright — the readiness - // probe (if configured) is the source of truth for "ready". - // Surface the init failure as a warning so a probe timeout - // later isn't mysterious. - _logger.LogWarning(ex, "InitializeAsync after reconnect threw; continuing to readiness probe."); - } - } - - // Application-readiness probe (closes #145). Serial transport - // re-enumeration succeeds well before the PIC32 application - // firmware is actually ready to answer protobuf status queries; - // if a downstream flow (LAN chip info, WiFi prep) starts before - // the app is up, those queries fail and callers reimplement - // their own retry. The probe is opt-in via options — when null, - // the legacy "serial reopened == done" semantics apply. - if (_options.PostReconnectReadinessProbe is { } probe) - { - await WaitForApplicationReadyAsync(device, probe, cancellationToken).ConfigureAwait(false); - } - } - - private async Task WaitForApplicationReadyAsync( - IStreamingDevice device, - Func> probe, - CancellationToken cancellationToken) - { - var totalTimeout = _options.PostReconnectReadinessTimeout; - var retryDelay = _options.PostReconnectReadinessRetryDelay; - - // Surface the wait at Information level so observers tailing the - // log can distinguish "stuck" from "deliberately polling". The - // wait can take up to PostReconnectReadinessTimeout (default 30s); - // without this, the JumpingToApp state appears hung beyond the - // initial transport reopen. - _logger.LogInformation( - "Waiting up to {Timeout} for device to become application-ready (post-reconnect readiness probe).", - totalTimeout); - var waitStart = DateTime.UtcNow; - - using var timeoutCts = new CancellationTokenSource(totalTimeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( - cancellationToken, timeoutCts.Token); - var linkedToken = linkedCts.Token; - - // Capture the most recent probe-thrown exception so a TimeoutException - // can carry the underlying cause as InnerException. Without this, - // deterministic probe failures (e.g. transport says it's open but - // the device never responds to status queries) report only as - // "timed out" — losing the actual error context unless Debug logs - // are on. - Exception? lastProbeException = null; - - // Tracks how many probe invocations have actually run. Distinct from - // the loop iteration counter so the timeout messages don't claim - // "attempt N" when the timeout fired before a probe ever executed. - var probesExecuted = 0; - while (true) - { - try - { - linkedToken.ThrowIfCancellationRequested(); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - throw new TimeoutException( - $"Device did not become application-ready within {totalTimeout} (probes executed: {probesExecuted}). " + - "The transport reconnected but the readiness probe never returned true; the device may still be initializing or the firmware may have failed to start.", - lastProbeException); - } - - try - { - probesExecuted++; - // WaitAsync(linkedToken) enforces the timeout deadline even - // when the probe ignores its own CancellationToken and would - // otherwise hang or return after the budget elapses. When - // the deadline fires, WaitAsync throws OperationCanceledException - // immediately — we don't keep waiting for the rogue probe. - var isReady = await probe(device, linkedToken) - .WaitAsync(linkedToken) - .ConfigureAwait(false); - - // Successful probe invocation (true OR false) means the most - // recent attempt completed normally. Clear lastProbeException - // so a later timeout doesn't carry forward a stale exception - // from an earlier failed attempt as its InnerException. - lastProbeException = null; - - if (isReady) - { - var elapsed = DateTime.UtcNow - waitStart; - _logger.LogInformation( - "Device became application-ready after {Elapsed} on probe attempt {Attempt}.", - elapsed, - probesExecuted); - return; - } - _logger.LogDebug("Application-ready probe returned false on attempt {Attempt}; will retry.", probesExecuted); - } - catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) - { - // Two cases reach here: - // 1. The wait deadline fired (timeoutCts canceled) — surface - // as TimeoutException so callers see the readiness budget. - // 2. The probe itself threw OperationCanceledException for - // some unrelated reason (its own internal CTS, etc). That - // must NOT crash the update loop — treat it as a probe - // failure and retry, same as any other thrown exception. - if (timeoutCts.IsCancellationRequested) - { - throw new TimeoutException( - $"Device did not become application-ready within {totalTimeout} (probes executed: {probesExecuted}). " + - "The wait for the readiness probe was canceled by the timeout — note the probe may ignore cancellation and continue running in the background.", - lastProbeException ?? ex); - } - - lastProbeException = ex; - _logger.LogDebug( - ex, - "Application-ready probe was canceled on attempt {Attempt}; treating as not-ready and retrying.", - probesExecuted); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - lastProbeException = ex; - _logger.LogDebug( - ex, - "Application-ready probe threw on attempt {Attempt}; treating as not-ready and retrying.", - probesExecuted); - } - - try - { - await Task.Delay(retryDelay, linkedToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - throw new TimeoutException( - $"Device did not become application-ready within {totalTimeout} (probes executed: {probesExecuted}). " + - "The transport reconnected but the readiness probe never returned true; the device may still be initializing or the firmware may have failed to start.", - lastProbeException); - } - } - } - - private async Task WaitForSerialReconnectAsync( - IStreamingDevice device, - CancellationToken cancellationToken) - { - // This loop is bounded by the caller's state timeout via ExecuteWithStateTimeoutAsync. - while (true) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (device.IsConnected) - { - return; - } - - try - { - device.Connect(); - if (device.IsConnected) - { - return; - } - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Serial reconnect attempt failed."); - } - - await Task.Delay(_options.PollInterval, cancellationToken).ConfigureAwait(false); - } - } - - private async Task ExecuteWithRetryAsync( - string operation, - int maxAttempts, - TimeSpan retryDelay, - Func action, - Func isTransient, - CancellationToken cancellationToken) - { - for (var attempt = 1; attempt <= maxAttempts; attempt++) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - await action(cancellationToken).ConfigureAwait(false); - return; - } - catch (Exception ex) when (attempt < maxAttempts && isTransient(ex)) - { - _logger.LogWarning( - ex, - "Operation '{Operation}' failed on attempt {Attempt}/{MaxAttempts}; retrying in {DelayMs} ms.", - operation, - attempt, - maxAttempts, - retryDelay.TotalMilliseconds); - - await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); - } - } - } - - private async Task ExecuteWithStateTimeoutAsync( - FirmwareUpdateState state, - string operation, - Func action, - CancellationToken cancellationToken) - { - var timeout = _options.GetStateTimeout(state); - using var timeoutCts = new CancellationTokenSource(timeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - try - { - await action(linkedCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - throw new TimeoutException(BuildStateTimeoutMessage(state, operation, timeout)); - } - } - - private async Task ExecuteWithStateTimeoutAsync( - FirmwareUpdateState state, - string operation, - Func> action, - CancellationToken cancellationToken) - { - var timeout = _options.GetStateTimeout(state); - using var timeoutCts = new CancellationTokenSource(timeout); - using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); - - try - { - return await action(linkedCts.Token).ConfigureAwait(false); - } - catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) - { - throw new TimeoutException(BuildStateTimeoutMessage(state, operation, timeout)); - } - } - - private string BuildStateTimeoutMessage( - FirmwareUpdateState state, - string operation, - TimeSpan timeout) - { - var message = - $"State '{state}' timed out while attempting to {operation} after {timeout.TotalSeconds:F1} seconds."; - - if (state != FirmwareUpdateState.WaitingForBootloader) - { - return message; - } - - var details = - $"No matching HID bootloader device was enumerated for VID=0x{_options.BootloaderVendorId:X4}, " + - $"PID=0x{_options.BootloaderProductId:X4} after {_bootloaderPollAttempts} poll attempt(s)."; - - if (_targetBootloaderDevicePath != null) - { - details += $" Target device path requested: {_targetBootloaderDevicePath}."; - } - - if (_targetBootloaderLocationKey != null) - { - details += $" Target location key requested: {_targetBootloaderLocationKey}."; - } - - if (_lastBootloaderEnumerationError == null) - { - return $"{message} {details}"; - } - - var errorSummary = FormatExceptionSummary(_lastBootloaderEnumerationError); - return $"{message} {details} Last HID enumeration error: {errorSummary}."; - } - - private static string FormatExceptionSummary(Exception exception) - { - var builder = new StringBuilder(); - var current = exception; - var firstSegment = true; - - while (current != null) - { - if (!firstSegment) - { - builder.Append(" | Inner "); - } - - builder.Append(current.GetType().Name); - builder.Append(": "); - builder.Append(current.Message); - current = current.InnerException; - firstSegment = false; - } - - return builder.ToString(); - } - - /// - /// Derives a 0-100 progress percentage for the WiFi (WINC) flash from the flash tool's live - /// stdout. The tool runs a fast local image-build phase (whose "written … (NN%)" lines reach - /// 100% and must be ignored, or they latch the bar near the top before the real flash starts) - /// followed by the multi-minute on-device write → read → verify phases. Those phases emit - /// block-address lines like 0x000000:[wwwwwwww] 0x008000:[wwwwwwww] … with no percent, - /// so this parser advances the bar from the highest block address seen relative to the flashed - /// range. Each phase occupies its own monotonically increasing band; - /// returns the new percent when it advances, or null when a line carries no progress. - /// - internal sealed class WifiFlashProgressParser - { - private static readonly Regex BlockAddressRegex = new( - @"0x(?[0-9a-fA-F]+)\s*:", - RegexOptions.Compiled | RegexOptions.CultureInvariant); - - private static readonly Regex VerifyRangeRegex = new( - @"verify range\s+0x(?[0-9a-fA-F]+)\s+to\s+0x(?[0-9a-fA-F]+)", - RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - - // Block size between consecutive addresses in the tool's progress lines (0x8000). - private const long BlockSize = 0x8000; - - // Default flashed range when the tool hasn't yet announced "verify range" (the WINC - // programmed region is 0x80000 = 512 KB); expanded if a larger address is observed. - private long _totalRange = 0x80000; - - // Base address of the flashed range. Block addresses in the tool output are absolute, so - // the covered fraction is measured relative to this start (0 unless "verify range" reports - // a non-zero base). - private long _rangeStart; - - private Phase _phase = Phase.PreFlash; - private double _lastPercent; - - private enum Phase - { - PreFlash, - Write, - Read, - Verify - } - - // Per-phase overall bands (write is weighted heaviest — it is by far the longest phase). - private static (double Start, double End) BandFor(Phase phase) => phase switch - { - Phase.Write => (5, 60), - Phase.Read => (60, 78), - Phase.Verify => (78, 100), - _ => (0, 0) - }; - - public double? Observe(string line) - { - if (string.IsNullOrWhiteSpace(line)) - { - return null; - } - - if (line.Contains("begin write operation", StringComparison.OrdinalIgnoreCase)) - { - return Advance(Phase.Write, BandFor(Phase.Write).Start); - } - - if (line.Contains("begin read operation", StringComparison.OrdinalIgnoreCase)) - { - return Advance(Phase.Read, BandFor(Phase.Read).Start); - } - - var verifyRange = VerifyRangeRegex.Match(line); - if (verifyRange.Success && - long.TryParse(verifyRange.Groups["start"].Value, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var rangeStart) && - long.TryParse(verifyRange.Groups["end"].Value, System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out var rangeEnd) && - rangeEnd > rangeStart) - { - _rangeStart = rangeStart; - _totalRange = rangeEnd - rangeStart; - return null; - } - - if (line.Contains("begin verify operation", StringComparison.OrdinalIgnoreCase)) - { - return Advance(Phase.Verify, BandFor(Phase.Verify).Start); - } - - // Block-address lines advance the current phase. Ignored before the device flash - // starts (PreFlash) so the image-build phase never moves the bar. - if (_phase != Phase.PreFlash) - { - var highestAddress = HighestBlockAddress(line); - if (highestAddress.HasValue) - { - // Block addresses are absolute; measure coverage from the range base so a - // non-zero start doesn't make the fraction saturate to 1 immediately. - var covered = highestAddress.Value - _rangeStart + BlockSize; - if (covered > _totalRange) - { - _totalRange = covered; - } - - var fraction = Math.Clamp(covered / (double)_totalRange, 0, 1); - var (start, end) = BandFor(_phase); - return Advance(_phase, start + (fraction * (end - start))); - } - } - - return null; - } - - private double? Advance(Phase phase, double candidatePercent) - { - if (phase > _phase) - { - _phase = phase; - } - - var clamped = Math.Clamp(candidatePercent, 0, 100); - - // Monotonic: never let the bar move backward (e.g. address resets to 0 at each new phase). - if (clamped <= _lastPercent) - { - return null; - } - - _lastPercent = clamped; - return clamped; - } - - private static long? HighestBlockAddress(string line) - { - long? highest = null; - foreach (Match match in BlockAddressRegex.Matches(line)) - { - if (long.TryParse( - match.Groups["addr"].Value, - System.Globalization.NumberStyles.HexNumber, - System.Globalization.CultureInfo.InvariantCulture, - out var address)) - { - if (highest is null || address > highest.Value) - { - highest = address; - } - } - } - - return highest; - } - } - - private static string BuildProcessLogExcerpt(ExternalProcessResult result) - { - var excerpt = result.StandardErrorLines - .Concat(result.StandardOutputLines) - .Where(line => !string.IsNullOrWhiteSpace(line)) - .Take(5) - .ToArray(); - - if (excerpt.Length == 0) - { - return "No process output captured."; - } - - return $"Process output excerpt: {string.Join(" | ", excerpt)}"; - } - - private static void EnsureDeviceConnected(IStreamingDevice device) - { - if (!device.IsConnected) - { - throw new InvalidOperationException("Device must be connected before starting firmware update."); - } - } - - // failureSubject names the operation that failed, so the message is honest about what - // the caller actually ran. Diagnostics pass their own subject: a health check or soft - // reset must not report "Firmware update failed" to a consumer (e.g. a recovery dialog) - // that deliberately probed the bootloader *instead of* starting an update. - private FirmwareUpdateException CreateFirmwareUpdateException( - FirmwareUpdateState failedState, - string failedOperation, - Exception exception, - Pic32CleanupOutcome cleanupOutcome = Pic32CleanupOutcome.NotEligible, - string failureSubject = "Firmware update") - { - if (exception is FirmwareUpdateException firmwareUpdateException) - { - // Already a fully-contextualized firmware exception (carries its own - // guidance). No flash-path operation throws one today, so the - // cleanupOutcome guidance below never has to be merged in here. - return firmwareUpdateException; - } - - var recoveryGuidance = BuildRecoveryGuidance(failedState, cleanupOutcome); - var message = $"{failureSubject} failed in state '{failedState}' while {failedOperation}."; - - return new FirmwareUpdateException( - failedState, - failedOperation, - message, - recoveryGuidance, - exception); - } - - private static string BuildRecoveryGuidance( - FirmwareUpdateState failedState, - Pic32CleanupOutcome cleanupOutcome) - { - // When a re-erase cleanup ran, its outcome — not the original failure - // state — drives the guidance: the operator needs to know whether the - // device is safe to simply re-flash or may be half-flashed. - switch (cleanupOutcome) - { - case Pic32CleanupOutcome.Recovered: - return "The update did not complete, but the device's application flash was automatically " + - "re-erased and it is now in a clean bootloader state — safe to re-flash. " + - "Simply re-run the firmware update."; - case Pic32CleanupOutcome.CleanupFailed: - return "The update failed and the automatic re-erase cleanup could not complete, so the device " + - "may be in a half-flashed state. Power-cycle the device into bootloader mode and re-run " + - "the firmware update; the next erase will restore a clean state."; - case Pic32CleanupOutcome.NotEligible: - default: - return BuildRecoveryGuidance(failedState); - } - } - - private static string BuildRecoveryGuidance(FirmwareUpdateState failedState) - { - return failedState switch - { - FirmwareUpdateState.PreparingDevice => - "Ensure the device is connected over USB and not currently busy streaming.", - FirmwareUpdateState.WaitingForBootloader => - "The device did not enter bootloader mode. Try unplugging/replugging USB, then retry.", - FirmwareUpdateState.Connecting => - "Bootloader was found but HID connection failed. Check USB cable stability and retry.", - FirmwareUpdateState.ErasingFlash => - "Flash erase failed. Retry update; if this persists, power-cycle the device and re-enter bootloader mode.", - FirmwareUpdateState.Programming => - "Programming failed. Retry update while keeping USB connected; device may still be recoverable in bootloader mode.", - FirmwareUpdateState.Verifying => - "Flash verification failed — the device's flash CRC did not match the firmware image. " + - "Retry the update and confirm the expected firmware package was selected.", - FirmwareUpdateState.ReconnectingAfterFlash => - "The firmware was flashed and verified successfully; only reconnecting to the device " + - "afterwards timed out. Unplug and replug USB, then reconnect — the update itself does " + - "not need to be re-run.", - FirmwareUpdateState.JumpingToApp => - "The device did not return to application mode. Power-cycle the device and reconnect.", - _ => - "Retry the update. If repeated failures occur, reconnect the device and attempt manual bootloader recovery." - }; - } - - private void ReportProgress( - IProgress? progress, - FirmwareUpdateState state, - double percentComplete, - string currentOperation, - long bytesWritten, - long totalBytes) - { - var clampedPercent = Math.Clamp(percentComplete, 0, 100); - _lastReportedPercent = clampedPercent; - - progress?.Report(new FirmwareUpdateProgress - { - State = state, - PercentComplete = clampedPercent, - CurrentOperation = currentOperation, - BytesWritten = Math.Max(0, bytesWritten), - TotalBytes = Math.Max(0, totalBytes) - }); - } - - private void TransitionToState(FirmwareUpdateState nextState, string operation) - { - if (CurrentState == nextState) - { - _currentOperation = operation; - return; - } - - if (!AllowedTransitions.TryGetValue(CurrentState, out var allowedStates) || - !allowedStates.Contains(nextState)) - { - throw new InvalidOperationException( - $"Invalid firmware update transition: {CurrentState} -> {nextState}."); - } - - var previousState = CurrentState; - CurrentState = nextState; - _currentOperation = operation; - - _logger.LogInformation( - "Firmware update state transition: {PreviousState} -> {CurrentState} ({Operation})", - previousState, - nextState, - operation); - - StateChanged?.Invoke(this, new FirmwareUpdateStateChangedEventArgs(previousState, nextState, operation)); - } - - private void ResetIfTerminalState() - { - if (CurrentState is FirmwareUpdateState.Complete - or FirmwareUpdateState.Failed - or FirmwareUpdateState.Recovered) - { - TransitionToState(FirmwareUpdateState.Idle, "Resetting state for next firmware update operation."); - } - } - - private async Task SafeDisconnectHidAsync() - { - if (!_hidTransport.IsConnected) - { - return; - } - - try - { - await _hidTransport.DisconnectAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to disconnect HID transport during cleanup."); - } - } - private void ThrowIfDisposed() { if (_disposed) diff --git a/src/Daqifi.Core/Firmware/Pic32BootloaderSession.cs b/src/Daqifi.Core/Firmware/Pic32BootloaderSession.cs new file mode 100644 index 00000000..29f437a7 --- /dev/null +++ b/src/Daqifi.Core/Firmware/Pic32BootloaderSession.cs @@ -0,0 +1,445 @@ +using Daqifi.Core.Communication.Transport; +using Daqifi.Core.Device.Discovery; +using Microsoft.Extensions.Logging; + +namespace Daqifi.Core.Firmware; + +/// +/// The low-level HID conversation with the PIC32 bootloader: enumerate and connect to the +/// bootloader device, read its version, erase, program and CRC-verify flash, and jump back to the +/// application. Every operation is a single bootloader exchange (with the configured transient +/// retry); the ordering, state transitions and failure handling belong to +/// . +/// +internal sealed class Pic32BootloaderSession +{ + private readonly FirmwareUpdateContext _context; + private readonly IHidTransport _hidTransport; + private readonly IBootloaderProtocol _bootloaderProtocol; + private readonly IHidDeviceEnumerator _hidDeviceEnumerator; + private readonly IUsbLocationProvider _usbLocationProvider; + + private int _bootloaderPollAttempts; + private Exception? _lastBootloaderEnumerationError; + private string? _targetBootloaderDevicePath; + private string? _targetBootloaderLocationKey; + + internal Pic32BootloaderSession( + FirmwareUpdateContext context, + IHidTransport hidTransport, + IBootloaderProtocol bootloaderProtocol, + IHidDeviceEnumerator hidDeviceEnumerator, + IUsbLocationProvider usbLocationProvider) + { + _context = context; + _hidTransport = hidTransport; + _bootloaderProtocol = bootloaderProtocol; + _hidDeviceEnumerator = hidDeviceEnumerator; + _usbLocationProvider = usbLocationProvider; + } + + internal bool IsConnected => _hidTransport.IsConnected; + + private ILogger Logger => _context.Logger; + + private FirmwareUpdateServiceOptions Options => _context.Options; + + /// + /// Parses and validates the HEX image before any device I/O, returning the programmable + /// records, the CRC regions used by the post-programming verify pass and the total byte count. + /// + internal (IReadOnlyList HexRecords, IReadOnlyList CrcRegions, long TotalBytes) + PrepareHexImage(string[] hexLines) + { + var hexRecords = _bootloaderProtocol.ParseHexFile(hexLines); + var totalBytes = hexRecords.Sum(record => (long)record.Length); + if (totalBytes <= 0) + { + throw new InvalidDataException("Firmware HEX file did not contain any writable records."); + } + + // Computed up front (alongside parsing) so the post-programming Verifying + // state can checksum exactly the bytes we programmed via the bootloader + // READ_CRC command. See VerifyFlashContentsAsync. + var crcRegions = _bootloaderProtocol.ComputeCrcRegions(hexLines); + + return (hexRecords, crcRegions, totalBytes); + } + + /// + /// Clears the per-run bootloader poll counters and targeting state so a + /// timeout message describes only the + /// current run. + /// + internal void ResetTargetingState(string? targetDevicePath = null, string? targetLocationKey = null) + { + _bootloaderPollAttempts = 0; + _lastBootloaderEnumerationError = null; + _targetBootloaderDevicePath = targetDevicePath; + _targetBootloaderLocationKey = targetLocationKey; + } + + /// + /// Records the target requested for this run so a + /// timeout can name it. + /// + internal void SetRequestedTarget(string? targetDevicePath, string? targetLocationKey) + { + _targetBootloaderDevicePath = targetDevicePath; + _targetBootloaderLocationKey = targetLocationKey; + } + + /// + /// Extra detail appended to a timeout, + /// naming the VID/PID searched, the poll attempts made, any requested target and the last + /// enumeration error. + /// + internal string DescribeBootloaderSearch() + { + var details = + $"No matching HID bootloader device was enumerated for VID=0x{Options.BootloaderVendorId:X4}, " + + $"PID=0x{Options.BootloaderProductId:X4} after {_bootloaderPollAttempts} poll attempt(s)."; + + if (_targetBootloaderDevicePath != null) + { + details += $" Target device path requested: {_targetBootloaderDevicePath}."; + } + + if (_targetBootloaderLocationKey != null) + { + details += $" Target location key requested: {_targetBootloaderLocationKey}."; + } + + if (_lastBootloaderEnumerationError == null) + { + return details; + } + + var errorSummary = FirmwareUpdateContext.FormatExceptionSummary(_lastBootloaderEnumerationError); + return $"{details} Last HID enumeration error: {errorSummary}."; + } + + /// + /// Polls HID enumeration until a bootloader matching the requested target appears. Bounded by + /// the caller's state timeout. + /// + internal async Task WaitForBootloaderDeviceAsync( + string? targetDevicePath, + string? targetLocationKey, + CancellationToken cancellationToken) + { + // A device path's physical location can't change while it stays enumerated, so caching + // per call (across poll iterations, not across separate update runs) avoids re-issuing a + // WMI query for the same candidate on every poll while targeting by location. + var locationCache = new Dictionary(StringComparer.Ordinal); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + _bootloaderPollAttempts++; + + IReadOnlyList devices; + try + { + devices = await _hidDeviceEnumerator + .EnumerateAsync(Options.BootloaderVendorId, Options.BootloaderProductId, cancellationToken) + .ConfigureAwait(false); + _lastBootloaderEnumerationError = null; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _lastBootloaderEnumerationError = ex; + throw new InvalidOperationException( + $"HID enumeration failed while searching for bootloader device " + + $"VID=0x{Options.BootloaderVendorId:X4}, PID=0x{Options.BootloaderProductId:X4} " + + $"on poll attempt {_bootloaderPollAttempts}.", + ex); + } + + // Multiple identical bootloaders can be enumerated at once; when a specific one was + // requested, match it by path (the rest stay held by the caller). Otherwise, if a + // location key was requested, resolve each candidate's physical-location key and match + // on that — this is what lets a caller target the bootloader a device rebooted INTO, + // before its device path exists, using the location it observed while the device was + // still in serial/app mode. Otherwise take the first match, preserving the + // single-device behavior. + // Ordinal (case-sensitive): a device path is an OS identifier and, in this flow, comes from + // the same in-process HID enumeration (via IHidPlatform) the caller used to obtain targetDevicePath. + var match = targetDevicePath != null + ? devices.FirstOrDefault(d => + string.Equals(d.DevicePath, targetDevicePath, StringComparison.Ordinal)) + : targetLocationKey != null + ? devices.FirstOrDefault(d => + string.Equals( + ResolveLocationCached(d.DevicePath, locationCache), + targetLocationKey, + StringComparison.Ordinal)) + : devices.FirstOrDefault(); + if (match != null) + { + return match; + } + + await Task.Delay(Options.PollInterval, cancellationToken).ConfigureAwait(false); + } + } + + private string? ResolveLocationCached(string devicePath, Dictionary cache) + { + if (cache.TryGetValue(devicePath, out var cached)) + { + return cached; + } + + var resolved = _usbLocationProvider.GetLocationKey(devicePath); + cache[devicePath] = resolved; + return resolved; + } + + internal async Task ConnectWithRetryAsync( + HidDeviceInfo bootloaderDevice, + string? targetDevicePath, + string? targetLocationKey, + CancellationToken cancellationToken) + { + await _context.ExecuteWithRetryAsync( + "connect HID bootloader", + Options.HidConnectRetryCount, + Options.HidConnectRetryDelay, + async ct => + { + if (_hidTransport.IsConnected) + { + await _hidTransport.DisconnectAsync().ConfigureAwait(false); + } + + // When a specific device was requested (by path or by location, several identical + // bootloaders present), connect to that exact device by path — bootloaderDevice was + // already matched on the requested criterion in WaitForBootloaderDeviceAsync. + // Otherwise fall back to VID/PID(+serial) first-match for the single-device case. + if (targetDevicePath != null || targetLocationKey != null) + { + await _hidTransport + .ConnectByPathAsync(bootloaderDevice.DevicePath, ct) + .ConfigureAwait(false); + } + else + { + await _hidTransport.ConnectAsync( + Options.BootloaderVendorId, + Options.BootloaderProductId, + bootloaderDevice.SerialNumber, + ct).ConfigureAwait(false); + } + }, + ex => ex is IOException or TimeoutException or InvalidOperationException, + cancellationToken).ConfigureAwait(false); + } + + internal async Task RequestVersionAsync(CancellationToken cancellationToken) + { + await _hidTransport + .WriteAsync(_bootloaderProtocol.CreateRequestVersionMessage(), cancellationToken) + .ConfigureAwait(false); + + var response = await _hidTransport + .ReadAsync(Options.BootloaderResponseTimeout, cancellationToken) + .ConfigureAwait(false); + + var version = _bootloaderProtocol.DecodeVersionResponse(response); + if (string.Equals(version, "Error", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Bootloader returned an invalid version response."); + } + + return version; + } + + internal async Task EraseFlashWithRetryAsync(CancellationToken cancellationToken) + { + await _context.ExecuteWithRetryAsync( + "erase flash", + Options.FlashWriteRetryCount, + Options.FlashWriteRetryDelay, + async ct => + { + await _hidTransport + .WriteAsync(_bootloaderProtocol.CreateEraseFlashMessage(), ct) + .ConfigureAwait(false); + + var response = await _hidTransport + .ReadAsync(Options.BootloaderResponseTimeout, ct) + .ConfigureAwait(false); + + if (!_bootloaderProtocol.DecodeEraseFlashResponse(response)) + { + throw new InvalidDataException("Bootloader erase acknowledgment was invalid."); + } + }, + ex => ex is IOException or TimeoutException or InvalidDataException, + cancellationToken).ConfigureAwait(false); + } + + internal async Task ProgramFlashAsync( + IReadOnlyList hexRecords, + long totalBytes, + IProgress? progress, + CancellationToken cancellationToken) + { + long bytesWritten = 0; + for (var index = 0; index < hexRecords.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var record = hexRecords[index]; + await _context.ExecuteWithRetryAsync( + $"program flash record {index + 1}", + Options.FlashWriteRetryCount, + Options.FlashWriteRetryDelay, + async ct => + { + var message = _bootloaderProtocol.CreateProgramFlashMessage(record); + await _hidTransport.WriteAsync(message, ct).ConfigureAwait(false); + + var response = await _hidTransport + .ReadAsync(Options.BootloaderResponseTimeout, ct) + .ConfigureAwait(false); + + if (!_bootloaderProtocol.DecodeProgramFlashResponse(response)) + { + throw new InvalidDataException( + $"Bootloader program acknowledgment was invalid for record {index + 1}."); + } + }, + ex => ex is IOException or TimeoutException or InvalidDataException, + cancellationToken).ConfigureAwait(false); + + bytesWritten += record.Length; + var completion = totalBytes <= 0 ? 90 : 20 + (bytesWritten / (double)totalBytes * 70); + _context.ReportProgress( + progress, + FirmwareUpdateState.Programming, + completion, + $"Programming record {index + 1} of {hexRecords.Count}", + bytesWritten, + totalBytes); + } + } + + internal async Task VerifyFlashContentsAsync( + IReadOnlyList crcRegions, + IProgress? progress, + long totalBytes, + CancellationToken cancellationToken) + { + if (crcRegions.Count == 0) + { + // No flashable regions to verify (degenerate/empty image). Fall back + // to confirming the bootloader is still responsive so we never jump + // to an application we couldn't reach over HID. + var version = await RequestVersionAsync(cancellationToken).ConfigureAwait(false); + Logger.LogInformation( + "No flash CRC regions to verify; confirmed bootloader liveness: {BootloaderVersion}.", + version); + return; + } + + for (var index = 0; index < crcRegions.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var region = crcRegions[index]; + await _context.ExecuteWithRetryAsync( + $"read flash CRC for region {index + 1} at 0x{region.Address:X8}", + Options.FlashWriteRetryCount, + Options.FlashWriteRetryDelay, + async ct => + { + await _hidTransport + .WriteAsync(_bootloaderProtocol.CreateReadCrcMessage(region.Address, region.Length), ct) + .ConfigureAwait(false); + + var response = await _hidTransport + .ReadAsync(Options.BootloaderResponseTimeout, ct) + .ConfigureAwait(false); + + ushort actualCrc; + try + { + actualCrc = _bootloaderProtocol.DecodeReadCrcResponse(response); + } + catch (InvalidDataException ex) + { + // A malformed / framing-corrupt READ_CRC frame is a + // transport-level fault, not evidence of bad flash. Surface + // it as transient (like a HID read error) so a one-off USB + // glitch is retried rather than failing the whole update — + // consistent with how the erase/program steps treat + // InvalidDataException. + throw new IOException( + $"READ_CRC response for region {index + 1} at 0x{region.Address:X8} was malformed; " + + "treating as a transient transport fault.", + ex); + } + + if (actualCrc != region.ExpectedCrc) + { + // A genuine CRC mismatch is deterministic — the flash does + // not match the image. Throw InvalidDataException, which the + // retry predicate excludes, so verification fails fast into + // the failure/cleanup path rather than masking a bad flash + // behind retries. + throw new InvalidDataException( + $"Flash CRC mismatch in region {index + 1} at 0x{region.Address:X8} " + + $"(length {region.Length}): expected 0x{region.ExpectedCrc:X4}, " + + $"device reported 0x{actualCrc:X4}."); + } + }, + // Retry transient transport faults: HID read errors, timeouts, and + // malformed frames (wrapped as IOException above). A CRC mismatch + // throws InvalidDataException, which is intentionally NOT retried — + // it is deterministic and must fail fast into the failure/cleanup path. + ex => ex is IOException or TimeoutException, + cancellationToken).ConfigureAwait(false); + + // Verifying occupies the 92→94% band (JumpingToApp starts at 95%). + var verifyPercent = 92 + ((index + 1) / (double)crcRegions.Count * 2); + _context.ReportProgress( + progress, + FirmwareUpdateState.Verifying, + verifyPercent, + $"Verified flash CRC region {index + 1} of {crcRegions.Count}", + totalBytes, + totalBytes); + } + + Logger.LogInformation( + "Flash CRC verification passed for {RegionCount} region(s).", + crcRegions.Count); + } + + /// + /// Writes the JMP_TO_APP command. Touches no flash — the bootloader simply hands control + /// to the application image and the device re-enumerates as USB CDC. + /// + internal Task SendJumpToApplicationAsync(CancellationToken cancellationToken) + => _hidTransport.WriteAsync(_bootloaderProtocol.CreateJumpToApplicationMessage(), cancellationToken); + + internal async Task SafeDisconnectAsync() + { + if (!_hidTransport.IsConnected) + { + return; + } + + try + { + await _hidTransport.DisconnectAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + Logger.LogWarning(ex, "Failed to disconnect HID transport during cleanup."); + } + } +} diff --git a/src/Daqifi.Core/Firmware/Pic32FirmwareUpdater.cs b/src/Daqifi.Core/Firmware/Pic32FirmwareUpdater.cs new file mode 100644 index 00000000..6d18b50f --- /dev/null +++ b/src/Daqifi.Core/Firmware/Pic32FirmwareUpdater.cs @@ -0,0 +1,731 @@ +using System.Runtime.ExceptionServices; +using Daqifi.Core.Communication.Producers; +using Daqifi.Core.Device; +using Microsoft.Extensions.Logging; + +namespace Daqifi.Core.Firmware; + +/// +/// The PIC32 bootloader half of : forces the device into +/// bootloader mode, waits for the HID bootloader to enumerate, connects, then erases, programs, +/// CRC-verifies and jumps back to the application. Also serves the standalone bootloader +/// diagnostics (health check / soft reset) and the post-failure re-erase cleanup. Drives the +/// individual bootloader exchanges through and the shared +/// state-machine, progress and retry plumbing through . +/// Callers must serialize invocations — the service facade holds the operation lock. +/// +internal sealed class Pic32FirmwareUpdater +{ + // States where a failure may have left the application flash partially + // written AND the HID bootloader is still connected, so re-erasing to a + // clean bootloader state is both necessary and possible. Failures in + // PreparingDevice/WaitingForBootloader/Connecting happen before any flash + // write; a JumpingToApp failure happens after HID has already been + // disconnected — neither is eligible for cleanup. + private static readonly IReadOnlySet CleanupEligibleStates + = new HashSet + { + FirmwareUpdateState.ErasingFlash, + FirmwareUpdateState.Programming, + FirmwareUpdateState.Verifying + }; + + private readonly FirmwareUpdateContext _context; + private readonly Pic32BootloaderSession _session; + + internal Pic32FirmwareUpdater(FirmwareUpdateContext context, Pic32BootloaderSession session) + { + _context = context; + _session = session; + } + + private ILogger Logger => _context.Logger; + + private FirmwareUpdateServiceOptions Options => _context.Options; + + internal async Task RunUpdateAsync( + IStreamingDevice device, + IReadOnlyList hexRecords, + IReadOnlyList crcRegions, + long totalBytes, + IProgress? progress, + string? targetDevicePath, + string? targetLocationKey, + CancellationToken cancellationToken) + { + // Recorded so a WaitingForBootloader timeout can name the requested path/location in its message. + _session.SetRequestedTarget(targetDevicePath, targetLocationKey); + + try + { + _context.TransitionToState(FirmwareUpdateState.PreparingDevice, "Preparing device for PIC32 firmware update."); + _context.ReportProgress(progress, FirmwareUpdateState.PreparingDevice, 0, _context.CurrentOperation, 0, totalBytes); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.PreparingDevice, + "prepare the device for bootloader mode", + async stateToken => + { + FirmwareUpdateContext.EnsureDeviceConnected(device); + + if (device.IsStreaming) + { + device.StopStreaming(); + } + + device.Send(ScpiMessageProducer.ForceBootloader); + await Task.Delay(Options.PostForceBootDelay, stateToken).ConfigureAwait(false); + device.Disconnect(); + }, + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.WaitingForBootloader, "Waiting for HID bootloader device."); + _context.ReportProgress(progress, FirmwareUpdateState.WaitingForBootloader, 5, _context.CurrentOperation, 0, totalBytes); + + var hidDevice = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.WaitingForBootloader, + "wait for HID bootloader enumeration", + ct => _session.WaitForBootloaderDeviceAsync(targetDevicePath, targetLocationKey, ct), + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.Connecting, "Connecting to HID bootloader."); + _context.ReportProgress(progress, FirmwareUpdateState.Connecting, 10, _context.CurrentOperation, 0, totalBytes); + + string version; + try + { + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Connecting, + "connect HID transport", + ct => _session.ConnectWithRetryAsync(hidDevice, targetDevicePath, targetLocationKey, ct), + cancellationToken).ConfigureAwait(false); + + version = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Connecting, + "request bootloader version", + _session.RequestVersionAsync, + cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // #298: a dirty HID bootloader handle left behind by another + // program (or a previous run) can make the connect or the + // version health check fail even though the device is + // physically present. Nothing has been erased yet, so it's + // safe to attempt one JMP_TO_APP soft reset to force a clean + // re-enumeration before giving up. + version = await RecoverBootloaderHealthWithSoftResetAsync( + ex, + targetDevicePath, + targetLocationKey, + cancellationToken).ConfigureAwait(false); + } + + Logger.LogInformation("Bootloader version response: {BootloaderVersion}", version); + + _context.TransitionToState(FirmwareUpdateState.ErasingFlash, "Erasing PIC32 flash."); + _context.ReportProgress(progress, FirmwareUpdateState.ErasingFlash, 15, _context.CurrentOperation, 0, totalBytes); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.ErasingFlash, + "erase flash", + _session.EraseFlashWithRetryAsync, + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.Programming, "Programming flash records."); + _context.ReportProgress(progress, FirmwareUpdateState.Programming, 20, _context.CurrentOperation, 0, totalBytes); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Programming, + "program flash records", + ct => _session.ProgramFlashAsync(hexRecords, totalBytes, progress, ct), + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.Verifying, "Verifying flash contents via CRC."); + _context.ReportProgress(progress, FirmwareUpdateState.Verifying, 92, _context.CurrentOperation, totalBytes, totalBytes); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Verifying, + "verify flash contents via CRC", + ct => _session.VerifyFlashContentsAsync(crcRegions, progress, totalBytes, ct), + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.JumpingToApp, "Jumping to application firmware."); + _context.ReportProgress(progress, FirmwareUpdateState.JumpingToApp, 95, _context.CurrentOperation, totalBytes, totalBytes); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.JumpingToApp, + "jump to application and reconnect serial transport", + ct => JumpToApplicationAndReconnectAsync(device, ct), + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.Complete, "PIC32 firmware update completed."); + _context.ReportProgress(progress, FirmwareUpdateState.Complete, 100, _context.CurrentOperation, totalBytes, totalBytes); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + var canceledState = _context.CurrentState; + Logger.LogWarning("PIC32 firmware update canceled in state {State}.", canceledState); + + // A cancel mid-flash still leaves the device half-flashed, so it must + // be cleaned up just like any other failure in a flash-touching state + // (acceptance criterion #208: never leave a half-flashed device). The + // cleanup runs on its own token, so the already-canceled operation + // token does not abort it. We still rethrow the cancellation. + await CleanUpAfterFailureAsync(canceledState, progress, totalBytes, canceled: true) + .ConfigureAwait(false); + throw; + } + catch (Exception ex) + { + // Capture the state/operation at the moment of failure BEFORE any + // cleanup transitions move us off it — these stay the diagnostic + // "where it broke" context on the thrown exception. + var failedState = _context.CurrentState; + var failedOperation = _context.CurrentOperation; + Logger.LogError(ex, "PIC32 firmware update failed in state {State}.", failedState); + + var cleanupOutcome = await CleanUpAfterFailureAsync( + failedState, progress, totalBytes).ConfigureAwait(false); + + throw _context.CreateFirmwareUpdateException( + failedState, failedOperation, ex, BuildRecoveryGuidance(failedState, cleanupOutcome)); + } + finally + { + await _session.SafeDisconnectAsync().ConfigureAwait(false); + } + } + + /// + /// Standalone bootloader health check: waits for the bootloader to enumerate, connects the HID + /// transport and reads back the bootloader version. Touches no flash. + /// + internal async Task RunHealthCheckAsync( + string? targetDevicePath, + CancellationToken cancellationToken) + { + // Track the phase so a failure is reported against the state it + // occurred in, with the matching recovery guidance — mirroring + // RunUpdateAsync's failedState/failedOperation capture. + var failedState = FirmwareUpdateState.WaitingForBootloader; + var failedOperation = "wait for HID bootloader enumeration"; + try + { + var hidDevice = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.WaitingForBootloader, + failedOperation, + innerCt => _session.WaitForBootloaderDeviceAsync(targetDevicePath, null, innerCt), + cancellationToken).ConfigureAwait(false); + + failedState = FirmwareUpdateState.Connecting; + failedOperation = "connect HID transport"; + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Connecting, + failedOperation, + innerCt => _session.ConnectWithRetryAsync(hidDevice, targetDevicePath, null, innerCt), + cancellationToken).ConfigureAwait(false); + + failedOperation = "request bootloader version"; + var version = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Connecting, + failedOperation, + _session.RequestVersionAsync, + cancellationToken).ConfigureAwait(false); + + Logger.LogInformation( + "Standalone bootloader health check succeeded; version {BootloaderVersion}.", version); + return version; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + throw _context.CreateFirmwareUpdateException( + failedState, failedOperation, ex, failureSubject: "Bootloader health check"); + } + } + + /// + /// Standalone bootloader soft reset: connects the HID transport and issues a single + /// JMP_TO_APP so the device leaves bootloader mode. Touches no flash. + /// + internal async Task RunSoftResetAsync( + string? targetDevicePath, + CancellationToken cancellationToken) + { + var failedState = FirmwareUpdateState.WaitingForBootloader; + var failedOperation = "wait for HID bootloader enumeration"; + try + { + var hidDevice = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.WaitingForBootloader, + failedOperation, + innerCt => _session.WaitForBootloaderDeviceAsync(targetDevicePath, null, innerCt), + cancellationToken).ConfigureAwait(false); + + failedState = FirmwareUpdateState.Connecting; + failedOperation = "connect HID transport"; + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Connecting, + failedOperation, + innerCt => _session.ConnectWithRetryAsync(hidDevice, targetDevicePath, null, innerCt), + cancellationToken).ConfigureAwait(false); + + failedState = FirmwareUpdateState.JumpingToApp; + failedOperation = "issue JMP_TO_APP soft reset"; + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.JumpingToApp, + failedOperation, + _session.SendJumpToApplicationAsync, + cancellationToken).ConfigureAwait(false); + + Logger.LogInformation( + "Standalone JMP_TO_APP soft reset issued to bootloader without touching flash."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + throw _context.CreateFirmwareUpdateException( + failedState, failedOperation, ex, failureSubject: "Bootloader soft reset"); + } + } + + /// + /// Describes the terminal disposition of a failed PIC32 update after the + /// optional re-erase cleanup pass, used to tailor the recovery guidance and + /// reflected by the service's terminal state. + /// + private enum Pic32CleanupOutcome + { + /// + /// Cleanup did not apply: the failure occurred before flash was written + /// (PreparingDevice/WaitingForBootloader/Connecting) or after the HID + /// bootloader was disconnected (JumpingToApp). Terminal state: Failed. + /// + NotEligible, + + /// + /// The application flash was re-erased successfully; the device is in a + /// clean bootloader state and can be re-flashed. Terminal state: Recovered. + /// + Recovered, + + /// + /// Cleanup was eligible but could not complete (the HID transport had + /// dropped, or the re-erase itself failed), so the device may be in a + /// half-flashed state. Terminal state: Failed. + /// + CleanupFailed + } + + /// + /// After a PIC32 update failure, re-erases the application flash when the + /// failure left the device half-flashed but still reachable over HID, so it + /// is never abandoned in a partially-programmed state. Drives the + /// CleaningUp → Recovered (success) or → Failed (cleanup failed) terminal + /// transitions and reports them via state/progress events. The update has + /// already failed; this only determines how safely it ends. + /// + private async Task CleanUpAfterFailureAsync( + FirmwareUpdateState failedState, + IProgress? progress, + long totalBytes, + bool canceled = false) + { + var frozenPercent = _context.LastReportedPercent; + var eligible = CleanupEligibleStates.Contains(failedState); + + if (!eligible || !_session.IsConnected) + { + // No re-erase will run. Either the failure never touched flash / the + // device is past HID (NotEligible — keep the per-state guidance), or + // a flash-touching failure left the HID transport unusable so we + // cannot re-erase (CleanupFailed — warn that it may be half-flashed). + // Both terminate in Failed. On the cancel path the rethrown + // OperationCanceledException carries no recovery guidance, so this + // terminal event text is the only channel observers get. + var outcome = eligible ? Pic32CleanupOutcome.CleanupFailed : Pic32CleanupOutcome.NotEligible; + + string failedOperation; + if (outcome == Pic32CleanupOutcome.CleanupFailed) + { + failedOperation = canceled + ? "PIC32 firmware update canceled; cleanup re-erase skipped because the HID transport " + + "disconnected — device may be in a half-flashed state." + : "Cleanup re-erase skipped: HID transport disconnected; device may be in a half-flashed state."; + Logger.LogWarning( + "Cannot run firmware re-erase cleanup after failure in {State}: HID transport is no longer " + + "connected; device may be in a half-flashed state.", + failedState); + } + else + { + failedOperation = canceled ? "PIC32 firmware update canceled." : _context.CurrentOperation; + } + + _context.TransitionToState(FirmwareUpdateState.Failed, failedOperation); + _context.ReportProgress(progress, FirmwareUpdateState.Failed, frozenPercent, failedOperation, 0, totalBytes); + return outcome; + } + + var cleaningOperation = canceled + ? "Update canceled; re-erasing flash to leave the device in a clean bootloader state." + : "Re-erasing flash to leave the device in a clean bootloader state."; + + try + { + // The CleaningUp notification runs inside the try: a throwing + // StateChanged subscriber or progress sink must land in the catch + // below (CleaningUp → Failed) rather than stranding the machine in + // the non-terminal CleaningUp state, which has no reset path. + _context.TransitionToState(FirmwareUpdateState.CleaningUp, cleaningOperation); + _context.ReportProgress(progress, FirmwareUpdateState.CleaningUp, frozenPercent, cleaningOperation, 0, totalBytes); + Logger.LogInformation( + "Attempting firmware re-erase cleanup after failure in {State}.", failedState); + + // Reuse the same retry-wrapped erase path as the main flow, but on a + // fresh timeout token: the cleanup must run on a best-effort basis + // even if the original operation token was already canceled, and it + // is bounded by the same budget as a normal erase. + using var cleanupCts = new CancellationTokenSource( + Options.GetStateTimeout(FirmwareUpdateState.CleaningUp)); + await _session.EraseFlashWithRetryAsync(cleanupCts.Token).ConfigureAwait(false); + + var recoveredOperation = canceled + ? "Update canceled; flash re-erased — device is in a clean bootloader state and can be re-flashed." + : "Flash re-erased; device is in a clean bootloader state and can be re-flashed."; + _context.TransitionToState(FirmwareUpdateState.Recovered, recoveredOperation); + _context.ReportProgress(progress, FirmwareUpdateState.Recovered, frozenPercent, recoveredOperation, 0, totalBytes); + Logger.LogInformation( + "Firmware re-erase cleanup succeeded; device is in a clean bootloader state."); + return Pic32CleanupOutcome.Recovered; + } + catch (Exception cleanupEx) + { + if (_context.CurrentState == FirmwareUpdateState.Recovered) + { + // The re-erase itself succeeded — a StateChanged subscriber or + // progress sink threw after the Recovered transition committed. + // The device is clean; a consumer callback must not turn that + // into a half-flashed verdict (and Recovered → Failed is not a + // legal transition). + Logger.LogWarning( + cleanupEx, + "A state/progress observer threw after the Recovered transition; cleanup itself succeeded."); + return Pic32CleanupOutcome.Recovered; + } + + const string cleanupFailedOperation = + "Cleanup re-erase failed; device may be in a half-flashed state."; + _context.TransitionToState(FirmwareUpdateState.Failed, cleanupFailedOperation); + _context.ReportProgress(progress, FirmwareUpdateState.Failed, frozenPercent, cleanupFailedOperation, 0, totalBytes); + Logger.LogError( + cleanupEx, + "Firmware re-erase cleanup failed after failure in {State}; device may be half-flashed.", + failedState); + return Pic32CleanupOutcome.CleanupFailed; + } + } + + private static string BuildRecoveryGuidance( + FirmwareUpdateState failedState, + Pic32CleanupOutcome cleanupOutcome) + { + // When a re-erase cleanup ran, its outcome — not the original failure + // state — drives the guidance: the operator needs to know whether the + // device is safe to simply re-flash or may be half-flashed. + switch (cleanupOutcome) + { + case Pic32CleanupOutcome.Recovered: + return "The update did not complete, but the device's application flash was automatically " + + "re-erased and it is now in a clean bootloader state — safe to re-flash. " + + "Simply re-run the firmware update."; + case Pic32CleanupOutcome.CleanupFailed: + return "The update failed and the automatic re-erase cleanup could not complete, so the device " + + "may be in a half-flashed state. Power-cycle the device into bootloader mode and re-run " + + "the firmware update; the next erase will restore a clean state."; + case Pic32CleanupOutcome.NotEligible: + default: + return FirmwareUpdateContext.BuildRecoveryGuidance(failedState); + } + } + + /// + /// Recovers from a failed health + /// check (bad connect or a garbage version response) by issuing one + /// JMP_TO_APP soft reset, waiting for the bootloader to re-enumerate, + /// and retrying the connect + version check exactly once. See #298: the + /// observed failure is a dirty HID bootloader handle left behind by another + /// program, which a clean reset clears without touching flash. + /// + private async Task RecoverBootloaderHealthWithSoftResetAsync( + Exception originalFailure, + string? targetDevicePath, + string? targetLocationKey, + CancellationToken cancellationToken) + { + Logger.LogWarning( + originalFailure, + "Bootloader connect/health-check failed in {State}; attempting a JMP_TO_APP soft-reset recovery before giving up.", + FirmwareUpdateState.Connecting); + + try + { + // Best-effort: the handle may already be unusable (that's often why + // the health check failed in the first place), so a write failure + // here just falls through to the original failure below rather than + // surfacing a new unhandled exception. + if (_session.IsConnected) + { + await _session.SendJumpToApplicationAsync(cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning( + ex, + "JMP_TO_APP soft-reset write failed; the bootloader handle is likely already unusable."); + // Rethrow via ExceptionDispatchInfo (not `throw originalFailure;`) so the + // original exception's stack trace still points at the actual + // connect/health-check failure site, not this recovery method. + ExceptionDispatchInfo.Capture(originalFailure).Throw(); + throw; // unreachable; satisfies flow analysis + } + finally + { + await _session.SafeDisconnectAsync().ConfigureAwait(false); + } + + try + { + var recoveredDevice = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.WaitingForBootloader, + "wait for HID bootloader re-enumeration after soft reset", + ct => _session.WaitForBootloaderDeviceAsync(targetDevicePath, targetLocationKey, ct), + cancellationToken).ConfigureAwait(false); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Connecting, + "reconnect HID transport after soft reset", + ct => _session.ConnectWithRetryAsync(recoveredDevice, targetDevicePath, targetLocationKey, ct), + cancellationToken).ConfigureAwait(false); + + var version = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Connecting, + "request bootloader version after soft reset", + _session.RequestVersionAsync, + cancellationToken).ConfigureAwait(false); + + Logger.LogInformation("Bootloader health restored after JMP_TO_APP soft reset."); + return version; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogWarning( + ex, + "Bootloader is still unhealthy after the JMP_TO_APP soft-reset recovery attempt."); + ExceptionDispatchInfo.Capture(originalFailure).Throw(); + throw; // unreachable; satisfies flow analysis + } + } + + private async Task JumpToApplicationAndReconnectAsync( + IStreamingDevice device, + CancellationToken cancellationToken) + { + await _session.SendJumpToApplicationAsync(cancellationToken).ConfigureAwait(false); + + await _session.SafeDisconnectAsync().ConfigureAwait(false); + await _context.WaitForSerialReconnectAsync(device, cancellationToken).ConfigureAwait(false); + + // Discard the race-winning serial handle from the USB CDC re-enumeration + // window. On macOS the first SerialPort.Open() that succeeds after a PIC32 + // reset is typically a "shadow" handle: IsOpen==true, but the kernel + // device-node isn't fully wired yet — writes silently drop and reads see + // zero bytes. A fresh open after a brief settling delay yields a clean + // binding. Symptom without this step: SCPI Sends after reconnect appear + // to succeed but the device never responds (LEDs stay off, readiness + // probe returns null indefinitely until budget expires). + // Opt out by setting PostReconnectStaleHandleDelay = TimeSpan.Zero + // (callers on platforms where the first open is already clean). + if (Options.PostReconnectStaleHandleDelay > TimeSpan.Zero) + { + Logger.LogInformation( + "Discarding race-winning serial handle; closing and re-opening after {Delay} to obtain a clean USB CDC binding.", + Options.PostReconnectStaleHandleDelay); + device.Disconnect(); + await Task.Delay(Options.PostReconnectStaleHandleDelay, cancellationToken).ConfigureAwait(false); + await _context.WaitForSerialReconnectAsync(device, cancellationToken).ConfigureAwait(false); + } + + // Wake the post-reset device. PIC32 application firmware boots + // dormant (LEDs off, WiFi subsystem unpowered, won't answer LAN + // queries) until SYSTem:POWer:STATe 1 is sent. InitializeAsync + // handles that plus the rest of the standard init sequence + // (echo off, stream format, etc.). Without this, callers writing + // a "natural" probe like GetLanChipInfoAsync would silently fail + // for tens of seconds because the device is still dormant. + // Skipped for non-DaqifiDevice transports (e.g. test fakes); they + // are responsible for their own readiness if needed. + if (device is DaqifiDevice initializableDevice) + { + Logger.LogInformation("Waking post-reset device via InitializeAsync."); + try + { + // Pass the update's token so a cancel during the post-reset wake isn't ignored + // while InitializeAsync waits (up to ChannelPopulationTimeout) for channels. + await initializableDevice.InitializeAsync(cancellationToken: cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + // Don't fail the firmware update outright — the readiness + // probe (if configured) is the source of truth for "ready". + // Surface the init failure as a warning so a probe timeout + // later isn't mysterious. + Logger.LogWarning(ex, "InitializeAsync after reconnect threw; continuing to readiness probe."); + } + } + + // Application-readiness probe (closes #145). Serial transport + // re-enumeration succeeds well before the PIC32 application + // firmware is actually ready to answer protobuf status queries; + // if a downstream flow (LAN chip info, WiFi prep) starts before + // the app is up, those queries fail and callers reimplement + // their own retry. The probe is opt-in via options — when null, + // the legacy "serial reopened == done" semantics apply. + if (Options.PostReconnectReadinessProbe is { } probe) + { + await WaitForApplicationReadyAsync(device, probe, cancellationToken).ConfigureAwait(false); + } + } + + private async Task WaitForApplicationReadyAsync( + IStreamingDevice device, + Func> probe, + CancellationToken cancellationToken) + { + var totalTimeout = Options.PostReconnectReadinessTimeout; + var retryDelay = Options.PostReconnectReadinessRetryDelay; + + // Surface the wait at Information level so observers tailing the + // log can distinguish "stuck" from "deliberately polling". The + // wait can take up to PostReconnectReadinessTimeout (default 30s); + // without this, the JumpingToApp state appears hung beyond the + // initial transport reopen. + Logger.LogInformation( + "Waiting up to {Timeout} for device to become application-ready (post-reconnect readiness probe).", + totalTimeout); + var waitStart = DateTime.UtcNow; + + using var timeoutCts = new CancellationTokenSource(totalTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, timeoutCts.Token); + var linkedToken = linkedCts.Token; + + // Capture the most recent probe-thrown exception so a TimeoutException + // can carry the underlying cause as InnerException. Without this, + // deterministic probe failures (e.g. transport says it's open but + // the device never responds to status queries) report only as + // "timed out" — losing the actual error context unless Debug logs + // are on. + Exception? lastProbeException = null; + + // Tracks how many probe invocations have actually run. Distinct from + // the loop iteration counter so the timeout messages don't claim + // "attempt N" when the timeout fired before a probe ever executed. + var probesExecuted = 0; + while (true) + { + try + { + linkedToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Device did not become application-ready within {totalTimeout} (probes executed: {probesExecuted}). " + + "The transport reconnected but the readiness probe never returned true; the device may still be initializing or the firmware may have failed to start.", + lastProbeException); + } + + try + { + probesExecuted++; + // WaitAsync(linkedToken) enforces the timeout deadline even + // when the probe ignores its own CancellationToken and would + // otherwise hang or return after the budget elapses. When + // the deadline fires, WaitAsync throws OperationCanceledException + // immediately — we don't keep waiting for the rogue probe. + var isReady = await probe(device, linkedToken) + .WaitAsync(linkedToken) + .ConfigureAwait(false); + + // Successful probe invocation (true OR false) means the most + // recent attempt completed normally. Clear lastProbeException + // so a later timeout doesn't carry forward a stale exception + // from an earlier failed attempt as its InnerException. + lastProbeException = null; + + if (isReady) + { + var elapsed = DateTime.UtcNow - waitStart; + Logger.LogInformation( + "Device became application-ready after {Elapsed} on probe attempt {Attempt}.", + elapsed, + probesExecuted); + return; + } + Logger.LogDebug("Application-ready probe returned false on attempt {Attempt}; will retry.", probesExecuted); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + // Two cases reach here: + // 1. The wait deadline fired (timeoutCts canceled) — surface + // as TimeoutException so callers see the readiness budget. + // 2. The probe itself threw OperationCanceledException for + // some unrelated reason (its own internal CTS, etc). That + // must NOT crash the update loop — treat it as a probe + // failure and retry, same as any other thrown exception. + if (timeoutCts.IsCancellationRequested) + { + throw new TimeoutException( + $"Device did not become application-ready within {totalTimeout} (probes executed: {probesExecuted}). " + + "The wait for the readiness probe was canceled by the timeout — note the probe may ignore cancellation and continue running in the background.", + lastProbeException ?? ex); + } + + lastProbeException = ex; + Logger.LogDebug( + ex, + "Application-ready probe was canceled on attempt {Attempt}; treating as not-ready and retrying.", + probesExecuted); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + lastProbeException = ex; + Logger.LogDebug( + ex, + "Application-ready probe threw on attempt {Attempt}; treating as not-ready and retrying.", + probesExecuted); + } + + try + { + await Task.Delay(retryDelay, linkedToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException( + $"Device did not become application-ready within {totalTimeout} (probes executed: {probesExecuted}). " + + "The transport reconnected but the readiness probe never returned true; the device may still be initializing or the firmware may have failed to start.", + lastProbeException); + } + } + } +} diff --git a/src/Daqifi.Core/Firmware/WifiFlashProgressParser.cs b/src/Daqifi.Core/Firmware/WifiFlashProgressParser.cs new file mode 100644 index 00000000..c7b31a2c --- /dev/null +++ b/src/Daqifi.Core/Firmware/WifiFlashProgressParser.cs @@ -0,0 +1,154 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Daqifi.Core.Firmware; + +/// +/// Derives a 0-100 progress percentage for the WiFi (WINC) flash from the flash tool's live +/// stdout. The tool runs a fast local image-build phase (whose "written … (NN%)" lines reach +/// 100% and must be ignored, or they latch the bar near the top before the real flash starts) +/// followed by the multi-minute on-device write → read → verify phases. Those phases emit +/// block-address lines like 0x000000:[wwwwwwww] 0x008000:[wwwwwwww] … with no percent, +/// so this parser advances the bar from the highest block address seen relative to the flashed +/// range. Each phase occupies its own monotonically increasing band; +/// returns the new percent when it advances, or null when a line carries no progress. +/// +internal sealed class WifiFlashProgressParser +{ + private static readonly Regex BlockAddressRegex = new( + @"0x(?[0-9a-fA-F]+)\s*:", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + private static readonly Regex VerifyRangeRegex = new( + @"verify range\s+0x(?[0-9a-fA-F]+)\s+to\s+0x(?[0-9a-fA-F]+)", + RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + // Block size between consecutive addresses in the tool's progress lines (0x8000). + private const long BlockSize = 0x8000; + + // Default flashed range when the tool hasn't yet announced "verify range" (the WINC + // programmed region is 0x80000 = 512 KB); expanded if a larger address is observed. + private long _totalRange = 0x80000; + + // Base address of the flashed range. Block addresses in the tool output are absolute, so + // the covered fraction is measured relative to this start (0 unless "verify range" reports + // a non-zero base). + private long _rangeStart; + + private Phase _phase = Phase.PreFlash; + private double _lastPercent; + + private enum Phase + { + PreFlash, + Write, + Read, + Verify + } + + // Per-phase overall bands (write is weighted heaviest — it is by far the longest phase). + private static (double Start, double End) BandFor(Phase phase) => phase switch + { + Phase.Write => (5, 60), + Phase.Read => (60, 78), + Phase.Verify => (78, 100), + _ => (0, 0) + }; + + public double? Observe(string line) + { + if (string.IsNullOrWhiteSpace(line)) + { + return null; + } + + if (line.Contains("begin write operation", StringComparison.OrdinalIgnoreCase)) + { + return Advance(Phase.Write, BandFor(Phase.Write).Start); + } + + if (line.Contains("begin read operation", StringComparison.OrdinalIgnoreCase)) + { + return Advance(Phase.Read, BandFor(Phase.Read).Start); + } + + var verifyRange = VerifyRangeRegex.Match(line); + if (verifyRange.Success && + long.TryParse(verifyRange.Groups["start"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rangeStart) && + long.TryParse(verifyRange.Groups["end"].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var rangeEnd) && + rangeEnd > rangeStart) + { + _rangeStart = rangeStart; + _totalRange = rangeEnd - rangeStart; + return null; + } + + if (line.Contains("begin verify operation", StringComparison.OrdinalIgnoreCase)) + { + return Advance(Phase.Verify, BandFor(Phase.Verify).Start); + } + + // Block-address lines advance the current phase. Ignored before the device flash + // starts (PreFlash) so the image-build phase never moves the bar. + if (_phase != Phase.PreFlash) + { + var highestAddress = HighestBlockAddress(line); + if (highestAddress.HasValue) + { + // Block addresses are absolute; measure coverage from the range base so a + // non-zero start doesn't make the fraction saturate to 1 immediately. + var covered = highestAddress.Value - _rangeStart + BlockSize; + if (covered > _totalRange) + { + _totalRange = covered; + } + + var fraction = Math.Clamp(covered / (double)_totalRange, 0, 1); + var (start, end) = BandFor(_phase); + return Advance(_phase, start + (fraction * (end - start))); + } + } + + return null; + } + + private double? Advance(Phase phase, double candidatePercent) + { + if (phase > _phase) + { + _phase = phase; + } + + var clamped = Math.Clamp(candidatePercent, 0, 100); + + // Monotonic: never let the bar move backward (e.g. address resets to 0 at each new phase). + if (clamped <= _lastPercent) + { + return null; + } + + _lastPercent = clamped; + return clamped; + } + + private static long? HighestBlockAddress(string line) + { + long? highest = null; + foreach (Match match in BlockAddressRegex.Matches(line)) + { + if (long.TryParse( + match.Groups["addr"].Value, + NumberStyles.HexNumber, + CultureInfo.InvariantCulture, + out var address)) + { + if (highest is null || address > highest.Value) + { + highest = address; + } + } + } + + return highest; + } +} diff --git a/src/Daqifi.Core/Firmware/WifiModuleUpdater.cs b/src/Daqifi.Core/Firmware/WifiModuleUpdater.cs new file mode 100644 index 00000000..ee50aa24 --- /dev/null +++ b/src/Daqifi.Core/Firmware/WifiModuleUpdater.cs @@ -0,0 +1,813 @@ +using Daqifi.Core.Communication.Producers; +using Daqifi.Core.Device; +using Microsoft.Extensions.Logging; + +namespace Daqifi.Core.Firmware; + +/// +/// The WiFi (WINC) module half of : probes the module's current +/// firmware version, puts the device into LAN firmware-update mode, drives the external WINC flash +/// tool (interactive prompt handshake, transient-failure retries, output-based success +/// verification), then reconnects and restores the LAN configuration. Owns the external process +/// runner and the firmware download service; shared state-machine, progress and retry plumbing +/// lives in . Callers must serialize invocations — the service +/// facade holds the operation lock. +/// +internal sealed class WifiModuleUpdater +{ + // WINC flash tool prompt markers (stdin handshake). + private const string WincBootPromptMarker = "Power cycle WINC and set to bootloader mode"; + private const string WincContinuePromptMarker = "Press any key to continue"; + + // WINC flash tool failure markers. The "transient" set is recoverable by re-running the + // tool once the device has settled into bridge mode; the full set forces a failure verdict. + private const string WifiBridgeIdQueryFailureMarker = "failed to read serial bridge ID query response"; + private const string WifiProgrammerInitFailureMarker = "failed to initialise programming firmware"; + private const string WifiProgrammingFailedMarker = "Programming device failed"; + private const string WifiReadXoFailedMarker = "Reading XO (offset) failed"; + private const string WifiBuildImageFailedMarker = "Building programming image failed"; + + private readonly FirmwareUpdateContext _context; + private readonly IExternalProcessRunner _externalProcessRunner; + private readonly IFirmwareDownloadService _firmwareDownloadService; + + internal WifiModuleUpdater( + FirmwareUpdateContext context, + IExternalProcessRunner externalProcessRunner, + IFirmwareDownloadService firmwareDownloadService) + { + _context = context; + _externalProcessRunner = externalProcessRunner; + _firmwareDownloadService = firmwareDownloadService; + } + + private ILogger Logger => _context.Logger; + + private FirmwareUpdateServiceOptions Options => _context.Options; + + internal async Task RunUpdateAsync( + IStreamingDevice device, + string firmwarePath, + IProgress? progress, + bool skipVersionCheck, + CancellationToken cancellationToken) + { + const long totalBytes = 100; + + try + { + if (!skipVersionCheck + && await IsWifiFirmwareUpToDateAsync(device, progress, cancellationToken).ConfigureAwait(false)) + { + return; + } + + _context.TransitionToState(FirmwareUpdateState.PreparingDevice, "Preparing device for WiFi module update."); + _context.ReportProgress(progress, FirmwareUpdateState.PreparingDevice, 0, _context.CurrentOperation, 0, totalBytes); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.PreparingDevice, + "prepare device for WiFi update mode", + async stateToken => + { + FirmwareUpdateContext.EnsureDeviceConnected(device); + + if (device.IsStreaming) + { + device.StopStreaming(); + } + + device.Send(ScpiMessageProducer.SetLanFirmwareUpdateMode); + await Task.Delay(Options.PostLanFirmwareModeDelay, stateToken).ConfigureAwait(false); + device.Disconnect(); + + // The OS does not free the USB-CDC COM handle the instant Disconnect returns. + // Wait so the external WINC flash tool can open the port; without this the tool + // fails to open it and exits in ~1s producing no programming output (caught by + // the output-based success verification below as a failure). + if (Options.PostLanDisconnectPortReleaseDelay > TimeSpan.Zero) + { + await Task.Delay(Options.PostLanDisconnectPortReleaseDelay, stateToken).ConfigureAwait(false); + } + }, + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.Programming, "Running WiFi module flash tool."); + _context.ReportProgress(progress, FirmwareUpdateState.Programming, 20, _context.CurrentOperation, 0, totalBytes); + + // Build a fresh request per attempt: the stdin prompt responder carries one-shot + // state (it answers the WINC prompt exactly once), so reusing a request across a + // retry would leave the responder already "spent". The factory takes the per-attempt + // linked token so the prompt-delay wait stays cancellable. + var processResult = await RunWifiFlashToolWithRetryAsync( + ct => BuildWifiProcessRequest(device, firmwarePath, progress, ct), + cancellationToken).ConfigureAwait(false); + + if (processResult.TimedOut) + { + throw new TimeoutException( + $"WiFi flashing process timed out after {Options.WifiProcessTimeout.TotalSeconds:F0} seconds " + + $"(exit code {processResult.ExitCode}). " + + BuildProcessLogExcerpt(processResult)); + } + + // Verify success from the tool's OWN output, not from its exit code or run duration. + // A genuine flash ends with "verify passed" then the success marker; when the tool + // cannot reach the WINC — most often because the device never released the serial port, + // so the tool couldn't open it and bailed in ~1s — it produces none of these. The exit + // code is unreliable in both directions (some WINC script/tool combinations emit failure + // markers yet still exit 0), so the success marker is the authority. + if (!ContainsAny(processResult.StandardOutputLines, Options.WifiFlashSuccessMarker)) + { + throw new IOException( + $"WiFi flashing did not complete successfully — the flash tool never reported " + + $"'{Options.WifiFlashSuccessMarker}'. {DescribeWifiFlashFailure(processResult)} " + + BuildProcessLogExcerpt(processResult)); + } + + // Everything past this point runs on an already-flashed, already-verified WINC image, + // so it gets its own state rather than sharing Verifying with the PIC32 CRC check — + // a reconnect timeout here is environmental, not a bad flash (#398 gap 4). + _context.TransitionToState( + FirmwareUpdateState.ReconnectingAfterFlash, + "Reconnecting device and restoring LAN configuration."); + _context.ReportProgress(progress, FirmwareUpdateState.ReconnectingAfterFlash, 92, _context.CurrentOperation, 92, totalBytes); + + await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.ReconnectingAfterFlash, + "reconnect serial transport after WiFi flash", + async stateToken => + { + await Task.Delay(Options.PostWifiReconnectDelay, stateToken).ConfigureAwait(false); + await _context.WaitForSerialReconnectAsync(device, stateToken).ConfigureAwait(false); + device.Send(ScpiMessageProducer.EnableNetworkLan); + device.Send(ScpiMessageProducer.ApplyNetworkLan); + device.Send(ScpiMessageProducer.SaveNetworkLan); + }, + cancellationToken).ConfigureAwait(false); + + _context.TransitionToState(FirmwareUpdateState.Complete, "WiFi module update completed."); + _context.ReportProgress(progress, FirmwareUpdateState.Complete, 100, _context.CurrentOperation, totalBytes, totalBytes); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _context.TransitionToState(FirmwareUpdateState.Failed, "WiFi module update canceled."); + _context.ReportProgress(progress, FirmwareUpdateState.Failed, _context.LastReportedPercent, _context.CurrentOperation, 0, totalBytes); + Logger.LogWarning("WiFi module update canceled."); + throw; + } + catch (Exception ex) + { + var failedState = _context.CurrentState; + var failedOperation = _context.CurrentOperation; + _context.TransitionToState(FirmwareUpdateState.Failed, failedOperation); + _context.ReportProgress(progress, FirmwareUpdateState.Failed, _context.LastReportedPercent, failedOperation, 0, totalBytes); + Logger.LogError(ex, "WiFi module update failed in state {State}.", failedState); + + throw _context.CreateFirmwareUpdateException(failedState, failedOperation, ex); + } + } + + internal async Task CheckStatusAsync( + IStreamingDevice device, + CancellationToken cancellationToken) + { + if (device is not ILanChipInfoProvider lanChipInfoProvider) + { + return new WifiFirmwareStatus + { + IsUpToDate = false, + Reason = WifiFirmwareStatusReason.DeviceDoesNotSupportLanQuery, + }; + } + + // Closes #301: right after a PIC32 reflash the WINC module comes back + // powered off, so the first GETChipInfo? probe below would always fail, + // report ChipInfoUnavailable, and send the caller into a needless + // multi-minute WiFi reflash. Powering it on first (mirroring what + // daqifi-desktop's FirmwareUpdateCoordinator does today) closes that gap. + // Skipped when the device isn't connected — Send would throw, and a + // disconnected device will fail the chip-info probe regardless. + if (Options.PowerOnWifiModuleBeforeProbe && device.IsConnected) + { + // Observe cancellation before this state-changing Send: a + // pre-cancelled call must not power on the device before the + // cancellation is surfaced to the caller. + cancellationToken.ThrowIfCancellationRequested(); + + try + { + device.Send(ScpiMessageProducer.TurnDeviceOn); + if (Options.PowerOnWifiModuleSettleDelay > TimeSpan.Zero) + { + await Task.Delay(Options.PowerOnWifiModuleSettleDelay, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Best-effort: the chip-info probe below has its own bounded + // retry and gracefully degrades to ChipInfoUnavailable, so a + // failure to send the power-on command must not abort the + // whole status check. Skip the settle delay too — there is + // nothing to settle if the send itself failed. + Logger.LogDebug(ex, "Failed to send WINC power-on command before chip-info probe; continuing without it."); + } + } + + // Bounded retry for the LAN chip-info probe (closes #144). Right + // after a PIC32 firmware update the application is up while WiFi + // is still finishing startup, so the first chip-info query can + // transiently fail; without retry, the WiFi version decision + // would short-circuit to ChipInfoUnavailable and flow on to a + // multi-minute reflash of already-current WiFi firmware. The + // retry budget is bounded (LanChipInfoMaxAttempts × RetryDelay) + // and observes cancellation between attempts. + var (chipInfo, wasLanNotInitialized) = await TryGetLanChipInfoWithRetryAsync( + device, lanChipInfoProvider, cancellationToken).ConfigureAwait(false); + if (chipInfo == null) + { + return new WifiFirmwareStatus + { + IsUpToDate = false, + Reason = wasLanNotInitialized + ? WifiFirmwareStatusReason.LanNotInitialized + : WifiFirmwareStatusReason.ChipInfoUnavailable, + }; + } + + FirmwareReleaseInfo? latestWifi; + try + { + latestWifi = await _firmwareDownloadService + .GetLatestWifiReleaseAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Logger.LogDebug(ex, "Failed to query latest WiFi firmware release; reporting status as LatestReleaseUnavailable."); + return new WifiFirmwareStatus + { + CurrentChipInfo = chipInfo, + IsUpToDate = false, + Reason = WifiFirmwareStatusReason.LatestReleaseUnavailable, + }; + } + + if (latestWifi == null) + { + return new WifiFirmwareStatus + { + CurrentChipInfo = chipInfo, + IsUpToDate = false, + Reason = WifiFirmwareStatusReason.LatestReleaseUnavailable, + }; + } + + // Only the device-reported version needs parsing; latestWifi.Version + // is already a strongly-typed FirmwareVersion from FirmwareDownloadService. + // Re-parsing TagName would risk divergence from the canonical Version + // (different tag prefix conventions, etc.) and cost an extra parse. + if (!FirmwareVersion.TryParse(chipInfo.FwVersion, out var deviceVersion)) + { + return new WifiFirmwareStatus + { + CurrentChipInfo = chipInfo, + LatestRelease = latestWifi, + IsUpToDate = false, + Reason = WifiFirmwareStatusReason.VersionUnparseable, + }; + } + + var isCurrent = deviceVersion >= latestWifi.Version; + return new WifiFirmwareStatus + { + CurrentChipInfo = chipInfo, + LatestRelease = latestWifi, + IsUpToDate = isCurrent, + Reason = isCurrent ? WifiFirmwareStatusReason.UpToDate : WifiFirmwareStatusReason.UpdateAvailable, + }; + } + + private async Task IsWifiFirmwareUpToDateAsync( + IStreamingDevice device, + IProgress? progress, + CancellationToken cancellationToken) + { + // Internal callsite: in addition to deciding the boolean, we must + // transition to Complete + report 100% progress so the caller's + // single UpdateWifiModuleAsync(...) call observes the same end-state + // as a successful flash. CheckWifiFirmwareStatusAsync (the public + // planning API) does not have that side effect — its callers own + // their own logging / UI transitions. + var status = await CheckStatusAsync(device, cancellationToken).ConfigureAwait(false); + + switch (status.Reason) + { + case WifiFirmwareStatusReason.UpdateAvailable: + Logger.LogInformation( + "WiFi firmware update available: device has {DeviceVersion}, latest is {LatestVersion}.", + status.CurrentChipInfo!.FwVersion, + status.LatestRelease!.TagName); + return false; + + case WifiFirmwareStatusReason.UpToDate: + var message = $"WiFi firmware is already up to date (device: {status.CurrentChipInfo!.FwVersion}, latest: {status.LatestRelease!.TagName})."; + Logger.LogInformation(message); + _context.TransitionToState(FirmwareUpdateState.Complete, message); + _context.ReportProgress(progress, FirmwareUpdateState.Complete, 100, message, 100, 100); + return true; + + default: + // DeviceDoesNotSupportLanQuery, ChipInfoUnavailable, + // LanNotInitialized, LatestReleaseUnavailable, + // VersionUnparseable — proceed with the flash conservatively. + return false; + } + } + + private async Task<(LanChipInfo? ChipInfo, bool WasLanNotInitialized)> TryGetLanChipInfoWithRetryAsync( + IStreamingDevice device, + ILanChipInfoProvider lanChipInfoProvider, + CancellationToken cancellationToken) + { + var maxAttempts = Math.Max(1, Options.LanChipInfoMaxAttempts); + var retryDelay = Options.LanChipInfoRetryDelay; + var totalTimeout = Options.LanChipInfoTotalTimeout; + + // Tracks the most recent failure's classification (reset on any + // non-LanNotInitialized outcome) so the caller can report the + // specific WifiFirmwareStatusReason.LanNotInitialized only when + // that was genuinely the terminal condition, not stale from an + // earlier attempt. Sent at most once per probe (closes #203) — + // repeatedly kicking APPLY would tear down and re-init the WINC + // on every failed attempt, risking disruption of an already- + // associated WiFi link for no additional benefit. + var lastFailureWasLanNotInitialized = false; + var hasSentLanApply = false; + + // Wall-clock budget guards against the pathological case where + // attempt-count × per-attempt-timeout + retry-delay sum vastly + // exceeds the configured retry budget (e.g., 3 × 2s device timeout + // + 2 × 2s delay = ~10s while the operation lock is held). Linking + // the caller's CT preserves cancellation semantics; the timeout + // CTS just adds a deadline. + using var timeoutCts = new CancellationTokenSource(totalTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, timeoutCts.Token); + var linkedToken = linkedCts.Token; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + linkedToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + Logger.LogDebug( + "LAN chip-info probe hit total timeout ({Timeout}) before attempt {Attempt}/{Max}.", + totalTimeout, + attempt, + maxAttempts); + return (null, lastFailureWasLanNotInitialized); + } + + try + { + var chipInfo = await lanChipInfoProvider.GetLanChipInfoAsync(linkedToken).ConfigureAwait(false); + if (chipInfo != null) + { + if (attempt > 1) + { + Logger.LogDebug( + "LAN chip-info query succeeded on attempt {Attempt}/{Max}.", + attempt, + maxAttempts); + } + return (chipInfo, false); + } + lastFailureWasLanNotInitialized = false; + Logger.LogDebug( + "LAN chip-info query returned null on attempt {Attempt}/{Max}.", + attempt, + maxAttempts); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + Logger.LogDebug( + "LAN chip-info probe hit total timeout ({Timeout}) during attempt {Attempt}/{Max}.", + totalTimeout, + attempt, + maxAttempts); + return (null, lastFailureWasLanNotInitialized); + } + catch (LanNotInitializedException ex) + { + lastFailureWasLanNotInitialized = true; + Logger.LogDebug( + ex, + "LAN chip-info query on attempt {Attempt}/{Max} reported the WINC state machine is not initialized.", + attempt, + maxAttempts); + + if (Options.KickLanApplyOnNotInitialized && !hasSentLanApply && device.IsConnected) + { + // Observe cancellation before this state-changing Send, mirroring + // the WINC power-on guard above: a cancelled probe must not still + // kick APPLY on the device. Uses the caller's token (not the + // linked timeout token) so a total-timeout expiry alone doesn't + // suppress a kick the caller never actually asked to cancel. + cancellationToken.ThrowIfCancellationRequested(); + + hasSentLanApply = true; + try + { + device.Send(ScpiMessageProducer.ApplyNetworkLan); + Logger.LogDebug("Sent LAN:APPLY to initialize the WINC state machine after a not-initialized chip-info response."); + } + catch (Exception sendEx) when (sendEx is not OperationCanceledException) + { + // Best-effort: falling through to the normal retry delay/loop + // below still gives the device a chance to recover on its own. + Logger.LogDebug(sendEx, "Failed to send LAN:APPLY after a not-initialized chip-info response; continuing retry loop without it."); + } + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + lastFailureWasLanNotInitialized = false; + Logger.LogDebug( + ex, + "LAN chip-info query failed on attempt {Attempt}/{Max}.", + attempt, + maxAttempts); + } + + if (attempt < maxAttempts) + { + try + { + await Task.Delay(retryDelay, linkedToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + Logger.LogDebug( + "LAN chip-info probe hit total timeout ({Timeout}) during retry delay after attempt {Attempt}/{Max}.", + totalTimeout, + attempt, + maxAttempts); + return (null, lastFailureWasLanNotInitialized); + } + } + } + + Logger.LogDebug( + "LAN chip-info query exhausted {Max} attempts; reporting status as {Reason}.", + maxAttempts, + lastFailureWasLanNotInitialized ? WifiFirmwareStatusReason.LanNotInitialized : WifiFirmwareStatusReason.ChipInfoUnavailable); + return (null, lastFailureWasLanNotInitialized); + } + + private ExternalProcessRequest BuildWifiProcessRequest( + IStreamingDevice device, + string firmwarePath, + IProgress? progress, + CancellationToken cancellationToken) + { + var toolPath = ResolveWifiToolPath(firmwarePath); + var port = ResolveWifiPort(device); + + var toolArguments = Options.WifiFlashToolArgumentsTemplate + .Replace("{port}", QuoteArgument(port), StringComparison.Ordinal) + .Replace("{firmwarePath}", QuoteArgument(firmwarePath), StringComparison.Ordinal); + + var executablePath = toolPath; + var executableArguments = toolArguments; + + var extension = Path.GetExtension(toolPath); + if ((extension.Equals(".cmd", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".bat", StringComparison.OrdinalIgnoreCase)) && + OperatingSystem.IsWindows()) + { + executablePath = "cmd.exe"; + executableArguments = $"/c \"{toolPath}\" {toolArguments}"; + } + + // Tracks the live device-flash phase (write/read/verify) from the tool's block-address + // output so the bar advances across the multi-minute flash, instead of latching to the + // image-build phase's "100%" lines and freezing. See WifiFlashProgressParser. + var progressParser = new WifiFlashProgressParser(); + var progressLock = new object(); + + return new ExternalProcessRequest + { + FileName = executablePath, + Arguments = executableArguments, + WorkingDirectory = Path.GetDirectoryName(toolPath), + Timeout = Options.WifiProcessTimeout, + OnStandardOutputLine = line => + { + Logger.LogInformation("WiFi flash output: {Line}", line); + + double processPercent; + lock (progressLock) + { + var updated = progressParser.Observe(line); + if (!updated.HasValue) + { + return; + } + + processPercent = updated.Value; + } + + // Map the 0-100 device-flash percent into the Programming state's 20-90 overall band. + var overallPercent = 20 + (processPercent * 0.70); + _context.ReportProgress( + progress, + FirmwareUpdateState.Programming, + overallPercent, + line, + (long)Math.Round(processPercent), + 100); + }, + OnStandardErrorLine = line => Logger.LogWarning("WiFi flash stderr: {Line}", line), + StandardInputResponseFactory = BuildWifiPromptResponder(cancellationToken) + }; + } + + /// + /// Builds the stdin responder for the WINC flash tool's interactive prompts. At the + /// "Power cycle WINC" prompt it fires the optional bridge-activation callback, waits + /// so the firmware can + /// finish bridge-mode init, then sends the empty continue line. The returned delegate carries + /// one-shot state, so a fresh responder must be built for each flash attempt. + /// + /// + /// The flash run's linked token (state timeout + caller cancellation). The prompt-response wait + /// observes it so a timeout or cancel unblocks the output-pump thread promptly instead of + /// sleeping out the full delay after the process has been killed. + /// + private Func BuildWifiPromptResponder(CancellationToken cancellationToken) + { + var continueSignalSent = false; + + return line => + { + if (line.Contains(WincBootPromptMarker, StringComparison.OrdinalIgnoreCase)) + { + if (continueSignalSent) + { + return null; + } + + if (Options.WifiBridgeActivationCallback is { } activate) + { + Logger.LogInformation("Activating WiFi bridge mode before WINC programming."); + try + { + activate(); + Logger.LogInformation("Bridge activation callback completed; waiting for firmware bridge init."); + } + catch (Exception ex) + { + // The bridge activation is best-effort — a failure here must not abort the + // flash; the tool may still reach the WINC and the success verification is + // the source of truth for the outcome. + Logger.LogWarning(ex, "WiFi bridge activation callback threw; continuing with the flash."); + } + } + else + { + Logger.LogInformation("WiFi flash tool requested WINC power-cycle; waiting before sending continue signal."); + } + + if (Options.WincBootPromptResponseDelay > TimeSpan.Zero) + { + // The responder runs inline on the process output-pump thread and the tool + // blocks on stdin until we return, so the wait must be synchronous (a fire-and- + // forget Task.Delay would not pause it). Block on a cancellable delay so a run + // timeout / cancel unblocks the pump immediately; if canceled, skip the continue + // signal — the process is being torn down anyway. + try + { + Task.Delay(Options.WincBootPromptResponseDelay, cancellationToken) + .GetAwaiter() + .GetResult(); + } + catch (OperationCanceledException) + { + Logger.LogDebug("WINC prompt-response wait canceled; skipping the continue signal."); + return null; + } + } + + continueSignalSent = true; + Logger.LogInformation("Sending continue signal to WiFi flash tool."); + return string.Empty; + } + + if (!continueSignalSent && + line.Contains(WincContinuePromptMarker, StringComparison.OrdinalIgnoreCase)) + { + continueSignalSent = true; + Logger.LogInformation("Sending continue signal to WiFi flash tool."); + return string.Empty; + } + + return null; + }; + } + + private async Task RunWifiFlashToolWithRetryAsync( + Func requestFactory, + CancellationToken cancellationToken) + { + var attempts = Math.Max(1, Options.WifiFlashAttempts); + ExternalProcessResult result = null!; + + for (var attempt = 1; attempt <= attempts; attempt++) + { + // Build the request inside the state-timeout lambda so the responder closes over the + // linked token (state timeout + caller cancellation) and its prompt-delay wait unblocks + // when the run is canceled or times out. + result = await _context.ExecuteWithStateTimeoutAsync( + FirmwareUpdateState.Programming, + "execute WiFi flash process", + ct => _externalProcessRunner.RunAsync(requestFactory(ct), ct), + cancellationToken).ConfigureAwait(false); + + // A timeout or a verified success ends the loop; so does a non-transient failure, + // since re-running the tool only helps when the device hadn't yet settled into bridge + // mode. Only a transient bridge-init failure with attempts remaining triggers a retry. + if (result.TimedOut || + ContainsAny(result.StandardOutputLines, Options.WifiFlashSuccessMarker) || + attempt >= attempts || + !IsTransientWifiFlashFailure(result)) + { + return result; + } + + Logger.LogWarning( + "WiFi flash tool reported a transient bridge-init failure on attempt {Attempt}/{Attempts}; retrying in {DelayMs} ms.", + attempt, + attempts, + Options.WifiFlashRetryDelay.TotalMilliseconds); + await Task.Delay(Options.WifiFlashRetryDelay, cancellationToken).ConfigureAwait(false); + } + + return result; + } + + /// + /// True when the result shows a transient bridge-init failure — the device hadn't finished + /// entering bridge mode when the tool issued its first query. Re-running the tool once the + /// device has settled typically succeeds, so these (and only these) are retried. + /// + private static bool IsTransientWifiFlashFailure(ExternalProcessResult result) + { + // Retry ONLY on the bridge-init markers — the device hadn't finished entering bridge mode + // when the tool issued its first query, which a re-run fixes. These markers co-occur with + // the generic "Programming device failed" / "Reading XO failed" lines in the real failure + // output, so keying on them alone still catches the transient case without retrying a + // genuine (non-recoverable) programming failure — which would only delay the real error and + // needlessly re-fire the bridge-activation callback. Scan both streams since tool/script + // versions route these lines inconsistently. + return ContainsAny(result.StandardErrorLines, WifiBridgeIdQueryFailureMarker, WifiProgrammerInitFailureMarker) + || ContainsAny(result.StandardOutputLines, WifiBridgeIdQueryFailureMarker, WifiProgrammerInitFailureMarker); + } + + /// + /// Produces a short human-readable reason for a flash that did not report the success marker, + /// distinguishing "the tool never opened the port" from a device-reported programming failure. + /// + private static string DescribeWifiFlashFailure(ExternalProcessResult result) + { + // A "Building programming image failed" is a LOCAL image-build failure that happens before + // any on-device flashing, so it must not be reported as a device-reachability failure. + if (ContainsAny(result.StandardErrorLines, WifiBuildImageFailedMarker) || + ContainsAny(result.StandardOutputLines, WifiBuildImageFailedMarker)) + { + return "The flash tool failed to build the programming image (before contacting the device)."; + } + + // Markers that imply the tool actually reached the device. Scan both streams — tool/script + // versions route these to stdout vs stderr inconsistently. + if (ContainsAny( + result.StandardErrorLines, + WifiBridgeIdQueryFailureMarker, + WifiProgrammerInitFailureMarker, + WifiProgrammingFailedMarker, + WifiReadXoFailedMarker) || + ContainsAny( + result.StandardOutputLines, + WifiBridgeIdQueryFailureMarker, + WifiProgrammerInitFailureMarker, + WifiProgrammingFailedMarker, + WifiReadXoFailedMarker)) + { + return "The flash tool reached the device but reported a programming failure."; + } + + // "No output" must consider BOTH streams — some failure modes (tool/port errors) print + // only to stderr, so checking stdout alone would mislabel them as "no output". + if (result.StandardOutputLines.Count == 0 && result.StandardErrorLines.Count == 0) + { + return "The flash tool produced no output — it likely could not open the serial port " + + "(the device may not have released it)."; + } + + if (result.StandardOutputLines.Count == 0 && result.StandardErrorLines.Count > 0) + { + return $"The flash tool wrote only to stderr and never programmed the device (exit code {result.ExitCode})."; + } + + return $"The flash tool exited with code {result.ExitCode} without completing the program."; + } + + private static bool ContainsAny(IReadOnlyList lines, params string[] markers) + { + foreach (var line in lines) + { + foreach (var marker in markers) + { + if (line.Contains(marker, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + + return false; + } + + private string ResolveWifiToolPath(string firmwarePath) + { + if (File.Exists(firmwarePath)) + { + return firmwarePath; + } + + if (Directory.Exists(firmwarePath)) + { + var matches = Directory.GetFiles( + firmwarePath, + Options.WifiFlashToolFileName, + SearchOption.AllDirectories); + + if (matches.Length == 0) + { + throw new FileNotFoundException( + $"Could not locate '{Options.WifiFlashToolFileName}' under '{firmwarePath}'."); + } + + return matches[0]; + } + + throw new FileNotFoundException("WiFi firmware path was not found.", firmwarePath); + } + + private string ResolveWifiPort(IStreamingDevice device) + { + if (!string.IsNullOrWhiteSpace(Options.WifiPortOverride)) + { + return Options.WifiPortOverride; + } + + if (!string.IsNullOrWhiteSpace(device.Name)) + { + return device.Name; + } + + throw new InvalidOperationException("Unable to resolve a serial port name for WiFi update."); + } + + private static string QuoteArgument(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "\"\""; + } + + var escaped = value.Replace("\"", "\\\"", StringComparison.Ordinal); + return escaped.IndexOfAny([' ', '\t']) >= 0 + ? $"\"{escaped}\"" + : escaped; + } + + private static string BuildProcessLogExcerpt(ExternalProcessResult result) + { + var excerpt = result.StandardErrorLines + .Concat(result.StandardOutputLines) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Take(5) + .ToArray(); + + if (excerpt.Length == 0) + { + return "No process output captured."; + } + + return $"Process output excerpt: {string.Join(" | ", excerpt)}"; + } +}