From 5aaf6b22a33e572f5fa4b403e64197ca4e972390 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:11:16 -0800 Subject: [PATCH 01/19] Add draft for benchmarking tool --- Harp.Toolkit/Benchmark/BenchmarkCommand.cs | 152 +++++++++++++++++ Harp.Toolkit/Benchmark/HarpTestAttribute.cs | 7 + Harp.Toolkit/Benchmark/HtmlReportGenerator.cs | 21 +++ Harp.Toolkit/Benchmark/Report.cs | 8 + Harp.Toolkit/Benchmark/ReportTemplate.cshtml | 116 +++++++++++++ Harp.Toolkit/Benchmark/Result.cs | 161 ++++++++++++++++++ Harp.Toolkit/Benchmark/Runner.cs | 53 ++++++ Harp.Toolkit/Benchmark/Suite.cs | 71 ++++++++ .../Benchmark/Suites/RoundTripTestSuite.cs | 42 +++++ .../Benchmark/Suites/TimestampSecondSuite.cs | 24 +++ Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs | 21 +++ src/Harp.Toolkit/Program.cs | 1 + 12 files changed, 677 insertions(+) create mode 100644 Harp.Toolkit/Benchmark/BenchmarkCommand.cs create mode 100644 Harp.Toolkit/Benchmark/HarpTestAttribute.cs create mode 100644 Harp.Toolkit/Benchmark/HtmlReportGenerator.cs create mode 100644 Harp.Toolkit/Benchmark/Report.cs create mode 100644 Harp.Toolkit/Benchmark/ReportTemplate.cshtml create mode 100644 Harp.Toolkit/Benchmark/Result.cs create mode 100644 Harp.Toolkit/Benchmark/Runner.cs create mode 100644 Harp.Toolkit/Benchmark/Suite.cs create mode 100644 Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs create mode 100644 Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs create mode 100644 Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs diff --git a/Harp.Toolkit/Benchmark/BenchmarkCommand.cs b/Harp.Toolkit/Benchmark/BenchmarkCommand.cs new file mode 100644 index 0000000..f6754ef --- /dev/null +++ b/Harp.Toolkit/Benchmark/BenchmarkCommand.cs @@ -0,0 +1,152 @@ +using System.CommandLine; +using Spectre.Console; + +namespace Harp.Toolkit; +public class BenchmarkCommand : Command +{ + public BenchmarkCommand() + : base("benchmark", "Run benchmark tests on the device.") + { + PortNameOption portNameOption = new(); + Option fileOption = new("--report") + { + Description = "Path to the HTML report generated after running tests.", + Required = false, + }; + + Option verboseOption = new("--verbose") + { + Description = "Show detailed results for each test.", + Required = false, + }; + Options.Add(portNameOption); + Options.Add(fileOption); + Options.Add(verboseOption); + SetAction(parsedResult => + { + string portName = parsedResult.GetRequiredValue(portNameOption); + FileInfo? reportFile = parsedResult.GetValue(fileOption); + bool verbose = parsedResult.GetValue(verboseOption); + return RunBenchmarks(portName, reportFile, verbose, CancellationToken.None); + }); + } + + static async Task RunBenchmarks(string portName, FileInfo? reportFile, bool verbose, CancellationToken cancellationToken) + { + AnsiConsole.MarkupLine($"Running tests on [bold]{portName}[/]..."); + + var runner = new CoreRunner(); + var report = new Report + { + DeviceName = $"Harp Device ({portName})", + RunDate = DateTime.Now + }; + + await AnsiConsole.Progress() + .StartAsync(async ctx => + { + var task = ctx.AddTask("[green]Running tests...[/]", true, runner.TestCount); + + await foreach (var (suite, result) in runner.RunAllAsync(portName, cancellationToken)) + { + task.Increment(1); + AnsiConsole.MarkupLine($"[grey]{suite.GetType().Name}::{result.Name}[/] .... {GetResultMarkup(result.Result)}"); + + var suiteResult = report.Suites.FirstOrDefault(s => s.Name == suite.GetType().Name); + if (suiteResult == null) + { + suiteResult = new SuiteResult + { + Name = suite.GetType().Name, + Description = suite.Description + }; + report.Suites.Add(suiteResult); + } + suiteResult.Results.Add(result); + } + }); + + if (verbose) + { + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule("[yellow]Detailed Results[/]")); + foreach (var suite in report.Suites) + { + AnsiConsole.MarkupLine($"[bold underline]{suite.Name}[/]"); + AnsiConsole.MarkupLine($"[dim]{suite.Description}[/]"); + + var table = new Table(); + table.AddColumn("Test Case"); + table.AddColumn("Status"); + table.AddColumn("Details"); + table.AddColumn("Message"); + + foreach (var test in suite.Results) + { + string details = ""; + string message = test.Result.Message ?? ""; + + if (test.Result is NumericBenchmarkResult bsr) + { + details = $"Mean: {bsr.Summary.Mean:F4}\nMedian: {bsr.Summary.Median:F4}\nStdDev: {bsr.Summary.StdDev:F4}\nMin: {bsr.Summary.Min:F4}\nMax: {bsr.Summary.Max:F4}\nPercentiles: 99th={bsr.Summary.Percentile99:F4}, 01th={bsr.Summary.Percentile01:F4}"; + } + else if (test.Result is ErrorResult er) + { + details = $"{er.Exception.GetType().Name}"; + } + else + { + var valProp = test.Result?.GetType().GetProperty("Value"); + if (valProp != null) + { + var val = valProp.GetValue(test.Result); + details = val?.ToString() ?? ""; + } + } + + table.AddRow( + new Markup($"[bold]{test.Name}[/]\n[dim]{test.Description}[/]"), + new Markup(GetResultMarkup(test.Result)), + new Markup(details), + new Markup(message) + ); + } + AnsiConsole.Write(table); + AnsiConsole.WriteLine(); + } + } + + if (reportFile != null) + { + AnsiConsole.Markup("Generating HTML report..."); + string html = await HtmlReportGenerator.GenerateAsync(report); + string fileName = reportFile?.FullName ?? $"TestReport_{DateTime.Now:yyyyMMdd_HHmmss}.html"; + await File.WriteAllTextAsync(fileName, html, cancellationToken); + AnsiConsole.MarkupLine($"[green]Done![/] Report generated: [link]{fileName}[/]"); + } + } + + static string GetResultMarkup(IResult result) + { + return result.Status switch + { + Status.Passed => "[green]Passed[/]", + Status.Failed => "[red]Failed[/]", + Status.Error => "[red]Error[/]", + Status.Skipped => "[yellow]Skipped[/]", + _ => $"[white]{result.Status}[/]" + }; + } + + class CoreRunner : Runner + { + public CoreRunner() : base() + { + AddSuite(new WhoAmISuite()); + AddSuite(new RoundTripTestSuite()); + AddSuite(new TimestampSecondsSuite()); + } + } +} + + diff --git a/Harp.Toolkit/Benchmark/HarpTestAttribute.cs b/Harp.Toolkit/Benchmark/HarpTestAttribute.cs new file mode 100644 index 0000000..f0bf7cb --- /dev/null +++ b/Harp.Toolkit/Benchmark/HarpTestAttribute.cs @@ -0,0 +1,7 @@ +namespace Harp.Toolkit; + +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class HarpTestAttribute : Attribute +{ + public string? Description { get; set; } +} diff --git a/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs b/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs new file mode 100644 index 0000000..ede8e25 --- /dev/null +++ b/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs @@ -0,0 +1,21 @@ +using System.Reflection; +using RazorLight; + +namespace Harp.Toolkit; + +public static class HtmlReportGenerator +{ + public static async Task GenerateAsync(Report report) + { + var engine = new RazorLightEngineBuilder() + .UseFileSystemProject(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)) + .UseMemoryCachingProvider() + .Build(); + + // The template is copied to the output directory under Reporting/ReportTemplate.cshtml + // RazorLight expects the path relative to the project root (which we set to the assembly location) + string templatePath = Path.Combine("Benchmark", "ReportTemplate.cshtml"); + + return await engine.CompileRenderAsync(templatePath, report); + } +} diff --git a/Harp.Toolkit/Benchmark/Report.cs b/Harp.Toolkit/Benchmark/Report.cs new file mode 100644 index 0000000..cc716f2 --- /dev/null +++ b/Harp.Toolkit/Benchmark/Report.cs @@ -0,0 +1,8 @@ +namespace Harp.Toolkit; + +public class Report +{ + public string DeviceName { get; set; } = "Unknown Device"; + public DateTime RunDate { get; set; } = DateTime.Now; + public List Suites { get; set; } = new(); +} diff --git a/Harp.Toolkit/Benchmark/ReportTemplate.cshtml b/Harp.Toolkit/Benchmark/ReportTemplate.cshtml new file mode 100644 index 0000000..9cdfbac --- /dev/null +++ b/Harp.Toolkit/Benchmark/ReportTemplate.cshtml @@ -0,0 +1,116 @@ +@using Harp.Toolkit +@model Harp.Toolkit.Report + + + + + + + Test Report - @Model.DeviceName + + + + +
+
+
+

@Model.DeviceName

+

Test Execution Report • @Model.RunDate.ToString("MMMM dd, yyyy HH:mm:ss")

+
+
+ Harp Toolkit Test +
+
+ + @foreach (var suite in Model.Suites) + { +
+
+

@suite.Name

+

@suite.Description

+
+
+
+ + + + + + + + + + + @foreach (var test in suite.Results) + { + + + + + + + } + +
Test CaseStatusResult DetailsMessage
+
@test.Name
+
@test.Description
+
+ + @(test.Result?.Status.ToString().ToUpper() ?? "SKIPPED") + + + @if (test.Result is NumericBenchmarkResult bsr) + { +
+
+
Mean: @bsr.Summary.Mean.ToString("F4")
+
Median: @bsr.Summary.Median.ToString("F4")
+
StdDev: @bsr.Summary.StdDev.ToString("F4")
+
+
+
Min: @bsr.Summary.Min.ToString("F4")
+
Max: @bsr.Summary.Max.ToString("F4")
+
+
+ } + else + { + var valProp = test.Result?.GetType().GetProperty("Value"); + object? val = null; + if (valProp != null) + { + val = valProp.GetValue(test.Result); + } + + if (val != null) + { + @val + } + } + + @if (test.Result is ErrorResult er && er.Exception != null) + { +
+
@er.Exception.ToString()
+
+ } +
+ @test.Result?.Message +
+
+
+
+ } +
+ + diff --git a/Harp.Toolkit/Benchmark/Result.cs b/Harp.Toolkit/Benchmark/Result.cs new file mode 100644 index 0000000..312f97a --- /dev/null +++ b/Harp.Toolkit/Benchmark/Result.cs @@ -0,0 +1,161 @@ + +namespace Harp.Toolkit; + + +public enum Status +{ + Passed, + Failed, + Skipped, + Error +} + +public interface IResult +{ + string? Message { get; } + + Status Status { get; } +} + +public class ErrorResult(Exception exception) : IResult +{ + public string? Message { get; } = exception.Message; + public Status Status { get; } = Status.Error; + public Exception Exception { get; } = exception; +} + +public class Result : IResult +{ + public Result(T value, Status status, string message = "") + { + Status = status; + Value = value; + Message = message; + } + + public Result(T value, Func predicate, Func? messageFactory = null) + { + bool evaluation = predicate(value); + Status = evaluation ? Status.Passed : Status.Failed; + Value = value; + Message = messageFactory?.Invoke(value, evaluation) ?? string.Empty; + } + + + public string Message { get; } + public Status Status { get; } + public T Value { get; } + + public override string? ToString() + { + return $"Result(Status={Status}, Value={Value}, Message={Message})"; + } +} + + +public class AssertionResult : Result +{ + public AssertionResult(bool value, string message = "") + : base(value, value ? Status.Passed : Status.Failed, message) + { + } + + public AssertionResult(bool value, Func? messageFactory = null) + : base( + value, + v => v, + messageFactory is null ? null : ((value, evaluation) => messageFactory(value))) + { + + } +} + + +public class NumericBenchmarkResult : Result +{ + + public NumericBenchmarkResult(double[] values, Status status, string message = "") + : base(values, status, message) + { + Summary = new BenchmarkSummary(values); + } + + public NumericBenchmarkResult(BenchmarkSummary summary, Status status, string message = "") + : base(summary.Values, status, message) + { + Summary = summary; + } + + public NumericBenchmarkResult(double[] values, Func predicate, Func? messageFactory = null) + : base(values, predicate, messageFactory) + { + Summary = new BenchmarkSummary(values); + } + + public BenchmarkSummary Summary { get; } +} + + +public class BenchmarkSummary +{ + public readonly double[] Values; + + + public BenchmarkSummary(double[] values) + { + Values = values ?? Array.Empty(); + // TODO consider copying here since we are mutating + Array.Sort(Values); + } + + public double Mean => Values.Length == 0 ? double.NaN : Values.Average(); + + public double StdDev + { + get + { + if (Values.Length == 0) return double.NaN; + var mean = Mean; + var sumOfSquares = Values.Sum(v => (v - mean) * (v - mean)); + return Math.Sqrt(sumOfSquares / Values.Length); + } + } + + public double Median + { + get + { + if (Values.Length == 0) return double.NaN; + int mid = Values.Length / 2; + if (Values.Length % 2 == 0) + return (Values[mid - 1] + Values[mid]) / 2.0; + else + return Values[mid]; + } + } + + public double Max => Values.Length == 0 ? double.NaN : Values[Values.Length - 1]; + + public double Min => Values.Length == 0 ? double.NaN : Values[0]; + + public double Percentile99 => Percentile(0.99); + public double Percentile01 => Percentile(0.01); + + public double Percentile(double percentile) + { + if (Values.Length == 0) return double.NaN; + if (percentile < 0f || percentile > 1.0f) + { + throw new ArgumentOutOfRangeException(nameof(percentile), "Percentile must be between 0 and 1."); + } + + double rank = percentile * (Values.Length - 1); + int lower = (int)Math.Floor(rank); + int upper = (int)Math.Ceiling(rank); + if (lower == upper) return Values[lower]; + // Apparently this is how you solve rounding with percentiles + // https://en.wikipedia.org/wiki/Percentile + double weight = rank - lower; + return Values[lower] * (1 - weight) + Values[upper] * weight; + } +} diff --git a/Harp.Toolkit/Benchmark/Runner.cs b/Harp.Toolkit/Benchmark/Runner.cs new file mode 100644 index 0000000..ea062ba --- /dev/null +++ b/Harp.Toolkit/Benchmark/Runner.cs @@ -0,0 +1,53 @@ +using System.Runtime.CompilerServices; + +namespace Harp.Toolkit; + +public class Runner +{ + private readonly List suites = new(); + + public Runner() + { + } + + public int TestCount => suites.Sum(s => s.TestCount); + + public IEnumerable CollectSuites() + { + return suites.AsReadOnly(); + } + + public async IAsyncEnumerable<(Suite Suite, MethodResult Result)> RunAllAsync(string portName, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var suite in suites) + { + await foreach (var result in suite.RunAllAsync(portName, cancellationToken)) + { + yield return (suite, result); + } + } + } + + public void AddSuite(Suite suite) + { + if (suite == null) + { + throw new ArgumentNullException(nameof(suite)); + } + suites.Add(suite); + } + + public void ClearSuites() + { + suites.Clear(); + } + + public bool RemoveSuite(Suite suite) + { + if (suite == null) + { + throw new ArgumentNullException(nameof(suite)); + } + return suites.Remove(suite); + } +} diff --git a/Harp.Toolkit/Benchmark/Suite.cs b/Harp.Toolkit/Benchmark/Suite.cs new file mode 100644 index 0000000..5f27464 --- /dev/null +++ b/Harp.Toolkit/Benchmark/Suite.cs @@ -0,0 +1,71 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Bonsai.Harp; + +namespace Harp.Toolkit; + + +public abstract class Suite +{ + public abstract string Description { get; } + + public int TestCount => CollectTests().Count(); + + private IEnumerable<(MethodInfo Method, HarpTestAttribute Attribute)> CollectTests() + { + return GetType() + .GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Select(m => (Method: m, Attribute: m.GetCustomAttribute()!)) + .Where(x => x.Attribute != null); + } + + public async IAsyncEnumerable RunAllAsync(string portName, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var (method, attr) in CollectTests()) + { + cancellationToken.ThrowIfCancellationRequested(); + + IResult testResult; + try + { + object? resultObj = method.Invoke(this, new object[] { portName }); + if (resultObj is Task task) + { + testResult = await task; + } + else if (resultObj is IResult syncResult) + { + testResult = syncResult; + } + else + { + throw new InvalidOperationException($"Test method '{method.Name}' must return IResult or Task."); + } + } + catch (Exception ex) + { + testResult = new ErrorResult(ex.InnerException ?? ex); + } + yield return new MethodResult + { + Result = testResult, + Name = method.Name, + Description = attr.Description ?? string.Empty + }; + } + } +} + +public class SuiteResult +{ + public required string Name { get; set; } + public string Description { get; set; } = string.Empty; + public List Results { get; set; } = new(); +} + +public class MethodResult +{ + public required string Name { get; set; } + public string Description { get; set; } = string.Empty; + public required IResult Result { get; set; } +} diff --git a/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs b/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs new file mode 100644 index 0000000..52031f7 --- /dev/null +++ b/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs @@ -0,0 +1,42 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit; + +public class RoundTripTestSuite : Suite +{ + private double maxRoundTripDelayMs; + public RoundTripTestSuite(double maxRoundTripDelayMs = 4.0) + { + this.maxRoundTripDelayMs = maxRoundTripDelayMs; + } + + public override string Description => "A bunch of tests to benchmark round trip read/writes."; + + [HarpTest(Description = "Benchmarks the round trip time for a WhoAmI read command.")] + public async Task BenchmarkRoundTrip(string portName) + { + const int n = 1000; + double[] timestamps = new double[n]; + HarpMessage probe = WhoAmI.FromPayload(MessageType.Read, default); + using (var device = new AsyncDevice(portName)) + { + for (int i = 0; i < n; i++) + { + var reply = await device.CommandAsync(probe); + timestamps[i] = reply.GetTimestamp(); + } + } + var derivatives = timestamps + .Zip(timestamps.Skip(1), (previous, current) => (current - previous) * 1e3) + .ToArray(); + var benchmark = new BenchmarkSummary(derivatives); + if (benchmark.Max > maxRoundTripDelayMs) + { + return new NumericBenchmarkResult(benchmark, Status.Failed, $"Round trip WhoAmI read benchmark exceeded maximum allowed delay of {maxRoundTripDelayMs} ms."); + } + else + { + return new NumericBenchmarkResult(benchmark, Status.Passed, "Round trip WhoAmI read benchmark."); + } + } +} diff --git a/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs b/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs new file mode 100644 index 0000000..75f9266 --- /dev/null +++ b/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs @@ -0,0 +1,24 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit; + +public class TimestampSecondsSuite : Suite +{ + public override string Description => "Timestamp Seconds Register Tests"; + + [HarpTest(Description = "Validates that the Timestamp Seconds register is writable.")] + public async Task IsWritable(string portName) + { + const uint setSeconds = 42; + using (var device = new AsyncDevice(portName)) + { + await device.WriteTimestampSecondsAsync(setSeconds); + await Task.Delay(1); + HarpMessage response = await device.CommandAsync(TimestampSeconds.FromPayload(MessageType.Read, default)); + double readSeconds = response.GetTimestamp(); + return new AssertionResult( + readSeconds - setSeconds < 1.0, + (success) => success ? $"`TimestampSeconds` register is writable and updates as expected." : $"`TimestampSeconds` register is not writable, Expected value: {setSeconds}, read value: {readSeconds}."); + } + } +} diff --git a/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs b/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs new file mode 100644 index 0000000..9954bc1 --- /dev/null +++ b/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs @@ -0,0 +1,21 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit; + +public class WhoAmISuite : Suite +{ + public override string Description => "WhoAmI Register Tests"; + + [HarpTest(Description = "Validates that the WhoAmI register exists and contains a value.")] + public async Task CheckWhoAmI(string portName) + { + using (var device = new AsyncDevice(portName)) + { + int value = await device.ReadWhoAmIAsync(); + return new Result( + value, + (v) => v > 0 && v < 9999, + (v, success) => success ? $"WhoAmI register contains valid value: {v}." : $"WhoAmI register contains invalid value: {v}."); + } + } +} diff --git a/src/Harp.Toolkit/Program.cs b/src/Harp.Toolkit/Program.cs index f269381..de2dbfe 100644 --- a/src/Harp.Toolkit/Program.cs +++ b/src/Harp.Toolkit/Program.cs @@ -16,6 +16,7 @@ static async Task Main(string[] args) rootCommand.Subcommands.Add(new ListCommand()); rootCommand.Subcommands.Add(new UpdateFirmwareCommand()); rootCommand.Subcommands.Add(new GenerateCommand()); + rootCommand.Subcommands.Add(new BenchmarkCommand()); rootCommand.SetAction(async parseResult => { var portName = parseResult.GetRequiredValue(portNameOption); From 3a3d90a8dc8cb0ebd08945415a77890eda6f0a6e Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:02:03 -0700 Subject: [PATCH 02/19] Add dependencies --- src/Harp.Toolkit/Harp.Toolkit.csproj | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Harp.Toolkit/Harp.Toolkit.csproj b/src/Harp.Toolkit/Harp.Toolkit.csproj index 0f204c9..31b201e 100644 --- a/src/Harp.Toolkit/Harp.Toolkit.csproj +++ b/src/Harp.Toolkit/Harp.Toolkit.csproj @@ -6,6 +6,7 @@ A tool for inspecting, updating and interfacing with Harp devices from the command-line. net8.0 enable + true @@ -14,6 +15,14 @@ + + - + + + PreserveNewest + + + + \ No newline at end of file From 5451a69a2c72beff8c424ba8313359a937d7d2a5 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:51:26 -0700 Subject: [PATCH 03/19] Refactor folder structure to latest version of the library --- {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/BenchmarkCommand.cs | 0 {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/HarpTestAttribute.cs | 0 .../Harp.Toolkit}/Benchmark/HtmlReportGenerator.cs | 0 {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Report.cs | 0 .../Harp.Toolkit}/Benchmark/ReportTemplate.cshtml | 0 {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Result.cs | 0 {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Runner.cs | 0 {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Suite.cs | 0 .../Harp.Toolkit}/Benchmark/Suites/RoundTripTestSuite.cs | 0 .../Harp.Toolkit}/Benchmark/Suites/TimestampSecondSuite.cs | 0 .../Harp.Toolkit}/Benchmark/Suites/WhoAmISuite.cs | 0 11 files changed, 0 insertions(+), 0 deletions(-) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/BenchmarkCommand.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/HarpTestAttribute.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/HtmlReportGenerator.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Report.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/ReportTemplate.cshtml (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Result.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Runner.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Suite.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Suites/RoundTripTestSuite.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Suites/TimestampSecondSuite.cs (100%) rename {Harp.Toolkit => src/Harp.Toolkit}/Benchmark/Suites/WhoAmISuite.cs (100%) diff --git a/Harp.Toolkit/Benchmark/BenchmarkCommand.cs b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs similarity index 100% rename from Harp.Toolkit/Benchmark/BenchmarkCommand.cs rename to src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs diff --git a/Harp.Toolkit/Benchmark/HarpTestAttribute.cs b/src/Harp.Toolkit/Benchmark/HarpTestAttribute.cs similarity index 100% rename from Harp.Toolkit/Benchmark/HarpTestAttribute.cs rename to src/Harp.Toolkit/Benchmark/HarpTestAttribute.cs diff --git a/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs b/src/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs similarity index 100% rename from Harp.Toolkit/Benchmark/HtmlReportGenerator.cs rename to src/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs diff --git a/Harp.Toolkit/Benchmark/Report.cs b/src/Harp.Toolkit/Benchmark/Report.cs similarity index 100% rename from Harp.Toolkit/Benchmark/Report.cs rename to src/Harp.Toolkit/Benchmark/Report.cs diff --git a/Harp.Toolkit/Benchmark/ReportTemplate.cshtml b/src/Harp.Toolkit/Benchmark/ReportTemplate.cshtml similarity index 100% rename from Harp.Toolkit/Benchmark/ReportTemplate.cshtml rename to src/Harp.Toolkit/Benchmark/ReportTemplate.cshtml diff --git a/Harp.Toolkit/Benchmark/Result.cs b/src/Harp.Toolkit/Benchmark/Result.cs similarity index 100% rename from Harp.Toolkit/Benchmark/Result.cs rename to src/Harp.Toolkit/Benchmark/Result.cs diff --git a/Harp.Toolkit/Benchmark/Runner.cs b/src/Harp.Toolkit/Benchmark/Runner.cs similarity index 100% rename from Harp.Toolkit/Benchmark/Runner.cs rename to src/Harp.Toolkit/Benchmark/Runner.cs diff --git a/Harp.Toolkit/Benchmark/Suite.cs b/src/Harp.Toolkit/Benchmark/Suite.cs similarity index 100% rename from Harp.Toolkit/Benchmark/Suite.cs rename to src/Harp.Toolkit/Benchmark/Suite.cs diff --git a/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs b/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs similarity index 100% rename from Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs rename to src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs diff --git a/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs b/src/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs similarity index 100% rename from Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs rename to src/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs diff --git a/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs b/src/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs similarity index 100% rename from Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs rename to src/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs From 4e5f47c776a9f925031923349fc7256e7332b840 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:27:16 -0700 Subject: [PATCH 04/19] Modify organization to match register's name --- .../CoreRegisters/R_ASSEMBLY_VERSION.cs | 21 +++++++++++++++++++ .../R_TIMESTAMP_SECOND.cs} | 4 ++-- .../R_WHO_AM_I.cs} | 4 ++-- .../Benchmark/Suites/RoundTripTestSuite.cs | 6 +++--- 4 files changed, 28 insertions(+), 7 deletions(-) create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs rename src/Harp.Toolkit/Benchmark/Suites/{TimestampSecondSuite.cs => CoreRegisters/R_TIMESTAMP_SECOND.cs} (92%) rename src/Harp.Toolkit/Benchmark/Suites/{WhoAmISuite.cs => CoreRegisters/R_WHO_AM_I.cs} (89%) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs new file mode 100644 index 0000000..5e33221 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs @@ -0,0 +1,21 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_ASSEMBLY_VERSION : Suite +{ + public override string Description => "WhoAmI Register Tests"; + + [HarpTest(Description = "Validates that the WhoAmI register exists and contains a value.")] + public async Task CheckWhoAmI(string portName) + { + using (var device = new AsyncDevice(portName)) + { + int value = await device.ReadWhoAmIAsync(); + return new Result( + value, + (v) => v > 0 && v < 9999, + (v, success) => success ? $"WhoAmI register contains valid value: {v}." : $"WhoAmI register contains invalid value: {v}."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs similarity index 92% rename from src/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs rename to src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs index 75f9266..1545913 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/TimestampSecondSuite.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs @@ -1,8 +1,8 @@  using Bonsai.Harp; -namespace Harp.Toolkit; +namespace Harp.Toolkit.Benchmark.Suites; -public class TimestampSecondsSuite : Suite +internal class R_TIMESTAMP_SECOND : Suite { public override string Description => "Timestamp Seconds Register Tests"; diff --git a/src/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs similarity index 89% rename from src/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs rename to src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs index 9954bc1..a64d047 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/WhoAmISuite.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs @@ -1,8 +1,8 @@  using Bonsai.Harp; -namespace Harp.Toolkit; +namespace Harp.Toolkit.Benchmark.Suites; -public class WhoAmISuite : Suite +internal class R_WHO_AM_I : Suite { public override string Description => "WhoAmI Register Tests"; diff --git a/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs b/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs index 52031f7..840b112 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs @@ -1,8 +1,8 @@  using Bonsai.Harp; -namespace Harp.Toolkit; +namespace Harp.Toolkit.Benchmark.Suites; -public class RoundTripTestSuite : Suite +internal class RoundTripTestSuite : Suite { private double maxRoundTripDelayMs; public RoundTripTestSuite(double maxRoundTripDelayMs = 4.0) @@ -17,7 +17,7 @@ public async Task BenchmarkRoundTrip(string portName) { const int n = 1000; double[] timestamps = new double[n]; - HarpMessage probe = WhoAmI.FromPayload(MessageType.Read, default); + HarpMessage probe = Bonsai.Harp.WhoAmI.FromPayload(MessageType.Read, default); using (var device = new AsyncDevice(portName)) { for (int i = 0; i < n; i++) From 04a18ca860392f3ce3ecb1d84a14b4a3511e6ca3 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Sun, 19 Apr 2026 18:36:16 -0700 Subject: [PATCH 05/19] Add AssemblyRegister test --- .../Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs index 5e33221..197d06a 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs @@ -4,18 +4,20 @@ namespace Harp.Toolkit.Benchmark.Suites; internal class R_ASSEMBLY_VERSION : Suite { - public override string Description => "WhoAmI Register Tests"; + public override string Description => "AssemblyVersion Register Tests"; - [HarpTest(Description = "Validates that the WhoAmI register exists and contains a value.")] - public async Task CheckWhoAmI(string portName) + [HarpTest(Description = "Validates the deprecated register AssemblyVersion returns 0x00.")] + public async Task IsReturnZero(string portName) { using (var device = new AsyncDevice(portName)) { - int value = await device.ReadWhoAmIAsync(); - return new Result( - value, - (v) => v > 0 && v < 9999, - (v, success) => success ? $"WhoAmI register contains valid value: {v}." : $"WhoAmI register contains invalid value: {v}."); + int value = await device.ReadAssemblyVersionAsync(); + bool isZero = value == 0x00; + return new AssertionResult( + isZero, + isZero => isZero ? + $"AssemblyVersion register correctly returned 0x00." : + $"AssemblyVersion register returned a non-zero value (0x{value:X2})"); } } } From 82addef64f0fd17d040f5ae8f8e2c0a92081b1c6 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:07:43 -0700 Subject: [PATCH 06/19] Add R_UID tests --- .../CoreRegisters/R_ASSEMBLY_VERSION.cs | 4 +- .../Benchmark/Suites/CoreRegisters/R_UID.cs | 38 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_UID.cs diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs index 197d06a..df07b42 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs @@ -7,7 +7,7 @@ internal class R_ASSEMBLY_VERSION : Suite public override string Description => "AssemblyVersion Register Tests"; [HarpTest(Description = "Validates the deprecated register AssemblyVersion returns 0x00.")] - public async Task IsReturnZero(string portName) + public async Task AssertReturnsZero(string portName) { using (var device = new AsyncDevice(portName)) { @@ -15,7 +15,7 @@ public async Task IsReturnZero(string portName) bool isZero = value == 0x00; return new AssertionResult( isZero, - isZero => isZero ? + x => x ? $"AssemblyVersion register correctly returned 0x00." : $"AssemblyVersion register returned a non-zero value (0x{value:X2})"); } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_UID.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_UID.cs new file mode 100644 index 0000000..31a7fee --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_UID.cs @@ -0,0 +1,38 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_UID : Suite +{ + private const byte address = 0x10; + private const byte expected_length = 16; + public override string Description => "UID Register Tests"; + + [HarpTest(Description = "Validates whether the UID register is 0 and thus likely not in use.")] + public async Task AssertLength(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var value = await device.ReadByteArrayAsync(address); + return new AssertionResult( + value.Length == expected_length, + x => x ? + $"Length is 16 as expected." : + $"Expected length of register to be 16, got {value.Length} instead"); + } + } + + [HarpTest(Description = "Checks if the register value is 0, indicating it is likely not used.")] + public async Task AssertReturnsZero(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var value = await device.ReadByteArrayAsync(address); + string msg = value.All(x => x == 0) ? "Value of all bytes is 0. Register likely not being used" : $"Register returned a non-zero value: {BitConverter.ToString(value)}"; + return new Result( + value, + Status.Passed, + msg); + } + } +} From c8f0459f8542f3a683ffa1b9874d6970ac32d0ee Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:40:07 -0700 Subject: [PATCH 07/19] Add SerialNumber register test --- .../Suites/CoreRegisters/R_SERIAL_NUMBER.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_SERIAL_NUMBER.cs diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_SERIAL_NUMBER.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_SERIAL_NUMBER.cs new file mode 100644 index 0000000..65a475b --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_SERIAL_NUMBER.cs @@ -0,0 +1,28 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_SERIAL_NUMBER : Suite +{ + public override string Description => "Serial Number Register Tests"; + + [HarpTest(Description = "Validates the contents of the register match the lower two bytes of R_UID")] + public async Task AssertConsitentWithUid(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var uidValue = await device.ReadByteArrayAsync(0x10); + if (uidValue.Length < 2) + throw new ArgumentException($"Expected UID register contents to be at least 2 bytes. Got {uidValue.Length}"); + var twoFirstBytes = BitConverter.ToInt16(uidValue, 0); + + var serialNumberValue = await device.ReadSerialNumberAsync(); + + return new AssertionResult( + twoFirstBytes == serialNumberValue, + x => x ? + $"SerialNumber register contents are consistent with UID register." : + $"SerialNumber register content (0x{serialNumberValue:X4}) does not match the first two bytes of UID register (0x{twoFirstBytes:X4})."); + } + } +} From 8a2215e0ae541151171580577d6838e3bca0e05d Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Sun, 19 Apr 2026 19:52:55 -0700 Subject: [PATCH 08/19] Add TimestampOffset register tests --- .../CoreRegisters/R_ASSEMBLY_VERSION.cs | 5 +-- .../CoreRegisters/R_TIMESTAMP_OFFSET.cs | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs index df07b42..0089263 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs @@ -11,10 +11,9 @@ public async Task AssertReturnsZero(string portName) { using (var device = new AsyncDevice(portName)) { - int value = await device.ReadAssemblyVersionAsync(); - bool isZero = value == 0x00; + var value = await device.ReadAssemblyVersionAsync(); return new AssertionResult( - isZero, + value == 0x00, x => x ? $"AssemblyVersion register correctly returned 0x00." : $"AssemblyVersion register returned a non-zero value (0x{value:X2})"); diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs new file mode 100644 index 0000000..99a83c5 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs @@ -0,0 +1,40 @@ + +using Bonsai.Harp; +using System.Threading; +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_TIMESTAMP_OFFSET : Suite +{ + private const byte address = 0x0F; + public override string Description => "Timestamp Offset Register Tests"; + + [HarpTest(Description = "Validates the deprecated register TimestampOffset returns 0x00.")] + public async Task AssertReturnsZero(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var value = await device.ReadByteAsync(address); + return new AssertionResult( + value == 0x00, + x => x ? + $"TimestampOffset register correctly returned 0x00." : + $"TimestampOffset register returned a non-zero value (0x{value:X2})"); + } + } + + [HarpTest(Description = "Validates the deprecated register TimestampOffset is NOT writable.")] + public async Task IsNotWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var req = HarpMessage.FromByte(address, MessageType.Write, 0x00); + var value = await device.CommandAsync(req); + + return new AssertionResult( + value.Error, + x => x ? + $"Device correctly reported an error when trying to write to TimestampOffset register" : + $"Timestamp Offset register is deprecated and MUST NOT allow writes."); + } + } +} From fdbb9539fba409ac5f6d5ca7eac251490f06436c Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Thu, 7 May 2026 09:49:12 -0700 Subject: [PATCH 09/19] Add additional tests for core registers --- .../Benchmark/BenchmarkCommand.cs | 66 ++++-- src/Harp.Toolkit/Benchmark/Runner.cs | 4 +- src/Harp.Toolkit/Benchmark/Suite.cs | 5 +- .../Suites/CoreRegisters/R_CLOCK_CONFIG.cs | 32 +++ .../Suites/CoreRegisters/R_CORE_VERSION_H.cs | 24 +++ .../Suites/CoreRegisters/R_CORE_VERSION_L.cs | 24 +++ .../Suites/CoreRegisters/R_DEVICE_NAME.cs | 36 ++++ .../Suites/CoreRegisters/R_FW_VERSION_H.cs | 24 +++ .../Suites/CoreRegisters/R_FW_VERSION_L.cs | 24 +++ .../Suites/CoreRegisters/R_HEARTBEAT.cs | 33 +++ .../Suites/CoreRegisters/R_HW_VERSION_H.cs | 24 +++ .../Suites/CoreRegisters/R_HW_VERSION_L.cs | 24 +++ .../Suites/CoreRegisters/R_OPERATION_CTRL.cs | 197 ++++++++++++++++++ .../Suites/CoreRegisters/R_RESET_DEV.cs | 18 ++ .../Benchmark/Suites/CoreRegisters/R_TAG.cs | 51 +++++ .../Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs | 56 +++++ .../CoreRegisters/R_TIMESTAMP_OFFSET.cs | 10 +- .../CoreRegisters/R_TIMESTAMP_SECOND.cs | 57 +++++ .../Suites/CoreRegisters/R_VERSION.cs | 51 +++++ .../Suites/CoreRegisters/R_WHO_AM_I.cs | 15 ++ .../Suites/CoreRegisters/_RegisterHelpers.cs | 50 +++++ 21 files changed, 793 insertions(+), 32 deletions(-) create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs diff --git a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs index f6754ef..9e67d89 100644 --- a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs +++ b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs @@ -1,5 +1,6 @@ using System.CommandLine; using Spectre.Console; +using Harp.Toolkit.Benchmark.Suites; namespace Harp.Toolkit; public class BenchmarkCommand : Command @@ -42,29 +43,30 @@ static async Task RunBenchmarks(string portName, FileInfo? reportFile, bool verb RunDate = DateTime.Now }; - await AnsiConsole.Progress() - .StartAsync(async ctx => - { - var task = ctx.AddTask("[green]Running tests...[/]", true, runner.TestCount); + int currentTest = 0; + await foreach (var (suite, result) in runner.RunAllAsync(portName, cancellationToken, (suite, testName, testDesc) => + { + // Print "Running" status before test execution (without newline) + currentTest++; + Console.Write($"({currentTest}/{runner.TestCount}) {suite.GetType().Name}::{testName} .... Running..."); + })) + { + // Clear the line by moving cursor to start and overwriting with spaces, then print result + Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r"); + AnsiConsole.MarkupLine($"[grey]({currentTest}/{runner.TestCount}) {suite.GetType().Name}::{result.Name}[/] .... {GetResultMarkup(result.Result)}"); - await foreach (var (suite, result) in runner.RunAllAsync(portName, cancellationToken)) + var suiteResult = report.Suites.FirstOrDefault(s => s.Name == suite.GetType().Name); + if (suiteResult == null) + { + suiteResult = new SuiteResult { - task.Increment(1); - AnsiConsole.MarkupLine($"[grey]{suite.GetType().Name}::{result.Name}[/] .... {GetResultMarkup(result.Result)}"); - - var suiteResult = report.Suites.FirstOrDefault(s => s.Name == suite.GetType().Name); - if (suiteResult == null) - { - suiteResult = new SuiteResult - { - Name = suite.GetType().Name, - Description = suite.Description - }; - report.Suites.Add(suiteResult); - } - suiteResult.Results.Add(result); - } - }); + Name = suite.GetType().Name, + Description = suite.Description + }; + report.Suites.Add(suiteResult); + } + suiteResult.Results.Add(result); + } if (verbose) { @@ -142,9 +144,27 @@ class CoreRunner : Runner { public CoreRunner() : base() { - AddSuite(new WhoAmISuite()); + AddSuite(new R_WHO_AM_I()); + AddSuite(new R_HW_VERSION_H()); + AddSuite(new R_HW_VERSION_L()); + AddSuite(new R_ASSEMBLY_VERSION()); + AddSuite(new R_CORE_VERSION_H()); + AddSuite(new R_CORE_VERSION_L()); + AddSuite(new R_FW_VERSION_H()); + AddSuite(new R_FW_VERSION_L()); + AddSuite(new R_TIMESTAMP_SECOND()); + AddSuite(new R_TIMESTAMP_MICRO()); + AddSuite(new R_OPERATION_CTRL()); + AddSuite(new R_RESET_DEV()); + AddSuite(new R_DEVICE_NAME()); + AddSuite(new R_SERIAL_NUMBER()); + AddSuite(new R_CLOCK_CONFIG()); + AddSuite(new R_TIMESTAMP_OFFSET()); + AddSuite(new R_UID()); + AddSuite(new R_TAG()); + AddSuite(new R_HEARTBEAT()); + AddSuite(new R_VERSION()); AddSuite(new RoundTripTestSuite()); - AddSuite(new TimestampSecondsSuite()); } } } diff --git a/src/Harp.Toolkit/Benchmark/Runner.cs b/src/Harp.Toolkit/Benchmark/Runner.cs index ea062ba..f243661 100644 --- a/src/Harp.Toolkit/Benchmark/Runner.cs +++ b/src/Harp.Toolkit/Benchmark/Runner.cs @@ -17,11 +17,11 @@ public IEnumerable CollectSuites() return suites.AsReadOnly(); } - public async IAsyncEnumerable<(Suite Suite, MethodResult Result)> RunAllAsync(string portName, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable<(Suite Suite, MethodResult Result)> RunAllAsync(string portName, [EnumeratorCancellation] CancellationToken cancellationToken = default, Action? onTestStart = null) { foreach (var suite in suites) { - await foreach (var result in suite.RunAllAsync(portName, cancellationToken)) + await foreach (var result in suite.RunAllAsync(portName, cancellationToken, (testName, testDesc) => onTestStart?.Invoke(suite, testName, testDesc))) { yield return (suite, result); } diff --git a/src/Harp.Toolkit/Benchmark/Suite.cs b/src/Harp.Toolkit/Benchmark/Suite.cs index 5f27464..3df8108 100644 --- a/src/Harp.Toolkit/Benchmark/Suite.cs +++ b/src/Harp.Toolkit/Benchmark/Suite.cs @@ -19,12 +19,15 @@ public abstract class Suite .Where(x => x.Attribute != null); } - public async IAsyncEnumerable RunAllAsync(string portName, [EnumeratorCancellation] CancellationToken cancellationToken = default) + public async IAsyncEnumerable RunAllAsync(string portName, [EnumeratorCancellation] CancellationToken cancellationToken = default, Action? onTestStart = null) { foreach (var (method, attr) in CollectTests()) { cancellationToken.ThrowIfCancellationRequested(); + // Notify that test is starting + onTestStart?.Invoke(method.Name, attr.Description ?? string.Empty); + IResult testResult; try { diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs new file mode 100644 index 0000000..637c346 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs @@ -0,0 +1,32 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_CLOCK_CONFIG : Suite +{ + private const byte address = 0x0E; + public override string Description => "Clock Configuration Register Tests"; + + [HarpTest(Description = "Validates that ClockConfig register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await RegisterHelpers.AssertReadableByteAsync(device, address, "ClockConfig"); + } + } + + [HarpTest(Description = "Reports clock synchronization capability: REP_ABLE (bit 3) and GEN_ABLE (bit 4).")] + public async Task ReportSyncCapability(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var value = await device.ReadByteAsync(address); + bool repAble = (value & (1 << 3)) != 0; + bool genAble = (value & (1 << 4)) != 0; + return new AssertionResult( + true, + $"ClockConfig sync capability: REP_ABLE={repAble}, GEN_ABLE={genAble}."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs new file mode 100644 index 0000000..bffcd9f --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs @@ -0,0 +1,24 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_CORE_VERSION_H : Suite +{ + private const byte address = 0x04; + public override string Description => "Core Version High Register Tests"; + + [HarpTest(Description = "Validates that CoreVersionHigh matches byte 0 of R_VERSION.")] + public async Task AssertConsistentWithVersion(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var versionArray = await device.ReadByteArrayAsync(0x13); + var registerValue = await device.ReadByteAsync(address); + return new AssertionResult( + registerValue == versionArray[0], + x => x + ? $"CoreVersionHigh (0x{registerValue:X2}) matches R_VERSION byte 0." + : $"CoreVersionHigh (0x{registerValue:X2}) does not match R_VERSION byte 0 (0x{versionArray[0]:X2})."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs new file mode 100644 index 0000000..a22f4ee --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs @@ -0,0 +1,24 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_CORE_VERSION_L : Suite +{ + private const byte address = 0x05; + public override string Description => "Core Version Low Register Tests"; + + [HarpTest(Description = "Validates that CoreVersionLow matches byte 1 of R_VERSION.")] + public async Task AssertConsistentWithVersion(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var versionArray = await device.ReadByteArrayAsync(0x13); + var registerValue = await device.ReadByteAsync(address); + return new AssertionResult( + registerValue == versionArray[1], + x => x + ? $"CoreVersionLow (0x{registerValue:X2}) matches R_VERSION byte 1." + : $"CoreVersionLow (0x{registerValue:X2}) does not match R_VERSION byte 1 (0x{versionArray[1]:X2})."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs new file mode 100644 index 0000000..3bdf6e8 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs @@ -0,0 +1,36 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_DEVICE_NAME : Suite +{ + private const byte address = 0x0C; + private const int expectedLength = 25; + public override string Description => "Device Name Register Tests"; + + [HarpTest(Description = "Validates that DeviceName register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + try + { + await device.ReadByteArrayAsync(address); + return new AssertionResult(true, "DeviceName is readable."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } + } + + [HarpTest(Description = "Validates that DeviceName register has exactly 25 bytes.")] + public async Task AssertLength(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await RegisterHelpers.AssertReadableArrayAsync(device, address, expectedLength, "DeviceName"); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs new file mode 100644 index 0000000..9637d3b --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs @@ -0,0 +1,24 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_FW_VERSION_H : Suite +{ + private const byte address = 0x06; + public override string Description => "Firmware Version High Register Tests"; + + [HarpTest(Description = "Validates that FwVersionHigh matches byte 3 of R_VERSION.")] + public async Task AssertConsistentWithVersion(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var versionArray = await device.ReadByteArrayAsync(0x13); + var registerValue = await device.ReadByteAsync(address); + return new AssertionResult( + registerValue == versionArray[3], + x => x + ? $"FwVersionHigh (0x{registerValue:X2}) matches R_VERSION byte 3." + : $"FwVersionHigh (0x{registerValue:X2}) does not match R_VERSION byte 3 (0x{versionArray[3]:X2})."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs new file mode 100644 index 0000000..d3862d0 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs @@ -0,0 +1,24 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_FW_VERSION_L : Suite +{ + private const byte address = 0x07; + public override string Description => "Firmware Version Low Register Tests"; + + [HarpTest(Description = "Validates that FwVersionLow matches byte 4 of R_VERSION.")] + public async Task AssertConsistentWithVersion(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var versionArray = await device.ReadByteArrayAsync(0x13); + var registerValue = await device.ReadByteAsync(address); + return new AssertionResult( + registerValue == versionArray[4], + x => x + ? $"FwVersionLow (0x{registerValue:X2}) matches R_VERSION byte 4." + : $"FwVersionLow (0x{registerValue:X2}) does not match R_VERSION byte 4 (0x{versionArray[4]:X2})."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs new file mode 100644 index 0000000..25a7322 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs @@ -0,0 +1,33 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_HEARTBEAT : Suite +{ + private const byte address = 0x12; + public override string Description => "Heartbeat Register Tests"; + + [HarpTest(Description = "Validates that Heartbeat register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await RegisterHelpers.AssertReadableByteAsync(device, address, "Heartbeat"); + } + } + + [HarpTest(Description = "Validates that Heartbeat register is NOT writable.")] + public async Task IsNotWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var req = HarpMessage.FromByte(address, MessageType.Write, 0x00); + var rejected = await RegisterHelpers.IsWriteRejectedAsync(device, req); + return new AssertionResult( + rejected, + x => x + ? "Heartbeat register correctly rejected write." + : "Heartbeat register should NOT be writable."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs new file mode 100644 index 0000000..e1d70e8 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs @@ -0,0 +1,24 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_HW_VERSION_H : Suite +{ + private const byte address = 0x01; + public override string Description => "Hardware Version High Register Tests"; + + [HarpTest(Description = "Validates that HwVersionHigh matches byte 6 of R_VERSION.")] + public async Task AssertConsistentWithVersion(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var versionArray = await device.ReadByteArrayAsync(0x13); + var registerValue = await device.ReadByteAsync(address); + return new AssertionResult( + registerValue == versionArray[6], + x => x + ? $"HwVersionHigh (0x{registerValue:X2}) matches R_VERSION byte 6." + : $"HwVersionHigh (0x{registerValue:X2}) does not match R_VERSION byte 6 (0x{versionArray[6]:X2})."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs new file mode 100644 index 0000000..a4e1b16 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs @@ -0,0 +1,24 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_HW_VERSION_L : Suite +{ + private const byte address = 0x02; + public override string Description => "Hardware Version Low Register Tests"; + + [HarpTest(Description = "Validates that HwVersionLow matches byte 7 of R_VERSION.")] + public async Task AssertConsistentWithVersion(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var versionArray = await device.ReadByteArrayAsync(0x13); + var registerValue = await device.ReadByteAsync(address); + return new AssertionResult( + registerValue == versionArray[7], + x => x + ? $"HwVersionLow (0x{registerValue:X2}) matches R_VERSION byte 7." + : $"HwVersionLow (0x{registerValue:X2}) does not match R_VERSION byte 7 (0x{versionArray[7]:X2})."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs new file mode 100644 index 0000000..b98eda1 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs @@ -0,0 +1,197 @@ +using Bonsai.Harp; +using System.Reactive.Linq; +using System.Collections.Concurrent; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_OPERATION_CTRL : Suite +{ + private const byte address = 0x0A; + public override string Description => "Operation Control Register Tests"; + + [HarpTest(Description = "Validates that OP_MODE bits can be round-tripped between Standby (0) and Active (1).")] + public async Task OpModeRoundTrip(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var original = await device.ReadByteAsync(address); + byte currentMode = (byte)(original & 0x03); + byte newMode = currentMode == 0x01 ? (byte)0x00 : (byte)0x01; + byte newValue = (byte)((original & ~0x03) | newMode); + + try + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, newValue)); + var readBack = await device.ReadByteAsync(address); + byte readMode = (byte)(readBack & 0x03); + + return new AssertionResult( + readMode == newMode, + x => x + ? $"OpModeRoundTrip: OP_MODE correctly round-tripped to {newMode}." + : $"OpModeRoundTrip: wrote OP_MODE={newMode}, read back OP_MODE={readMode}."); + } + finally + { + // Always restore original state + try + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, original)); + } + catch + { + // Ignore errors during restoration + } + } + } + } + + [HarpTest(Description = "Validates that ALIVE_EN (deprecated, bit 7) can be toggled, or reports as unsupported.")] + public async Task AliveEnWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await TestOptionalBitAsync(device, "AliveEn", 0x80); + } + } + + [HarpTest(Description = "Validates that OPLED_EN (optional, bit 6) can be toggled, or reports as unsupported.")] + public async Task OpLedEnWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await TestOptionalBitAsync(device, "OpLedEn", 0x40); + } + } + + [HarpTest(Description = "Validates that VISUAL_EN (optional, bit 5) can be toggled, or reports as unsupported.")] + public async Task VisualEnWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await TestOptionalBitAsync(device, "VisualEn", 0x20); + } + } + + [HarpTest(Description = "Validates that enabling HEARTBEAT_EN causes the device to emit R_HEARTBEAT events.")] + public async Task HeartbeatEnEmitsEvents(string portName) + { + byte originalOpCtrl = 0; + IDisposable? subscription = null; + + try + { + // Read original state before modifying + using (var device = new AsyncDevice(portName)) + { + originalOpCtrl = await device.ReadByteAsync(address); + } + + var harpDevice = new Bonsai.Harp.Device { PortName = portName, Heartbeat = EnableFlag.Enabled }; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); + cts.Token.Register(() => tcs.TrySetResult(false)); + + subscription = harpDevice.Generate() + .Where(m => m.Address == 0x12 && m.MessageType == MessageType.Event) + .Take(1) + .Subscribe( + _ => tcs.TrySetResult(true), + ex => tcs.TrySetException(ex)); + + bool received = await tcs.Task; + + return new AssertionResult( + received, + x => x + ? "HeartbeatEnEmitsEvents: heartbeat event received within 2s." + : "HeartbeatEnEmitsEvents: no heartbeat event received within 2s."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + finally + { + subscription?.Dispose(); + await Task.Delay(200); // Fudge delay to ensure port is released + + // Always restore original Operation Control state + using (var device = new AsyncDevice(portName)) + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, originalOpCtrl)); + } + + } + } + + [HarpTest(Description = "Validates that the DUMP bit triggers a burst of all core register reads after an OpCtrl write.")] + public async Task DumpEmitsRegisterBurst(string portName) + { + byte originalOpCtrl = 0; + var messages = new ConcurrentQueue(); + IDisposable? subscription = null; + + try + { + // Read original state before modifying + using (var device = new AsyncDevice(portName)) + { + originalOpCtrl = await device.ReadByteAsync(address); + } + + var harpDevice = new Bonsai.Harp.Device { PortName = portName, DumpRegisters = true }; + subscription = harpDevice.Generate() + .Subscribe(m => messages.Enqueue(m)); + + await Task.Delay(1000); + + var snapshot = messages.ToList(); + + int opCtrlWriteIdx = -1; + for (int i = 0; i < snapshot.Count; i++) + { + if (snapshot[i].Address == 0x0A && snapshot[i].MessageType == MessageType.Write) + { + opCtrlWriteIdx = i; + break; + } + } + + if (opCtrlWriteIdx < 0) + return new AssertionResult(false, "DumpEmitsRegisterBurst: no Write reply at OpCtrl (0x0A) found."); + + var coreReads = snapshot + .Select((m, i) => (msg: m, idx: i)) + .Where(x => x.msg.Address <= 0x13 && x.msg.MessageType == MessageType.Read) + .ToList(); + + bool writeBeforeAllReads = coreReads.All(x => opCtrlWriteIdx < x.idx); + if (!writeBeforeAllReads) + return new AssertionResult(false, "DumpEmitsRegisterBurst: OpCtrl Write reply did not precede all core Read replies."); + + var presentAddresses = coreReads.Select(x => (int)x.msg.Address).Distinct().ToHashSet(); + var missing = Enumerable.Range(0, 0x14).Where(a => !presentAddresses.Contains(a)).ToList(); + if (missing.Count > 0) + return new AssertionResult(false, + $"DumpEmitsRegisterBurst: missing Read replies for {missing.Count} core address(es): {string.Join(", ", missing.Select(a => $"0x{a:X2}"))}."); + + return new AssertionResult(true, "DumpEmitsRegisterBurst: all 20 core register reads received after OpCtrl write."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + finally + { + subscription?.Dispose(); + await Task.Delay(200); + + // Ensure we restore original state even though DUMP is transient + using (var device = new AsyncDevice(portName)) + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, originalOpCtrl)); + } + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs new file mode 100644 index 0000000..3aabc6f --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs @@ -0,0 +1,18 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_RESET_DEV : Suite +{ + private const byte address = 0x0B; + public override string Description => "Reset Device Register Tests"; + + [HarpTest(Description = "Validates that ResetDev register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await RegisterHelpers.AssertReadableByteAsync(device, address, "ResetDev"); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs new file mode 100644 index 0000000..3a4f291 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs @@ -0,0 +1,51 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_TAG : Suite +{ + private const byte address = 0x11; + private const int expectedLength = 8; + public override string Description => "Tag Register Tests"; + + [HarpTest(Description = "Validates that Tag register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + try + { + await device.ReadByteArrayAsync(address); + return new AssertionResult(true, "Tag is readable."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } + } + + [HarpTest(Description = "Validates that Tag register has exactly 8 bytes.")] + public async Task AssertLength(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await RegisterHelpers.AssertReadableArrayAsync(device, address, expectedLength, "Tag"); + } + } + + [HarpTest(Description = "Validates that Tag register is NOT writable.")] + public async Task IsNotWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var req = HarpMessage.FromByte(address, MessageType.Write, 0x00); + var rejected = await RegisterHelpers.IsWriteRejectedAsync(device, req); + return new AssertionResult( + rejected, + x => x + ? "Tag register correctly rejected write." + : "Tag register should NOT be writable."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs new file mode 100644 index 0000000..f863303 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs @@ -0,0 +1,56 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_TIMESTAMP_MICRO : Suite +{ + private const byte address = 0x09; + public override string Description => "Timestamp Microseconds Register Tests"; + + [HarpTest(Description = "Validates that TimestampMicro register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + try + { + await device.ReadByteArrayAsync(address); + return new AssertionResult(true, "TimestampMicro is readable."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } + } + + [HarpTest(Description = "Validates that TimestampMicro register is NOT writable.")] + public async Task IsNotWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var req = HarpMessage.FromUInt16(address, MessageType.Write, 0); + var rejected = await RegisterHelpers.IsWriteRejectedAsync(device, req); + return new AssertionResult( + rejected, + x => x + ? "TimestampMicro register correctly rejected write." + : "TimestampMicro register should NOT be writable."); + } + } + + [HarpTest(Description = "Validates that TimestampMicro value is within bounds (0 to 31249).")] + public async Task ValueWithinBounds(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var rawBytes = await device.ReadByteArrayAsync(address); + var microValue = BitConverter.ToUInt16(rawBytes, 0); + return new AssertionResult( + microValue < 31250, + x => x + ? $"TimestampMicro value ({microValue}) is within expected bounds (< 31250)." + : $"TimestampMicro value ({microValue}) exceeds expected maximum (31249)."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs index 99a83c5..e67623c 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs @@ -1,6 +1,5 @@  using Bonsai.Harp; -using System.Threading; namespace Harp.Toolkit.Benchmark.Suites; internal class R_TIMESTAMP_OFFSET : Suite @@ -28,13 +27,12 @@ public async Task IsNotWritable(string portName) using (var device = new AsyncDevice(portName)) { var req = HarpMessage.FromByte(address, MessageType.Write, 0x00); - var value = await device.CommandAsync(req); - + var rejected = await RegisterHelpers.IsWriteRejectedAsync(device, req); return new AssertionResult( - value.Error, + rejected, x => x ? - $"Device correctly reported an error when trying to write to TimestampOffset register" : - $"Timestamp Offset register is deprecated and MUST NOT allow writes."); + "Device correctly reported an error when trying to write to TimestampOffset register." : + "Timestamp Offset register is deprecated and MUST NOT allow writes."); } } } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs index 1545913..489be6d 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs @@ -1,5 +1,6 @@  using Bonsai.Harp; +using System.Diagnostics; namespace Harp.Toolkit.Benchmark.Suites; internal class R_TIMESTAMP_SECOND : Suite @@ -21,4 +22,60 @@ public async Task IsWritable(string portName) (success) => success ? $"`TimestampSeconds` register is writable and updates as expected." : $"`TimestampSeconds` register is not writable, Expected value: {setSeconds}, read value: {readSeconds}."); } } + + [HarpTest(Description = "Validates that TimestampSeconds register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + try + { + await device.ReadTimestampSecondsAsync(); + return new AssertionResult(true, "TimestampSeconds is readable."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } + } + + [HarpTest(Description = "Validates that TimestampSeconds register is monotonically non-decreasing.")] + public async Task IsMonotonic(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var first = await device.ReadTimestampSecondsAsync(); + await Task.Delay(100); + var second = await device.ReadTimestampSecondsAsync(); + return new AssertionResult( + second >= first, + x => x + ? $"TimestampSeconds is monotonic: {first} -> {second}." + : $"TimestampSeconds decreased from {first} to {second}."); + } + } + + [HarpTest(Description = "Validates that writing a past timestamp value takes effect and can be read back.")] + public async Task WritePastValueRoundTrip(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var sw = Stopwatch.StartNew(); + var current = await device.ReadTimestampSecondsAsync(); + var tPast = current >= 10 ? current - 10 : 0u; + + await device.WriteTimestampSecondsAsync(tPast); + await Task.Delay(50); + + var readBack = await device.ReadTimestampSecondsAsync(); + bool withinBounds = Math.Abs((long)readBack - (long)tPast) <= 1; + + return new AssertionResult( + withinBounds, + x => x + ? $"WritePastValueRoundTrip: wrote {tPast}, read back {readBack} (within 1s tolerance)." + : $"WritePastValueRoundTrip: wrote {tPast}, read back {readBack} (difference {Math.Abs((long)readBack - (long)tPast)}s, expected <= 1)."); + } + } } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs new file mode 100644 index 0000000..68b01cd --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs @@ -0,0 +1,51 @@ +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_VERSION : Suite +{ + private const byte address = 0x13; + private const int expectedLength = 32; + public override string Description => "Version Register Tests"; + + [HarpTest(Description = "Validates that Version register is readable.")] + public async Task IsReadable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + try + { + await device.ReadByteArrayAsync(address); + return new AssertionResult(true, "Version is readable."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } + } + + [HarpTest(Description = "Validates that Version register has exactly 32 bytes.")] + public async Task AssertLength(string portName) + { + using (var device = new AsyncDevice(portName)) + { + return await RegisterHelpers.AssertReadableArrayAsync(device, address, expectedLength, "Version"); + } + } + + [HarpTest(Description = "Validates that Version register is NOT writable.")] + public async Task IsNotWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var req = HarpMessage.FromByte(address, MessageType.Write, 0x00); + var rejected = await RegisterHelpers.IsWriteRejectedAsync(device, req); + return new AssertionResult( + rejected, + x => x + ? "Version register correctly rejected write." + : "Version register should NOT be writable."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs index a64d047..368fae3 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs @@ -18,4 +18,19 @@ public async Task CheckWhoAmI(string portName) (v, success) => success ? $"WhoAmI register contains valid value: {v}." : $"WhoAmI register contains invalid value: {v}."); } } + + [HarpTest(Description = "Validates that the WhoAmI register is NOT writable.")] + public async Task IsNotWritable(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var req = HarpMessage.FromUInt16(0x00, MessageType.Write, 0); + var rejected = await RegisterHelpers.IsWriteRejectedAsync(device, req); + return new AssertionResult( + rejected, + x => x ? + "WhoAmI register correctly rejected write." : + "WhoAmI register should NOT be writable."); + } + } } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs new file mode 100644 index 0000000..a465759 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs @@ -0,0 +1,50 @@ + +using Bonsai.Harp; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal static class RegisterHelpers +{ + public static async Task IsWriteRejectedAsync(AsyncDevice device, HarpMessage write) + { + try + { + await device.CommandAsync(write); + return false; + } + catch (HarpException) + { + return true; + } + } + + public static async Task AssertReadableArrayAsync(AsyncDevice device, int address, int expectedLength, string registerName) + { + try + { + var value = await device.ReadByteArrayAsync(address); + return new AssertionResult( + value.Length == expectedLength, + x => x + ? $"{registerName} is readable and has expected length ({expectedLength})." + : $"{registerName} returned {value.Length} bytes, expected {expectedLength}."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } + + public static async Task AssertReadableByteAsync(AsyncDevice device, int address, string registerName) + { + try + { + await device.ReadByteAsync(address); + return new AssertionResult(true, $"{registerName} is readable."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } +} From ca1522a4a15c811e9a02c3bf274266fe12da4104 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Thu, 7 May 2026 13:54:38 -0700 Subject: [PATCH 10/19] Fix register type --- .../Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs index f863303..3fdc73f 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; @@ -14,7 +14,7 @@ public async Task IsReadable(string portName) { try { - await device.ReadByteArrayAsync(address); + await device.ReadUInt16Async(address); return new AssertionResult(true, "TimestampMicro is readable."); } catch (Exception ex) @@ -44,8 +44,7 @@ public async Task ValueWithinBounds(string portName) { using (var device = new AsyncDevice(portName)) { - var rawBytes = await device.ReadByteArrayAsync(address); - var microValue = BitConverter.ToUInt16(rawBytes, 0); + var microValue = await device.ReadUInt16Async(address); return new AssertionResult( microValue < 31250, x => x From ffb8363dd23ef6a673ead70e1d2f79e81a695b68 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Thu, 7 May 2026 15:28:10 -0700 Subject: [PATCH 11/19] Add wrapper for stream-able writes --- .../Suites/CoreRegisters/R_OPERATION_CTRL.cs | 70 +++++++++++++------ .../Suites/CoreRegisters/_RegisterHelpers.cs | 40 ++++++++++- 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs index b98eda1..223ba15 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs @@ -77,29 +77,25 @@ public async Task VisualEnWritable(string portName) public async Task HeartbeatEnEmitsEvents(string portName) { byte originalOpCtrl = 0; - IDisposable? subscription = null; try { - // Read original state before modifying using (var device = new AsyncDevice(portName)) { originalOpCtrl = await device.ReadByteAsync(address); } + await Task.Delay(500); // The previous one needs some time to disconnect + + var harpDevice = new Bonsai.Harp.Device { PortName = portName }; + var responses = await RegisterHelpers.WriteToTransportAsync( + portName, + new[] { HarpMessage.FromByte(address, MessageType.Write, 0xE5) }, + TimeSpan.FromSeconds(0.5)); + var messages = await harpDevice.Generate() + .TakeUntil(Observable.Timer(TimeSpan.FromSeconds(2))) + .ToList(); - var harpDevice = new Bonsai.Harp.Device { PortName = portName, Heartbeat = EnableFlag.Enabled }; - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - cts.Token.Register(() => tcs.TrySetResult(false)); - - subscription = harpDevice.Generate() - .Where(m => m.Address == 0x12 && m.MessageType == MessageType.Event) - .Take(1) - .Subscribe( - _ => tcs.TrySetResult(true), - ex => tcs.TrySetException(ex)); - - bool received = await tcs.Task; + bool received = messages.Any(m => m.Address == 0x18 && m.MessageType == MessageType.Event); return new AssertionResult( received, @@ -113,15 +109,11 @@ public async Task HeartbeatEnEmitsEvents(string portName) } finally { - subscription?.Dispose(); - await Task.Delay(200); // Fudge delay to ensure port is released - - // Always restore original Operation Control state + await Task.Delay(200); // Wait for port to be released before reopening using (var device = new AsyncDevice(portName)) { await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, originalOpCtrl)); } - } } @@ -194,4 +186,42 @@ public async Task DumpEmitsRegisterBurst(string portName) } } } + + private static async Task TestOptionalBitAsync(AsyncDevice device, string bitName, byte bitMask) + { + var original = await device.ReadByteAsync(address); + byte toggled = (byte)(original ^ bitMask); + + try + { + try + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, toggled)); + } + catch (HarpException) + { + return new Result(false, Status.Skipped, + $"{bitName} is optional/deprecated and not supported by this device."); + } + + var readBack = await device.ReadByteAsync(address); + bool bitChanged = (readBack & bitMask) == (toggled & bitMask); + + return new AssertionResult( + bitChanged, + x => x + ? $"{bitName}: bit correctly toggled." + : $"{bitName}: bit did not change after write (expected {(toggled & bitMask) != 0}, got {(readBack & bitMask) != 0})."); + } + finally + { + try + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, original)); + } + catch + { + } + } + } } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs index a465759..0179719 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs @@ -1,10 +1,48 @@ - + using Bonsai.Harp; +using System.Reactive.Linq; +using System.Reactive.Subjects; namespace Harp.Toolkit.Benchmark.Suites; internal static class RegisterHelpers { + /// + /// Opens a Device connection, writes messages via the synchronous transport, + /// collects all received messages for the specified duration, then cleans up. + /// + public static async Task> WriteToTransportAsync( + string portName, + IEnumerable messagesToWrite, + TimeSpan listenDuration, + Action? configureDevice = null) + { + var harpDevice = new Bonsai.Harp.Device { PortName = portName }; + configureDevice?.Invoke(harpDevice); + + var source = new Subject(); + var collected = new List(); + var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); + + using var subscription = harpDevice.Generate(source) + .Subscribe( + onNext: m => collected.Add(m), + onError: ex => tcs.TrySetException(ex)); + + // Small delay to let the transport connect + await Task.Delay(200); + + foreach (var msg in messagesToWrite) + { + source.OnNext(msg); + } + + await Task.Delay(listenDuration); + + source.OnCompleted(); + tcs.TrySetResult(collected); + return await tcs.Task; + } public static async Task IsWriteRejectedAsync(AsyncDevice device, HarpMessage write) { try From 9027f2b79d2d553a74d89351b17243b29a7cc786 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Thu, 7 May 2026 15:28:58 -0700 Subject: [PATCH 12/19] Escape potential special characters --- src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs index 9e67d89..c2749e7 100644 --- a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs +++ b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs @@ -107,10 +107,10 @@ static async Task RunBenchmarks(string portName, FileInfo? reportFile, bool verb } table.AddRow( - new Markup($"[bold]{test.Name}[/]\n[dim]{test.Description}[/]"), + new Markup($"[bold]{Markup.Escape(test.Name)}[/]\n[dim]{Markup.Escape(test.Description)}[/]"), new Markup(GetResultMarkup(test.Result)), - new Markup(details), - new Markup(message) + new Markup(Markup.Escape(details)), + new Markup(Markup.Escape(message)) ); } AnsiConsole.Write(table); From 6bb06c3a4d51c6c2431bcf279ae2965487369a54 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Thu, 7 May 2026 15:56:27 -0700 Subject: [PATCH 13/19] Make method generic and pass delegate --- .../Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs | 11 +++++++++-- .../Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs | 6 +++--- .../Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs | 4 ++-- .../Suites/CoreRegisters/_RegisterHelpers.cs | 4 ++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs index 637c346..50b044e 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs @@ -1,3 +1,4 @@ +using System.Text; using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; @@ -12,7 +13,7 @@ public async Task IsReadable(string portName) { using (var device = new AsyncDevice(portName)) { - return await RegisterHelpers.AssertReadableByteAsync(device, address, "ClockConfig"); + return await RegisterHelpers.AssertReadableAsync(a => device.ReadByteAsync(a), address, "ClockConfig"); } } @@ -24,9 +25,15 @@ public async Task ReportSyncCapability(string portName) var value = await device.ReadByteAsync(address); bool repAble = (value & (1 << 3)) != 0; bool genAble = (value & (1 << 4)) != 0; + StringBuilder sb = new StringBuilder("ClockConfig sync capability:"); + sb.Append("\n"); + sb.Append(repAble ? "Device can repeat clock signal" : "Device cannot repeat clock signal"); + sb.Append("\n"); + sb.Append(genAble ? "Device can generate clock signal" : "Device cannot generate clock signal"); + sb.Append("\n"); return new AssertionResult( true, - $"ClockConfig sync capability: REP_ABLE={repAble}, GEN_ABLE={genAble}."); + sb.ToString()); } } } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs index 25a7322..ffb3e4c 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HEARTBEAT.cs @@ -1,10 +1,10 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; internal class R_HEARTBEAT : Suite { - private const byte address = 0x12; + private const byte address = 18; public override string Description => "Heartbeat Register Tests"; [HarpTest(Description = "Validates that Heartbeat register is readable.")] @@ -12,7 +12,7 @@ public async Task IsReadable(string portName) { using (var device = new AsyncDevice(portName)) { - return await RegisterHelpers.AssertReadableByteAsync(device, address, "Heartbeat"); + return await RegisterHelpers.AssertReadableAsync(a => device.ReadUInt16Async(a), address, "Heartbeat"); } } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs index 3aabc6f..4e80a83 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; @@ -12,7 +12,7 @@ public async Task IsReadable(string portName) { using (var device = new AsyncDevice(portName)) { - return await RegisterHelpers.AssertReadableByteAsync(device, address, "ResetDev"); + return await RegisterHelpers.AssertReadableAsync(a => device.ReadByteAsync(a), address, "ResetDev"); } } } diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs index 0179719..dfb82f8 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs @@ -73,11 +73,11 @@ public static async Task AssertReadableArrayAsync(AsyncDevice device, i } } - public static async Task AssertReadableByteAsync(AsyncDevice device, int address, string registerName) + public static async Task AssertReadableAsync(Func> readFunc, int address, string registerName) { try { - await device.ReadByteAsync(address); + await readFunc(address); return new AssertionResult(true, $"{registerName} is readable."); } catch (Exception ex) From 6816bf93891bd5e6a21d114077e1ca7df87c861d Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Thu, 7 May 2026 16:21:47 -0700 Subject: [PATCH 14/19] Format --- .../Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs | 2 +- .../Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs | 2 +- .../Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs | 2 +- .../Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs | 2 +- .../Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs | 2 +- .../Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs | 2 +- .../Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs | 2 +- src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs | 2 +- src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs index bffcd9f..6bc733a 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_H.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs index a22f4ee..7be1036 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CORE_VERSION_L.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs index 3bdf6e8..f0d516c 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_DEVICE_NAME.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs index 9637d3b..0808222 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_H.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs index d3862d0..637e7a1 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_FW_VERSION_L.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs index e1d70e8..41bff61 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_H.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs index a4e1b16..fdd328c 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_HW_VERSION_L.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs index 3a4f291..7406bfe 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TAG.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs index 68b01cd..e914bf4 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_VERSION.cs @@ -1,4 +1,4 @@ -using Bonsai.Harp; +using Bonsai.Harp; namespace Harp.Toolkit.Benchmark.Suites; From 86e379d4d621f4382ef05203cc78cba8c5b3ef36 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Thu, 7 May 2026 16:22:43 -0700 Subject: [PATCH 15/19] Favor async transport for writing operation control state --- .../Suites/CoreRegisters/R_OPERATION_CTRL.cs | 49 ++++++------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs index 223ba15..9f54c70 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs @@ -118,11 +118,9 @@ public async Task HeartbeatEnEmitsEvents(string portName) } [HarpTest(Description = "Validates that the DUMP bit triggers a burst of all core register reads after an OpCtrl write.")] - public async Task DumpEmitsRegisterBurst(string portName) + public async Task RegisterDump(string portName) { byte originalOpCtrl = 0; - var messages = new ConcurrentQueue(); - IDisposable? subscription = null; try { @@ -132,43 +130,28 @@ public async Task DumpEmitsRegisterBurst(string portName) originalOpCtrl = await device.ReadByteAsync(address); } - var harpDevice = new Bonsai.Harp.Device { PortName = portName, DumpRegisters = true }; - subscription = harpDevice.Generate() - .Subscribe(m => messages.Enqueue(m)); - - await Task.Delay(1000); - - var snapshot = messages.ToList(); + var harpDevice = new Bonsai.Harp.Device { PortName = portName }; + var messages = await RegisterHelpers.WriteToTransportAsync( + portName, + new[] { HarpMessage.FromByte(address, MessageType.Write, (byte)(originalOpCtrl | 0x08)) }, + TimeSpan.FromSeconds(1)); - int opCtrlWriteIdx = -1; - for (int i = 0; i < snapshot.Count; i++) + var opRegWriteResponse = messages.FirstOrDefault(m => m.Address == address && m.MessageType == MessageType.Write); + if (opRegWriteResponse == null) { - if (snapshot[i].Address == 0x0A && snapshot[i].MessageType == MessageType.Write) - { - opCtrlWriteIdx = i; - break; - } + return new AssertionResult(false, "No response received for OpCtrl write."); } - - if (opCtrlWriteIdx < 0) - return new AssertionResult(false, "DumpEmitsRegisterBurst: no Write reply at OpCtrl (0x0A) found."); - - var coreReads = snapshot + var coreReads = messages .Select((m, i) => (msg: m, idx: i)) - .Where(x => x.msg.Address <= 0x13 && x.msg.MessageType == MessageType.Read) + .Where(x => x.msg.Address <= 32 && x.msg.MessageType == MessageType.Read) .ToList(); - - bool writeBeforeAllReads = coreReads.All(x => opCtrlWriteIdx < x.idx); - if (!writeBeforeAllReads) - return new AssertionResult(false, "DumpEmitsRegisterBurst: OpCtrl Write reply did not precede all core Read replies."); - - var presentAddresses = coreReads.Select(x => (int)x.msg.Address).Distinct().ToHashSet(); - var missing = Enumerable.Range(0, 0x14).Where(a => !presentAddresses.Contains(a)).ToList(); + var uniqueCoreAddresses = coreReads.Select(x => x.msg.Address).Distinct().ToHashSet(); + var missing = Enumerable.Range(0, 18).Where(a => !uniqueCoreAddresses.Contains(a)).ToList(); if (missing.Count > 0) return new AssertionResult(false, - $"DumpEmitsRegisterBurst: missing Read replies for {missing.Count} core address(es): {string.Join(", ", missing.Select(a => $"0x{a:X2}"))}."); + $"Missing Read replies for {missing.Count} core address(es): {string.Join(", ", missing.Select(a => $"0x{a:X2}"))}."); - return new AssertionResult(true, "DumpEmitsRegisterBurst: all 20 core register reads received after OpCtrl write."); + return new AssertionResult(true, "All core register reads received after OpCtrl write."); } catch (Exception ex) { @@ -176,9 +159,7 @@ public async Task DumpEmitsRegisterBurst(string portName) } finally { - subscription?.Dispose(); await Task.Delay(200); - // Ensure we restore original state even though DUMP is transient using (var device = new AsyncDevice(portName)) { From 3a71cfbcd465cc0614d9a42bdae1361e6df4ebc8 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:55:43 -0700 Subject: [PATCH 16/19] Fix hearbeat tests --- .../Suites/CoreRegisters/R_OPERATION_CTRL.cs | 106 ++++++++++++++++-- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs index 9f54c70..3226e29 100644 --- a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs @@ -77,25 +77,24 @@ public async Task VisualEnWritable(string portName) public async Task HeartbeatEnEmitsEvents(string portName) { byte originalOpCtrl = 0; + ushort whoAmI = 0; try { using (var device = new AsyncDevice(portName)) { originalOpCtrl = await device.ReadByteAsync(address); + whoAmI = await device.ReadUInt16Async(WhoAmI.Address); } await Task.Delay(500); // The previous one needs some time to disconnect - var harpDevice = new Bonsai.Harp.Device { PortName = portName }; - var responses = await RegisterHelpers.WriteToTransportAsync( + var harpDevice = new Bonsai.Harp.Device(whoAmI) { PortName = portName }; + var messages = await RegisterHelpers.WriteToTransportAsync( portName, new[] { HarpMessage.FromByte(address, MessageType.Write, 0xE5) }, - TimeSpan.FromSeconds(0.5)); - var messages = await harpDevice.Generate() - .TakeUntil(Observable.Timer(TimeSpan.FromSeconds(2))) - .ToList(); + TimeSpan.FromSeconds(2.0)); - bool received = messages.Any(m => m.Address == 0x18 && m.MessageType == MessageType.Event); + bool received = messages.Any(m => m.Address == 18 && m.MessageType == MessageType.Event); return new AssertionResult( received, @@ -117,10 +116,100 @@ public async Task HeartbeatEnEmitsEvents(string portName) } } + [HarpTest(Description = "Validates that HEARTBEAT_EN (bit 2) takes precedence over ALIVE_EN (bit 7): when both are set, R_HEARTBEAT events are emitted and R_TIMESTAMP_SECOND events are not.")] + public async Task HeartbeatEnPrecedenceOverAliveEn(string portName) + { + byte originalOpCtrl = 0; + ushort whoAmI = 0; + + try + { + using (var device = new AsyncDevice(portName)) + { + originalOpCtrl = await device.ReadByteAsync(address); + whoAmI = await device.ReadUInt16Async(WhoAmI.Address); + } + await Task.Delay(500); + + var harpDevice = new Bonsai.Harp.Device(whoAmI) { PortName = portName }; + // Set both ALIVE_EN (bit 7) and HEARTBEAT_EN (bit 2) with Active mode (bit 0) + var messages = await RegisterHelpers.WriteToTransportAsync( + portName, + new[] { HarpMessage.FromByte(address, MessageType.Write, 0x85) }, + TimeSpan.FromSeconds(2.0)); + + bool receivedHeartbeat = messages.Any(m => m.Address == 18 && m.MessageType == MessageType.Event); + bool receivedTimestamp = messages.Any(m => m.Address == 8 && m.MessageType == MessageType.Event); + + if (!receivedHeartbeat) + return new AssertionResult(false, "HeartbeatEnPrecedenceOverAliveEn: no R_HEARTBEAT event received within 2s (expected HEARTBEAT_EN to take precedence)."); + if (receivedTimestamp) + return new AssertionResult(false, "HeartbeatEnPrecedenceOverAliveEn: R_TIMESTAMP_SECOND event received when HEARTBEAT_EN should suppress it."); + + return new AssertionResult(true, "HeartbeatEnPrecedenceOverAliveEn: R_HEARTBEAT events received and R_TIMESTAMP_SECOND correctly suppressed."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + finally + { + await Task.Delay(200); + using (var device = new AsyncDevice(portName)) + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, originalOpCtrl)); + } + } + } + + [HarpTest(Description = "Validates that ALIVE_EN (deprecated, bit 7) causes R_TIMESTAMP_SECOND events to be emitted when HEARTBEAT_EN is not set.")] + public async Task AliveEnEmitsTimestampEvents(string portName) + { + byte originalOpCtrl = 0; + ushort whoAmI = 0; + + try + { + using (var device = new AsyncDevice(portName)) + { + originalOpCtrl = await device.ReadByteAsync(address); + whoAmI = await device.ReadUInt16Async(WhoAmI.Address); + } + await Task.Delay(500); + + var harpDevice = new Bonsai.Harp.Device(whoAmI) { PortName = portName }; + // Set only ALIVE_EN (bit 7) with Active mode (bit 0); HEARTBEAT_EN (bit 2) is cleared + var messages = await RegisterHelpers.WriteToTransportAsync( + portName, + new[] { HarpMessage.FromByte(address, MessageType.Write, 0x81) }, + TimeSpan.FromSeconds(2.0)); + + bool receivedTimestamp = messages.Any(m => m.Address == 8 && m.MessageType == MessageType.Event); + + if (!receivedTimestamp) + return new Result(false, Status.Skipped, "AliveEnEmitsTimestampEvents: ALIVE_EN is deprecated and R_TIMESTAMP_SECOND events were not emitted."); + + return new AssertionResult(true, "AliveEnEmitsTimestampEvents: R_TIMESTAMP_SECOND event received within 2s."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + finally + { + await Task.Delay(200); + using (var device = new AsyncDevice(portName)) + { + await device.CommandAsync(HarpMessage.FromByte(address, MessageType.Write, originalOpCtrl)); + } + } + } + [HarpTest(Description = "Validates that the DUMP bit triggers a burst of all core register reads after an OpCtrl write.")] public async Task RegisterDump(string portName) { byte originalOpCtrl = 0; + ushort whoAmI = 0; try { @@ -128,9 +217,10 @@ public async Task RegisterDump(string portName) using (var device = new AsyncDevice(portName)) { originalOpCtrl = await device.ReadByteAsync(address); + whoAmI = await device.ReadUInt16Async(WhoAmI.Address); } - var harpDevice = new Bonsai.Harp.Device { PortName = portName }; + var harpDevice = new Bonsai.Harp.Device(whoAmI) { PortName = portName }; var messages = await RegisterHelpers.WriteToTransportAsync( portName, new[] { HarpMessage.FromByte(address, MessageType.Write, (byte)(originalOpCtrl | 0x08)) }, From 77e92ddce9aec9a308c2eae3dab11e8ac1ad4325 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Tue, 23 Jun 2026 04:56:17 -0700 Subject: [PATCH 17/19] Format --- .../Generate/GenerateRegisterMetadataCommand.cs | 8 ++++---- src/Harp.Toolkit/Generate/GeneratorHelper.cs | 2 +- src/Harp.Toolkit/TaskExtensions.cs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Harp.Toolkit/Generate/GenerateRegisterMetadataCommand.cs b/src/Harp.Toolkit/Generate/GenerateRegisterMetadataCommand.cs index 28431e0..a30cf4a 100644 --- a/src/Harp.Toolkit/Generate/GenerateRegisterMetadataCommand.cs +++ b/src/Harp.Toolkit/Generate/GenerateRegisterMetadataCommand.cs @@ -16,10 +16,10 @@ public GenerateRegisterMetadataCommand() OutputPathOption outputPathOption = new(); Argument registerWorksheetPathArgument = ArgumentValidation.AcceptExistingOnly( new Argument("registers.xls") - { - Description = "The path to the file describing the device registers.", - Arity = ArgumentArity.ExactlyOne - }); + { + Description = "The path to the file describing the device registers.", + Arity = ArgumentArity.ExactlyOne + }); Arguments.Add(registerWorksheetPathArgument); Options.Add(outputPathOption); diff --git a/src/Harp.Toolkit/Generate/GeneratorHelper.cs b/src/Harp.Toolkit/Generate/GeneratorHelper.cs index 1f1d10e..9552482 100644 --- a/src/Harp.Toolkit/Generate/GeneratorHelper.cs +++ b/src/Harp.Toolkit/Generate/GeneratorHelper.cs @@ -41,7 +41,7 @@ public static bool AssertNoGeneratorErrors(CompilerErrorCollection errors) Console.Error.WriteLine(errorLog.ToString()); return !errors.HasErrors; } - + return true; } } diff --git a/src/Harp.Toolkit/TaskExtensions.cs b/src/Harp.Toolkit/TaskExtensions.cs index 60668ad..19f2309 100644 --- a/src/Harp.Toolkit/TaskExtensions.cs +++ b/src/Harp.Toolkit/TaskExtensions.cs @@ -1,4 +1,4 @@ -namespace Harp.Toolkit; +namespace Harp.Toolkit; static class TaskExtensions { @@ -13,4 +13,4 @@ internal static async Task WithTimeout(this Task task, int? millisecond } else throw new TimeoutException("There was a timeout while awaiting the device response."); } -} \ No newline at end of file +} From 513024da65e8c719cb3abfd98dfeafa0ab02c50b Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:52:39 -0700 Subject: [PATCH 18/19] Add clock alignment and PPS synchronization benchmark tests --- .../Benchmark/BenchmarkCommand.cs | 40 +++++++- .../Benchmark/ClockTestOptions.cs | 18 ++++ .../Benchmark/Suites/ClockTestSuite.cs | 95 +++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 src/Harp.Toolkit/Benchmark/ClockTestOptions.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/ClockTestSuite.cs diff --git a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs index c2749e7..e4b82a9 100644 --- a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs +++ b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs @@ -1,5 +1,6 @@ using System.CommandLine; using Spectre.Console; +using Harp.Toolkit.Benchmark; using Harp.Toolkit.Benchmark.Suites; namespace Harp.Toolkit; @@ -20,23 +21,53 @@ public BenchmarkCommand() Description = "Show detailed results for each test.", Required = false, }; + + Option clockPortOption = new("--clock-port") + { + Description = "Serial port of the reference clock device. Enables clock alignment tests.", + Required = false, + }; + + Option regClockOption = new("--pps-address") + { + Description = "Register address on the tested device (--port) that emits an event whenever the incoming PPS signal goes high. Enables PPS alignment test.", + Required = false, + }; + + Option clockSamplesOption = new("--clock-samples") + { + Description = "Number of PPS event pairs to collect for the PPS alignment test. Default: 5.", + Required = false, + }; + clockSamplesOption.DefaultValueFactory = _ => 5; + Options.Add(portNameOption); Options.Add(fileOption); Options.Add(verboseOption); + Options.Add(clockPortOption); + Options.Add(regClockOption); + Options.Add(clockSamplesOption); SetAction(parsedResult => { string portName = parsedResult.GetRequiredValue(portNameOption); FileInfo? reportFile = parsedResult.GetValue(fileOption); bool verbose = parsedResult.GetValue(verboseOption); - return RunBenchmarks(portName, reportFile, verbose, CancellationToken.None); + string? clockPort = parsedResult.GetValue(clockPortOption); + ClockTestOptions? clockOptions = clockPort is null ? null : new ClockTestOptions( + ClockPort: clockPort, + PpsAddress: parsedResult.GetValue(regClockOption), + ClockSamples: parsedResult.GetValue(clockSamplesOption)); + return RunBenchmarks(portName, reportFile, verbose, clockOptions, CancellationToken.None); }); } - static async Task RunBenchmarks(string portName, FileInfo? reportFile, bool verbose, CancellationToken cancellationToken) + static async Task RunBenchmarks(string portName, FileInfo? reportFile, bool verbose, ClockTestOptions? clockOptions, CancellationToken cancellationToken) { AnsiConsole.MarkupLine($"Running tests on [bold]{portName}[/]..."); + if (clockOptions is not null) + AnsiConsole.MarkupLine($"Clock reference device: [bold]{clockOptions.ClockPort}[/]"); - var runner = new CoreRunner(); + var runner = new CoreRunner(clockOptions); var report = new Report { DeviceName = $"Harp Device ({portName})", @@ -142,7 +173,7 @@ static string GetResultMarkup(IResult result) class CoreRunner : Runner { - public CoreRunner() : base() + public CoreRunner(ClockTestOptions? clockOptions = null) : base() { AddSuite(new R_WHO_AM_I()); AddSuite(new R_HW_VERSION_H()); @@ -165,6 +196,7 @@ public CoreRunner() : base() AddSuite(new R_HEARTBEAT()); AddSuite(new R_VERSION()); AddSuite(new RoundTripTestSuite()); + AddSuite(new ClockTestSuite(clockOptions)); } } } diff --git a/src/Harp.Toolkit/Benchmark/ClockTestOptions.cs b/src/Harp.Toolkit/Benchmark/ClockTestOptions.cs new file mode 100644 index 0000000..0fb6c28 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/ClockTestOptions.cs @@ -0,0 +1,18 @@ +namespace Harp.Toolkit.Benchmark; + +/// +/// Options for clock alignment and PPS synchronization tests run against a reference clock device. +/// +/// +/// Serial port of the reference clock device (WhiteRabbit). Enabling this option runs the +/// simultaneous WhoAmI timestamp comparison test. +/// +/// +/// Register address on the tested device that emits an event whenever the incoming PPS signal +/// goes high. When provided, also runs the PPS alignment test. +/// +/// Number of PPS event pairs to collect for the PPS alignment test. +internal record ClockTestOptions( + string ClockPort, + int? PpsAddress = null, + int ClockSamples = 5); diff --git a/src/Harp.Toolkit/Benchmark/Suites/ClockTestSuite.cs b/src/Harp.Toolkit/Benchmark/Suites/ClockTestSuite.cs new file mode 100644 index 0000000..2172e83 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/ClockTestSuite.cs @@ -0,0 +1,95 @@ + +using Bonsai.Harp; +using Harp.Toolkit.Benchmark; + +namespace Harp.Toolkit.Benchmark.Suites; + +internal class ClockTestSuite : Suite +{ + private const byte OperationControlAddress = 0x0A; + private readonly ClockTestOptions? options; + + public ClockTestSuite(ClockTestOptions? options) + { + this.options = options; + } + + public override string Description => "Tests clock alignment and PPS synchronization accuracy against a reference clock device."; + + [HarpTest(Description = "Sends 100 simultaneous WhoAmI reads to both devices and compares embedded timestamps to measure clock offset.")] + public async Task SimultaneousWhoAmI(string portName) + { + if (options is null) + return new Result(false, Status.Skipped, "No clock port provided (--clock-port)."); + + const int n = 100; + double[] deltas = new double[n]; + var probe = WhoAmI.FromPayload(MessageType.Read, default); + + using var testedDevice = new AsyncDevice(portName); + using var clockDevice = new AsyncDevice(options.ClockPort); + + for (int i = 0; i < n; i++) + { + var results = await Task.WhenAll(testedDevice.CommandAsync(probe), clockDevice.CommandAsync(probe)); + deltas[i] = results[0].GetTimestamp() - results[1].GetTimestamp(); + await Task.Delay(new Random().Next(20, 70)); + } + + var summary = new BenchmarkSummary(deltas); + return new NumericBenchmarkResult( + summary, + Status.Passed, + $"Clock offset: mean={summary.Mean:F6}s ({summary.Mean * 1e3:F3}ms), " + + $"stddev={summary.StdDev:F6}s ({summary.StdDev * 1e3:F3}ms)"); + } + + [HarpTest(Description = "Subscribes to PPS events on both devices and compares timestamps to measure hardware clock synchronization accuracy.")] + public async Task PPSEventAlignment(string portName) + { + if (options is null) + return new Result(false, Status.Skipped, "No clock port provided (--clock-port)."); + if (!options.PpsAddress.HasValue) + return new Result(false, Status.Skipped, "No tested device register provided (--reg-clock)."); + + // The clock device (WhiteRabbit) emits a TimestampSecond event (0x08) on every PPS tick. + // ALIVE_EN (0x80) | OP_MODE (0x01) enables those events. + // TODO: consider also supporting Heartbeat (0x12) via HEARTBEAT_EN (0x04) | OP_MODE (0x01). + const int clockDeviceReg = 0x08; + const byte clockDeviceOpCtrl = 0x81; + + var listenDuration = TimeSpan.FromSeconds(options.ClockSamples + 5); + var allMessages = await Task.WhenAll( + RegisterHelpers.WriteToTransportAsync( + options.ClockPort, + [HarpMessage.FromByte(OperationControlAddress, MessageType.Write, clockDeviceOpCtrl)], + listenDuration), + RegisterHelpers.WriteToTransportAsync( + portName, + [HarpMessage.FromByte(OperationControlAddress, MessageType.Write, 0x01)], + listenDuration)); + + var clockEvents = allMessages[0] + .Where(m => m.MessageType == MessageType.Event && m.Address == clockDeviceReg) + .Take(options.ClockSamples).ToList(); + var testedEvents = allMessages[1] + .Where(m => m.MessageType == MessageType.Event && m.Address == options.PpsAddress!.Value) + .Take(options.ClockSamples).ToList(); + + int pairCount = Math.Min(clockEvents.Count, testedEvents.Count); + if (pairCount == 0) + return new AssertionResult(false, + $"No event pairs received within {listenDuration.TotalSeconds}s " + + $"(clock register 0x{clockDeviceReg:X2}, tested register 0x{options.PpsAddress!.Value:X2})."); + + var deltas = clockEvents + .Zip(testedEvents, (c, t) => c.GetTimestamp() - t.GetTimestamp()) + .ToArray(); + var summary = new BenchmarkSummary(deltas); + return new NumericBenchmarkResult( + summary, + Status.Passed, + $"PPS delta ({pairCount} pairs): mean={summary.Mean:F6}s ({summary.Mean * 1e3:F3}ms), " + + $"stddev={summary.StdDev:F6}s ({summary.StdDev * 1e3:F3}ms)"); + } +} From 327dcb488f4eea0ab7a94f6acf4adf180fa2f948 Mon Sep 17 00:00:00 2001 From: bruno-f-cruz <7049351+bruno-f-cruz@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:22:19 -0700 Subject: [PATCH 19/19] Add benchmark suite validating registers via generated device.yml interface --- .../Benchmark/BenchmarkCommand.cs | 33 +++- .../Benchmark/ClockTestOptions.cs | 2 +- .../Benchmark/GeneratedInterfaceCompiler.cs | 78 +++++++++ src/Harp.Toolkit/Benchmark/Suite.cs | 38 ++++- .../Benchmark/Suites/DeviceInterfaceSuite.cs | 151 ++++++++++++++++++ src/Harp.Toolkit/Harp.Toolkit.csproj | 2 + 6 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 src/Harp.Toolkit/Benchmark/GeneratedInterfaceCompiler.cs create mode 100644 src/Harp.Toolkit/Benchmark/Suites/DeviceInterfaceSuite.cs diff --git a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs index e4b82a9..c5619e3 100644 --- a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs +++ b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs @@ -1,7 +1,9 @@ using System.CommandLine; using Spectre.Console; +using Harp.Generators; using Harp.Toolkit.Benchmark; using Harp.Toolkit.Benchmark.Suites; +using Harp.Toolkit.Generate; namespace Harp.Toolkit; public class BenchmarkCommand : Command @@ -41,12 +43,20 @@ public BenchmarkCommand() }; clockSamplesOption.DefaultValueFactory = _ => 5; + Option deviceYmlOption = new("--device-yml") + { + Description = "Path to the device's device.yml. Enables validation of the generated C# interface against a live read of every declared register, and cross-checks WhoAmI/firmware/hardware versions.", + Required = false, + }; + OptionValidation.AcceptExistingOnly(deviceYmlOption); + Options.Add(portNameOption); Options.Add(fileOption); Options.Add(verboseOption); Options.Add(clockPortOption); Options.Add(regClockOption); Options.Add(clockSamplesOption); + Options.Add(deviceYmlOption); SetAction(parsedResult => { string portName = parsedResult.GetRequiredValue(portNameOption); @@ -57,17 +67,28 @@ public BenchmarkCommand() ClockPort: clockPort, PpsAddress: parsedResult.GetValue(regClockOption), ClockSamples: parsedResult.GetValue(clockSamplesOption)); - return RunBenchmarks(portName, reportFile, verbose, clockOptions, CancellationToken.None); + FileInfo? deviceYml = parsedResult.GetValue(deviceYmlOption); + return RunBenchmarks(portName, reportFile, verbose, clockOptions, deviceYml, CancellationToken.None); }); } - static async Task RunBenchmarks(string portName, FileInfo? reportFile, bool verbose, ClockTestOptions? clockOptions, CancellationToken cancellationToken) + static async Task RunBenchmarks(string portName, FileInfo? reportFile, bool verbose, ClockTestOptions? clockOptions, FileInfo? deviceYml, CancellationToken cancellationToken) { AnsiConsole.MarkupLine($"Running tests on [bold]{portName}[/]..."); if (clockOptions is not null) AnsiConsole.MarkupLine($"Clock reference device: [bold]{clockOptions.ClockPort}[/]"); - var runner = new CoreRunner(clockOptions); + DeviceInfo? deviceMetadata = null; + string? deviceRawYaml = null; + if (deviceYml is not null) + { + AnsiConsole.Markup($"Loading device metadata from [bold]{deviceYml.FullName}[/]..."); + deviceMetadata = GeneratorHelper.ReadDeviceMetadata(deviceYml.FullName); + deviceRawYaml = await File.ReadAllTextAsync(deviceYml.FullName, cancellationToken); + AnsiConsole.MarkupLine($" [green]Done![/] ({deviceMetadata.Registers.Count} registers)"); + } + + var runner = new CoreRunner(clockOptions, deviceMetadata, deviceRawYaml); var report = new Report { DeviceName = $"Harp Device ({portName})", @@ -173,7 +194,10 @@ static string GetResultMarkup(IResult result) class CoreRunner : Runner { - public CoreRunner(ClockTestOptions? clockOptions = null) : base() + public CoreRunner( + ClockTestOptions? clockOptions = null, + DeviceInfo? deviceMetadata = null, + string? deviceRawYaml = null) : base() { AddSuite(new R_WHO_AM_I()); AddSuite(new R_HW_VERSION_H()); @@ -197,6 +221,7 @@ public CoreRunner(ClockTestOptions? clockOptions = null) : base() AddSuite(new R_VERSION()); AddSuite(new RoundTripTestSuite()); AddSuite(new ClockTestSuite(clockOptions)); + AddSuite(new DeviceInterfaceSuite(deviceMetadata, deviceRawYaml)); } } } diff --git a/src/Harp.Toolkit/Benchmark/ClockTestOptions.cs b/src/Harp.Toolkit/Benchmark/ClockTestOptions.cs index 0fb6c28..875f4cd 100644 --- a/src/Harp.Toolkit/Benchmark/ClockTestOptions.cs +++ b/src/Harp.Toolkit/Benchmark/ClockTestOptions.cs @@ -1,4 +1,4 @@ -namespace Harp.Toolkit.Benchmark; +namespace Harp.Toolkit.Benchmark; /// /// Options for clock alignment and PPS synchronization tests run against a reference clock device. diff --git a/src/Harp.Toolkit/Benchmark/GeneratedInterfaceCompiler.cs b/src/Harp.Toolkit/Benchmark/GeneratedInterfaceCompiler.cs new file mode 100644 index 0000000..0d55673 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/GeneratedInterfaceCompiler.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using System.Text; +using Harp.Generators; +using Harp.Toolkit.Generate; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Emit; +using Microsoft.Extensions.DependencyModel; + +namespace Harp.Toolkit.Benchmark; + +/// +/// Generates the C# interface for a device.yml (via ), +/// compiles it in-memory, and returns its register address-to-type map so callers can +/// invoke each register's own generated parser reflectively. +/// +internal static class GeneratedInterfaceCompiler +{ + public static IReadOnlyDictionary Compile(DeviceInfo deviceOnlyMetadata, string rawDeviceYaml, string @namespace) + { + var generator = new InterfaceGenerator(deviceOnlyMetadata, @namespace); + var implementation = generator.GenerateImplementation(); + if (!GeneratorHelper.AssertNoGeneratorErrors(generator.Errors)) + throw new InvalidOperationException("Interface generation from device.yml completed with errors."); + + var syntaxTree = CSharpSyntaxTree.ParseText(implementation.Device); + var compilation = CSharpCompilation.Create( + $"HarpGeneratedInterface_{@namespace}", + new[] { syntaxTree }, + GetMetadataReferences(), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, allowUnsafe: true)); + + // The generated Device class's static constructor reads device.yml back from + // an embedded "{Namespace}.device.yml" manifest resource (e.g. to expose it via + // the Metadata property) - without it, merely accessing RegisterMap throws. + var rawYamlBytes = Encoding.UTF8.GetBytes(rawDeviceYaml); + var deviceYamlResource = new ResourceDescription( + $"{@namespace}.device.yml", + () => new MemoryStream(rawYamlBytes), + isPublic: true); + + using var peStream = new MemoryStream(); + var result = compilation.Emit(peStream, manifestResources: new[] { deviceYamlResource }); + if (!result.Success) + { + var errors = string.Join(Environment.NewLine, result.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error)); + throw new InvalidOperationException($"Failed to compile the interface generated from device.yml:{Environment.NewLine}{errors}"); + } + + var assembly = Assembly.Load(peStream.ToArray()); + var deviceType = assembly.GetType($"{@namespace}.Device") + ?? throw new InvalidOperationException($"Compiled assembly does not contain type '{@namespace}.Device'."); + var registerMapProperty = deviceType.GetProperty("RegisterMap", BindingFlags.Public | BindingFlags.Static) + ?? throw new InvalidOperationException($"'{@namespace}.Device' does not expose a static RegisterMap property."); + + return (IReadOnlyDictionary)registerMapProperty.GetValue(null)!; + } + + // Reuses this project's existing PreserveCompilationContext setup (already required + // for RazorLight's own runtime compilation) to resolve the full reference-assembly + // closure, including Bonsai.Harp/Bonsai.Core, which the generated code depends on. + private static IReadOnlyList GetMetadataReferences() + { + var context = DependencyContext.Default + ?? throw new InvalidOperationException("No DependencyContext available for compiling the generated interface."); + + var paths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var library in context.CompileLibraries) + { + foreach (var path in library.ResolveReferencePaths()) + { + paths.Add(path); + } + } + + return paths.Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)).ToList(); + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suite.cs b/src/Harp.Toolkit/Benchmark/Suite.cs index 3df8108..1278b68 100644 --- a/src/Harp.Toolkit/Benchmark/Suite.cs +++ b/src/Harp.Toolkit/Benchmark/Suite.cs @@ -9,7 +9,14 @@ public abstract class Suite { public abstract string Description { get; } - public int TestCount => CollectTests().Count(); + /// + /// Tests whose number and identity is only known at runtime. + /// During test collection, these will be enumerated and run + /// after the fixed methods. + /// + protected virtual IReadOnlyList DynamicTests { get; } = new List(); + + public int TestCount => CollectTests().Count() + DynamicTests.Count; private IEnumerable<(MethodInfo Method, HarpTestAttribute Attribute)> CollectTests() { @@ -56,9 +63,38 @@ public async IAsyncEnumerable RunAllAsync(string portName, [Enumer Description = attr.Description ?? string.Empty }; } + + foreach (var test in DynamicTests) + { + cancellationToken.ThrowIfCancellationRequested(); + + onTestStart?.Invoke(test.Name, test.Description); + + IResult testResult; + try + { + testResult = await test.Run(portName, cancellationToken); + } + catch (Exception ex) + { + testResult = new ErrorResult(ex); + } + yield return new MethodResult + { + Result = testResult, + Name = test.Name, + Description = test.Description + }; + } } } +/// +/// A test whose name and behavior is determined at runtime rather than declared +/// with on a fixed method. +/// +public record DynamicTest(string Name, string Description, Func> Run); + public class SuiteResult { public required string Name { get; set; } diff --git a/src/Harp.Toolkit/Benchmark/Suites/DeviceInterfaceSuite.cs b/src/Harp.Toolkit/Benchmark/Suites/DeviceInterfaceSuite.cs new file mode 100644 index 0000000..ba2150b --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/DeviceInterfaceSuite.cs @@ -0,0 +1,151 @@ +using System.Reflection; +using Bonsai.Harp; +using Harp.Generators; +using Harp.Toolkit.Benchmark; + +namespace Harp.Toolkit.Benchmark.Suites; + +/// +/// Validates a live device against the C# interface actually generated from device.yml, +/// not just the schema: for every register, it reads the live reply and parses it with +/// that register's own generated GetPayload(HarpMessage) (found via the generated +/// , address -> register type, rather than +/// by guessing method names), catching real codegen/parser bugs (bad bit offsets, wrong +/// enum casts, mismatched payload struct fields) that a schema-only structural check +/// can't. It also cross-checks the WhoAmI/firmware/hardware version reported by the +/// device against device.yml. Core registers (WhoAmI, Heartbeat, version registers, etc.) +/// are covered automatically via the base +/// chain that generated code links into, so this only needs to generate/compile +/// device-specific registers. +/// +/// +/// Generating/compiling the interface and the identity check are always exactly one +/// test each, so they're plain methods like every other +/// suite. Only the per-register checks are s, since the +/// register set is only known once device.yml has been parsed. The compile itself +/// happens eagerly in the constructor (not inside ) +/// so the resulting register map is available up front to build those dynamic tests. +/// +internal class DeviceInterfaceSuite : Suite +{ + private readonly DeviceInfo? metadata; + private readonly IReadOnlyDictionary? registerMap; + private readonly Exception? compileError; + private readonly IReadOnlyList dynamicTests; + + public DeviceInterfaceSuite(DeviceInfo? metadata, string? rawYaml) + { + this.metadata = metadata; + + if (metadata is not null && rawYaml is not null) + { + try + { + registerMap = GeneratedInterfaceCompiler.Compile(metadata, rawYaml, $"Harp.{metadata.Device}"); + } + catch (Exception ex) + { + while (ex is TargetInvocationException or TypeInitializationException && ex.InnerException is not null) + ex = ex.InnerException; + compileError = ex; + } + } + + dynamicTests = registerMap is null + ? new List() + : BuildRegisterTests(registerMap); + } + + protected override IReadOnlyList DynamicTests => dynamicTests; + + public override string Description => + "Validates the C# interface generated from device.yml by parsing live register replies with its own generated parsers, and cross-checks WhoAmI/firmware/hardware versions."; + + [HarpTest(Description = "Generates and compiles the C# interface from device.yml.")] + public Task GenerateAndCompileInterface(string portName) + { + IResult result = metadata is null + ? new Result(false, Status.Skipped, "No device.yml provided (--device-yml).") + : registerMap is not null + ? new AssertionResult(true, $"Generated and compiled the interface with {registerMap.Count} registers.") + : new ErrorResult(compileError!); + return Task.FromResult(result); + } + + [HarpTest(Description = "Compares the WhoAmI/firmware/hardware version reported by the device against device.yml.")] + public async Task DeviceIdentity(string portName) + { + if (metadata is null) + return new Result(false, Status.Skipped, "No device.yml provided (--device-yml)."); + + using var device = new AsyncDevice(portName); + var mismatches = new List(); + + int whoAmI = await device.ReadWhoAmIAsync(); + if (whoAmI != metadata.WhoAmI) + mismatches.Add($"WhoAmI: device={whoAmI}, device.yml={metadata.WhoAmI}"); + + // HarpVersion.Satisfies treats a null Major/Minor on the argument as a wildcard, + // so a device.yml that only pins a major version (minor left unset) is honored + // automatically - no need to hand-roll that comparison. + var firmware = await device.ReadFirmwareVersionAsync(); + if (metadata.FirmwareVersion is not null && !firmware.Satisfies(metadata.FirmwareVersion)) + mismatches.Add($"Firmware version: device={firmware}, device.yml={metadata.FirmwareVersion}"); + + var hardware = await device.ReadHardwareVersionAsync(); + if (metadata.HardwareTargets is not null && !hardware.Satisfies(metadata.HardwareTargets)) + mismatches.Add($"Hardware version: device={hardware}, device.yml={metadata.HardwareTargets}"); + + return new AssertionResult( + mismatches.Count == 0, + _ => mismatches.Count == 0 + ? $"WhoAmI={whoAmI}, Firmware={firmware}, Hardware={hardware} match device.yml." + : string.Join("; ", mismatches)); + } + + private static IReadOnlyList BuildRegisterTests(IReadOnlyDictionary registerMap) + { + return registerMap + .Where(entry => entry.Value.IsPublic) + .OrderBy(entry => entry.Key) + .Select(entry => new DynamicTest( + entry.Value.Name, + $"Reads register '{entry.Value.Name}' (address {entry.Key}) and parses the reply with its generated GetPayload parser.", + (portName, cancellationToken) => CheckRegisterAsync(entry.Key, entry.Value, portName, cancellationToken))) + .ToList(); + } + + private static async Task CheckRegisterAsync(int address, Type registerType, string portName, CancellationToken cancellationToken) + { + const BindingFlags staticMembers = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static; + + var getPayload = registerType.GetMethod("GetPayload", staticMembers, null, new[] { typeof(HarpMessage) }, null); + if (getPayload is null) + return new Result(false, Status.Skipped, $"Generated register '{registerType.Name}' has no GetPayload(HarpMessage) parser."); + + var payloadType = (PayloadType)registerType.GetField("RegisterType", staticMembers)!.GetValue(null)!; + + using var device = new AsyncDevice(portName); + HarpMessage reply; + try + { + reply = await device.CommandAsync(HarpCommand.Read(address, payloadType), cancellationToken); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + + try + { + var value = getPayload.Invoke(null, new object?[] { reply }); + return new AssertionResult(true, $"Register '{registerType.Name}' parsed successfully: {value}."); + } + catch (TargetInvocationException ex) + { + // Unwrap so a real generated-parser bug (bad bit offset, wrong enum cast, ...) + // is reported distinctly from the reflection-invocation exception itself. + return new ErrorResult(ex.InnerException ?? ex); + } + } +} diff --git a/src/Harp.Toolkit/Harp.Toolkit.csproj b/src/Harp.Toolkit/Harp.Toolkit.csproj index 31b201e..11e0fd5 100644 --- a/src/Harp.Toolkit/Harp.Toolkit.csproj +++ b/src/Harp.Toolkit/Harp.Toolkit.csproj @@ -13,6 +13,8 @@ + +