diff --git a/Harp.Toolkit.sln b/Harp.Toolkit.sln index 35c9b1a..095bd8b 100644 --- a/Harp.Toolkit.sln +++ b/Harp.Toolkit.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Harp.Toolkit", "src\Harp.Toolkit\Harp.Toolkit.csproj", "{BFC25910-BC44-4792-9CDE-5B3A17D0B157}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Harp.Toolkit.Tests", "src\Harp.Toolkit.Tests\Harp.Toolkit.Tests.csproj", "{1F352150-EC14-445E-873E-24CA0F38BFE9}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{DEE5DD87-39C1-BF34-B639-A387DCCF972B}" ProjectSection(SolutionItems) = preProject build\Common.csproj.props = build\Common.csproj.props @@ -25,10 +27,10 @@ Global {BFC25910-BC44-4792-9CDE-5B3A17D0B157}.Debug|Any CPU.Build.0 = Debug|Any CPU {BFC25910-BC44-4792-9CDE-5B3A17D0B157}.Release|Any CPU.ActiveCfg = Release|Any CPU {BFC25910-BC44-4792-9CDE-5B3A17D0B157}.Release|Any CPU.Build.0 = Release|Any CPU - {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Release|Any CPU.Build.0 = Release|Any CPU + {1F352150-EC14-445E-873E-24CA0F38BFE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F352150-EC14-445E-873E-24CA0F38BFE9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F352150-EC14-445E-873E-24CA0F38BFE9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F352150-EC14-445E-873E-24CA0F38BFE9}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/README.md b/docs/README.md index cd8ef6c..89d6555 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,17 +32,23 @@ Tool for inspecting, updating and interfacing with Harp devices, with automatic Each read waits up to 2000 milliseconds for a response. Pass `--timeout` to change that default, or `--timeout -1` to wait indefinitely. -6. To update the device firmware from a local HEX file: +6. To restore the tool at any point, run: ```cmd - dotnet harp.toolkit update --port COM4 Behavior-fw3.2-harp1.13-hw2.0-ass0.hex + dotnet tool restore ``` -7. To restore the tool at any point, run: +## Firmware Update - ```cmd - dotnet tool restore - ``` +`harp.toolkit` can write a firmware image to a connected device, checking that the image is compatible before writing anything: + +```cmd +dotnet harp.toolkit update --port COM4 Behavior-fw3.3-harp1.15-hw2.0-ass0.hex +``` + +An update resets the device, so avoid updating firmware that is part of a running experiment, since interrupting an update leaves the device in bootloader mode until one completes successfully. + +See [Firmware Update](https://harp-tech.org/toolkit/articles/update.html) for the naming convention used by firmware images, how to recover a device left in bootloader mode, and the available options. ## Code Generation diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml index 2571da1..156b591 100644 --- a/docs/articles/toc.yml +++ b/docs/articles/toc.yml @@ -1,4 +1,5 @@ - name: Introduction href: ../index.md +- href: update.md - href: generate.md - href: verify.md \ No newline at end of file diff --git a/docs/articles/update.md b/docs/articles/update.md new file mode 100644 index 0000000..890828c --- /dev/null +++ b/docs/articles/update.md @@ -0,0 +1,90 @@ +# Firmware Update + +`harp.toolkit` can write a firmware image to a connected device over its serial port, using the bootloader built into the ATxmega core. The image states the device name and hardware version it targets, and the update checks it is compatible before writing anything. + +Devices built on the Pico core are not currently supported. They update through a different process, and their firmware is distributed as `.uf2` images rather than Intel HEX. + +> [!Warning] +> An update resets the device, so avoid updating firmware that is part of a running experiment. Interrupting an update leaves the device in bootloader mode, where it stops answering Harp commands until an update completes successfully. This is a fail-safe rather than damage, and [Recovering a device in bootloader mode](#recovering-a-device-in-bootloader-mode) describes the recovery. + +## Updating a device + +An update needs the serial port and a firmware image. + +```ps1 +dotnet harp.toolkit update --port COM3 Behavior-fw3.3-harp1.15-hw2.0-ass0.hex +``` + +The command reads the device name and hardware version, checks that the image is compatible, resets the device into its bootloader, writes the image one page at a time, and then leaves the bootloader so the new firmware starts. Progress is reported as a percentage while the image is written. + +A device takes a moment to answer Harp again once an update finishes, measured at a median of about two seconds and occasionally over ten. A tool that reconnects immediately may find the device unresponsive and should retry before treating it as a failure. + +#### Firmware image +```ps1 + +``` + +Path to the firmware image in Intel HEX format. The file must exist, and its name must follow the convention described in [Firmware image names](#firmware-image-names). This argument is required. + +#### Serial port +```ps1 +--port +``` + +Name of the serial port used to communicate with the device. This option is required. + +#### Response timeout +```ps1 +--timeout +``` + +Time in milliseconds to wait for the device to answer each Harp command. It applies while the update reads the device identity and requests the reset. The default is 2000, and `-1` waits indefinitely. Increase it if the device is slow to answer, for example immediately after an earlier update. It does not affect the bootloader protocol, which uses its own timeout. + +### Exit code + +The command exits 0 once the image is written and the device has left the bootloader, and 1 otherwise. Every failure is reported as a message rather than a stack trace. A failure after the device has been reset also reports the percentage reached. + +## Firmware image names + +The update reads the device and version information from the file name, not from the contents of the image. The name must follow this convention: + +``` +-fw-harp-hw-ass.hex +``` + +`` is the device name reported by `R_DEVICE_NAME`. ``, `` and `` are two-part versions, and `` is the board assembly number. A preview build adds a `-preview` suffix. + +An image built for a range of hardware revisions states `x` in place of a hardware version component. For example, `hw1.x` marks an image that is valid for every revision of hardware 1. + +The update refuses an image when the device name does not match the connected device, or when the device does not satisfy the hardware version. The message names both sides, so the mismatch is visible without opening the file. + +## Recovering a device in bootloader mode + +The bootloader records that a page was written, and stays in control until an update completes. That record survives a power cycle. A disconnected cable or a closed terminal can therefore leave the device in bootloader mode, without going back to the firmware it had before. + +A device in that state does not answer Harp commands, so an ordinary update cannot read its identity and cannot reach it. The update detects the condition and reports it: + +``` +The device on the serial port COM3 specified with --port did not respond in time. The device +is in bootloader mode, left there by an interrupted update. Re-run with --force, which skips +the compatibility check when the device cannot answer. +``` + +Repeat the update with `--force` to recover the device. + +```ps1 +dotnet harp.toolkit update --port COM3 --force Behavior-fw3.3-harp1.15-hw2.0-ass0.hex +``` + +If the forced update does not reach the device either, power cycle it first and repeat. A partial page in the receive buffer holds the bootloader until it is reset. + +#### Force an update +```ps1 +--force +``` + +Writes the image without the compatibility check. + +The flag covers two situations that cannot be separated. A device in bootloader mode reports no identity, so there is nothing to check the image against. Recovering a device in that state therefore requires this flag. + +The other use is firmware development. Do not use the flag only because an update was refused. The flag removes the only check that prevents an update with an image targeting a different device. If an image names a different device, it is usually the wrong image. diff --git a/src/Harp.Toolkit.Tests/AssemblyInfo.cs b/src/Harp.Toolkit.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..b4d320e --- /dev/null +++ b/src/Harp.Toolkit.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize] diff --git a/src/Harp.Toolkit.Tests/HardwareTestHelper.cs b/src/Harp.Toolkit.Tests/HardwareTestHelper.cs new file mode 100644 index 0000000..e1c4a94 --- /dev/null +++ b/src/Harp.Toolkit.Tests/HardwareTestHelper.cs @@ -0,0 +1,260 @@ +using System.Diagnostics; +using System.Globalization; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Bonsai.Harp; + +namespace Harp.Toolkit.Tests; + +/// +/// Provides shared configuration, opt-in checks and measurement reporting to tests that require +/// a physical Harp device to be connected. +/// +/// +/// A test built on these should skip itself when no port has been configured, so the ordinary +/// suite runs on a machine without hardware and without any command line option. Any new test +/// should call one of the require methods below before opening a connection and interacting with +/// the physical device. +/// +static class HardwareTestHelper +{ + /// + /// The test category used to select or exclude tests requiring physical hardware. + /// + public const string Category = "Hardware"; + + const string PortVariable = "HARP_TOOLKIT_TEST_PORT"; + const string ResetConsentVariable = "HARP_TOOLKIT_TEST_ALLOW_RESET"; + const string FirmwareVariable = "HARP_TOOLKIT_TEST_FIRMWARE"; + const string TestIterationsVariable = "HARP_TOOLKIT_TEST_ITERATIONS"; + + /// + /// Returns the serial port of the device under test, or skips the calling test + /// when no port has been configured. + /// + public static string RequirePort() + { + var portName = Environment.GetEnvironmentVariable(PortVariable); + if (string.IsNullOrEmpty(portName)) + { + Assert.Inconclusive( + $"Set {PortVariable} to the serial port of a connected Harp device to run this test."); + } + + return portName; + } + + /// + /// Skips the calling test unless the operator has explicitly consented to tests + /// that reset the device. + /// + /// + /// Resetting is separated from so that configuring a port + /// is never sufficient to run a test that changes device state. + /// + public static void RequireResetConsent() + { + var consent = Environment.GetEnvironmentVariable(ResetConsentVariable); + if (!string.Equals(consent, "1", StringComparison.Ordinal)) + { + Assert.Inconclusive( + $"This test resets the device under test. Set {ResetConsentVariable} to 1 to consent."); + } + } + + /// + /// Returns the path of the firmware image to use, or skips the calling test when none + /// has been configured. + /// + /// + /// The variable carries both the configuration and the consent, since a test that + /// flashes the device has no safe default. The image must match the device, because + /// the update refuses unsupported firmware unless forced. + /// + public static string RequireFirmware() + { + var path = Environment.GetEnvironmentVariable(FirmwareVariable); + if (string.IsNullOrEmpty(path)) + { + Assert.Inconclusive( + $"Set {FirmwareVariable} to a firmware image matching the device to run this test. The test may flash the device repeatedly."); + } + else if (!File.Exists(path)) + { + Assert.Inconclusive($"No firmware image was found at '{path}'."); + } + + return path; + } + + /// + /// Returns the configured number of iterations, or + /// when none has been configured, so a longer run can be requested without a rebuild. + /// + public static int GetTestIterations(int defaultValue) + { + var configuredValue = Environment.GetEnvironmentVariable(TestIterationsVariable); + if (!string.IsNullOrEmpty(configuredValue) && + int.TryParse(configuredValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var iterations) && + iterations > 0) + { + return iterations; + } + + return defaultValue; + } + + /// + /// Opens a connection to the device on the specified port and reads its identity, returning + /// the exception that prevented a response or when it responded. + /// + /// + /// Reading the identity class is enough to establish that a physical device sitting on a port is + /// a Harp device. The timeout only has to exceed the time a responding device can take, and the + /// slowest Harp implementation still replies well under ten milliseconds. + /// + static async Task TryReadWhoAmIAsync(string portName) + { + const int ResponseTimeoutMilliseconds = 500; + + try + { + using (var device = new AsyncDevice(portName)) + { + var response = device.ReadWhoAmIAsync(); + if (await Task.WhenAny(response, Task.Delay(ResponseTimeoutMilliseconds)) != response) + { + return new TimeoutException("The device did not respond within the timeout."); + } + + await response; + return null; + } + } + catch (Exception ex) + { + return ex; + } + } + + /// + /// Polls the device on the specified port until it responds and returns the elapsed duration, + /// or when it never responded within the timeout. + /// + /// + /// This helper is intended to wait for a device application that is still starting, without + /// the need for a fixed settle delay. Polling is safe here only because the device is known + /// to be running its application. A device in bootloader mode would be held there by the + /// polling commands. Use to handle that case instead. + /// + public static async Task WaitUntilDeviceRespondsAsync(string portName, int timeoutMilliseconds) + { + const int PollIntervalMilliseconds = 50; + + var stopwatch = Stopwatch.StartNew(); + do + { + if (await TryReadWhoAmIAsync(portName) == null) + { + return stopwatch.Elapsed.TotalMilliseconds; + } + + await Task.Delay(PollIntervalMilliseconds); + } + while (stopwatch.ElapsedMilliseconds < timeoutMilliseconds); + + return null; + } + + /// + /// Determines whether a Harp device on the specified port responds, waiting quietly before + /// each check, and returns the exception that prevented a response or + /// when the device responded. + /// + /// + /// The ATxmega bootloader clears its fall-through timer on every byte it receives and does + /// not respond to Harp at all, so a host that keeps probing holds the device in the + /// bootloader rather than detecting it. The quiet wait therefore has to exceed the + /// fall-through, roughly three seconds, plus the further interval the application needs to + /// start responding, measured at over a second. The returned exception describes which + /// condition was found. A refused port clears on its own within about a tenth of a second, + /// while a silent device may be starting up or still waiting. + /// + public static async Task CheckDeviceResponseAsync(string portName) + { + const int QuietMilliseconds = 5000; + const int MaxResponseChecks = 2; + + Exception? lastError = null; + for (int i = 0; i < MaxResponseChecks; i++) + { + await Task.Delay(QuietMilliseconds); + lastError = await TryReadWhoAmIAsync(portName); + if (lastError == null) + { + return null; + } + } + + return lastError; + } + + /// + /// Writes the distribution of a set of timing samples to the test output. + /// + /// + /// The samples are copied before sorting, so the caller retains acquisition order. + /// + public static void ReportTimingStats(TestContext context, string label, IReadOnlyList milliseconds) + { + if (milliseconds.Count == 0) + { + context.WriteLine("{0}: no samples", label); + return; + } + + var sorted = milliseconds.OrderBy(sample => sample).ToArray(); + context.WriteLine(string.Format( + CultureInfo.InvariantCulture, + "{0}: n={1} min={2:F1}ms median={3:F1}ms max={4:F1}ms", + label, + sorted.Length, + sorted[0], + Median(sorted), + sorted[sorted.Length - 1])); + } + + /// + /// Writes a failure count and every recorded failure to the test output. + /// + public static void ReportFailures(TestContext context, string label, IReadOnlyList failures, int total) + { + context.WriteLine("{0}: {1} of {2}", label, failures.Count, total); + foreach (var failure in failures) + { + context.WriteLine(" {0}", failure); + } + } + + /// + /// Returns the chain of exception type names, outermost first, so that a wrapped cause stays + /// visible in a one-line summary. + /// + public static string DescribeExceptionTypes(Exception exception) + { + var types = new List(); + for (Exception? current = exception; current != null; current = current.InnerException) + { + types.Add(current.GetType().Name); + } + + return string.Join(" -> ", types); + } + + static double Median(double[] sorted) + { + var middle = sorted.Length / 2; + return sorted.Length % 2 == 0 + ? (sorted[middle - 1] + sorted[middle]) / 2.0 + : sorted[middle]; + } +} diff --git a/src/Harp.Toolkit.Tests/Harp.Toolkit.Tests.csproj b/src/Harp.Toolkit.Tests/Harp.Toolkit.Tests.csproj new file mode 100644 index 0000000..18e4e96 --- /dev/null +++ b/src/Harp.Toolkit.Tests/Harp.Toolkit.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + + + + + + + + + + + + + diff --git a/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs b/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs new file mode 100644 index 0000000..50963c6 --- /dev/null +++ b/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs @@ -0,0 +1,260 @@ +using System.Diagnostics; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Harp.Toolkit.Firmware.ATxmega; + +namespace Harp.Toolkit.Tests; + +/// +/// Repeatedly updates the firmware of a physical device and classifies any failures by the stage +/// they occurred at, so that a change to +/// +/// can be judged against a measured failure rate. +/// +/// +/// This exercises the real call, which matters because the failures are timing dependent and +/// none of them reproduce through a simplified stand-in. +/// +[TestClass] +[DoNotParallelize] +public class TestFirmwareUpdate +{ + /// + /// The time allowed for the device to start responding again after a successful update, before + /// the next one begins. + /// + const int ReadyTimeoutMilliseconds = 15000; + + /// + /// The time allowed for the device to answer a Harp command during an update, held independent + /// of the default carried by the tool so that changing the default cannot change a measurement. + /// + const int ResponseTimeoutMilliseconds = 2000; + + public TestContext TestContext { get; set; } = null!; + + /// + /// Records a progress value synchronously, so the last stage reached is observable at the + /// point an exception is caught. + /// + /// + /// is unsuitable here because it marshals its callbacks, so the + /// final value can arrive after the exception has already been handled. + /// + sealed class StageProgress : IProgress + { + const int BootloaderStage = 40; + + public int Stage { get; private set; } = -1; + + public int BootloaderAttempts { get; private set; } + + public void Report(int value) + { + if (value == BootloaderStage) + { + BootloaderAttempts++; + } + + Stage = value; + } + } + + static async Task TryForceUpdateAsync(string portName, DeviceFirmware firmware) + { + try + { + await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate: true, ResponseTimeoutMilliseconds); + return null; + } + catch (Exception ex) + { + return ex; + } + } + + [TestMethod] + [TestCategory(HardwareTestHelper.Category)] + public async Task UpdateFirmware_RepeatedUpdates_ClassifyFailuresByStage() + { + var portName = HardwareTestHelper.RequirePort(); + var firmwarePath = HardwareTestHelper.RequireFirmware(); + var iterations = HardwareTestHelper.GetTestIterations(5); + HardwareTestHelper.RequireResetConsent(); + + var firmware = DeviceFirmware.FromFile(firmwarePath); + TestContext.WriteLine("firmware image: {0}", firmwarePath); + + // Wait until the device responds, to avoid starting with a device still restarting + if (!(await HardwareTestHelper.WaitUntilDeviceRespondsAsync(portName, ReadyTimeoutMilliseconds)).HasValue) + { + Assert.Inconclusive("The device did not respond before the run started."); + } + + var successMilliseconds = new List(); + var failures = new List(); + var failureStages = new Dictionary(); + var failureTypes = new Dictionary(); + var recoveryFailures = new List(); + var responseMilliseconds = new List(); + var readyMilliseconds = new List(); + var lateResponses = 0; + var recovered = 0; + var retried = 0; + var attempted = 0; + string? setupFailure = null; + + // Stage 30 means a failure while reopening the port or waiting for the bootloader, and + // beyond 40 while writing the image, which leaves the device in bootloader mode. + for (int i = 0; i < iterations; i++) + { + attempted = i + 1; + var progress = new StageProgress(); + var stopwatch = Stopwatch.StartNew(); + try + { + await Bootloader.UpdateFirmwareAsync( + portName, firmware, forceUpdate: false, ResponseTimeoutMilliseconds, progress); + var elapsed = stopwatch.Elapsed.TotalMilliseconds; + successMilliseconds.Add(elapsed); + if (progress.BootloaderAttempts > 1) + { + retried++; + TestContext.WriteLine( + "iteration {0}: succeeded after {1} attempts at the bootloader", + i, + progress.BootloaderAttempts); + } + + // Start the next update from a device that has demonstrably responded, rather + // than after a fixed delay, so that every iteration begins in the same state. + var ready = await HardwareTestHelper.WaitUntilDeviceRespondsAsync(portName, ReadyTimeoutMilliseconds); + if (ready.HasValue) + { + readyMilliseconds.Add(ready.GetValueOrDefault()); + TestContext.WriteLine( + "iteration {0}: succeeded in {1:F0}ms, responding again after {2:F0}ms", + i, + elapsed, + ready.GetValueOrDefault()); + } + else + { + var noResponse = await HardwareTestHelper.CheckDeviceResponseAsync(portName); + if (noResponse != null) + { + setupFailure = string.Format( + "iteration {0}: succeeded in {1:F0}ms but the device did not respond again ({2}: {3})", + i, + elapsed, + noResponse.GetType().Name, + noResponse.Message); + TestContext.WriteLine(setupFailure); + TestContext.WriteLine(" abandoning the run rather than updating a device in an unknown state"); + break; + } + + lateResponses++; + TestContext.WriteLine( + "iteration {0}: succeeded in {1:F0}ms, did not respond within {2}ms but responded when left quiet", + i, + elapsed, + ReadyTimeoutMilliseconds); + } + } + catch (Exception ex) + { + var failureType = HardwareTestHelper.DescribeExceptionTypes(ex); + var description = string.Format( + "iteration {0}: reached stage {1} on bootloader attempt {2}: {3}: {4}", + i, + progress.Stage, + progress.BootloaderAttempts, + failureType, + ex.Message); + failures.Add(description); + TestContext.WriteLine(description); + + failureStages.TryGetValue(progress.Stage, out var stageCount); + failureStages[progress.Stage] = stageCount + 1; + failureTypes.TryGetValue(failureType, out var typeCount); + failureTypes[failureType] = typeCount + 1; + + var responseTimer = Stopwatch.StartNew(); + var noResponse = await HardwareTestHelper.CheckDeviceResponseAsync(portName); + if (noResponse == null) + { + responseMilliseconds.Add(responseTimer.Elapsed.TotalMilliseconds); + TestContext.WriteLine(" device responded again after {0:F0}ms", responseTimer.Elapsed.TotalMilliseconds); + continue; + } + + TestContext.WriteLine( + " device did not respond after {0:F0}ms ({1}: {2}), forcing an update to recover it", + responseTimer.Elapsed.TotalMilliseconds, + noResponse.GetType().Name, + noResponse.Message); + + var recoveryError = await TryForceUpdateAsync(portName, firmware); + if (recoveryError == null) + { + recoveryError = await HardwareTestHelper.CheckDeviceResponseAsync(portName); + } + + if (recoveryError == null) + { + recovered++; + TestContext.WriteLine(" recovered"); + continue; + } + + recoveryFailures.Add(string.Format( + "iteration {0}: reached stage {1} and left the device unresponsive ({2}), recovery failed with {3}: {4}", + i, + progress.Stage, + noResponse.GetType().Name, + recoveryError.GetType().Name, + recoveryError.Message)); + TestContext.WriteLine(" recovery failed, abandoning the run"); + break; + } + } + + if (attempted < iterations) + { + TestContext.WriteLine("abandoned after {0} of {1} iterations", attempted, iterations); + } + + TestContext.WriteLine("succeeded: {0} of {1}", successMilliseconds.Count, attempted); + HardwareTestHelper.ReportTimingStats(TestContext, "successful update duration", successMilliseconds); + + foreach (var stage in failureStages) + { + TestContext.WriteLine("failures at stage {0}: {1}", stage.Key, stage.Value); + } + + foreach (var type in failureTypes) + { + TestContext.WriteLine("failures of type {0}: {1}", type.Key, type.Value); + } + + HardwareTestHelper.ReportTimingStats(TestContext, "time to respond again after a successful update", readyMilliseconds); + TestContext.WriteLine("successful updates after which the device responded only when left quiet: {0}", lateResponses); + HardwareTestHelper.ReportTimingStats(TestContext, "time until the device responded again after a failure", responseMilliseconds); + TestContext.WriteLine("failures that left the device unresponsive: {0}", recovered + recoveryFailures.Count); + TestContext.WriteLine("recovered by forcing an update: {0}", recovered); + TestContext.WriteLine("updates that succeeded only after a retry: {0}", retried); + HardwareTestHelper.ReportFailures(TestContext, "failures", failures, attempted); + HardwareTestHelper.ReportFailures(TestContext, "recovery failures", recoveryFailures, attempted); + + Assert.IsEmpty( + recoveryFailures, + "A firmware update left the device unresponsive and forcing an update did not recover it."); + Assert.IsEmpty(failures, "At least one firmware update failed. The stage and exception type identify where."); + + if (setupFailure != null) + { + Assert.Inconclusive( + "The run was abandoned because a device stopped responding after a successful update. " + setupFailure); + } + } +} diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs new file mode 100644 index 0000000..67f280c --- /dev/null +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -0,0 +1,269 @@ +using System.IO.Ports; +using Bonsai.Harp; + +namespace Harp.Toolkit.Firmware.ATxmega; + +/// +/// Provides asynchronous operations to update a device firmware using the dedicated bootloader protocol. +/// +public static class Bootloader +{ + const int HeaderSize = 15; + const int DefaultBaudRate = 1000000; + + const int FlushDelayMilliseconds = 500; + const int BootloaderTimeoutMilliseconds = 500; + + const int WritePage = 0x0; + const int ReadPageSize = 0x66; + const int ExitBootloader = 0x77; + + const int MinPageSize = 64; + const int MaxPageSize = 4096; + + const int NoError = 0; + const int UndefinedError = 1; + const int InvalidAddress = 2; + const int InvalidDataLength = 3; + + /// + /// Asynchronously updates the firmware of the Harp device on the specified port. + /// + /// The name of the serial port used to communicate with the Harp device. + /// The binary firmware image to upload to the device. + /// + /// true to indicate that the firmware should be uploaded even if the device reports unsupported hardware, + /// or is in bootloader mode; false to throw an exception if the firmware is not supported, or the device + /// is in an invalid state. + /// + /// The time to wait, in milliseconds, for the device to answer a Harp command. + /// The optional object used to report update progress. + /// + /// The task object representing the asynchronous firmware update operation. + /// + public static async Task UpdateFirmwareAsync( + string portName, + DeviceFirmware firmware, + bool forceUpdate, + int timeout, + IProgress? progress = default) + { + try + { + using (var device = new AsyncDevice(portName)) + { + progress?.Report(10); + if (!forceUpdate) + { + var hardwareVersion = await device.ReadHardwareVersionAsync().WithTimeout(timeout); + var deviceName = await device.ReadDeviceNameAsync().WithTimeout(timeout); + if (!firmware.Metadata.Supports(deviceName, hardwareVersion)) + { + throw new HarpException( + $"The firmware file is for {firmware.Metadata.DeviceName} with hardware version " + + $"{firmware.Metadata.HardwareVersion}, but the device on this port is {deviceName} " + + $"with hardware version {hardwareVersion}."); + } + } + + progress?.Report(20); + var reset = await device.ReadResetDeviceAsync().WithTimeout(timeout); + if ((reset & ResetFlags.BootFromEeprom) != 0) + { + await device.WriteResetDeviceAsync(ResetFlags.RestoreEeprom); + } + else if ((reset & ResetFlags.BootFromDefault) != 0) + { + await device.WriteResetDeviceAsync(ResetFlags.RestoreDefault); + } + else throw new HarpException("The device is in an unexpected boot mode."); + } + } + catch (Exception ex) when (ex is TimeoutException || ex is IOException) + { + if (!forceUpdate) + { + throw; + } + } + + await Task.Delay(FlushDelayMilliseconds); + progress?.Report(30); + + const int MaxAttempts = 3; + for (int i = 1; i <= MaxAttempts; i++) + { + try + { + using (var bootloader = new SerialPort(portName, DefaultBaudRate, Parity.None, 8, StopBits.One)) + { + bootloader.Handshake = Handshake.None; + bootloader.Open(); + await Task.Delay(FlushDelayMilliseconds); + var pageSize = await ReadPageSizeAsync(bootloader.BaseStream); + progress?.Report(40); + + var bytesWritten = 0; + var reportSize = pageSize * 8; + var dataMessage = new byte[pageSize + HeaderSize]; + while (bytesWritten < firmware.Data.Length) + { + CreateBootloaderMessage(dataMessage, WritePage, bytesWritten, firmware.Data, bytesWritten, pageSize); + await BootloaderCommandAsync(bootloader.BaseStream, dataMessage); + bytesWritten += pageSize; + if (bytesWritten % reportSize == 0) + { + progress?.Report(40 + bytesWritten * 50 / firmware.Data.Length); + } + } + + progress?.Report(90); + CreateBootloaderMessage(dataMessage, ExitBootloader, 0, firmware.Data, 0, pageSize); + await BootloaderCommandAsync(bootloader.BaseStream, dataMessage); + progress?.Report(100); + break; + }; + } + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || + ex is TimeoutException || ex is InvalidOperationException) + { + if (i < MaxAttempts) + { + await Task.Delay(FlushDelayMilliseconds); + continue; + } + + throw; + } + } + } + + /// + /// Asynchronously determines whether a device in bootloader mode is listening on the specified port. + /// + /// The name of the serial port used to communicate with the Harp device. + /// + /// The task object representing the asynchronous operation. The + /// property is true if a device answered the bootloader protocol; otherwise, false. + /// + /// + /// The bootloader restarts its timeout on every byte it receives, so the device is probed exactly + /// once. Probing repeatedly would hold a device in bootloader mode. The port is opened after a + /// settle delay, since a port closed moments earlier can still refuse to open, which would + /// otherwise be reported as the absence of a bootloader. + /// + public static async Task IsBootloaderAsync(string portName) + { + try + { + await Task.Delay(FlushDelayMilliseconds); + using var bootloader = new SerialPort(portName, DefaultBaudRate, Parity.None, 8, StopBits.One); + bootloader.Handshake = Handshake.None; + bootloader.Open(); + await ReadPageSizeAsync(bootloader.BaseStream); + return true; + } + catch (Exception ex) when (ex is HarpException or TimeoutException or IOException or + UnauthorizedAccessException or InvalidOperationException) + { + return false; + } + } + + static ushort GetMessageChecksum(byte[] messageBytes) + { + var checksum = (ushort)0; + unchecked + { + for (int i = 0; i < messageBytes.Length - 2; i++) + { + checksum += messageBytes[i]; + } + } + return checksum; + } + + static bool IsValidChecksum(byte[] messageBytes) + { + var checksum = GetMessageChecksum(messageBytes); + var messageChecksum = (messageBytes[messageBytes.Length - 1] << 8) + messageBytes[messageBytes.Length - 2]; + return checksum == messageChecksum; + } + + static void CreateBootloaderMessage(byte[] messageBytes, int opcode, int address, byte[] data, int offset, int count) + { + messageBytes[0] = 1; + messageBytes[1] = 2; + messageBytes[2] = 3; + messageBytes[3] = (byte)opcode; + messageBytes[4] = 0; //error + messageBytes[5] = (byte)address; + messageBytes[6] = (byte)(address >> 8); + messageBytes[7] = (byte)(address >> 16); + messageBytes[8] = (byte)(address >> 24); + messageBytes[9] = (byte)count; + messageBytes[10] = (byte)(count >> 8); + messageBytes[11] = (byte)(count >> 16); + messageBytes[12] = (byte)(count >> 24); + Array.Copy(data, offset, messageBytes, 13, count); + var checksum = GetMessageChecksum(messageBytes); + messageBytes[messageBytes.Length - 2] = (byte)checksum; + messageBytes[messageBytes.Length - 1] = (byte)(checksum >> 8); + } + + static byte[] CreateBootloaderMessage(int opcode, int address, params byte[] data) + { + var messageBytes = new byte[data.Length + HeaderSize]; + CreateBootloaderMessage(messageBytes, opcode, address, data, 0, data.Length); + return messageBytes; + } + + static async Task ReadPageSizeAsync(Stream stream) + { + var message = CreateBootloaderMessage(ReadPageSize, address: 0); + await BootloaderCommandAsync(stream, message); + var pageSize = BitConverter.ToInt32(message, startIndex: 9); + if (pageSize < MinPageSize || pageSize > MaxPageSize || (pageSize & (pageSize - 1)) != 0) + { + throw new HarpException("The device reported an invalid bootloader page size."); + } + + return pageSize; + } + + static async Task BootloaderCommandAsync(Stream stream, byte[] message) + { + var bytesRead = 0; + var opcode = message[3]; + await stream.WriteAsync(message, 0, message.Length); + while (bytesRead < message.Length) + { + bytesRead += await stream.ReadAsync(message, bytesRead, message.Length - bytesRead) + .WithTimeout(BootloaderTimeoutMilliseconds); + } + + if (bytesRead != message.Length) + { + throw new HarpException("The device responded with an invalid buffer length."); + } + + if (message[0] != 1 || message[1] != 2 || message[2] != 3 || message[3] != opcode) + { + throw new HarpException("The device did not respond with a bootloader message."); + } + + if (!IsValidChecksum(message)) + { + throw new HarpException("The device responded with an invalid response checksum."); + } + + switch (message[4]) + { + case NoError: break; + case UndefinedError: throw new HarpException("The device reported an undefined error while updating the bootloader logic."); + case InvalidAddress: throw new HarpException("The device reported an invalid address while updating the bootloader logic."); + case InvalidDataLength: throw new HarpException("The device reported an invalid data length while writing the bootloader page."); + default: throw new HarpException("The device reported an unknown error while updating the bootloader logic."); + } + } +} diff --git a/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs new file mode 100644 index 0000000..9dfac38 --- /dev/null +++ b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs @@ -0,0 +1,193 @@ +namespace Harp.Toolkit.Firmware.ATxmega; + +/// +/// Represents a hardware control firmware image which can be uploaded into a Harp device. +/// +public sealed class DeviceFirmware +{ + const int DefaultPageSize = 512; + + private DeviceFirmware(FirmwareMetadata metadata, byte[] data) + { + Metadata = metadata; + Data = data; + } + + /// + /// Gets information about the firmware version and supported devices on which it can be installed. + /// + public FirmwareMetadata Metadata { get; private set; } + + /// + /// Gets the binary representation of the firmware to be installed on the device. + /// + public byte[] Data { get; private set; } + + static char ReadChar(StreamReader stream, char[] hexDigit) + { + var read = stream.Read(hexDigit, 0, 1); + if (read != 1) throw new ArgumentException("Invalid hex code specification found in hex stream.", nameof(stream)); + return hexDigit[0]; + } + + static byte ReadHexByte(StreamReader stream, char[] hexDigit) + { + var read = stream.Read(hexDigit, 0, 2); + if (read != 2) throw new ArgumentException("Invalid hex code specification found in hex stream.", nameof(stream)); + return Convert.ToByte(new string(hexDigit, 0, 2), 16); + } + + static ushort ReadHexUInt16(StreamReader stream, char[] hexDigit) + { + var read = stream.Read(hexDigit, 0, 4); + if (read != 4) throw new ArgumentException("Invalid hex code specification found in hex stream.", nameof(stream)); + return Convert.ToUInt16(new string(hexDigit, 0, 4), 16); + } + + static int ReadHexData(StreamReader stream, char[] hexDigit, ref short[] data, int offset, int count, int pageSize) + { + var end = offset + count; + if (end > data.Length) + { + var numPages = end / pageSize + (end % pageSize > 0 ? 1 : 0); + Expand(ref data, numPages * pageSize); + } + + var sum = 0; + for (int i = offset; i < end; i++) + { + data[i] = ReadHexByte(stream, hexDigit); + sum += data[i]; + } + return sum; + } + + static int Checksum(ushort value) + { + return (byte)value + (byte)(value >> 8); + } + + static void Expand(ref short[] data, int newSize) + { + var offset = data.Length; + Array.Resize(ref data, newSize); + for (int i = offset; i < data.Length; i++) + { + data[i] = -1; + } + } + + /// + /// Creates a object from the specified file in Intel HEX format + /// using the default page size. + /// + /// The name of the file from which to create the . + /// + /// A new object representing the extracted binary firware blob, + /// together with the metadata extracted from the firmware file name. + /// + public static DeviceFirmware FromFile(string path) + { + var metadata = FirmwareMetadata.Parse(Path.GetFileNameWithoutExtension(path)); + return FromFile(metadata, path); + } + + /// + /// Creates a object from the specified metadata and file + /// in Intel HEX format. + /// + /// The metadata describing the firmware version and supported devices. + /// The name of the file from which to create the . + /// + /// A new object representing the extracted binary firware blob, + /// together with the specified metadata. + /// + public static DeviceFirmware FromFile(FirmwareMetadata metadata, string path) + { + using (var stream = File.OpenRead(path)) + { + return FromStream(metadata, stream, DefaultPageSize); + } + } + + /// + /// Creates a object extracted from the specified ASCII + /// stream in Intel HEX format, the specified metadata and page size. + /// + /// The metadata describing the firmware version and supported devices. + /// The ASCII stream in Intel HEX format from which to extract the device firmware. + /// The size of the memory blocks used to upload the device firmware. + /// + /// A new object representing the extracted binary firware blob, + /// together with the specified metadata. + /// + public static DeviceFirmware FromStream(FirmwareMetadata metadata, Stream stream, int pageSize) + { + const char StartCode = ':'; + using (var reader = new StreamReader(stream)) + { + var lineNumber = 0; + var baseAddress = 0; + var hexDigit = new char[4]; + var data = new short[0]; + Expand(ref data, pageSize); + while (!reader.EndOfStream) + { + if (ReadChar(reader, hexDigit) != StartCode) + { + throw new ArgumentException($"{lineNumber}: Invalid record start code found in hex stream."); + } + + var sum = 0; + var count = ReadHexByte(reader, hexDigit); + var address = ReadHexUInt16(reader, hexDigit); + var recordType = (RecordType)ReadHexByte(reader, hexDigit); + switch (recordType) + { + case RecordType.Data: + sum = ReadHexData(reader, hexDigit, ref data, baseAddress + address, count, pageSize); + break; + case RecordType.EndOfFile: break; + case RecordType.ExtendedSegmentAddress: + if (count != 2) throw new ArgumentException($"{lineNumber}: Invalid extended segment address payload found in hex stream."); + var segmentAddress = ReadHexUInt16(reader, hexDigit); + baseAddress = segmentAddress * 16; + sum = Checksum(segmentAddress); + break; + case RecordType.ExtendedLinearAddress: + if (count != 2) throw new ArgumentException($"{lineNumber}: Invalid extended linear address payload found in hex stream."); + var extendedAddress = ReadHexUInt16(reader, hexDigit); + baseAddress = extendedAddress << 16; + sum = Checksum(extendedAddress); + break; + case RecordType.StartLinearAddress: + case RecordType.StartSegmentAddress: throw new NotSupportedException($"{lineNumber}: Unsupported record type found in hex stream."); + default: throw new ArgumentException($"{lineNumber}: Invalid record type found in hex stream."); + } + + sum = (byte)(sum + count + Checksum(address) + (byte)recordType); + var checksum = sum + ReadHexByte(reader, hexDigit); + if ((byte)checksum != 0) + { + throw new ArgumentException($"{lineNumber}: Invalid data checksum found in hex stream."); + } + + reader.ReadLine(); + lineNumber++; + } + + var byteCode = Array.ConvertAll(data, value => (byte)value); + return new DeviceFirmware(metadata, byteCode); + } + } + + enum RecordType : byte + { + Data = 0, + EndOfFile = 1, + ExtendedSegmentAddress = 2, + StartSegmentAddress = 3, + ExtendedLinearAddress = 4, + StartLinearAddress = 5 + } +} diff --git a/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs b/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs new file mode 100644 index 0000000..612ffda --- /dev/null +++ b/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs @@ -0,0 +1,238 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Text.RegularExpressions; +using Bonsai.Harp; + +namespace Harp.Toolkit.Firmware.ATxmega; + +/// +/// Represents information about the device, firmware version and hardware version numbers +/// contained in a particular device or hex file. +/// +public sealed class FirmwareMetadata : IEquatable +{ + const string FloatingWildcard = "x"; + + static readonly Regex MetadataRegex = new Regex("^(?\\w+)-fw(?\\d+\\.\\d+)-harp(?\\d+\\.\\d+)-hw(?(?:x|\\d+)\\.(?:x|\\d+))-ass(?x|\\d+)(?:-preview(?\\d+))?$"); + + /// + /// Initializes a new instance of the class with the + /// specified device name, the firmware version and compatible hardware versions. + /// + /// The unique identifier of the device type on which the firmware should be installed. + /// The version of the firmware contained in the device or hex file. + /// The version of the Harp core implemented by the firmware. + /// The hardware version of the device, or range of hardware versions supported by the firmware. + /// The board assembly version of the device, or range of assembly versions supported by the firmware. + /// The optional prerelease number, for preview versions of the firmware. + public FirmwareMetadata( + string deviceName, + HarpVersion firmwareVersion, + HarpVersion coreVersion, + HarpVersion hardwareVersion, + int? assemblyVersion = default, + int? prereleaseVersion = default) + { + DeviceName = deviceName ?? throw new ArgumentNullException(nameof(deviceName)); + FirmwareVersion = firmwareVersion ?? throw new ArgumentNullException(nameof(firmwareVersion)); + CoreVersion = coreVersion ?? throw new ArgumentNullException(nameof(coreVersion)); + HardwareVersion = hardwareVersion ?? throw new ArgumentNullException(nameof(hardwareVersion)); + AssemblyVersion = assemblyVersion; + PrereleaseVersion = prereleaseVersion; + } + + /// + /// Gets the unique identifier of the device type on which the firmware should be installed. + /// + public string DeviceName { get; } + + /// + /// Gets the version of the firmware contained in the device or hex file. + /// + public HarpVersion FirmwareVersion { get; } + + /// + /// Gets the version of the Harp core implemented by the firmware. + /// + public HarpVersion CoreVersion { get; } + + /// + /// Gets the hardware version of the device, or range of hardware versions supported by the firmware. + /// + public HarpVersion HardwareVersion { get; } + + /// + /// Gets the board assembly version of the device, or range of assembly versions supported by the firmware. + /// + public int? AssemblyVersion { get; } + + /// + /// Gets the optional prerelease number, for preview versions of the firmware. + /// + public int? PrereleaseVersion { get; } + + /// + /// Returns whether the firmware supports the specified hardware version + /// and board assembly number. + /// + /// The identifier of the device to check for compatibility. + /// The hardware version to check for compatibility. + /// The optional board assembly version to check for compatibility. + /// + /// true if the firmware supports the specified and + /// ; otherwise, false. + /// + public bool Supports(string deviceName, HarpVersion hardwareVersion, int assemblyVersion = default) + { + return DeviceName == deviceName && + HardwareVersion.Satisfies(hardwareVersion) && + (!AssemblyVersion.HasValue || AssemblyVersion.GetValueOrDefault() == assemblyVersion); + } + + /// + /// Determines whether the specified object is equal to the current metadata. + /// + /// The object to compare with the current metadata. + /// + /// true if the specified object is equal to the current metadata; + /// otherwise, false. + /// + public override bool Equals(object? obj) + { + if (obj is FirmwareMetadata version) return Equals(version); + else return false; + } + + /// + /// Determines whether the specified metadata object is equal to the current metadata. + /// + /// The metadata object to compare with the current metadata. + /// + /// true if the specified metadata object is equal to the current metadata; + /// otherwise, false. + /// + public bool Equals(FirmwareMetadata? other) + { + if (other is null) return false; + return DeviceName == other.DeviceName && + FirmwareVersion.Equals(other.FirmwareVersion) && + CoreVersion.Equals(other.CoreVersion) && + HardwareVersion.Equals(other.HardwareVersion) && + AssemblyVersion == other.AssemblyVersion && + PrereleaseVersion == other.PrereleaseVersion; + } + + /// + /// Computes the hash code for the current metadata object. + /// + /// + /// The hash code for the current metadata object, extracted from a combination + /// of hashes for the device name and various version numbers. + /// + public override int GetHashCode() + { + return 17 * DeviceName.GetHashCode() + + 8971 * FirmwareVersion.GetHashCode() + + 2803 * CoreVersion.GetHashCode() + + 691 * HardwareVersion.GetHashCode() + + 1409 * AssemblyVersion.GetHashCode() + + 2333 * PrereleaseVersion.GetHashCode(); + } + + /// + /// Determines whether the values on both sides of the equality operator + /// are equal. + /// + /// The value on the left-hand side of the operator. + /// The value on the right-hand side of the operator. + /// + /// true if the value on the left-hand side of the operator is equal + /// to the value on the right-hand side; otherwise, false. + /// + public static bool operator ==(FirmwareMetadata? lhs, FirmwareMetadata? rhs) + { + if (lhs is null) return rhs is null; + else return lhs.Equals(rhs); + } + + /// + /// Determines whether the values on both sides of the inequality operator + /// are not equal. + /// + /// The value on the left-hand side of the operator. + /// The value on the right-hand side of the operator. + /// + /// true if the value on the left-hand side of the operator is not equal + /// to the value on the right-hand side; otherwise, false. + /// + public static bool operator !=(FirmwareMetadata? lhs, FirmwareMetadata? rhs) + { + if (lhs is null) return !(rhs is null); + else return !lhs.Equals(rhs); + } + + /// + /// Converts a string representation of the to its + /// equivalent value. + /// + /// The string representing the . + /// The equivalent object for the specified string representation. + public static FirmwareMetadata Parse(string input) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + if (!TryParse(input, out var result)) + { + throw new ArgumentException("Invalid Harp firmware metadata specification string.", nameof(input)); + } + + return result; + } + + /// + /// Converts a string representation of the to its + /// equivalent value. A return value indicates whether the conversion succeeded. + /// + /// The string representing the . + /// + /// When this method returns, contains the equivalent object + /// for the specified string representation if the conversion was successful; + /// otherwise, contains null. + /// + /// true if the conversion was successful; otherwise, false. + public static bool TryParse(string input, [NotNullWhen(true)] out FirmwareMetadata? metadata) + { + if (input == null) throw new ArgumentNullException(nameof(input)); + var match = MetadataRegex.Match(input); + if (match.Success && match.Groups.Count == 7) + { + var deviceName = match.Groups[1].Value; + var firmwareVersion = HarpVersion.Parse(match.Groups[2].Value); + var coreVersion = HarpVersion.Parse(match.Groups[3].Value); + var hardwareVersion = HarpVersion.Parse(match.Groups[4].Value); + var assemblyVersion = match.Groups[5].Value == FloatingWildcard ? (int?)null : int.Parse(match.Groups[5].Value); + var prereleaseVersion = string.IsNullOrEmpty(match.Groups[6].Value) ? (int?)null : int.Parse(match.Groups[6].Value); + metadata = new FirmwareMetadata(deviceName, firmwareVersion, coreVersion, hardwareVersion, assemblyVersion, prereleaseVersion); + return true; + } + else + { + metadata = null; + return false; + } + } + + /// + /// Converts the object to its equivalent string representation. + /// + /// + /// The string representation of the object. + /// + public override string ToString() + { + var prerelease = PrereleaseVersion.HasValue ? $"-preview{PrereleaseVersion.GetValueOrDefault()}" : string.Empty; + var assemblyNumber = AssemblyVersion.HasValue + ? AssemblyVersion.GetValueOrDefault().ToString(CultureInfo.InvariantCulture) + : FloatingWildcard; + return $"{DeviceName}-fw{FirmwareVersion}-harp{CoreVersion}-hw{HardwareVersion}-ass{assemblyNumber}{prerelease}"; + } +} diff --git a/src/Harp.Toolkit/Harp.Toolkit.csproj b/src/Harp.Toolkit/Harp.Toolkit.csproj index bbfa6ee..d93cffe 100644 --- a/src/Harp.Toolkit/Harp.Toolkit.csproj +++ b/src/Harp.Toolkit/Harp.Toolkit.csproj @@ -16,6 +16,7 @@ + diff --git a/src/Harp.Toolkit/ImmediateProgress.cs b/src/Harp.Toolkit/ImmediateProgress.cs new file mode 100644 index 0000000..33c4a8d --- /dev/null +++ b/src/Harp.Toolkit/ImmediateProgress.cs @@ -0,0 +1,17 @@ +namespace Harp.Toolkit; + +/// +/// Reports progress on the thread that calls , unlike +/// , which posts each report to the thread pool. +/// +internal sealed class ImmediateProgress : IProgress +{ + readonly Action handler; + + public ImmediateProgress(Action handler) + { + this.handler = handler; + } + + public void Report(T value) => handler(value); +} diff --git a/src/Harp.Toolkit/PortErrors.cs b/src/Harp.Toolkit/PortErrors.cs index 6c1e51a..f3d78f4 100644 --- a/src/Harp.Toolkit/PortErrors.cs +++ b/src/Harp.Toolkit/PortErrors.cs @@ -1,4 +1,6 @@ -namespace Harp.Toolkit; +using Bonsai.Harp; + +namespace Harp.Toolkit; static class PortErrors { @@ -42,6 +44,9 @@ public static bool TryDescribe(this PortNameOption portOption, Exception excepti case TimeoutException: message = $"The device on the serial port {portName} specified with {portOption.Name} did not respond in time."; return true; + case HarpException: + message = exception.Message; + return true; default: message = string.Empty; return false; diff --git a/src/Harp.Toolkit/ProgressBar.cs b/src/Harp.Toolkit/ProgressBar.cs deleted file mode 100644 index aa0ee32..0000000 --- a/src/Harp.Toolkit/ProgressBar.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace Harp.Toolkit; - -internal static class ProgressBar -{ - public static void Update(int percent) - { - Console.CursorLeft = 0; - Write(percent); - } - - public static void Write(int percent) - { - const int Length = 10; - Console.Write("["); - var p = percent / Length; - for (int i = 0; i < Length; i++) - { - Console.Write(i < p ? '■' : ' '); - } - Console.Write("] {0,3:##0}%", percent); - } -} diff --git a/src/Harp.Toolkit/TaskExtensions.cs b/src/Harp.Toolkit/TaskExtensions.cs index 72e5a8b..ddd7bcf 100644 --- a/src/Harp.Toolkit/TaskExtensions.cs +++ b/src/Harp.Toolkit/TaskExtensions.cs @@ -6,7 +6,7 @@ internal static async Task WithTimeout(this Task task, int milliseconds { if (await Task.WhenAny(task, Task.Delay(millisecondsDelay)) == task) { - return task.Result; + return await task; } else throw new TimeoutException("There was a timeout while awaiting the device response."); } diff --git a/src/Harp.Toolkit/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs index 5d41f9e..6c2547a 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -1,14 +1,61 @@ using System.CommandLine; -using Bonsai.Harp; +using Spectre.Console; +using Harp.Toolkit.Firmware.ATxmega; namespace Harp.Toolkit; public class UpdateFirmwareCommand : Command { + const string FirmwareNameHint = + "The name carries the device and version numbers, as in " + + "-fw-harp-hw-ass.hex."; + + const string SupportedFirmwareHint = + "The update writes Intel HEX images to devices built on the ATxmega core. Devices built " + + "on other cores update through a different process."; + + const string InterruptedUpdateHint = + "The update may have left the device in bootloader mode. Run the update again, and if the " + + "device does not respond, power cycle it and re-run with --force, since a device in " + + "bootloader mode cannot report its identity for the compatibility check."; + + const string NoResponseHint = + "No device answered the bootloader protocol. The device can still be restarting from a " + + "previous operation, or it can be on a different port."; + + const string BootloaderModeHint = + "The device is in bootloader mode, left there by an interrupted update. Re-run with " + + "--force, which skips the compatibility check when the device cannot answer."; + + const int DeviceResetStage = 30; + + static bool TryDescribeInterruption(Exception exception, int percent, out string message) + { + switch (exception) + { + case FileNotFoundException: + case UnauthorizedAccessException: + case InvalidOperationException: + case OperationCanceledException: + message = $"The connection to the device was lost while updating at {percent}%."; + return true; + case TimeoutException: + message = $"The device stopped responding while updating at {percent}%."; + return true; + case Bonsai.Harp.HarpException: + message = $"{exception.Message} The update stopped at {percent}%."; + return true; + default: + message = $"The update failed at {percent}%. {exception.GetType().Name}: {exception.Message}"; + return true; + } + } + public UpdateFirmwareCommand() : base("update", "Update the device firmware from a local HEX file.") { DevicePortNameOption portNameOption = new(); + PortTimeoutOption portTimeoutOption = new(); Argument firmwareArgument = ArgumentValidation.AcceptExistingOnly( new Argument("firmware") { @@ -25,11 +72,12 @@ public UpdateFirmwareCommand() Option forceUpdateOption = new("--force") { - Description = "Force a firmware update regardless of compatibility." + Description = "Force a firmware update, skipping the compatibility check when the device cannot answer." }; Arguments.Add(firmwareArgument); Options.Add(portNameOption); + Options.Add(portTimeoutOption); Options.Add(firmwarePathOption); Options.Add(forceUpdateOption); Validators.Add(result => @@ -46,19 +94,69 @@ public UpdateFirmwareCommand() { var firmwarePath = parseResult.GetValue(firmwareArgument) ?? parseResult.GetValue(firmwarePathOption)!; var portName = parseResult.GetRequiredValue(portNameOption); + var portTimeout = parseResult.GetRequiredValue(portTimeoutOption); var forceUpdate = parseResult.GetValue(forceUpdateOption); - var firmware = DeviceFirmware.FromFile(firmwarePath.FullName); - Console.WriteLine($"{firmware.Metadata}"); return portNameOption.ReportErrorsAsync(portName, async () => { - ProgressBar.Write(0); + if (!string.Equals(firmwarePath.Extension, ".hex", StringComparison.OrdinalIgnoreCase)) + { + Console.Error.WriteLine( + $"The firmware file {firmwarePath.Name} is not an Intel HEX image. {SupportedFirmwareHint}"); + return 1; + } + + if (!FirmwareMetadata.TryParse(Path.GetFileNameWithoutExtension(firmwarePath.Name), out var metadata)) + { + Console.Error.WriteLine( + $"The name of the firmware file {firmwarePath.Name} does not follow the Harp convention. " + + $"{FirmwareNameHint}"); + return 1; + } + + DeviceFirmware firmware; try { - var progress = new Progress(ProgressBar.Update); - await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, progress); + firmware = DeviceFirmware.FromFile(metadata, firmwarePath.FullName); + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + Console.Error.WriteLine( + $"The firmware file {firmwarePath.Name} is not a valid Intel HEX image. {ex.Message}"); + return 1; } - finally { Console.WriteLine(); } + + Console.WriteLine($"{firmware.Metadata}"); + var lastProgress = -1; + try + { + await AnsiConsole.Progress().AutoClear(true).StartAsync(async context => + { + var task = context.AddTask("Updating firmware"); + var progress = new ImmediateProgress(percent => + { + lastProgress = percent; + task.Value = percent; + }); + await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, portTimeout, progress); + }); + } + catch (Exception ex) when (lastProgress >= DeviceResetStage && + TryDescribeInterruption(ex, lastProgress, out var interruption)) + { + Console.Error.WriteLine($"{interruption} {InterruptedUpdateHint}"); + return 1; + } + catch (TimeoutException ex) when (!forceUpdate && + portNameOption.TryDescribe(ex, portName, out var cause)) + { + var hint = await Bootloader.IsBootloaderAsync(portName) ? BootloaderModeHint : NoResponseHint; + Console.Error.WriteLine($"{cause} {hint}"); + return 1; + } + + Console.WriteLine("Firmware updated."); + return 0; }); }); }