diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..d32441d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "docfx": { + "version": "2.78.3", + "commands": [ + "docfx" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index d0f6ba9..c9a9d0d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,27 +1,46 @@ -# To learn more about .editorconfig see https://aka.ms/editorconfigdocs -############################### -# Core EditorConfig Options # -############################### -# All files +root = true + [*] indent_style = space -# XML project files -[*.{csproj,vcxproj,vcxproj.filters,proj,projitems,shproj}] +#-------------------------------------------------------------------------------------------------- +# XML, JSON, and web files +#-------------------------------------------------------------------------------------------------- +[*.{xml,csproj,vcxproj,vcxproj.filters,shproj,props,targets,config,nuspec,resx,vsixmanifest,wxs,vstemplate,slnx}] indent_size = 2 -# XML config files -[*.{props,targets,ruleset,config,nuspec,resx,vsixmanifest,vsct}] +[*.json] indent_size = 2 -# Code files -[*.{cs,csx}] +[*.{html,css}] +indent_size = 2 + +#-------------------------------------------------------------------------------------------------- +# C++ +#-------------------------------------------------------------------------------------------------- +[*.{c,cpp,h,hpp,ixx}] indent_size = 4 +charset = utf-8-bom +trim_trailing_whitespace = true insert_final_newline = true + +#-------------------------------------------------------------------------------------------------- +# C# +#-------------------------------------------------------------------------------------------------- +[*.{cs,csx}] +indent_size = 4 charset = utf-8-bom -############################### -# .NET Coding Conventions # -############################### -[*.{cs}] -# Organize usings -dotnet_sort_system_directives_first = true +trim_trailing_whitespace = true +insert_final_newline = true + +# Language keyword vs full type name +# Predefined for members, etc does not create a message because the explicitly sized types are conveient in interop scenarios where the bit size matters. +dotnet_style_predefined_type_for_locals_parameters_members = true:none +dotnet_style_predefined_type_for_member_access = true:suggestion + +# Instantiate argument exceptions correctly +dotnet_diagnostic.CA2208.severity = warning + +# Don't complain about not using modern collection syntax +dotnet_style_prefer_collection_expression = never +csharp_style_prefer_range_operator = false diff --git a/.gitattributes b/.gitattributes index eb356c9..dd82528 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,11 +3,13 @@ # Scripts *.cmd text eol=crlf +*.sh text eol=lf *.ps1 text # Config *.gitignore text *.gitattributes text +*.gitmodules text eol=lf *.editorconfig text *.git-blame-ignore-revs text *.sln text @@ -39,4 +41,4 @@ LICENSE text *.png binary *.ico binary *.gif binary -*.svg text \ No newline at end of file +*.svg text diff --git a/.github/workflows/Harp.Toolkit.yml b/.github/workflows/Harp.Toolkit.yml new file mode 100644 index 0000000..a069ac4 --- /dev/null +++ b/.github/workflows/Harp.Toolkit.yml @@ -0,0 +1,291 @@ +# ======================================================================================================================================================================= +# Harp.Toolkit CI/CD +# ======================================================================================================================================================================= +# Index: +# * Build, test, and package .NET +# * Build documentation +# * Publish packages to GitHub +# * Publish packages to NuGet.org +# * Publish documentation +# ======================================================================================================================================================================= +# Note that this is a generic workflow meant for all Bonsai packages. Minor local modifications are fine, see https://github.com/bonsai-rx/prefect for more information. +# ======================================================================================================================================================================= +name: Harp.Toolkit +on: + push: + # This prevents tag pushes from triggering this workflow + branches: ['**'] + pull_request: + release: + types: [published] + workflow_dispatch: + inputs: + publish-documentation: + description: "Publish documentation to GitHub Pages?" + default: "false" +env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_GENERATE_ASPNET_CERTIFICATE: false + ContinuousIntegrationBuild: true +jobs: + # ===================================================================================================================================================================== + # Build, test, and package .NET + # ___ _ _ _ _ _ _ _ _ _ ___ _____ + # | _ )_ _(_) |__| | | |_ ___ __| |_ __ _ _ _ __| | _ __ __ _ __| |____ _ __ _ ___ | \| | __|_ _| + # | _ \ || | | / _` |_ | _/ -_|_-< _|_ / _` | ' \/ _` | | '_ \/ _` / _| / / _` / _` / -_) _| .` | _| | | + # |___/\_,_|_|_\__,_( ) \__\___/__/\__( ) \__,_|_||_\__,_| | .__/\__,_\__|_\_\__,_\__, \___| (_)_|\_|___| |_| + # |/ |/ |_| |___/ + # ===================================================================================================================================================================== + build: + strategy: + fail-fast: false + matrix: + platform: + - name: Windows x64 + os: windows-latest + rid: win-x64 + - name: Linux x64 + os: ubuntu-latest + rid: linux-x64 + configuration: ['debug', 'release'] + include: + - platform: + rid: win-x64 + configuration: release + collect-packages: true + name: ${{matrix.platform.name}} ${{matrix.configuration}} + runs-on: ${{matrix.platform.os}} + outputs: + need-workflow-image-render: ${{steps.configure-build.outputs.need-workflow-image-render}} + steps: + # ----------------------------------------------------------------------- Checkout + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + # ----------------------------------------------------------------------- Set up tools + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + + # ----------------------------------------------------------------------- Configure build + - name: Configure build + id: configure-build + uses: bonsai-rx/configure-build@v1 + + # ----------------------------------------------------------------------- Build + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration ${{matrix.configuration}} + + # ----------------------------------------------------------------------- Pack + - name: Pack + id: pack + run: dotnet pack --no-restore --no-build --configuration ${{matrix.configuration}} + + # ----------------------------------------------------------------------- Test + - name: Test .NET 8 + run: dotnet test --no-restore --no-build --configuration ${{matrix.configuration}} --verbosity normal --framework net8.0 + + # ----------------------------------------------------------------------- Collect artifacts + - name: Collect NuGet packages + uses: actions/upload-artifact@v4 + if: matrix.collect-packages && steps.pack.outcome == 'success' && always() + with: + name: Packages + if-no-files-found: error + path: artifacts/package/${{matrix.configuration}}/** + + # ===================================================================================================================================================================== + # Build documentation + # ___ _ _ _ _ _ _ _ + # | _ )_ _(_) |__| | __| |___ __ _ _ _ __ ___ _ _| |_ __ _| |_(_)___ _ _ + # | _ \ || | | / _` | / _` / _ \/ _| || | ' \/ -_) ' \ _/ _` | _| / _ \ ' \ + # |___/\_,_|_|_\__,_| \__,_\___/\__|\_,_|_|_|_\___|_||_\__\__,_|\__|_\___/_||_| + # ===================================================================================================================================================================== + build-documentation: + name: Build documentation + runs-on: ubuntu-latest + steps: + # ----------------------------------------------------------------------- Checkout + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + # ----------------------------------------------------------------------- Set up tools + - name: Set up .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + + - name: Set up .NET tools + run: dotnet tool restore + + # ----------------------------------------------------------------------- Configure build + - name: Configure build + id: configure-build + uses: bonsai-rx/configure-build@v1 + + # ----------------------------------------------------------------------- Restore + - name: Restore + run: dotnet restore + + # ----------------------------------------------------------------------- Build metadata + - name: Build metadata + id: build-metadata + run: dotnet docfx metadata docs/docfx.json --noRestore + + # ----------------------------------------------------------------------- Build documentation + - name: Build documentation + id: build-documentation + run: dotnet docfx build docs/docfx.json + + # ----------------------------------------------------------------------- Collect artifacts + - name: Collect documentation metadata + uses: actions/upload-artifact@v4 + if: steps.build-metadata.outcome == 'success' && always() + with: + name: DocumentationMetadata + if-no-files-found: error + path: artifacts/docs/api/ + + - name: Collect documentation artifact + uses: actions/upload-artifact@v4 + if: steps.build-documentation.outcome == 'success' && always() + with: + name: DocumentationWebsite + if-no-files-found: error + path: artifacts/docs/site/ + + # ===================================================================================================================================================================== + # Publish packages to GitHub + # ___ _ _ _ _ _ _ ___ _ _ _ _ _ + # | _ \_ _| |__| (_)__| |_ _ __ __ _ __| |____ _ __ _ ___ ___ | |_ ___ / __(_) |_| || |_ _| |__ + # | _/ || | '_ \ | (_-< ' \ | '_ \/ _` / _| / / _` / _` / -_|_-< | _/ _ \ | (_ | | _| __ | || | '_ \ + # |_| \_,_|_.__/_|_/__/_||_| | .__/\__,_\__|_\_\__,_\__, \___/__/ \__\___/ \___|_|\__|_||_|\_,_|_.__/ + # |_| |___/ + # ===================================================================================================================================================================== + publish-github: + name: Publish packages to GitHub + runs-on: ubuntu-latest + needs: build + permissions: + # Needed to attach files to releases + contents: write + # Needed to upload to GitHub Packages + packages: write + if: github.event_name == 'push' || github.event_name == 'release' + steps: + # ----------------------------------------------------------------------- Set up .NET + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + + # ----------------------------------------------------------------------- Download built packages + - name: Download built packages + uses: actions/download-artifact@v4 + with: + name: Packages + path: artifacts/packages/ + + # ----------------------------------------------------------------------- Upload release assets + - name: Upload release assets + if: github.event_name == 'release' + run: gh release upload --repo ${{github.repository}} ${{github.event.release.tag_name}} artifacts/packages/* --clobber + env: + GH_TOKEN: ${{github.token}} + + # ----------------------------------------------------------------------- Push to GitHub Packages + - name: Push to GitHub Packages + run: dotnet nuget push "artifacts/packages/*.nupkg" --skip-duplicate --no-symbols --api-key ${{secrets.GITHUB_TOKEN}} --source https://nuget.pkg.github.com/${{github.repository_owner}} + env: + # This is a workaround for https://github.com/NuGet/Home/issues/9775 + DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER: 0 + + # ===================================================================================================================================================================== + # Publish packages to NuGet.org + # ___ _ _ _ _ _ _ _ _ ___ _ + # | _ \_ _| |__| (_)__| |_ _ __ __ _ __| |____ _ __ _ ___ ___ | |_ ___ | \| |_ _ / __|___| |_ ___ _ _ __ _ + # | _/ || | '_ \ | (_-< ' \ | '_ \/ _` / _| / / _` / _` / -_|_-< | _/ _ \ | .` | || | (_ / -_) _|_/ _ \ '_/ _` | + # |_| \_,_|_.__/_|_/__/_||_| | .__/\__,_\__|_\_\__,_\__, \___/__/ \__\___/ |_|\_|\_,_|\___\___|\__(_)___/_| \__, | + # |_| |___/ |___/ + # ===================================================================================================================================================================== + publish-packages-nuget-org: + name: Publish packages to NuGet.org + runs-on: ubuntu-latest + environment: public-release + needs: build + if: github.event_name == 'release' + steps: + # ----------------------------------------------------------------------- Set up .NET + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.x + + # ----------------------------------------------------------------------- Download built packages + - name: Download built packages + uses: actions/download-artifact@v4 + with: + name: Packages + path: artifacts/packages/ + + # ----------------------------------------------------------------------- Push to NuGet.org + - name: Push to NuGet.org + run: dotnet nuget push "artifacts/packages/*.nupkg" --api-key ${{secrets.NUGET_API_KEY}} --source ${{vars.NUGET_API_URL}} + env: + # This is a workaround for https://github.com/NuGet/Home/issues/9775 + DOTNET_SYSTEM_NET_HTTP_USESOCKETSHTTPHANDLER: 0 + + + # ===================================================================================================================================================================== + # Publish documentation + # ___ _ _ _ _ _ _ _ _ + # | _ \_ _| |__| (_)__| |_ __| |___ __ _ _ _ __ ___ _ _| |_ __ _| |_(_)___ _ _ + # | _/ || | '_ \ | (_-< ' \ / _` / _ \/ _| || | ' \/ -_) ' \ _/ _` | _| / _ \ ' \ + # |_| \_,_|_.__/_|_/__/_||_| \__,_\___/\__|\_,_|_|_|_\___|_||_\__\__,_|\__|_\___/_||_| + # ===================================================================================================================================================================== + publish-documentation: + name: Publish documentation + runs-on: ubuntu-latest + # Publishing is not strictly necessary here, but if we're going to do a public release we want to wait to publish the docs until it goes out + needs: [build-documentation, publish-packages-nuget-org] + permissions: + # Both required by actions/deploy-pages + pages: write + id-token: write + environment: + # Intentionally not using the "default" github-pages environment as it's not compatible with this workflow + name: documentation-website + url: ${{steps.publish.outputs.page_url}} + # Only run if the workflow isn't dying and build-documentation was successful and either A) we're releasing or B) we have continuous deployment enabled + if: | + !cancelled() && !failure() && needs.build-documentation.result == 'success' + && (github.event_name == 'release' + || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish-documentation == 'true') + || (vars.CONTINUOUS_DOCUMENTATION && github.event_name != 'pull_request') + ) + steps: + # ----------------------------------------------------------------------- Download documentation website components + - name: Download documentation website + uses: actions/download-artifact@v4 + with: + name: DocumentationWebsite + + # ----------------------------------------------------------------------- Collect artifacts + - name: Upload final documentation website artifact + uses: actions/upload-pages-artifact@v3 + with: + path: '.' + + # ----------------------------------------------------------------------- Publish to GitHub Pages + - name: Publish to GitHub Pages + id: publish + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5c4ae19..8c2e811 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,2 @@ -.vs -.vscode -bin -obj -*.user -*.suo -*.hex +.vs/ +/artifacts/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2e596f3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "docs/harp-docfx"] + path = docs/harp-docfx + url = https://github.com/harp-tech/docfx-tools.git diff --git a/Directory.Build.props b/Directory.Build.props index 0f4757f..91586df 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,27 +1,3 @@ - - - - harp-tech - icon.png - A tool for inspecting, updating and interfacing with Harp devices from the command-line. - harp.toolkit - Harp Toolkit Device Firmware - LICENSE - README.md - Copyright © harp-tech and Contributors 2024 - snupkg - false - ..\bin\$(Configuration) - https://github.com/harp-tech/harp-cli - https://github.com/harp-tech/harp-cli.git - - git - true - - - - - - - + + \ No newline at end of file diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..b7ac253 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/Harp.Toolkit.sln b/Harp.Toolkit.sln index 3f68202..35c9b1a 100644 --- a/Harp.Toolkit.sln +++ b/Harp.Toolkit.sln @@ -3,20 +3,34 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Harp.Toolkit", "Harp.Toolkit\Harp.Toolkit.csproj", "{BFC25910-BC44-4792-9CDE-5B3A17D0B157}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Harp.Toolkit", "src\Harp.Toolkit\Harp.Toolkit.csproj", "{BFC25910-BC44-4792-9CDE-5B3A17D0B157}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{DEE5DD87-39C1-BF34-B639-A387DCCF972B}" + ProjectSection(SolutionItems) = preProject + build\Common.csproj.props = build\Common.csproj.props + build\Common.csproj.targets = build\Common.csproj.targets + build\Common.Tests.csproj.props = build\Common.Tests.csproj.props + build\icon.png = build\icon.png + build\Package.props = build\Package.props + build\Project.csproj.props = build\Project.csproj.props + EndProjectSection EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Release|Any CPU = Release|Any CPU EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {BFC25910-BC44-4792-9CDE-5B3A17D0B157}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {BFC25910-BC44-4792-9CDE-5B3A17D0B157}.Debug|Any CPU.Build.0 = Debug|Any CPU {BFC25910-BC44-4792-9CDE-5B3A17D0B157}.Release|Any CPU.ActiveCfg = Release|Any CPU {BFC25910-BC44-4792-9CDE-5B3A17D0B157}.Release|Any CPU.Build.0 = Release|Any CPU + {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC58E518-3522-4B04-9A05-DE7F14A51FFA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/Harp.Toolkit/Harp.Toolkit.csproj b/Harp.Toolkit/Harp.Toolkit.csproj deleted file mode 100644 index 076ea54..0000000 --- a/Harp.Toolkit/Harp.Toolkit.csproj +++ /dev/null @@ -1,16 +0,0 @@ - - - - Exe - net8.0 - enable - 0.1.0 - enable - - - - - - - - diff --git a/Harp.Toolkit/Program.cs b/Harp.Toolkit/Program.cs deleted file mode 100644 index 502a73a..0000000 --- a/Harp.Toolkit/Program.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System.CommandLine; -using System.IO.Ports; -using Bonsai.Harp; - -namespace Harp.Toolkit; - -internal class Program -{ - static async Task Main(string[] args) - { - Option portNameOption = new("--port") - { - Description = "Specifies the name of the serial port used to communicate with the device.", - Required = true - }; - - Option portTimeoutOption = new("--timeout") - { - Description = "Specifies an optional timeout, in milliseconds, to receive a response from the device." - }; - - Option firmwarePathOption = new("--path") - { - Description = "Specifies the path of the firmware file to write to the device.", - Required = true - }; - - Option forceUpdateOption = new("--force") - { - Description = "Indicates whether to force a firmware update on the device regardless of compatibility." - }; - - var listCommand = new Command("list", description: "Lists all available system serial ports."); - listCommand.SetAction(parseResult => - { - var portNames = SerialPort.GetPortNames(); - Console.WriteLine($"PortNames: [{string.Join(", ", portNames)}]"); - }); - - var updateCommand = new Command("update", description: "Update the device firmware from a local HEX file."); - updateCommand.Options.Add(portNameOption); - updateCommand.Options.Add(firmwarePathOption); - updateCommand.Options.Add(forceUpdateOption); - updateCommand.SetAction(async parseResult => - { - var firmwarePath = parseResult.GetRequiredValue(firmwarePathOption); - var portName = parseResult.GetRequiredValue(portNameOption); - var forceUpdate = parseResult.GetValue(forceUpdateOption); - - var firmware = DeviceFirmware.FromFile(firmwarePath.FullName); - Console.WriteLine($"{firmware.Metadata}"); - ProgressBar.Write(0); - try - { - var progress = new Progress(ProgressBar.Update); - await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, progress); - } - finally { Console.WriteLine(); } - }); - - var rootCommand = new RootCommand("Tool for inspecting, updating and interfacing with Harp devices."); - rootCommand.Options.Add(portNameOption); - rootCommand.Options.Add(portTimeoutOption); - rootCommand.Subcommands.Add(listCommand); - rootCommand.Subcommands.Add(updateCommand); - rootCommand.SetAction(async parseResult => - { - var portName = parseResult.GetRequiredValue(portNameOption); - var portTimeout = parseResult.GetValue(portTimeoutOption); - - using var device = new AsyncDevice(portName); - var whoAmI = await device.ReadWhoAmIAsync().WithTimeout(portTimeout); - var hardwareVersion = await device.ReadHardwareVersionAsync(); - var firmwareVersion = await device.ReadFirmwareVersionAsync(); - var timestamp = await device.ReadTimestampSecondsAsync(); - var deviceName = await device.ReadDeviceNameAsync(); - Console.WriteLine($"Harp device found in {portName}"); - Console.WriteLine($"DeviceName: {deviceName}"); - Console.WriteLine($"WhoAmI: {whoAmI}"); - Console.WriteLine($"Hw: {hardwareVersion.Major}.{hardwareVersion.Minor}"); - Console.WriteLine($"Fw: {firmwareVersion.Major}.{firmwareVersion.Minor}"); - Console.WriteLine($"Timestamp (s): {timestamp}"); - Console.WriteLine(); - }); - - var parseResult = rootCommand.Parse(args); - await parseResult.InvokeAsync(); - } -} diff --git a/LICENSE b/LICENSE index 2eb1335..99e6742 100644 --- a/LICENSE +++ b/LICENSE @@ -1,13 +1,11 @@ -MIT License +Copyright (c) harp-tech and Contributors -Copyright (c) 2024 harp-tech and Contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. @@ -18,4 +16,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/build/Common.Tests.csproj.props b/build/Common.Tests.csproj.props new file mode 100644 index 0000000..c42b25f --- /dev/null +++ b/build/Common.Tests.csproj.props @@ -0,0 +1,6 @@ + + + false + false + + \ No newline at end of file diff --git a/build/Common.csproj.props b/build/Common.csproj.props new file mode 100644 index 0000000..ed9a2c0 --- /dev/null +++ b/build/Common.csproj.props @@ -0,0 +1,61 @@ + + + + + Debug + AnyCPU + + + + + 12.0 + true + strict + enable + true + true + + + + + true + + icon.png + $(MSBuildThisFileDirectory)icon.png + + LICENSE + $(MSBuildThisFileDirectory)../LICENSE + + README.md + $(MSBuildThisFileDirectory)../docs/README.md + $(MSBuildThisFileDirectory)README.nuget.md + $(MSBuildProjectDirectory)\README.md + $(MSBuildProjectDirectory)\README.nuget.md + + + false + true + snupkg + + + false + true + + + + + $(WarningsAsErrors);NU1701;CS7035 + + + + + + \ No newline at end of file diff --git a/build/Common.csproj.targets b/build/Common.csproj.targets new file mode 100644 index 0000000..07f4916 --- /dev/null +++ b/build/Common.csproj.targets @@ -0,0 +1,46 @@ + + + + + + + + + + + $(TargetName.ToLowerInvariant()) + DotnetTool + + + 1591,1573 + + + + + + + + 0 + 42.42.42-dev$(DevVersion) + + $(CiBuildVersion) + + + + + + + + + + + + \ No newline at end of file diff --git a/build/Package.props b/build/Package.props new file mode 100644 index 0000000..62de0bc --- /dev/null +++ b/build/Package.props @@ -0,0 +1,9 @@ + + + Harp Toolkit + https://harp-tech.org/toolkit + + harp-tech + Copyright © harp-tech and Contributors + + \ No newline at end of file diff --git a/build/Project.csproj.props b/build/Project.csproj.props new file mode 100644 index 0000000..c1df222 --- /dev/null +++ b/build/Project.csproj.props @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/icon.png b/build/icon.png similarity index 100% rename from icon.png rename to build/icon.png diff --git a/README.md b/docs/README.md similarity index 80% rename from README.md rename to docs/README.md index d2068c8..1763169 100644 --- a/README.md +++ b/docs/README.md @@ -41,3 +41,11 @@ Tool for inspecting, updating and interfacing with Harp devices. ```cmd dotnet tool restore ``` + +## Contributing + +Bug reports and contributions are welcome at [the GitHub repository](https://github.com/harp-tech/toolkit). + +## License + +`Harp.Toolkit` is released as open-source under the [MIT license](https://licenses.nuget.org/MIT). diff --git a/docs/articles/generate.md b/docs/articles/generate.md new file mode 100644 index 0000000..00bf134 --- /dev/null +++ b/docs/articles/generate.md @@ -0,0 +1,91 @@ +# Code Generation + +`harp.toolkit` can be used to automatically generate firmware and interface code from device and IO pin configuration metadata files. + +## Editing device metadata + +1. Install [Visual Studio Code](https://code.visualstudio.com/) +2. Install the [YAML extension](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml). + +The device interface can be described using a `device.yml` file. A complete specification of all device registers, including bit masks, group masks, and payload formats needs to be provided. + +```yaml +%YAML 1.1 +--- +# yaml-language-server: $schema=https://harp-tech.org/draft-02/schema/device.json +device: DeviceName +whoAmI: 0000 +firmwareVersion: "0.1" +hardwareTargets: "0.0" +registers: + DigitalInputs: + address: 32 + type: U8 + access: Event +``` + +## Generating device interface code + +A complete reactive interface to communicate with the device can be generated from the `device.yml` metadata file. + +```ps1 +dotnet harp.toolkit generate interface +``` + +The following options are available to configure the generated output. + +#### Namespace +```ps1 +-ns, --namespace +``` + +Specifies the namespace for the generated code. The default namespace is `Harp.DeviceName` where `DeviceName` is the name of the device specified in the `device.yml` file. + +#### Output location +```ps1 +-o, --output +``` + +Specifies the location where to place the generated output. The default is the current directory. Usually this will point to the folder of your `.csproj` interface project. + +## Generating device firmware code + +Device firmware interface stubs can be generated by providing an additional metadata file, `ios.yml`, describing IO pin configuration. Currently only the [Harp Core ATxmega](https://harp-tech.org/core.atxmega/) is supported. + +```ps1 +dotnet harp.toolkit generate firmware +``` + +The following options are available to configure the generated output. + +#### Generate implementation stubs +```ps1 +--implementation +``` + +Specifies whether to generate implementation stubs. In general this should be run only when starting development of a new device, to provide templates for all required functions. It should also be run when significantly changing the device metadata file, to ensure alignment between firmware and device interface naming conventions. + +#### Output location +```ps1 +-o, --output +``` + +Specifies the location where to place the generated output. The default is the current directory. Usually this will point to the folder of your `.cproj` firmware project. + +## Generating device metadata + +The device metadata and IO pin configuration files can be generated from legacy XLS worksheet files describing the device. Both the `device.yml` and `ios.yml` will be generated from a single `registers.xls` file. + +```ps1 +dotnet harp.toolkit generate metadata +``` + +#### Output location +```ps1 +-o, --output +``` + +Specifies the location where to place the generated output. The default is the current directory. + +> [!Warning] +> The `registers.xls` file format is deprecated and is no longer recommended for editing device metadata as it lacks important features of the new YAML format, including complex payload spec formats and mixed access registers. diff --git a/docs/articles/toc.yml b/docs/articles/toc.yml new file mode 100644 index 0000000..1ce9c53 --- /dev/null +++ b/docs/articles/toc.yml @@ -0,0 +1,3 @@ +- name: Introduction + href: ../index.md +- href: generate.md \ No newline at end of file diff --git a/docs/build.ps1 b/docs/build.ps1 new file mode 100644 index 0000000..22cb40f --- /dev/null +++ b/docs/build.ps1 @@ -0,0 +1,14 @@ +[CmdletBinding()] param ( + [string[]]$docfxArgs +) +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +Push-Location $PSScriptRoot +try { + dotnet docfx metadata + dotnet docfx build $docfxArgs +} finally { + Pop-Location +} diff --git a/docs/docfx.json b/docs/docfx.json new file mode 100644 index 0000000..1f3dcc2 --- /dev/null +++ b/docs/docfx.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json", + "metadata": [ + { + "src": [ + { + "src": "../src/", + "files": "**/*.csproj", + "exclude": [ + "**/*.Tests.csproj", + "**/*Template.csproj" + ] + } + ], + "output": "../artifacts/docs/api/", + "enumSortOrder": "declaringOrder", + "memberLayout": "separatePages", + "filter": "filter.yml" + } + ], + "build": { + "content": [ + { + "files": [ + "*.md", + "toc.yml", + "{articles,tutorials,examples}/**/*.md", + "{articles,tutorials,examples}/**/toc.yml" + ], + "exclude": "README.md" + }, + { + "src": "../artifacts/docs/api/", + "dest": "api", + "files": "**/*.yml" + } + ], + "resource": [ + { + "files": [ + "images/**", + "{articles,tutorials,examples}/**/*.{bonsai,svg}" + ] + } + ], + "overwrite": [ + "apidoc/**/*.md" + ], + "output": "../artifacts/docs/site/", + "template": [ + "default", + "modern", + "harp-docfx/template", + "template" + ], + "sitemap": { + "baseUrl": "https://harp-tech.org/toolkit" + }, + "globalMetadata": { + "_appName": "Toolkit", + "_appTitle": "Harp.Toolkit", + "_appFooter": "© harp-tech and Contributors. Made with docfx", + "_enableNewTab": true, + "_enableSearch": true, + "_gitContribute": { + "apiSpecFolder": "docs/apidoc" + } + }, + "markdownEngineProperties": { + "markdigExtensions": [ + "attributes", + "customcontainers" + ] + } + } +} \ No newline at end of file diff --git a/docs/filter.yml b/docs/filter.yml new file mode 100644 index 0000000..472eb43 --- /dev/null +++ b/docs/filter.yml @@ -0,0 +1,4 @@ +apiRules: +- exclude: + hasAttribute: + uid: System.ObsoleteAttribute diff --git a/docs/harp-docfx b/docs/harp-docfx new file mode 160000 index 0000000..035d4e6 --- /dev/null +++ b/docs/harp-docfx @@ -0,0 +1 @@ +Subproject commit 035d4e69d875d9bc02e87372c9549e4483164654 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..78c0d6e --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +[!INCLUDE [](README.md)] \ No newline at end of file diff --git a/docs/template/public/main.css b/docs/template/public/main.css new file mode 100644 index 0000000..fdb72e4 --- /dev/null +++ b/docs/template/public/main.css @@ -0,0 +1 @@ +@import "bonsai.css"; diff --git a/docs/template/public/main.js b/docs/template/public/main.js new file mode 100644 index 0000000..35532da --- /dev/null +++ b/docs/template/public/main.js @@ -0,0 +1,13 @@ +import WorkflowContainer from "./workflow.js" + +export default { + defaultTheme: 'light', + iconLinks: [{ + icon: 'github', + href: 'https://github.com/harp-tech/toolkit', + title: 'GitHub' + }], + start: () => { + WorkflowContainer.init(); + } +} diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..18bbc71 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,2 @@ +- name: Documentation + href: articles/ \ No newline at end of file diff --git a/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs new file mode 100644 index 0000000..c2749e7 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/BenchmarkCommand.cs @@ -0,0 +1,172 @@ +using System.CommandLine; +using Spectre.Console; +using Harp.Toolkit.Benchmark.Suites; + +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 + }; + + 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)}"); + + 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]{Markup.Escape(test.Name)}[/]\n[dim]{Markup.Escape(test.Description)}[/]"), + new Markup(GetResultMarkup(test.Result)), + new Markup(Markup.Escape(details)), + new Markup(Markup.Escape(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 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()); + } + } +} + + diff --git a/src/Harp.Toolkit/Benchmark/HarpTestAttribute.cs b/src/Harp.Toolkit/Benchmark/HarpTestAttribute.cs new file mode 100644 index 0000000..f0bf7cb --- /dev/null +++ b/src/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/src/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs b/src/Harp.Toolkit/Benchmark/HtmlReportGenerator.cs new file mode 100644 index 0000000..ede8e25 --- /dev/null +++ b/src/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/src/Harp.Toolkit/Benchmark/Report.cs b/src/Harp.Toolkit/Benchmark/Report.cs new file mode 100644 index 0000000..cc716f2 --- /dev/null +++ b/src/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/src/Harp.Toolkit/Benchmark/ReportTemplate.cshtml b/src/Harp.Toolkit/Benchmark/ReportTemplate.cshtml new file mode 100644 index 0000000..9cdfbac --- /dev/null +++ b/src/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/src/Harp.Toolkit/Benchmark/Result.cs b/src/Harp.Toolkit/Benchmark/Result.cs new file mode 100644 index 0000000..312f97a --- /dev/null +++ b/src/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/src/Harp.Toolkit/Benchmark/Runner.cs b/src/Harp.Toolkit/Benchmark/Runner.cs new file mode 100644 index 0000000..f243661 --- /dev/null +++ b/src/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, Action? onTestStart = null) + { + foreach (var suite in suites) + { + await foreach (var result in suite.RunAllAsync(portName, cancellationToken, (testName, testDesc) => onTestStart?.Invoke(suite, testName, testDesc))) + { + 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/src/Harp.Toolkit/Benchmark/Suite.cs b/src/Harp.Toolkit/Benchmark/Suite.cs new file mode 100644 index 0000000..3df8108 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suite.cs @@ -0,0 +1,74 @@ +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, 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 + { + 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/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..0089263 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_ASSEMBLY_VERSION.cs @@ -0,0 +1,22 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit.Benchmark.Suites; + +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 AssertReturnsZero(string portName) + { + using (var device = new AsyncDevice(portName)) + { + var value = await device.ReadAssemblyVersionAsync(); + return new AssertionResult( + 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_CLOCK_CONFIG.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs new file mode 100644 index 0000000..50b044e --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_CLOCK_CONFIG.cs @@ -0,0 +1,39 @@ +using System.Text; +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.AssertReadableAsync(a => device.ReadByteAsync(a), 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; + 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, + sb.ToString()); + } + } +} 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..6bc733a --- /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..7be1036 --- /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..f0d516c --- /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..0808222 --- /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..637e7a1 --- /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..ffb3e4c --- /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 = 18; + 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.AssertReadableAsync(a => device.ReadUInt16Async(a), 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..41bff61 --- /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..fdd328c --- /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..9f54c70 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_OPERATION_CTRL.cs @@ -0,0 +1,208 @@ +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; + + try + { + 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(); + + bool received = messages.Any(m => m.Address == 0x18 && m.MessageType == MessageType.Event); + + 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 + { + 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)); + } + } + } + + [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; + + 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 }; + var messages = await RegisterHelpers.WriteToTransportAsync( + portName, + new[] { HarpMessage.FromByte(address, MessageType.Write, (byte)(originalOpCtrl | 0x08)) }, + TimeSpan.FromSeconds(1)); + + var opRegWriteResponse = messages.FirstOrDefault(m => m.Address == address && m.MessageType == MessageType.Write); + if (opRegWriteResponse == null) + { + return new AssertionResult(false, "No response received for OpCtrl write."); + } + var coreReads = messages + .Select((m, i) => (msg: m, idx: i)) + .Where(x => x.msg.Address <= 32 && x.msg.MessageType == MessageType.Read) + .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, + $"Missing Read replies for {missing.Count} core address(es): {string.Join(", ", missing.Select(a => $"0x{a:X2}"))}."); + + return new AssertionResult(true, "All core register reads received after OpCtrl write."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + finally + { + 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)); + } + } + } + + 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/R_RESET_DEV.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_RESET_DEV.cs new file mode 100644 index 0000000..4e80a83 --- /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.AssertReadableAsync(a => device.ReadByteAsync(a), address, "ResetDev"); + } + } +} 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})."); + } + } +} 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..7406bfe --- /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..3fdc73f --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_MICRO.cs @@ -0,0 +1,55 @@ +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.ReadUInt16Async(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 microValue = await device.ReadUInt16Async(address); + 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 new file mode 100644 index 0000000..e67623c --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_OFFSET.cs @@ -0,0 +1,38 @@ + +using Bonsai.Harp; +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 rejected = await RegisterHelpers.IsWriteRejectedAsync(device, req); + return new AssertionResult( + 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."); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs new file mode 100644 index 0000000..489be6d --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_TIMESTAMP_SECOND.cs @@ -0,0 +1,81 @@ + +using Bonsai.Harp; +using System.Diagnostics; +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_TIMESTAMP_SECOND : 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}."); + } + } + + [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_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); + } + } +} 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..e914bf4 --- /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 new file mode 100644 index 0000000..368fae3 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/R_WHO_AM_I.cs @@ -0,0 +1,36 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit.Benchmark.Suites; + +internal class R_WHO_AM_I : 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}."); + } + } + + [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..dfb82f8 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/CoreRegisters/_RegisterHelpers.cs @@ -0,0 +1,88 @@ + +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 + { + 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 AssertReadableAsync(Func> readFunc, int address, string registerName) + { + try + { + await readFunc(address); + return new AssertionResult(true, $"{registerName} is readable."); + } + catch (Exception ex) + { + return new ErrorResult(ex); + } + } +} diff --git a/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs b/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs new file mode 100644 index 0000000..840b112 --- /dev/null +++ b/src/Harp.Toolkit/Benchmark/Suites/RoundTripTestSuite.cs @@ -0,0 +1,42 @@ + +using Bonsai.Harp; +namespace Harp.Toolkit.Benchmark.Suites; + +internal 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 = Bonsai.Harp.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/src/Harp.Toolkit/Generate/GenerateCommand.cs b/src/Harp.Toolkit/Generate/GenerateCommand.cs new file mode 100644 index 0000000..607f1c2 --- /dev/null +++ b/src/Harp.Toolkit/Generate/GenerateCommand.cs @@ -0,0 +1,23 @@ +using System.CommandLine; + +namespace Harp.Toolkit.Generate; + +public class GenerateCommand : Command +{ + public GenerateCommand() + : base("generate", "Generate firmware or interface code for Harp devices.") + { + Subcommands.Add(new GenerateInterfaceCommand()); + Subcommands.Add(new GenerateFirmwareCommand()); + Subcommands.Add(new GenerateRegisterMetadataCommand()); + } + + internal static void WriteFileContents(string path, IEnumerable> generatedFileContents) + { + foreach ((var fileName, var fileContents) in generatedFileContents) + { + Console.WriteLine($"Generating {fileName}..."); + File.WriteAllText(Path.Combine(path, fileName), fileContents); + } + } +} diff --git a/src/Harp.Toolkit/Generate/GenerateFirmwareCommand.cs b/src/Harp.Toolkit/Generate/GenerateFirmwareCommand.cs new file mode 100644 index 0000000..78f42d6 --- /dev/null +++ b/src/Harp.Toolkit/Generate/GenerateFirmwareCommand.cs @@ -0,0 +1,45 @@ +using System.CommandLine; +using Harp.Generators; + +namespace Harp.Toolkit.Generate; + +class GenerateFirmwareCommand : Command +{ + public GenerateFirmwareCommand() + : base("firmware", "Generate firmware headers and implementation template.") + { + MetadataPathArgument metadataPathArgument = new(); + IOMetadataPathOption iosMetadataPathOption = new(); + OutputPathOption outputPathOption = new(); + + Option generateImplementationOption = new("--implementation") + { + Description = "Indicates whether to generate implementation (.c) files. The default is false." + }; + + Arguments.Add(metadataPathArgument); + Options.Add(iosMetadataPathOption); + Options.Add(generateImplementationOption); + Options.Add(outputPathOption); + + SetAction(parseResult => + { + var outputPath = parseResult.GetRequiredValue(outputPathOption); + var registerMetadataFileName = parseResult.GetRequiredValue(metadataPathArgument).FullName; + var iosMetadataFileName = parseResult.GetRequiredValue(iosMetadataPathOption).FullName; + var generateImplementation = parseResult.GetValue(generateImplementationOption); + + var deviceMetadata = GeneratorHelper.ReadDeviceMetadata(registerMetadataFileName); + var portPinMetadata = GeneratorHelper.ReadPortPinMetadata(iosMetadataFileName); + var generator = new FirmwareGenerator(deviceMetadata, portPinMetadata); + var headers = generator.GenerateHeaders(); + var implementation = generateImplementation ? generator.GenerateImplementation() : default; + if (GeneratorHelper.AssertNoGeneratorErrors(generator.Errors)) + { + GenerateCommand.WriteFileContents(outputPath.FullName, headers); + if (generateImplementation) + GenerateCommand.WriteFileContents(outputPath.FullName, implementation); + } + }); + } +} diff --git a/src/Harp.Toolkit/Generate/GenerateInterfaceCommand.cs b/src/Harp.Toolkit/Generate/GenerateInterfaceCommand.cs new file mode 100644 index 0000000..5cd70ab --- /dev/null +++ b/src/Harp.Toolkit/Generate/GenerateInterfaceCommand.cs @@ -0,0 +1,35 @@ +using System.CommandLine; +using Harp.Generators; + +namespace Harp.Toolkit.Generate; + +class GenerateInterfaceCommand : Command +{ + public GenerateInterfaceCommand() + : base("interface", "Generate reactive programming API and async API.") + { + MetadataPathArgument metadataPathArgument = new(); + OutputPathOption outputPathOption = new(); + Option namespaceOption = new("-ns", "--namespace") + { + Description = "The namespace for the generated code. The default is `Harp.DeviceName`." + }; + + Arguments.Add(metadataPathArgument); + Options.Add(namespaceOption); + Options.Add(outputPathOption); + + SetAction(parseResult => + { + var outputPath = parseResult.GetRequiredValue(outputPathOption); + var metadataPath = parseResult.GetRequiredValue(metadataPathArgument); + var ns = parseResult.GetValue(namespaceOption); + + var deviceMetadata = GeneratorHelper.ReadDeviceMetadata(metadataPath.FullName); + var generator = new InterfaceGenerator(deviceMetadata, ns ?? $"Harp.{deviceMetadata.Device}"); + var implementation = generator.GenerateImplementation(); + if (GeneratorHelper.AssertNoGeneratorErrors(generator.Errors)) + GenerateCommand.WriteFileContents(outputPath.FullName, implementation); + }); + } +} diff --git a/src/Harp.Toolkit/Generate/GenerateRegisterMetadataCommand.cs b/src/Harp.Toolkit/Generate/GenerateRegisterMetadataCommand.cs new file mode 100644 index 0000000..28431e0 --- /dev/null +++ b/src/Harp.Toolkit/Generate/GenerateRegisterMetadataCommand.cs @@ -0,0 +1,179 @@ +using System.CommandLine; +using System.Data; +using Bonsai.Harp; +using ExcelDataReader; +using Harp.Generators; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Harp.Toolkit.Generate; + +class GenerateRegisterMetadataCommand : Command +{ + public GenerateRegisterMetadataCommand() + : base("metadata", "Generate device register metadata file from legacy XLS files.") + { + OutputPathOption outputPathOption = new(); + Argument registerWorksheetPathArgument = ArgumentValidation.AcceptExistingOnly( + new Argument("registers.xls") + { + Description = "The path to the file describing the device registers.", + Arity = ArgumentArity.ExactlyOne + }); + + Arguments.Add(registerWorksheetPathArgument); + Options.Add(outputPathOption); + + SetAction(parseResult => + { + var outputPath = parseResult.GetRequiredValue(outputPathOption); + var registerWorksheetPath = parseResult.GetRequiredValue(registerWorksheetPathArgument); + var deviceInfo = new DeviceInfo(); + var iosMetadata = new Dictionary(); + + System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); + using var sourceStream = File.Open(registerWorksheetPath.FullName, FileMode.Open, FileAccess.Read); + using var reader = ExcelReaderFactory.CreateReader(sourceStream); + var result = reader.AsDataSet(); + foreach (DataTable table in result.Tables) + { + ParseMetadataTable(table, deviceInfo); + ParseRegisterTable(table, deviceInfo); + ParseIosTable(table, iosMetadata); + } + + var registerMetadataPath = Path.Combine(outputPath.FullName, "device.yml"); + var iosMetadataPath = Path.Combine(outputPath.FullName, "ios.yml"); + File.WriteAllText(registerMetadataPath, MetadataSerializer.Instance.Serialize(deviceInfo)); + File.WriteAllText(iosMetadataPath, MetadataSerializer.Instance.Serialize(iosMetadata)); + }); + } + + static void ParseMetadataTable(DataTable table, DeviceInfo deviceInfo) + { + if (table.TableName != "meta data") + return; + + deviceInfo.WhoAmI = Convert.ToUInt16(table.Rows[0][1]); + var fwMajor = Convert.ToUInt16(table.Rows[1][1]); + var fwMinor = Convert.ToUInt16(table.Rows[2][1]); + deviceInfo.FirmwareVersion = new HarpVersion(fwMajor, fwMinor); + var hwMajor = Convert.ToUInt16(table.Rows[3][1]); + var hwMinor = Convert.ToUInt16(table.Rows[4][1]); + deviceInfo.HardwareTargets = new HarpVersion(hwMajor, hwMinor); + deviceInfo.Device = (string)table.Rows[6][1]; + } + + static void ParseRegisterTable(DataTable table, DeviceInfo deviceInfo) + { + if (table.TableName != "registers") + return; + + var registerOffset = 32; + for (int i = 1; i < table.Rows.Count; i++) + { + var row = table.Rows[i]; + var registerName = row[5] as string; + if (string.IsNullOrEmpty(registerName)) + continue; + + registerName = FirmwareNamingConvention.Instance.Reverse(registerName); + var registerInfo = new RegisterInfo + { + Address = registerOffset++, + Type = (PayloadType)Enum.Parse(typeof(PayloadType), ((string)row[2]).Replace("I", "S"), ignoreCase: true), + Description = row[6] as string ?? string.Empty + }; + deviceInfo.Registers[registerName] = registerInfo; + } + } + + static InputPinMode ParseInputPinMode(string value) + { + return value switch + { + "up" => InputPinMode.PullUp, + "down" => InputPinMode.PullDown, + "tristate" => InputPinMode.TriState, + "busholder" => InputPinMode.BusHolder, + _ => throw new ArgumentException("Invalid input pin mode.", nameof(value)) + }; + } + + static OutputPinMode ParseOutputPinMode(string value) + { + return value switch + { + "digital" => OutputPinMode.Digital, + "wire_or" => OutputPinMode.WiredOr, + "wire_and" => OutputPinMode.WiredAnd, + "wired_or_pull" => OutputPinMode.WiredOrPull, + "wired_and_pull" => OutputPinMode.WiredAndPull, + _ => throw new ArgumentException("Invalid output pin mode.", nameof(value)) + }; + } + + static InterruptPriority ParseInterruptPriority(string value) + { + return value switch + { + "off" => InterruptPriority.Off, + "low" => InterruptPriority.Low, + "med" => InterruptPriority.Medium, + "high" => InterruptPriority.High, + _ => throw new ArgumentException("Invalid interrupt priority.", nameof(value)) + }; + } + + static TriggerMode ParseTriggerMode(string value) + { + return value switch + { + "rising_edge" => TriggerMode.Rising, + "falling_edge" => TriggerMode.Falling, + "both_edges" => TriggerMode.Toggle, + "low_level" => TriggerMode.Low, + "no_interrupt" => TriggerMode.None, + _ => throw new ArgumentException("Invalid trigger mode.", nameof(value)) + }; + } + + static void ParseIosTable(DataTable table, Dictionary iosMetadata) + { + if (table.TableName != "ios") + return; + + for (int i = 1; i < table.Rows.Count; i++) + { + var row = table.Rows[i]; + var pinName = row[0] as string; + if (string.IsNullOrEmpty(pinName)) + continue; + + var direction = (PinDirection)Enum.Parse(typeof(PinDirection), (string)row[3], ignoreCase: true); + PortPinInfo pinInfo = direction switch + { + PinDirection.Input => new InputPinInfo + { + TriggerMode = ParseTriggerMode((string)row[8]), + InterruptPriority = ParseInterruptPriority((string)row[9]), + InterruptNumber = row[10] is not DBNull ? Convert.ToInt32(row[10]) : null, + PinMode = ParseInputPinMode((string)row[7]) + }, + _ => new OutputPinInfo + { + AllowRead = (string)row[4] == "yes", + PinMode = ParseOutputPinMode((string)row[5]), + InitialState = Convert.ToInt32(row[6]) == 0 ? LogicState.Low : LogicState.High, + Invert = (string)row[12] == "yes" + } + }; + + pinInfo.Direction = direction; + pinInfo.Port = (string)row[1]; + pinInfo.PinNumber = Convert.ToInt32(row[2]); + pinInfo.Description = row[11] as string ?? string.Empty; + iosMetadata[pinName] = pinInfo; + } + } +} diff --git a/src/Harp.Toolkit/Generate/GeneratorHelper.cs b/src/Harp.Toolkit/Generate/GeneratorHelper.cs new file mode 100644 index 0000000..1f1d10e --- /dev/null +++ b/src/Harp.Toolkit/Generate/GeneratorHelper.cs @@ -0,0 +1,47 @@ +using System.CodeDom.Compiler; +using System.Text; +using Harp.Generators; +using YamlDotNet.Core; + +namespace Harp.Toolkit.Generate; + +public static class GeneratorHelper +{ + public static DeviceInfo ReadDeviceMetadata(string path) + { + using var reader = new StreamReader(path); + var parser = new MergingParser(new Parser(reader)); + return MetadataDeserializer.Instance.Deserialize(parser); + } + + public static Dictionary ReadPortPinMetadata(string path) + { + using var reader = new StreamReader(path); + return MetadataDeserializer.Instance.Deserialize>(reader); + } + + public static IEnumerable> GetPortPinsOfType(IDictionary portPins) where T : PortPinInfo + { + return from item in portPins + where item.Value is T + select new KeyValuePair(item.Key, (T)item.Value); + } + + public static bool AssertNoGeneratorErrors(CompilerErrorCollection errors) + { + if (errors.Count > 0) + { + var errorLog = new StringBuilder(); + errorLog.AppendLine("Code generation has completed with errors:"); + foreach (CompilerError error in errors) + { + var warningString = error.IsWarning ? "warning" : "error"; + errorLog.AppendLine($"{error.FileName}: {warningString}: {error.ErrorText}"); + } + Console.Error.WriteLine(errorLog.ToString()); + return !errors.HasErrors; + } + + return true; + } +} diff --git a/src/Harp.Toolkit/Generate/IOMetadataPathOption.cs b/src/Harp.Toolkit/Generate/IOMetadataPathOption.cs new file mode 100644 index 0000000..9fd7d98 --- /dev/null +++ b/src/Harp.Toolkit/Generate/IOMetadataPathOption.cs @@ -0,0 +1,14 @@ +using System.CommandLine; + +namespace Harp.Toolkit.Generate; + +public class IOMetadataPathOption : Option +{ + public IOMetadataPathOption() + : base("--ios") + { + OptionValidation.AcceptExistingOnly(this); + Description = "The path to the file describing the device IO pins."; + DefaultValueFactory = result => result.AcceptExistingOnly(new FileInfo("ios.yml")); + } +} diff --git a/src/Harp.Toolkit/Generate/MetadataPathArgument.cs b/src/Harp.Toolkit/Generate/MetadataPathArgument.cs new file mode 100644 index 0000000..b8a3acd --- /dev/null +++ b/src/Harp.Toolkit/Generate/MetadataPathArgument.cs @@ -0,0 +1,15 @@ +using System.CommandLine; + +namespace Harp.Toolkit.Generate; + +public class MetadataPathArgument : Argument +{ + public MetadataPathArgument() + : base("metadataPath") + { + ArgumentValidation.AcceptExistingOnly(this); + Description = "The path to the file describing the device registers."; + DefaultValueFactory = result => result.AcceptExistingOnly(new FileInfo("device.yml")); + Arity = ArgumentArity.ZeroOrOne; + } +} diff --git a/src/Harp.Toolkit/Generate/OutputPathOption.cs b/src/Harp.Toolkit/Generate/OutputPathOption.cs new file mode 100644 index 0000000..bb54983 --- /dev/null +++ b/src/Harp.Toolkit/Generate/OutputPathOption.cs @@ -0,0 +1,13 @@ +using System.CommandLine; + +namespace Harp.Toolkit.Generate; + +public class OutputPathOption : Option +{ + public OutputPathOption() + : base("-o", "--output") + { + Description = "Location to place the generated output. The default is the current directory."; + DefaultValueFactory = _ => new DirectoryInfo(Environment.CurrentDirectory); + } +} diff --git a/src/Harp.Toolkit/Harp.Toolkit.csproj b/src/Harp.Toolkit/Harp.Toolkit.csproj new file mode 100644 index 0000000..31b201e --- /dev/null +++ b/src/Harp.Toolkit/Harp.Toolkit.csproj @@ -0,0 +1,28 @@ + + + + Exe + true + A tool for inspecting, updating and interfacing with Harp devices from the command-line. + net8.0 + enable + true + + + + + + + + + + + + + + + PreserveNewest + + + + \ No newline at end of file diff --git a/src/Harp.Toolkit/ListCommand.cs b/src/Harp.Toolkit/ListCommand.cs new file mode 100644 index 0000000..12abf22 --- /dev/null +++ b/src/Harp.Toolkit/ListCommand.cs @@ -0,0 +1,17 @@ +using System.CommandLine; +using System.IO.Ports; + +namespace Harp.Toolkit; + +public class ListCommand : Command +{ + public ListCommand() + : base("list", "Lists all available system serial ports.") + { + SetAction(parseResult => + { + var portNames = SerialPort.GetPortNames(); + Console.WriteLine($"PortNames: [{string.Join(", ", portNames)}]"); + }); + } +} diff --git a/src/Harp.Toolkit/PortNameOption.cs b/src/Harp.Toolkit/PortNameOption.cs new file mode 100644 index 0000000..b9168df --- /dev/null +++ b/src/Harp.Toolkit/PortNameOption.cs @@ -0,0 +1,13 @@ +using System.CommandLine; + +namespace Harp.Toolkit; + +public class PortNameOption : Option +{ + public PortNameOption() + : base("--port") + { + Description = "Specifies the name of the serial port used to communicate with the device."; + Required = true; + } +} diff --git a/src/Harp.Toolkit/PortTimeoutOption.cs b/src/Harp.Toolkit/PortTimeoutOption.cs new file mode 100644 index 0000000..c0d5ee3 --- /dev/null +++ b/src/Harp.Toolkit/PortTimeoutOption.cs @@ -0,0 +1,12 @@ +using System.CommandLine; + +namespace Harp.Toolkit; + +public class PortTimeoutOption : Option +{ + public PortTimeoutOption() + : base("--timeout") + { + Description = "Specifies an optional timeout, in milliseconds, to receive a response from the device."; + } +} diff --git a/src/Harp.Toolkit/Program.cs b/src/Harp.Toolkit/Program.cs new file mode 100644 index 0000000..de2dbfe --- /dev/null +++ b/src/Harp.Toolkit/Program.cs @@ -0,0 +1,43 @@ +using System.CommandLine; +using Bonsai.Harp; +using Harp.Toolkit.Generate; + +namespace Harp.Toolkit; + +internal class Program +{ + static async Task Main(string[] args) + { + RootCommand rootCommand = new("Tool for inspecting, updating and interfacing with Harp devices."); + PortNameOption portNameOption = new(); + PortTimeoutOption portTimeoutOption = new(); + rootCommand.Options.Add(portNameOption); + rootCommand.Options.Add(portTimeoutOption); + 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); + var portTimeout = parseResult.GetValue(portTimeoutOption); + + using var device = new AsyncDevice(portName); + var whoAmI = await device.ReadWhoAmIAsync().WithTimeout(portTimeout); + var hardwareVersion = await device.ReadHardwareVersionAsync(); + var firmwareVersion = await device.ReadFirmwareVersionAsync(); + var timestamp = await device.ReadTimestampSecondsAsync(); + var deviceName = await device.ReadDeviceNameAsync(); + Console.WriteLine($"Harp device found in {portName}"); + Console.WriteLine($"DeviceName: {deviceName}"); + Console.WriteLine($"WhoAmI: {whoAmI}"); + Console.WriteLine($"Hw: {hardwareVersion.Major}.{hardwareVersion.Minor}"); + Console.WriteLine($"Fw: {firmwareVersion.Major}.{firmwareVersion.Minor}"); + Console.WriteLine($"Timestamp (s): {timestamp}"); + Console.WriteLine(); + }); + + var parseResult = rootCommand.Parse(args); + await parseResult.InvokeAsync(); + } +} diff --git a/Harp.Toolkit/ProgressBar.cs b/src/Harp.Toolkit/ProgressBar.cs similarity index 100% rename from Harp.Toolkit/ProgressBar.cs rename to src/Harp.Toolkit/ProgressBar.cs diff --git a/src/Harp.Toolkit/SymbolValidation.cs b/src/Harp.Toolkit/SymbolValidation.cs new file mode 100644 index 0000000..0dea40c --- /dev/null +++ b/src/Harp.Toolkit/SymbolValidation.cs @@ -0,0 +1,13 @@ +using System.CommandLine.Parsing; + +namespace Harp.Toolkit; + +internal static class SymbolValidation +{ + public static FileInfo AcceptExistingOnly(this SymbolResult result, FileInfo fileInfo) + { + if (!Path.Exists(fileInfo.FullName)) + result.AddError($"File does not exist: '{fileInfo}'."); + return fileInfo; + } +} diff --git a/Harp.Toolkit/TaskExtensions.cs b/src/Harp.Toolkit/TaskExtensions.cs similarity index 100% rename from Harp.Toolkit/TaskExtensions.cs rename to src/Harp.Toolkit/TaskExtensions.cs diff --git a/src/Harp.Toolkit/UpdateFirmwareCommand.cs b/src/Harp.Toolkit/UpdateFirmwareCommand.cs new file mode 100644 index 0000000..c346735 --- /dev/null +++ b/src/Harp.Toolkit/UpdateFirmwareCommand.cs @@ -0,0 +1,43 @@ +using System.CommandLine; +using Bonsai.Harp; + +namespace Harp.Toolkit; + +public class UpdateFirmwareCommand : Command +{ + public UpdateFirmwareCommand() + : base("update", "Update the device firmware from a local HEX file.") + { + PortNameOption portNameOption = new(); + Option firmwarePathOption = new("--path") + { + Description = "Specifies the path of the firmware file to write to the device.", + Required = true + }; + + Option forceUpdateOption = new("--force") + { + Description = "Indicates whether to force a firmware update on the device regardless of compatibility." + }; + + Options.Add(portNameOption); + Options.Add(firmwarePathOption); + Options.Add(forceUpdateOption); + SetAction(async parseResult => + { + var firmwarePath = parseResult.GetRequiredValue(firmwarePathOption); + var portName = parseResult.GetRequiredValue(portNameOption); + var forceUpdate = parseResult.GetValue(forceUpdateOption); + + var firmware = DeviceFirmware.FromFile(firmwarePath.FullName); + Console.WriteLine($"{firmware.Metadata}"); + ProgressBar.Write(0); + try + { + var progress = new Progress(ProgressBar.Update); + await Bootloader.UpdateFirmwareAsync(portName, firmware, forceUpdate, progress); + } + finally { Console.WriteLine(); } + }); + } +}