From 2e0dd7882579d0007be1332f1e76a16f5b1e687e Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 14 Sep 2026 22:38:00 +0100 Subject: [PATCH 01/11] Display update progress with Spectre.Console The update command now renders progress through Spectre.Console rather than writing a progress bar directly to the console, so progress is also reported correctly when standard output is redirected. Progress reports are applied on the thread that calls Report instead of being posted to the thread pool, so a report can no longer be applied out of order or concurrently with another. A completed update prints Firmware updated. --- src/Harp.Toolkit/ImmediateProgress.cs | 17 +++++++++++++++++ src/Harp.Toolkit/ProgressBar.cs | 22 ---------------------- src/Harp.Toolkit/UpdateFirmwareCommand.cs | 11 ++++++----- 3 files changed, 23 insertions(+), 27 deletions(-) create mode 100644 src/Harp.Toolkit/ImmediateProgress.cs delete mode 100644 src/Harp.Toolkit/ProgressBar.cs 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/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/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs index 5d41f9e..fec3c9e 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -1,5 +1,6 @@ using System.CommandLine; using Bonsai.Harp; +using Spectre.Console; namespace Harp.Toolkit; @@ -52,13 +53,13 @@ public UpdateFirmwareCommand() Console.WriteLine($"{firmware.Metadata}"); return portNameOption.ReportErrorsAsync(portName, async () => { - ProgressBar.Write(0); - try + await AnsiConsole.Progress().StartAsync(async context => { - var progress = new Progress(ProgressBar.Update); + var task = context.AddTask("Updating firmware"); + var progress = new ImmediateProgress(percent => task.Value = percent); await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, progress); - } - finally { Console.WriteLine(); } + }); + Console.WriteLine("Firmware updated."); }); }); } From 447288ca65580ff4c4136f8019b75be69fd2bdbc Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 14 Sep 2026 23:05:31 +0100 Subject: [PATCH 02/11] Port the firmware update routines into the toolkit The update command now uses firmware routines carried by the toolkit. Bootloader, DeviceFirmware and FirmwareMetadata are copied from bonsai-rx/harp at e38b422 into a single ATxmega folder, since the paged hex image and the file naming convention are both specific to that bootloader. The copy keeps the released behavior of rethrowing the original exception when the device does not answer an unforced update, instead of wrapping it, so a missing or denied port is still reported by name rather than as an unhandled error. Upload retry and the post-dispose wait have been ported, so a failed upload is now retried up to three times and the serial port is released before the bootloader is opened. --- .../Firmware/ATxmega/Bootloader.cs | 229 +++++++++++++++++ .../Firmware/ATxmega/DeviceFirmware.cs | 196 +++++++++++++++ .../Firmware/ATxmega/FirmwareMetadata.cs | 237 ++++++++++++++++++ src/Harp.Toolkit/Harp.Toolkit.csproj | 1 + src/Harp.Toolkit/TaskExtensions.cs | 2 +- src/Harp.Toolkit/UpdateFirmwareCommand.cs | 2 +- 6 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs create mode 100644 src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs create mode 100644 src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs new file mode 100644 index 0000000..0a86f8b --- /dev/null +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -0,0 +1,229 @@ +using System.IO.Ports; +using System.Reactive.Linq; +using Bonsai.Harp; + +#nullable disable + +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 FlushDelayMilliseconds = 500; + + const int WritePage = 0x0; + const int ReadPageSize = 0x66; + const int ExitBootloader = 0x77; + + 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. + /// The optional object used to report update progress. + /// + /// The task object representing the asynchronous firmware update operation. + /// + public static Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, IProgress progress = default) + { + return UpdateFirmwareAsync(portName, firmware, forceUpdate: false, progress: progress); + } + + /// + /// 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 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, IProgress progress = default) + { + var flushDelay = TimeSpan.FromMilliseconds(FlushDelayMilliseconds); + try + { + using (var device = new AsyncDevice(portName)) + { + progress?.Report(10); + if (!forceUpdate) + { + var hardwareVersion = await device.ReadHardwareVersionAsync().WithTimeout(FlushDelayMilliseconds); + var deviceName = await device.ReadDeviceNameAsync().WithTimeout(FlushDelayMilliseconds); + if (!firmware.Metadata.Supports(deviceName, hardwareVersion)) + { + throw new ArgumentException("The specified firmware is not supported.", nameof(firmware)); + } + } + + progress?.Report(20); + var reset = await device.ReadResetDeviceAsync().WithTimeout(FlushDelayMilliseconds); + 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 Observable.Timer(flushDelay); + progress?.Report(30); + + const int MaxAttempts = 3; + const int DefaultBaudRate = 1000000; + 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 Observable.Timer(flushDelay); + 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) + { + if (i < MaxAttempts) + { + await Observable.Timer(flushDelay); + continue; + } + + throw; + } + } + } + + 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); + return BitConverter.ToInt32(message, startIndex: 9); + } + + static async Task BootloaderCommandAsync(Stream stream, byte[] message) + { + var bytesRead = 0; + await stream.WriteAsync(message, 0, message.Length); + while (bytesRead < message.Length) + { + bytesRead += await stream.ReadAsync(message, bytesRead, message.Length - bytesRead) + .WithTimeout(FlushDelayMilliseconds); + } + + if (bytesRead != message.Length) + { + throw new HarpException("The device responded with an invalid buffer length."); + } + + if (!IsValidChecksum(message)) + { + throw new HarpException("The device responded with an invalid response checksum."); + } + + switch (message[4]) + { + 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."); + case NoError: + default: + break; + } + } +} diff --git a/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs new file mode 100644 index 0000000..d1df48e --- /dev/null +++ b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs @@ -0,0 +1,196 @@ +#nullable disable + +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) + { + return FromFile(path, DefaultPageSize); + } + + /// + /// Creates a object from the specified file in Intel HEX format + /// and a specified page size. + /// + /// The name of the file from which to create the . + /// The size of the memory blocks used to upload the device firmware. + /// + /// 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, int pageSize) + { + var metadata = Path.GetFileNameWithoutExtension(path); + using (var stream = File.OpenRead(path)) + { + return FromStream(metadata, stream, pageSize); + } + } + + /// + /// Creates a object extracted from the specified ASCII + /// stream in Intel HEX format, the specified metadata string and page size. + /// + /// The firmware metadata encoded in a text string representation. + /// 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 metadata extracted from the firmware file name. + /// + public static DeviceFirmware FromStream(string metadata, Stream stream, int pageSize) + { + const char StartCode = ':'; + var firmwareMetadata = FirmwareMetadata.Parse(metadata); + 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(firmwareMetadata, 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..1176a47 --- /dev/null +++ b/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs @@ -0,0 +1,237 @@ +using System.Globalization; +using System.Text.RegularExpressions; +using Bonsai.Harp; + +#nullable disable + +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.Value == 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 FirmwareMetadata 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, 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.Value}" : string.Empty; + var assemblyNumber = AssemblyVersion.HasValue ? AssemblyVersion.Value.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/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 fec3c9e..3378bb9 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -1,6 +1,6 @@ using System.CommandLine; -using Bonsai.Harp; using Spectre.Console; +using Harp.Toolkit.Firmware.ATxmega; namespace Harp.Toolkit; From f2ec124e73c3c14187db8dcd00e3ffd841ea96ea Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 14 Sep 2026 23:15:26 +0100 Subject: [PATCH 03/11] Annotate firmware update routines for nullability Remove the nullable disable pragmas from the ported firmware code and annotate it. The optional progress reporter, the equality members and the metadata TryParse result are all declared nullable to match what the bodies already did, and TryParse carries NotNullWhen so Parse can return its result directly. The comparison operators on FirmwareMetadata now take nullable operands, so comparing metadata against null no longer warns at the call site. --- src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs | 6 ++---- .../Firmware/ATxmega/DeviceFirmware.cs | 4 +--- .../Firmware/ATxmega/FirmwareMetadata.cs | 17 ++++++++--------- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs index 0a86f8b..7f0a68a 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -2,8 +2,6 @@ using System.Reactive.Linq; using Bonsai.Harp; -#nullable disable - namespace Harp.Toolkit.Firmware.ATxmega; /// @@ -32,7 +30,7 @@ public static class Bootloader /// /// The task object representing the asynchronous firmware update operation. /// - public static Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, IProgress progress = default) + public static Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, IProgress? progress = default) { return UpdateFirmwareAsync(portName, firmware, forceUpdate: false, progress: progress); } @@ -51,7 +49,7 @@ public static Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, /// /// The task object representing the asynchronous firmware update operation. /// - public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, bool forceUpdate, IProgress progress = default) + public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, bool forceUpdate, IProgress? progress = default) { var flushDelay = TimeSpan.FromMilliseconds(FlushDelayMilliseconds); try diff --git a/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs index d1df48e..36b1b97 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs @@ -1,6 +1,4 @@ -#nullable disable - -namespace Harp.Toolkit.Firmware.ATxmega; +namespace Harp.Toolkit.Firmware.ATxmega; /// /// Represents a hardware control firmware image which can be uploaded into a Harp device. diff --git a/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs b/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs index 1176a47..93f2f85 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs @@ -1,9 +1,8 @@ -using System.Globalization; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.RegularExpressions; using Bonsai.Harp; -#nullable disable - namespace Harp.Toolkit.Firmware.ATxmega; /// @@ -98,7 +97,7 @@ public bool Supports(string deviceName, HarpVersion hardwareVersion, int assembl /// true if the specified object is equal to the current metadata; /// otherwise, false. /// - public override bool Equals(object obj) + public override bool Equals(object? obj) { if (obj is FirmwareMetadata version) return Equals(version); else return false; @@ -112,7 +111,7 @@ public override bool Equals(object obj) /// true if the specified metadata object is equal to the current metadata; /// otherwise, false. /// - public bool Equals(FirmwareMetadata other) + public bool Equals(FirmwareMetadata? other) { if (other is null) return false; return DeviceName == other.DeviceName && @@ -150,7 +149,7 @@ public override int GetHashCode() /// 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) + public static bool operator ==(FirmwareMetadata? lhs, FirmwareMetadata? rhs) { if (lhs is null) return rhs is null; else return lhs.Equals(rhs); @@ -166,7 +165,7 @@ public override int GetHashCode() /// 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) + public static bool operator !=(FirmwareMetadata? lhs, FirmwareMetadata? rhs) { if (lhs is null) return !(rhs is null); else return !lhs.Equals(rhs); @@ -181,7 +180,7 @@ public override int GetHashCode() public static FirmwareMetadata Parse(string input) { if (input == null) throw new ArgumentNullException(nameof(input)); - if (!TryParse(input, out FirmwareMetadata result)) + if (!TryParse(input, out var result)) { throw new ArgumentException("Invalid Harp firmware metadata specification string.", nameof(input)); } @@ -200,7 +199,7 @@ public static FirmwareMetadata Parse(string input) /// otherwise, contains null. /// /// true if the conversion was successful; otherwise, false. - public static bool TryParse(string input, out FirmwareMetadata metadata) + public static bool TryParse(string input, [NotNullWhen(true)] out FirmwareMetadata? metadata) { if (input == null) throw new ArgumentNullException(nameof(input)); var match = MetadataRegex.Match(input); From b16b0a42689d335f31b4d6fb82e80ff307ad3e31 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Mon, 14 Sep 2026 23:28:26 +0100 Subject: [PATCH 04/11] Add a hardware test for repeated firmware updates Port the firmware update test and its hardware helper from bonsai-rx/harp at e38b422. One test, skipped automatically when no port is configured, so dotnet test runs the ordinary suite without a device and without any flag. It repeats the update, classifies each failure by the stage it reached, waits for the device to respond before starting the next one, and forces an update to recover a device left in bootloader mode. When a device does not respond within the ceiling, the test stops polling and waits quietly instead, since polling holds a device in bootloader mode, and it abandons the run. The port, the firmware image, the reset consent and the iteration count are read from the HARP_TOOLKIT_TEST_PORT, _FIRMWARE, _ALLOW_RESET and _ITERATIONS environment variables. The solution also drops the build configuration left behind by the device project template, which was split into its own repository. --- Harp.Toolkit.sln | 10 +- src/Harp.Toolkit.Tests/AssemblyInfo.cs | 3 + src/Harp.Toolkit.Tests/HardwareTestHelper.cs | 260 ++++++++++++++++++ .../Harp.Toolkit.Tests.csproj | 18 ++ src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs | 252 +++++++++++++++++ 5 files changed, 539 insertions(+), 4 deletions(-) create mode 100644 src/Harp.Toolkit.Tests/AssemblyInfo.cs create mode 100644 src/Harp.Toolkit.Tests/HardwareTestHelper.cs create mode 100644 src/Harp.Toolkit.Tests/Harp.Toolkit.Tests.csproj create mode 100644 src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs 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/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..0a6467c --- /dev/null +++ b/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs @@ -0,0 +1,252 @@ +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; + + 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); + 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, 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); + } + } +} From 9893e7d6fddb927519ff8622e369644146c0b190 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 15 Sep 2026 09:06:34 +0100 Subject: [PATCH 05/11] Report update failures as messages, not traces The update command now reports a bad firmware file name, a malformed hex image, an incompatible device and every error the bootloader reports as a message and exit code 1, where each previously printed an unhandled stack trace. The firmware file is read inside the error handler so a failure reading it can be reported, and DeviceFirmware takes the parsed metadata, so it is parsed only once. The compatibility check now names both sides, so a refused update says which device and hardware version the file is for and which the device reports. --- .../Firmware/ATxmega/Bootloader.cs | 5 +++- .../Firmware/ATxmega/DeviceFirmware.cs | 27 +++++++++--------- src/Harp.Toolkit/PortErrors.cs | 7 ++++- src/Harp.Toolkit/UpdateFirmwareCommand.cs | 28 +++++++++++++++++-- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs index 7f0a68a..f5356ff 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -63,7 +63,10 @@ public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware fir var deviceName = await device.ReadDeviceNameAsync().WithTimeout(FlushDelayMilliseconds); if (!firmware.Metadata.Supports(deviceName, hardwareVersion)) { - throw new ArgumentException("The specified firmware is not supported.", nameof(firmware)); + 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}."); } } diff --git a/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs index 36b1b97..9dfac38 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/DeviceFirmware.cs @@ -88,43 +88,42 @@ static void Expand(ref short[] data, int newSize) /// public static DeviceFirmware FromFile(string path) { - return FromFile(path, DefaultPageSize); + var metadata = FirmwareMetadata.Parse(Path.GetFileNameWithoutExtension(path)); + return FromFile(metadata, path); } /// - /// Creates a object from the specified file in Intel HEX format - /// and a specified page size. + /// 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 . - /// The size of the memory blocks used to upload the device firmware. /// /// A new object representing the extracted binary firware blob, - /// together with the metadata extracted from the firmware file name. + /// together with the specified metadata. /// - public static DeviceFirmware FromFile(string path, int pageSize) + public static DeviceFirmware FromFile(FirmwareMetadata metadata, string path) { - var metadata = Path.GetFileNameWithoutExtension(path); using (var stream = File.OpenRead(path)) { - return FromStream(metadata, stream, pageSize); + return FromStream(metadata, stream, DefaultPageSize); } } /// /// Creates a object extracted from the specified ASCII - /// stream in Intel HEX format, the specified metadata string and page size. + /// stream in Intel HEX format, the specified metadata and page size. /// - /// The firmware metadata encoded in a text string representation. + /// 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 metadata extracted from the firmware file name. + /// together with the specified metadata. /// - public static DeviceFirmware FromStream(string metadata, Stream stream, int pageSize) + public static DeviceFirmware FromStream(FirmwareMetadata metadata, Stream stream, int pageSize) { const char StartCode = ':'; - var firmwareMetadata = FirmwareMetadata.Parse(metadata); using (var reader = new StreamReader(stream)) { var lineNumber = 0; @@ -178,7 +177,7 @@ public static DeviceFirmware FromStream(string metadata, Stream stream, int page } var byteCode = Array.ConvertAll(data, value => (byte)value); - return new DeviceFirmware(firmwareMetadata, byteCode); + return new DeviceFirmware(metadata, byteCode); } } 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/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs index 3378bb9..a9569c5 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -6,6 +6,10 @@ 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."; + public UpdateFirmwareCommand() : base("update", "Update the device firmware from a local HEX file.") { @@ -49,10 +53,29 @@ public UpdateFirmwareCommand() var portName = parseResult.GetRequiredValue(portNameOption); var forceUpdate = parseResult.GetValue(forceUpdateOption); - var firmware = DeviceFirmware.FromFile(firmwarePath.FullName); - Console.WriteLine($"{firmware.Metadata}"); return portNameOption.ReportErrorsAsync(portName, async () => { + 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 + { + 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; + } + + Console.WriteLine($"{firmware.Metadata}"); await AnsiConsole.Progress().StartAsync(async context => { var task = context.AddTask("Updating firmware"); @@ -60,6 +83,7 @@ await AnsiConsole.Progress().StartAsync(async context => await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, progress); }); Console.WriteLine("Firmware updated."); + return 0; }); }); } From 8ae81ccf86452733739e8820a5e2817bc5a384dc Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 15 Sep 2026 10:08:21 +0100 Subject: [PATCH 06/11] Report how far an interrupted update got An update that fails after the device has been reset now reports the percentage it reached and that the device may be left in bootloader mode, rather than the message describing the port. A lost connection, a device that stops responding and an error reported by the device are each named, and any other failure is reported with its exception type instead of an unhandled stack trace. The upload also retries when the serial port reports it is no longer open, which is the first error from a device unplugged mid update, before the reopen fails. A device that does not answer before the reset now also reports that it may still be restarting, or may be in bootloader mode and need a forced update, where it previously said only that it did not respond in time. --- .../Firmware/ATxmega/Bootloader.cs | 3 +- src/Harp.Toolkit/UpdateFirmwareCommand.cs | 69 +++++++++++++++++-- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs index f5356ff..ecba42f 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -129,7 +129,8 @@ public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware fir break; }; } - catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || ex is TimeoutException) + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException || + ex is TimeoutException || ex is InvalidOperationException) { if (i < MaxAttempts) { diff --git a/src/Harp.Toolkit/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs index a9569c5..7aa6110 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -10,6 +10,44 @@ public class UpdateFirmwareCommand : Command "The name carries the device and version numbers, as in " + "-fw-harp-hw-ass.hex."; + 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 = + "The device may still be restarting from a previous operation, in which case running the " + + "update again will succeed."; + + const string BootloaderModeHint = + "A device left in bootloader mode by an interrupted update does not answer Harp commands. " + + "If that is what happened here, 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.") { @@ -76,12 +114,33 @@ public UpdateFirmwareCommand() } Console.WriteLine($"{firmware.Metadata}"); - await AnsiConsole.Progress().StartAsync(async context => + var lastProgress = -1; + try { - var task = context.AddTask("Updating firmware"); - var progress = new ImmediateProgress(percent => task.Value = percent); - await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, progress); - }); + await AnsiConsole.Progress().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, 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)) + { + Console.Error.WriteLine($"{cause} {NoResponseHint} {BootloaderModeHint}"); + return 1; + } + Console.WriteLine("Firmware updated."); return 0; }); From 2053554c88189ca533abda7dd2ceb9aa61e1af2c Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 15 Sep 2026 10:35:50 +0100 Subject: [PATCH 07/11] Validate bootloader replies A bootloader reply is now accepted only when it carries the protocol header, echoes the opcode that was sent and reports a known error code, where previously a correct checksum was the only requirement and an unrecognised error code counted as success. The page size is also rejected unless it is a power of two within the range the cores use. --- .../Firmware/ATxmega/Bootloader.cs | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs index ecba42f..787bc1d 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -16,6 +16,9 @@ public static class Bootloader 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; @@ -195,12 +198,19 @@ static async Task ReadPageSizeAsync(Stream stream) { var message = CreateBootloaderMessage(ReadPageSize, address: 0); await BootloaderCommandAsync(stream, message); - return BitConverter.ToInt32(message, startIndex: 9); + 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) { @@ -213,6 +223,11 @@ static async Task BootloaderCommandAsync(Stream stream, byte[] message) 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."); @@ -220,12 +235,11 @@ static async Task BootloaderCommandAsync(Stream stream, byte[] message) 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."); - case NoError: - default: - break; + default: throw new HarpException("The device reported an unknown error while updating the bootloader logic."); } } } From 5a84792d1c1035bce31b54d2caf7cb456968c413 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 15 Sep 2026 10:55:15 +0100 Subject: [PATCH 08/11] Detect bootloader mode before advising --force The update command now probes the bootloader protocol when a device does not answer a Harp command, and reports that the device is in bootloader mode and needs --force only when a bootloader answers. The command also accepts --timeout, which now binds the Harp reads during an update in place of a fixed 500 ms. --- src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs | 14 +++- .../Firmware/ATxmega/Bootloader.cs | 66 +++++++++++++------ src/Harp.Toolkit/UpdateFirmwareCommand.cs | 17 +++-- 3 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs b/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs index 0a6467c..50963c6 100644 --- a/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs +++ b/src/Harp.Toolkit.Tests/TestFirmwareUpdate.cs @@ -6,7 +6,8 @@ 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 +/// they occurred at, so that a change to +/// /// can be judged against a measured failure rate. /// /// @@ -23,6 +24,12 @@ public class TestFirmwareUpdate /// 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!; /// @@ -56,7 +63,7 @@ public void Report(int value) { try { - await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate: true); + await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate: true, ResponseTimeoutMilliseconds); return null; } catch (Exception ex) @@ -105,7 +112,8 @@ public async Task UpdateFirmware_RepeatedUpdates_ClassifyFailuresByStage() var stopwatch = Stopwatch.StartNew(); try { - await Bootloader.UpdateFirmwareAsync(portName, firmware, progress); + await Bootloader.UpdateFirmwareAsync( + portName, firmware, forceUpdate: false, ResponseTimeoutMilliseconds, progress); var elapsed = stopwatch.Elapsed.TotalMilliseconds; successMilliseconds.Add(elapsed); if (progress.BootloaderAttempts > 1) diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs index 787bc1d..19ac463 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -10,7 +10,10 @@ namespace Harp.Toolkit.Firmware.ATxmega; 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; @@ -24,20 +27,6 @@ public static class Bootloader 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. - /// The optional object used to report update progress. - /// - /// The task object representing the asynchronous firmware update operation. - /// - public static Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, IProgress? progress = default) - { - return UpdateFirmwareAsync(portName, firmware, forceUpdate: false, progress: progress); - } - /// /// Asynchronously updates the firmware of the Harp device on the specified port. /// @@ -48,11 +37,17 @@ public static Task UpdateFirmwareAsync(string portName, DeviceFirmware firmware, /// 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, IProgress? progress = default) + public static async Task UpdateFirmwareAsync( + string portName, + DeviceFirmware firmware, + bool forceUpdate, + int timeout, + IProgress? progress = default) { var flushDelay = TimeSpan.FromMilliseconds(FlushDelayMilliseconds); try @@ -62,8 +57,8 @@ public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware fir progress?.Report(10); if (!forceUpdate) { - var hardwareVersion = await device.ReadHardwareVersionAsync().WithTimeout(FlushDelayMilliseconds); - var deviceName = await device.ReadDeviceNameAsync().WithTimeout(FlushDelayMilliseconds); + var hardwareVersion = await device.ReadHardwareVersionAsync().WithTimeout(timeout); + var deviceName = await device.ReadDeviceNameAsync().WithTimeout(timeout); if (!firmware.Metadata.Supports(deviceName, hardwareVersion)) { throw new HarpException( @@ -74,7 +69,7 @@ public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware fir } progress?.Report(20); - var reset = await device.ReadResetDeviceAsync().WithTimeout(FlushDelayMilliseconds); + var reset = await device.ReadResetDeviceAsync().WithTimeout(timeout); if ((reset & ResetFlags.BootFromEeprom) != 0) { await device.WriteResetDeviceAsync(ResetFlags.RestoreEeprom); @@ -98,7 +93,6 @@ public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware fir progress?.Report(30); const int MaxAttempts = 3; - const int DefaultBaudRate = 1000000; for (int i = 1; i <= MaxAttempts; i++) { try @@ -146,6 +140,38 @@ public static async Task UpdateFirmwareAsync(string portName, DeviceFirmware fir } } + /// + /// 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 Observable.Timer(TimeSpan.FromMilliseconds(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; @@ -215,7 +241,7 @@ static async Task BootloaderCommandAsync(Stream stream, byte[] message) while (bytesRead < message.Length) { bytesRead += await stream.ReadAsync(message, bytesRead, message.Length - bytesRead) - .WithTimeout(FlushDelayMilliseconds); + .WithTimeout(BootloaderTimeoutMilliseconds); } if (bytesRead != message.Length) diff --git a/src/Harp.Toolkit/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs index 7aa6110..83490dc 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -16,13 +16,12 @@ public class UpdateFirmwareCommand : Command "bootloader mode cannot report its identity for the compatibility check."; const string NoResponseHint = - "The device may still be restarting from a previous operation, in which case running the " + - "update again will succeed."; + "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 = - "A device left in bootloader mode by an interrupted update does not answer Harp commands. " + - "If that is what happened here, re-run with --force, which skips the compatibility check " + - "when the device cannot answer."; + "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; @@ -52,6 +51,7 @@ 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") { @@ -73,6 +73,7 @@ public UpdateFirmwareCommand() Arguments.Add(firmwareArgument); Options.Add(portNameOption); + Options.Add(portTimeoutOption); Options.Add(firmwarePathOption); Options.Add(forceUpdateOption); Validators.Add(result => @@ -89,6 +90,7 @@ 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); return portNameOption.ReportErrorsAsync(portName, async () => @@ -125,7 +127,7 @@ await AnsiConsole.Progress().StartAsync(async context => lastProgress = percent; task.Value = percent; }); - await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, progress); + await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, portTimeout, progress); }); } catch (Exception ex) when (lastProgress >= DeviceResetStage && @@ -137,7 +139,8 @@ await AnsiConsole.Progress().StartAsync(async context => catch (TimeoutException ex) when (!forceUpdate && portNameOption.TryDescribe(ex, portName, out var cause)) { - Console.Error.WriteLine($"{cause} {NoResponseHint} {BootloaderModeHint}"); + var hint = await Bootloader.IsBootloaderAsync(portName) ? BootloaderModeHint : NoResponseHint; + Console.Error.WriteLine($"{cause} {hint}"); return 1; } From d6be21689845318546c5f1a8e35cbfbe310d7ee1 Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 15 Sep 2026 12:46:05 +0100 Subject: [PATCH 09/11] Apply code style to the firmware routines Await Task.Delay rather than an Rx timer for the settle delays, which removes the only use of System.Reactive from the firmware folder, and prefer reading a nullable through GetValueOrDefault. --- src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs | 10 ++++------ src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs | 8 +++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs index 19ac463..67f280c 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/Bootloader.cs @@ -1,5 +1,4 @@ using System.IO.Ports; -using System.Reactive.Linq; using Bonsai.Harp; namespace Harp.Toolkit.Firmware.ATxmega; @@ -49,7 +48,6 @@ public static async Task UpdateFirmwareAsync( int timeout, IProgress? progress = default) { - var flushDelay = TimeSpan.FromMilliseconds(FlushDelayMilliseconds); try { using (var device = new AsyncDevice(portName)) @@ -89,7 +87,7 @@ public static async Task UpdateFirmwareAsync( } } - await Observable.Timer(flushDelay); + await Task.Delay(FlushDelayMilliseconds); progress?.Report(30); const int MaxAttempts = 3; @@ -101,7 +99,7 @@ public static async Task UpdateFirmwareAsync( { bootloader.Handshake = Handshake.None; bootloader.Open(); - await Observable.Timer(flushDelay); + await Task.Delay(FlushDelayMilliseconds); var pageSize = await ReadPageSizeAsync(bootloader.BaseStream); progress?.Report(40); @@ -131,7 +129,7 @@ public static async Task UpdateFirmwareAsync( { if (i < MaxAttempts) { - await Observable.Timer(flushDelay); + await Task.Delay(FlushDelayMilliseconds); continue; } @@ -158,7 +156,7 @@ public static async Task IsBootloaderAsync(string portName) { try { - await Observable.Timer(TimeSpan.FromMilliseconds(FlushDelayMilliseconds)); + await Task.Delay(FlushDelayMilliseconds); using var bootloader = new SerialPort(portName, DefaultBaudRate, Parity.None, 8, StopBits.One); bootloader.Handshake = Handshake.None; bootloader.Open(); diff --git a/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs b/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs index 93f2f85..612ffda 100644 --- a/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs +++ b/src/Harp.Toolkit/Firmware/ATxmega/FirmwareMetadata.cs @@ -86,7 +86,7 @@ public bool Supports(string deviceName, HarpVersion hardwareVersion, int assembl { return DeviceName == deviceName && HardwareVersion.Satisfies(hardwareVersion) && - (!AssemblyVersion.HasValue || AssemblyVersion.Value == assemblyVersion); + (!AssemblyVersion.HasValue || AssemblyVersion.GetValueOrDefault() == assemblyVersion); } /// @@ -229,8 +229,10 @@ public static bool TryParse(string input, [NotNullWhen(true)] out FirmwareMetada /// public override string ToString() { - var prerelease = PrereleaseVersion.HasValue ? $"-preview{PrereleaseVersion.Value}" : string.Empty; - var assemblyNumber = AssemblyVersion.HasValue ? AssemblyVersion.Value.ToString(CultureInfo.InvariantCulture) : FloatingWildcard; + 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}"; } } From 7de721121639d861dde4fe6bb9ad13e51b15654d Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 15 Sep 2026 13:23:04 +0100 Subject: [PATCH 10/11] Clear the progress bar when the update finishes The update command now removes the progress bar from the terminal when it stops. The percentage reached is reported in the failure message. --- src/Harp.Toolkit/UpdateFirmwareCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Harp.Toolkit/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs index 83490dc..2bbe25a 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -119,7 +119,7 @@ public UpdateFirmwareCommand() var lastProgress = -1; try { - await AnsiConsole.Progress().StartAsync(async context => + await AnsiConsole.Progress().AutoClear(true).StartAsync(async context => { var task = context.AddTask("Updating firmware"); var progress = new ImmediateProgress(percent => From 44dd53c9f28c905f5c3548da6bb88da70cf4b00c Mon Sep 17 00:00:00 2001 From: glopesdev Date: Tue, 15 Sep 2026 15:16:51 +0100 Subject: [PATCH 11/11] Document firmware update and decline other cores Add an article covering the naming convention followed by firmware images, the options, and how to recover a device left in bootloader mode by an interrupted update. The landing page gains a new section beside the ones for code generation and verification, and the quickstart drops its update step, leaving every step there read-only. An image that is not an Intel HEX file is now refused with a message naming the cores the update supports. The description of the --force option also covers both of its intended uses, since the flag is the only way to recover a device in bootloader mode. --- docs/README.md | 18 +++-- docs/articles/toc.yml | 1 + docs/articles/update.md | 90 +++++++++++++++++++++++ src/Harp.Toolkit/UpdateFirmwareCommand.cs | 13 +++- 4 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 docs/articles/update.md 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/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs index 2bbe25a..6c2547a 100644 --- a/src/Harp.Toolkit/UpdateFirmwareCommand.cs +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -10,6 +10,10 @@ public class UpdateFirmwareCommand : Command "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 " + @@ -68,7 +72,7 @@ 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); @@ -95,6 +99,13 @@ public UpdateFirmwareCommand() return portNameOption.ReportErrorsAsync(portName, async () => { + 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(