Skip to content
159 changes: 159 additions & 0 deletions src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,165 @@ public async Task UpdateWifiModuleAsync_WhenDeviceDoesNotSupportLanQuery_Proceed
Assert.Contains("SYSTem:COMMUnicate:LAN:FWUpdate", device.SentCommands);
}

[Fact]
public async Task CheckWifiFirmwareStatusAsync_WhenVersionMatches_ReturnsUpToDate()
{
// Closes #143: callers can now make the version decision themselves
// without triggering UpdateWifiModuleAsync's hidden internal probe.
// This test asserts the new public planning method returns the
// expected status object and DOES NOT mutate service state.
var wifiRelease = new FirmwareReleaseInfo
{
Version = new FirmwareVersion(19, 5, 4, null, 0),
TagName = "19.5.4",
IsPreRelease = false
};
var device = new FakeLanChipInfoStreamingDevice("COM9", chipInfo: new LanChipInfo
{
ChipId = 1234,
FwVersion = "19.5.4",
BuildDate = "Jan 8 2019"
});

var service = new FirmwareUpdateService(
new FakeHidTransport(),
new FakeFirmwareDownloadService { LatestWifiRelease = wifiRelease },
new FakeExternalProcessRunner(),
NullLogger<FirmwareUpdateService>.Instance,
new FakeBootloaderProtocol([[0x10]]),
new FakeHidDeviceEnumerator([]),
CreateFastOptions());

var status = await service.CheckWifiFirmwareStatusAsync(device);

Assert.True(status.IsUpToDate);
Assert.Equal(WifiFirmwareStatusReason.UpToDate, status.Reason);
Assert.NotNull(status.CurrentChipInfo);
Assert.Equal("19.5.4", status.CurrentChipInfo!.FwVersion);
Assert.NotNull(status.LatestRelease);
Assert.Equal("19.5.4", status.LatestRelease!.TagName);

// The planning method must NOT mutate service state (the internal
// IsWifiFirmwareUpToDateAsync transitions to Complete on its
// hit-path; CheckWifiFirmwareStatusAsync must not, so callers can
// call it freely without locking out a subsequent flash.
Assert.Equal(FirmwareUpdateState.Idle, service.CurrentState);
}

[Fact]
public async Task CheckWifiFirmwareStatusAsync_WhenVersionOlder_ReturnsUpdateAvailable()
{
var wifiRelease = new FirmwareReleaseInfo
{
Version = new FirmwareVersion(19, 6, 1, null, 0),
TagName = "19.6.1",
IsPreRelease = false
};
var device = new FakeLanChipInfoStreamingDevice("COM10", chipInfo: new LanChipInfo
{
ChipId = 1234,
FwVersion = "19.5.4",
BuildDate = "Jan 8 2019"
});

var service = new FirmwareUpdateService(
new FakeHidTransport(),
new FakeFirmwareDownloadService { LatestWifiRelease = wifiRelease },
new FakeExternalProcessRunner(),
NullLogger<FirmwareUpdateService>.Instance,
new FakeBootloaderProtocol([[0x10]]),
new FakeHidDeviceEnumerator([]),
CreateFastOptions());

var status = await service.CheckWifiFirmwareStatusAsync(device);

Assert.False(status.IsUpToDate);
Assert.Equal(WifiFirmwareStatusReason.UpdateAvailable, status.Reason);
Assert.Equal(FirmwareUpdateState.Idle, service.CurrentState);
}

[Fact]
public async Task CheckWifiFirmwareStatusAsync_WhenDeviceDoesNotSupportLanQuery_ReturnsDeviceDoesNotSupportLanQuery()
{
var device = new FakeStreamingDevice("COM11");

var service = new FirmwareUpdateService(
new FakeHidTransport(),
new FakeFirmwareDownloadService(),
new FakeExternalProcessRunner(),
NullLogger<FirmwareUpdateService>.Instance,
new FakeBootloaderProtocol([[0x10]]),
new FakeHidDeviceEnumerator([]),
CreateFastOptions());

var status = await service.CheckWifiFirmwareStatusAsync(device);

Assert.False(status.IsUpToDate);
Assert.Equal(WifiFirmwareStatusReason.DeviceDoesNotSupportLanQuery, status.Reason);
Assert.Null(status.CurrentChipInfo);
Assert.Null(status.LatestRelease);
}

[Fact]
public async Task UpdateWifiModuleAsync_WithSkipVersionCheck_BypassesProbeAndAlwaysFlashes()
{
// The motivating case for #143: caller already made the version
// decision (via CheckWifiFirmwareStatusAsync). Pass skipVersionCheck:true
// so Core does NOT re-probe the device — even when the device's
// current version equals the latest, the flash flow runs.
var wifiRelease = new FirmwareReleaseInfo
{
Version = new FirmwareVersion(19, 5, 4, null, 0),
TagName = "19.5.4",
IsPreRelease = false
};
var device = new FakeLanChipInfoStreamingDevice("COM12", chipInfo: new LanChipInfo
{
ChipId = 1234,
FwVersion = "19.5.4", // Matches latest — would normally short-circuit
BuildDate = "Jan 8 2019"
});

var externalProcessRunner = new FakeExternalProcessRunner
{
NextResult = new ExternalProcessResult(0, false, TimeSpan.FromMilliseconds(10), [], [])
};

var options = CreateFastOptions();
options.PostLanFirmwareModeDelay = TimeSpan.FromMilliseconds(5);
options.PostWifiReconnectDelay = TimeSpan.FromMilliseconds(5);

var service = new FirmwareUpdateService(
new FakeHidTransport(),
new FakeFirmwareDownloadService { LatestWifiRelease = wifiRelease },
externalProcessRunner,
NullLogger<FirmwareUpdateService>.Instance,
new FakeBootloaderProtocol([[0x10]]),
new FakeHidDeviceEnumerator([]),
options);

var firmwareDir = CreateTempDirectory();
File.WriteAllText(Path.Combine(firmwareDir, "winc_flash_tool.cmd"), "@echo off");

try
{
await service.UpdateWifiModuleAsync(
device,
firmwareDir,
progress: null,
skipVersionCheck: true);
}
finally
{
Directory.Delete(firmwareDir, recursive: true);
}

// Even though device version matched latest, flash ran because
// skipVersionCheck bypassed the probe.
Assert.Equal(FirmwareUpdateState.Complete, service.CurrentState);
Assert.Contains("SYSTem:COMMUnicate:LAN:FWUpdate", device.SentCommands);
}

private static FirmwareUpdateServiceOptions CreateFastOptions()
{
return new FirmwareUpdateServiceOptions
Expand Down
118 changes: 98 additions & 20 deletions src/Daqifi.Core/Firmware/FirmwareUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ public async Task UpdateWifiModuleAsync(
IStreamingDevice device,
string firmwarePath,
IProgress<FirmwareUpdateProgress>? progress = null,
bool skipVersionCheck = false,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(device);
Expand All @@ -174,10 +175,21 @@ public async Task UpdateWifiModuleAsync(
}

await RunExclusiveAsync(
ct => RunWifiUpdateAsync(device, firmwarePath, progress, ct),
ct => RunWifiUpdateAsync(device, firmwarePath, progress, skipVersionCheck, ct),
cancellationToken).ConfigureAwait(false);
}

/// <inheritdoc />
public async Task<WifiFirmwareStatus> CheckWifiFirmwareStatusAsync(
IStreamingDevice device,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(device);
ThrowIfDisposed();

return await CheckWifiFirmwareStatusCoreAsync(device, cancellationToken).ConfigureAwait(false);
}
Comment thread
qodo-code-review[bot] marked this conversation as resolved.

private async Task RunExclusiveAsync(
Func<CancellationToken, Task> operation,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -330,13 +342,15 @@ private async Task RunWifiUpdateAsync(
IStreamingDevice device,
string firmwarePath,
IProgress<FirmwareUpdateProgress>? progress,
bool skipVersionCheck,
CancellationToken cancellationToken)
{
const long totalBytes = 100;

try
{
if (await IsWifiFirmwareUpToDateAsync(device, progress, cancellationToken).ConfigureAwait(false))
if (!skipVersionCheck
&& await IsWifiFirmwareUpToDateAsync(device, progress, cancellationToken).ConfigureAwait(false))
{
return;
}
Expand Down Expand Up @@ -432,10 +446,50 @@ private async Task<bool> IsWifiFirmwareUpToDateAsync(
IStreamingDevice device,
IProgress<FirmwareUpdateProgress>? 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,
// LatestReleaseUnavailable, VersionUnparseable — proceed
// with the flash conservatively.
return false;
}
}

private async Task<WifiFirmwareStatus> CheckWifiFirmwareStatusCoreAsync(
IStreamingDevice device,
CancellationToken cancellationToken)
{
if (device is not ILanChipInfoProvider lanChipInfoProvider)
{
return false;
return new WifiFirmwareStatus
{
IsUpToDate = false,
Reason = WifiFirmwareStatusReason.DeviceDoesNotSupportLanQuery,
};
}

LanChipInfo? chipInfo;
Expand All @@ -445,13 +499,21 @@ private async Task<bool> IsWifiFirmwareUpToDateAsync(
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Failed to query LAN chip info; proceeding with WiFi firmware update.");
return false;
_logger.LogDebug(ex, "Failed to query LAN chip info; reporting status as ChipInfoUnavailable.");
return new WifiFirmwareStatus
{
IsUpToDate = false,
Reason = WifiFirmwareStatusReason.ChipInfoUnavailable,
};
}

if (chipInfo == null)
{
return false;
return new WifiFirmwareStatus
{
IsUpToDate = false,
Reason = WifiFirmwareStatusReason.ChipInfoUnavailable,
};
}

FirmwareReleaseInfo? latestWifi;
Expand All @@ -463,29 +525,45 @@ private async Task<bool> IsWifiFirmwareUpToDateAsync(
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogDebug(ex, "Failed to query latest WiFi firmware release; proceeding with update.");
return false;
_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 false;
return new WifiFirmwareStatus
{
CurrentChipInfo = chipInfo,
IsUpToDate = false,
Reason = WifiFirmwareStatusReason.LatestReleaseUnavailable,
};
}

if (!IsWifiVersionCurrent(chipInfo.FwVersion, latestWifi.TagName))
if (!FirmwareVersion.TryParse(chipInfo.FwVersion, out _) ||
!FirmwareVersion.TryParse(latestWifi.TagName, out _))
{
_logger.LogInformation(
"WiFi firmware update available: device has {DeviceVersion}, latest is {LatestVersion}.",
chipInfo.FwVersion,
latestWifi.TagName);
return false;
return new WifiFirmwareStatus
{
CurrentChipInfo = chipInfo,
LatestRelease = latestWifi,
IsUpToDate = false,
Reason = WifiFirmwareStatusReason.VersionUnparseable,
};
}

var message = $"WiFi firmware is already up to date (device: {chipInfo.FwVersion}, latest: {latestWifi.TagName}).";
_logger.LogInformation(message);
TransitionToState(FirmwareUpdateState.Complete, message);
ReportProgress(progress, FirmwareUpdateState.Complete, 100, message, 100, 100);
return true;
var isCurrent = IsWifiVersionCurrent(chipInfo.FwVersion, latestWifi.TagName);
return new WifiFirmwareStatus
{
CurrentChipInfo = chipInfo,
LatestRelease = latestWifi,
IsUpToDate = isCurrent,
Reason = isCurrent ? WifiFirmwareStatusReason.UpToDate : WifiFirmwareStatusReason.UpdateAvailable,
};
}

private static bool IsWifiVersionCurrent(string deviceVersion, string latestTagName)
Expand Down
21 changes: 21 additions & 0 deletions src/Daqifi.Core/Firmware/IFirmwareUpdateService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,30 @@ Task UpdateFirmwareAsync(
/// <param name="firmwarePath">Path to a WiFi tool executable/script or directory containing it.</param>
/// <param name="progress">Optional progress reporter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="skipVersionCheck">
/// When true, bypass the internal version probe and always run the flash
/// flow. Use after a separate <see cref="CheckWifiFirmwareStatusAsync"/>
/// call so the device isn't queried twice — see issue #143 for the
/// motivating callsite.
/// </param>
Task UpdateWifiModuleAsync(
IStreamingDevice device,
string firmwarePath,
IProgress<FirmwareUpdateProgress>? progress = null,
bool skipVersionCheck = false,
CancellationToken cancellationToken = default);
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
Outdated

/// <summary>
/// Probes the device for its current WiFi chip info and looks up the
/// latest WiFi firmware release, returning the comparison result without
/// mutating service state. Lets callers (typically GUI/desktop integrations)
/// surface their own logging, retry policy, or UI before deciding whether
/// to call <see cref="UpdateWifiModuleAsync"/>. Pass
/// <c>skipVersionCheck: true</c> to that call to avoid a second probe.
/// </summary>
/// <param name="device">The connected streaming device to inspect.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<WifiFirmwareStatus> CheckWifiFirmwareStatusAsync(
IStreamingDevice device,
CancellationToken cancellationToken = default);
}
Loading
Loading