Compare commits

...

No commits in common. 'main' and 'master' have entirely different histories.
main ... master

  1. 407
      .gitignore
  2. 85
      CLAUDE.md
  3. 3
      IOModuleTestBlazor.slnx
  4. 170
      IOModuleTestBlazor/CanWorker.cs
  5. 23
      IOModuleTestBlazor/Components/App.razor
  6. 23
      IOModuleTestBlazor/Components/Layout/MainLayout.razor
  7. 98
      IOModuleTestBlazor/Components/Layout/MainLayout.razor.css
  8. 37
      IOModuleTestBlazor/Components/Layout/NavMenu.razor
  9. 105
      IOModuleTestBlazor/Components/Layout/NavMenu.razor.css
  10. 31
      IOModuleTestBlazor/Components/Layout/ReconnectModal.razor
  11. 157
      IOModuleTestBlazor/Components/Layout/ReconnectModal.razor.css
  12. 63
      IOModuleTestBlazor/Components/Layout/ReconnectModal.razor.js
  13. 661
      IOModuleTestBlazor/Components/Pages/CanMonitor.razor
  14. 36
      IOModuleTestBlazor/Components/Pages/Error.razor
  15. 346
      IOModuleTestBlazor/Components/Pages/Home.razor
  16. 5
      IOModuleTestBlazor/Components/Pages/NotFound.razor
  17. 373
      IOModuleTestBlazor/Components/Pages/RelayDrumMachine.razor
  18. 230
      IOModuleTestBlazor/Components/Pages/SerialTerminal.razor
  19. 4
      IOModuleTestBlazor/Components/Pages/SerialTerminal.razor.js
  20. 6
      IOModuleTestBlazor/Components/Routes.razor
  21. 13
      IOModuleTestBlazor/Components/_Imports.razor
  22. 16
      IOModuleTestBlazor/IOModuleTestBlazor.csproj
  23. 36
      IOModuleTestBlazor/Models/CanModels.cs
  24. 34
      IOModuleTestBlazor/Program.cs
  25. 15
      IOModuleTestBlazor/Properties/PublishProfiles/FolderProfile.pubxml
  26. 23
      IOModuleTestBlazor/Properties/launchSettings.json
  27. 204
      IOModuleTestBlazor/Services/CanService.cs
  28. 53
      IOModuleTestBlazor/Services/ICanService.cs
  29. 29
      IOModuleTestBlazor/Services/ISerialPortService.cs
  30. 143
      IOModuleTestBlazor/Services/SerialPortService.cs
  31. 8
      IOModuleTestBlazor/appsettings.Development.json
  32. 16
      IOModuleTestBlazor/appsettings.json
  33. 60
      IOModuleTestBlazor/wwwroot/app.css
  34. BIN
      IOModuleTestBlazor/wwwroot/favicon.png
  35. 4085
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css
  36. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map
  37. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css
  38. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map
  39. 4084
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css
  40. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map
  41. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css
  42. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map
  43. 597
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css
  44. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map
  45. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css
  46. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.min.css.map
  47. 594
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css
  48. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.css.map
  49. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css
  50. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.rtl.min.css.map
  51. 5402
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css
  52. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.css.map
  53. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css
  54. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.min.css.map
  55. 5393
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css
  56. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.css.map
  57. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css
  58. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap-utilities.rtl.min.css.map
  59. 12057
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.css
  60. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.css.map
  61. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css
  62. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.min.css.map
  63. 12030
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css
  64. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.css.map
  65. 6
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css
  66. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/css/bootstrap.rtl.min.css.map
  67. 6314
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js
  68. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.js.map
  69. 7
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js
  70. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.bundle.min.js.map
  71. 4447
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js
  72. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.js.map
  73. 7
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js
  74. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.esm.min.js.map
  75. 4494
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.js
  76. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.js.map
  77. 7
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js
  78. 1
      IOModuleTestBlazor/wwwroot/lib/bootstrap/dist/js/bootstrap.min.js.map
  79. 93
      README.md

407
.gitignore vendored

@ -1,400 +1,17 @@
# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
# VS / Rider
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
.idea/
*.user
*.suo
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# Build output
bin/
obj/
# NuGet Packages
# NuGet
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
packages/
# OS
Thumbs.db
.DS_Store

@ -0,0 +1,85 @@
# CLAUDE.md — IO Module Blazor Dashboard
## Project Overview
Blazor Server app (.NET 10) for monitoring and controlling the STM32H723ZG Nucleo IO module. Communicates over CAN (via PCAN-USB adapter) and serial (via ST-Link VCP).
## Architecture
```
PCAN Hardware ──► CanWorker (IHostedService) ──► CanService (singleton) ──► Razor pages
ST-Link VCP ──► SerialPortService (singleton, event-driven) ──► SerialTerminal.razor
```
- **Blazor Server** — all C# runs server-side; browser connects via WebSocket/SignalR
- **No background worker for serial**`System.IO.Ports.SerialPort` fires `DataReceived` events internally; `SerialPortService` handles buffering directly
## Key Files
| File | Purpose |
|------|---------|
| `Services/ICanService.cs` / `CanService.cs` | PCAN adapter: send, receive, filter, signal extraction |
| `Services/ISerialPortService.cs` / `SerialPortService.cs` | Serial port: open/close, send lines, receive buffer |
| `CanWorker.cs` | `IHostedService` — continuous CAN RX loop, loads config from `appsettings.json` |
| `Models/CanModels.cs` | `CanMessageDto`, `CanFilter`, `CanBitmask` |
| `Components/Pages/Home.razor` | Button status + termination toggles + CAN message list |
| `Components/Pages/CanMonitor.razor` | Full CAN UI: connect, stream, filters, bitmasks, send |
| `Components/Pages/SerialTerminal.razor` | Serial terminal for NVMEM commands |
## CAN Sending Pattern
```csharp
// PcanMessage.Data is read-only — pass data via the constructor, NOT object initializer + CopyTo
var data = new byte[8];
data[0] = payload;
var msg = new PcanMessage(0x240, MessageType.Standard, 8, data, false);
var result = CanService.Write(msg); // returns PcanStatus.OK on success
```
## UI State Update Pattern
Pages poll `CanService.GetLatestMessages()` via a `Timer` (50ms on Home, 100ms on CanMonitor):
```csharp
_updateTimer = new Timer(_ => InvokeAsync(() => { /* update state */ StateHasChanged(); }), null, 0, 50);
```
Serial terminal subscribes to `ISerialPortService.DataReceived` event instead of polling.
## CAN Message Map
| ID | Direction | Handler |
|----|-----------|---------|
| 0x210 | RX | User button → `button1Active` in Home.razor |
| 0x220 | RX | CN9-29 button → `button2Active` |
| 0x230 | RX | CN9-30 button → `button3Active` |
| 0x240 | TX | Termination control — sent from `Home.razor:SendTermination()` |
| 0x250 | RX | Termination pin status — bit0=TERM_ON, bit1=TERM_OFF; syncs `_termOn`/`_termOff` in Home.razor |
| 0x241 | TX | NVMEM write — sent from SerialTerminal via CAN (if needed) |
| 0x242 | TX | NVMEM read request |
| 0x243 | RX | NVMEM read response |
## CAN Config (`appsettings.json`)
```json
"CanOptions": {
"Channel": "", // empty = auto-scan PCAN USB
"Bitrate": "Pcan500",
"Filters": [], // CanFilter objects
"Bitmasks": [] // CanBitmask objects for signal extraction
}
```
## Serial Port Notes
- `SerialPortService` is a singleton — only one port open at a time
- Buffers last 200 lines; sent lines prefixed `> `, received prefixed `< `
- NVMEM commands: `r <addr_hex> <len>` and `w <addr_hex> <bytes...>` at 115200 baud
- Full command reference: `../STM32Nucleo/docs/NVMEMCommands.md`
## Build / Run
```
dotnet run --project IOModuleTestBlazor
```
Requires PCAN-Basic drivers installed. Serial port access requires the app to run with permission to open COM ports (no special config needed on Windows).

@ -0,0 +1,3 @@
<Solution>
<Project Path="IOModuleTestBlazor/IOModuleTestBlazor.csproj" />
</Solution>

@ -0,0 +1,170 @@
using Peak.Can.Basic;
using IOModuleTestBlazor.Models;
using IOModuleTestBlazor.Services;
namespace IOModuleTestBlazor;
/// <summary>
/// Background service that initialises the PCAN adapter and stores CAN messages
/// in the CanService for polling by UI components.
/// </summary>
public class CanWorker(
ILogger<CanWorker> logger,
IConfiguration configuration,
ICanService canService) : BackgroundService
{
public override Task StartAsync(CancellationToken cancellationToken)
{
// Seed filters and bitmasks from appsettings regardless of connection state
foreach (var f in configuration.GetSection("CanOptions:Filters").Get<CanFilter[]>() ?? [])
canService.AddFilter(f);
foreach (var b in configuration.GetSection("CanOptions:Bitmasks").Get<CanBitmask[]>() ?? [])
canService.AddBitmask(b);
// Auto-connect if config is present; failures are non-fatal so the app
// still starts and the user can configure the connection from the UI.
try
{
var channel = ResolveChannel();
var bitrate = ResolveBitrate();
canService.Initialize(channel, bitrate);
logger.LogInformation("CAN channel {Channel} ready at {Bitrate}", channel, bitrate);
}
catch (Exception ex)
{
logger.LogWarning(ex, "CAN auto-connect failed — configure via the UI.");
}
return base.StartAsync(cancellationToken);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
// Pick up any reinit request from the UI
if (canService.TryConsumePendingReinit(out var newChannel, out var newBitrate))
{
logger.LogInformation("Reinitializing CAN: {Channel} at {Bitrate}", newChannel, newBitrate);
if (canService.IsConnected) canService.Uninitialize();
try
{
canService.Initialize(newChannel, newBitrate);
logger.LogInformation("CAN reinitialized: {Channel} at {Bitrate}", newChannel, newBitrate);
}
catch (Exception ex)
{
logger.LogError(ex, "CAN reinitialization failed.");
}
continue;
}
if (!canService.IsConnected)
{
await Task.Delay(100, stoppingToken);
continue;
}
// Process multiple messages in batch to drain buffer faster and reduce latency
int messagesProcessed = 0;
const int maxBatchSize = 10; // Process up to 10 messages per loop iteration
while (messagesProcessed < maxBatchSize)
{
var result = canService.Read(out PcanMessage msg, out ulong timestamp);
if (result == PcanStatus.OK)
{
if (!canService.PassesFilter(msg.ID))
{
logger.LogDebug("[FILTERED] ID=0x{Id:X3} blocked by filter", msg.ID);
messagesProcessed++;
continue;
}
var data = new byte[msg.DLC];
for (int i = 0; i < msg.DLC; i++)
data[i] = msg.Data[i];
var signals = canService.ExtractSignals(msg.ID, data);
var messageDto = new CanMessageDto(
Id: msg.ID,
Data: data,
Dlc: msg.DLC,
TimestampUs: timestamp,
IsExtended: msg.MsgType == MessageType.Extended,
Signals: signals.Count > 0 ? signals : null
);
canService.UpdateLatestMessage(messageDto);
logger.LogDebug("[RX] ID=0x{Id:X3} DLC={Dlc} Data={Data} Published to UI",
msg.ID, msg.DLC, Convert.ToHexString(data));
messagesProcessed++;
}
else if (result == PcanStatus.ReceiveQueueEmpty)
{
// No more messages in buffer, exit batch processing
break;
}
else
{
logger.LogError("CAN read error: {Error}", GetErrorText(result));
await Task.Delay(100, stoppingToken);
break;
}
}
// Small yield to prevent 100% CPU usage when no messages
if (messagesProcessed == 0)
{
await Task.Yield();
}
}
}
public override Task StopAsync(CancellationToken cancellationToken)
{
canService.Uninitialize();
logger.LogInformation("CAN channel uninitialized.");
return base.StopAsync(cancellationToken);
}
// ── Channel / bitrate resolution ──────────────────────────────────────────
private PcanChannel ResolveChannel()
{
var name = configuration["CanOptions:Channel"];
if (!string.IsNullOrWhiteSpace(name) &&
Enum.TryParse(name, ignoreCase: true, out PcanChannel configured))
return configured;
logger.LogInformation("No channel configured — scanning for available PCAN USB channels...");
var available = canService.GetAvailableChannels();
if (available.Count == 0)
throw new InvalidOperationException("No PCAN USB channels detected.");
logger.LogInformation("Auto-selected channel: {Channel}", available[0]);
return available[0];
}
private Bitrate ResolveBitrate()
{
var name = configuration["CanOptions:Bitrate"];
if (!string.IsNullOrWhiteSpace(name) &&
Enum.TryParse(name, ignoreCase: true, out Bitrate configured))
return configured;
logger.LogInformation("No bitrate configured — defaulting to 500 kbps.");
return Bitrate.Pcan500;
}
private static string GetErrorText(PcanStatus status)
{
try { Api.GetErrorText(status, out var text); return text; }
catch (PcanBasicException) { return status.ToString(); }
}
}

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["IOModuleTestBlazor.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

@ -0,0 +1,98 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

@ -0,0 +1,37 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">IOModuleTestBlazor</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="can-monitor">
<span class="bi bi-activity-nav-menu" aria-hidden="true"></span> CAN Monitor
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="relay-drum">
<span class="bi bi-music-note-nav-menu" aria-hidden="true"></span> Relay Drum Machine
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="serial">
<span class="bi bi-terminal-nav-menu" aria-hidden="true"></span> Serial Terminal
</NavLink>
</div>
</nav>
</div>

@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>

@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

@ -0,0 +1,661 @@
@page "/can-monitor"
@rendermode InteractiveServer
@implements IDisposable
@using Peak.Can.Basic
@inject ICanService CanService
<PageTitle>CAN Monitor</PageTitle>
<h1>CAN Monitor</h1>
@* ── Connection ──────────────────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-body py-2">
@if (CanService.IsConnected && !showConnectionForm)
{
<div class="d-flex align-items-center gap-3">
<span class="badge bg-success">● Connected</span>
<span class="font-monospace fw-semibold">@CanService.ChannelName</span>
<span class="text-muted">@BitrateLabel(CanService.CurrentBitrate)</span>
<button class="btn btn-sm btn-outline-secondary ms-auto"
@onclick="() => { showConnectionForm = true; ScanChannels(); }">
Change
</button>
</div>
}
else
{
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-0">Channel</label>
<select class="form-select form-select-sm font-monospace" @bind="selectedChannel"
style="min-width:100px">
@if (availableChannels.Count == 0)
{
<option value="">— none found —</option>
}
@foreach (var ch in availableChannels)
{
<option value="@ch.ToString()">@ch.ToString()</option>
}
</select>
</div>
<div class="col-auto">
<label class="form-label small mb-0">Bitrate</label>
<select class="form-select form-select-sm" @bind="selectedBitrate"
style="min-width:120px">
@foreach (var (val, label) in BitrateOptions)
{
<option value="@val.ToString()">@label</option>
}
</select>
</div>
<div class="col-auto d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" @onclick="ScanChannels">Scan</button>
<button class="btn btn-sm btn-primary" @onclick="Connect"
disabled="@(availableChannels.Count == 0 || isConnecting)">
@(isConnecting ? "Connecting…" : CanService.IsConnected ? "Reconnect" : "Connect")
</button>
@if (CanService.IsConnected)
{
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => showConnectionForm = false">Cancel</button>
}
</div>
</div>
@if (availableChannels.Count == 0)
{
<div class="text-warning small mt-1">
No PCAN USB channels detected. Plug in your adapter and click Scan.
</div>
}
@if (connectError != null)
{
<div class="text-danger small mt-1">@connectError</div>
}
}
</div>
</div>
@* ── Live messages ──────────────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-0 ps-0">
<ul class="nav nav-tabs border-0 mb-0">
<li class="nav-item">
<button class="nav-link rounded-0 @(msgTab == "stream" ? "active" : "")"
@onclick='() => msgTab = "stream"'>
Stream
<span class="badge bg-secondary ms-1">@messages.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link rounded-0 @(msgTab == "latest" ? "active" : "")"
@onclick='() => msgTab = "latest"'>
Latest
<span class="badge bg-secondary ms-1">@latestMessages.Count</span>
</button>
</li>
</ul>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="ClearMessages">Clear</button>
</div>
@if (msgTab == "stream")
{
<div style="max-height:320px; overflow-y:auto;">
<table class="table table-sm table-hover font-monospace mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Timestamp (µs)</th>
<th>ID</th>
<th>DLC</th>
<th>Data</th>
<th>Signals</th>
</tr>
</thead>
<tbody>
@lock (messages)
{
foreach (var m in messages)
{
<tr>
<td>@m.TimestampUs</td>
<td>@($"0x{m.Id:X3}")</td>
<td>@m.Dlc</td>
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "–")</td>
<td>@SignalsText(m)</td>
</tr>
}
}
</tbody>
</table>
</div>
}
else
{
<div style="max-height:320px; overflow-y:auto;">
<table class="table table-sm table-hover font-monospace mb-0">
<thead class="table-light sticky-top">
<tr>
<th>ID</th>
<th>DLC</th>
<th>Data</th>
<th>Signals</th>
<th>Timestamp (µs)</th>
<th class="text-end">Count</th>
</tr>
</thead>
<tbody>
@foreach (var m in GetLatestRows())
{
<tr>
<td>@($"0x{m.Id:X3}")</td>
<td>@m.Dlc</td>
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "–")</td>
<td>@SignalsText(m)</td>
<td>@m.TimestampUs</td>
<td class="text-end">@updateCounts.GetValueOrDefault(m.Id)</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@* ── Filters + Send (two columns) ──────────────────────────────────────────── *@
<div class="row g-3 mb-3">
@* Filters *@
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header">
Filters
<span class="text-muted small ms-2">
@(filters.Count == 0 ? "(no filters — all messages pass)" : $"({filters.Count} active)")
</span>
</div>
<div class="card-body">
@if (filters.Count > 0)
{
<table class="table table-sm mb-2">
<thead><tr><th>ID</th><th>Mask</th><th>Description</th><th></th></tr></thead>
<tbody>
@foreach (var f in filters)
{
<tr>
<td class="font-monospace">@($"0x{f.MessageId:X3}")</td>
<td class="font-monospace">@($"0x{f.Mask:X3}")</td>
<td>@f.Description</td>
<td>
<button class="btn btn-sm btn-outline-danger py-0 px-2"
@onclick="() => RemoveFilter(f.Id)">×</button>
</td>
</tr>
}
</tbody>
</table>
}
<div class="row g-1 align-items-end">
<div class="col-3">
<label class="form-label small mb-0">ID (hex)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. 100" @bind="newFilterId" />
</div>
<div class="col-3">
<label class="form-label small mb-0">Mask (hex)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. 7FF" @bind="newFilterMask" />
</div>
<div class="col-4">
<label class="form-label small mb-0">Description</label>
<input class="form-control form-control-sm"
placeholder="optional" @bind="newFilterDesc" />
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary w-100" @onclick="AddFilter">Add</button>
</div>
</div>
@if (filterError != null)
{
<div class="text-danger small mt-1">@filterError</div>
}
@if (filters.Count > 0)
{
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearFilters">Clear all</button>
}
</div>
</div>
</div>
@* Send *@
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">Send Message</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small mb-0">CAN ID (hex)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. 7FF" @bind="sendIdHex" />
</div>
<div class="mb-2">
<label class="form-label small mb-0">Data bytes (hex, space-separated)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. DE AD BE EF" @bind="sendDataHex" />
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="extFrame" @bind="sendExtended" />
<label class="form-check-label small" for="extFrame">Extended frame (29-bit ID)</label>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-success" @onclick="SendMessage">Send</button>
@if (sendFeedback != null)
{
<span class="small @(sendOk ? "text-success" : "text-danger")">@sendFeedback</span>
}
</div>
</div>
</div>
</div>
</div>
@* ── Bitmasks ───────────────────────────────────────────────────────────────── *@
<div class="card">
<div class="card-header">Signal Bitmasks</div>
<div class="card-body">
@if (bitmasks.Count == 0)
{
<p class="text-muted small mb-2">
No bitmasks configured. Add one to extract named signal values from a message's data bytes.<br />
<em>Formula: physicalValue = ((rawUint64 &amp; DataMask) &gt;&gt; RightShift) × Scale + Offset</em>
</p>
}
else
{
<table class="table table-sm mb-2">
<thead>
<tr>
<th>Msg ID</th><th>Signal</th><th>DataMask (64-bit)</th>
<th>Shift</th><th>Scale</th><th>Offset</th><th>Unit</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var b in bitmasks)
{
<tr>
<td class="font-monospace">@($"0x{b.MessageId:X3}")</td>
<td>@b.SignalName</td>
<td class="font-monospace small">@($"0x{b.DataMask:X16}")</td>
<td>@b.RightShift</td>
<td>@b.Scale</td>
<td>@b.Offset</td>
<td>@b.Unit</td>
<td>
<button class="btn btn-sm btn-outline-danger py-0 px-2"
@onclick="() => RemoveBitmask(b.Id)">×</button>
</td>
</tr>
}
</tbody>
</table>
}
<div class="row g-1 align-items-end">
<div class="col-sm-2">
<label class="form-label small mb-0">Msg ID (hex)</label>
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 100" @bind="newBmMsgId" />
</div>
<div class="col-sm-2">
<label class="form-label small mb-0">Signal name</label>
<input class="form-control form-control-sm" placeholder="e.g. EngineRPM" @bind="newBmName" />
</div>
<div class="col-sm-3">
<label class="form-label small mb-0">DataMask (64-bit hex)</label>
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 00FF000000000000" @bind="newBmMask" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Shift</label>
<input class="form-control form-control-sm" @bind="newBmShift" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Scale</label>
<input class="form-control form-control-sm" @bind="newBmScale" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Offset</label>
<input class="form-control form-control-sm" @bind="newBmOffset" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Unit</label>
<input class="form-control form-control-sm" placeholder="rpm" @bind="newBmUnit" />
</div>
<div class="col-sm-1">
<button class="btn btn-sm btn-primary w-100" @onclick="AddBitmask">Add</button>
</div>
</div>
@if (bitmaskError != null)
{
<div class="text-danger small mt-1">@bitmaskError</div>
}
@if (bitmasks.Count > 0)
{
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearBitmasks">Clear all</button>
}
</div>
</div>
@code {
// ── Connection form ───────────────────────────────────────────────────────
private List<PcanChannel> availableChannels = [];
private string selectedChannel = "";
private string selectedBitrate = Bitrate.Pcan500.ToString();
private bool showConnectionForm;
private bool isConnecting;
private string? connectError;
private static readonly (Bitrate Value, string Label)[] BitrateOptions =
[
(Bitrate.Pcan1000, "1000 kbps"),
(Bitrate.Pcan800, "800 kbps"),
(Bitrate.Pcan500, "500 kbps"),
(Bitrate.Pcan250, "250 kbps"),
(Bitrate.Pcan125, "125 kbps"),
(Bitrate.Pcan100, "100 kbps"),
(Bitrate.Pcan50, "50 kbps"),
(Bitrate.Pcan20, "20 kbps"),
(Bitrate.Pcan10, "10 kbps"),
(Bitrate.Pcan5, "5 kbps"),
];
// ── Data ──────────────────────────────────────────────────────────────────
private readonly List<CanMessageDto> messages = [];
private readonly Dictionary<uint, CanMessageDto> latestMessages = new();
private readonly Dictionary<uint, int> updateCounts = new();
private string msgTab = "stream";
private const int MaxMessages = 200;
private List<CanFilter> filters = [];
private List<CanBitmask> bitmasks = [];
// ── Filter form ───────────────────────────────────────────────────────────
private string newFilterId = "";
private string newFilterMask = "7FF";
private string newFilterDesc = "";
private string? filterError;
// ── Bitmask form ──────────────────────────────────────────────────────────
private string newBmMsgId = "";
private string newBmName = "";
private string newBmMask = "";
private int newBmShift = 0;
private double newBmScale = 1.0;
private double newBmOffset = 0.0;
private string newBmUnit = "";
private string? bitmaskError;
// ── Send form ─────────────────────────────────────────────────────────────
private string sendIdHex = "";
private string sendDataHex = "";
private bool sendExtended;
private string? sendFeedback;
private bool sendOk;
// ── Lifecycle ─────────────────────────────────────────────────────────────
private Timer? _updateTimer;
protected override void OnInitialized()
{
filters = CanService.Filters.ToList();
bitmasks = CanService.Bitmasks.ToList();
// Start timer for polling CAN messages
_updateTimer = new Timer(UpdateMessages, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(100));
ScanChannels();
if (CanService.IsConnected)
{
selectedChannel = CanService.ChannelName;
selectedBitrate = CanService.CurrentBitrate.ToString();
}
else
{
showConnectionForm = true;
}
}
private void UpdateMessages(object? state)
{
InvokeAsync(() =>
{
var newMessages = CanService.GetLatestMessages();
bool hasChanges = false;
lock (latestMessages)
{
foreach (var (id, msg) in newMessages)
{
if (!latestMessages.TryGetValue(id, out var existing) ||
msg.TimestampUs > existing.TimestampUs)
{
latestMessages[id] = msg;
updateCounts[id] = updateCounts.GetValueOrDefault(id) + 1;
// Add to stream messages
lock (messages)
{
messages.Insert(0, msg);
if (messages.Count > MaxMessages)
messages.RemoveAt(messages.Count - 1);
}
hasChanges = true;
}
}
}
if (hasChanges)
{
StateHasChanged();
}
});
}
public void Dispose() => _updateTimer?.Dispose();
// ── Connection ────────────────────────────────────────────────────────────
private void ScanChannels()
{
availableChannels = CanService.GetAvailableChannels().ToList();
if (availableChannels.Count > 0 &&
!availableChannels.Any(c => c.ToString() == selectedChannel))
selectedChannel = availableChannels[0].ToString();
}
private async Task Connect()
{
connectError = null;
if (!Enum.TryParse<PcanChannel>(selectedChannel, out var channel))
{ connectError = "Please select a channel."; return; }
if (!Enum.TryParse<Bitrate>(selectedBitrate, out var bitrate))
{ connectError = "Please select a bitrate."; return; }
isConnecting = true;
CanService.RequestReinitialize(channel, bitrate);
// Poll until the worker completes the reinit (typically < 50 ms)
for (int i = 0; i < 20; i++)
{
await Task.Delay(100);
if (CanService.IsConnected) break;
}
isConnecting = false;
if (CanService.IsConnected)
showConnectionForm = false;
else
connectError = "Connection failed. Check the adapter is plugged in and try again.";
StateHasChanged();
}
private static string BitrateLabel(Bitrate b) => b switch
{
Bitrate.Pcan1000 => "1000 kbps",
Bitrate.Pcan800 => "800 kbps",
Bitrate.Pcan500 => "500 kbps",
Bitrate.Pcan250 => "250 kbps",
Bitrate.Pcan125 => "125 kbps",
Bitrate.Pcan100 => "100 kbps",
Bitrate.Pcan50 => "50 kbps",
Bitrate.Pcan20 => "20 kbps",
Bitrate.Pcan10 => "10 kbps",
Bitrate.Pcan5 => "5 kbps",
_ => b.ToString()
};
// ── Messages ──────────────────────────────────────────────────────────────
private void ClearMessages()
{
lock (messages) messages.Clear();
lock (latestMessages) { latestMessages.Clear(); updateCounts.Clear(); }
}
// ── Filters ───────────────────────────────────────────────────────────────
private void AddFilter()
{
filterError = null;
if (!TryParseHex32(newFilterId, out uint msgId))
{ filterError = "Invalid ID — enter a hex value e.g. 100"; return; }
if (!TryParseHex32(newFilterMask, out uint mask))
{ filterError = "Invalid mask — enter a hex value e.g. 7FF"; return; }
var filter = new CanFilter { MessageId = msgId, Mask = mask, Description = newFilterDesc };
CanService.AddFilter(filter);
filters = CanService.Filters.ToList();
newFilterId = ""; newFilterMask = "7FF"; newFilterDesc = "";
}
private void RemoveFilter(Guid id)
{
CanService.RemoveFilter(id);
filters = CanService.Filters.ToList();
}
private void ClearFilters()
{
CanService.ClearFilters();
filters = [];
}
// ── Bitmasks ──────────────────────────────────────────────────────────────
private void AddBitmask()
{
bitmaskError = null;
if (!TryParseHex32(newBmMsgId, out uint msgId))
{ bitmaskError = "Invalid Msg ID"; return; }
if (string.IsNullOrWhiteSpace(newBmName))
{ bitmaskError = "Signal name is required"; return; }
if (!TryParseHex64(newBmMask, out ulong mask))
{ bitmaskError = "Invalid 64-bit mask — enter hex e.g. 00FF000000000000"; return; }
var bitmask = new CanBitmask
{
MessageId = msgId,
SignalName = newBmName.Trim(),
DataMask = mask,
RightShift = newBmShift,
Scale = newBmScale,
Offset = newBmOffset,
Unit = string.IsNullOrWhiteSpace(newBmUnit) ? null : newBmUnit
};
CanService.AddBitmask(bitmask);
bitmasks = CanService.Bitmasks.ToList();
newBmMsgId = ""; newBmName = ""; newBmMask = "";
newBmShift = 0; newBmScale = 1.0; newBmOffset = 0.0; newBmUnit = "";
}
private void RemoveBitmask(Guid id)
{
CanService.RemoveBitmask(id);
bitmasks = CanService.Bitmasks.ToList();
}
private void ClearBitmasks()
{
CanService.ClearBitmasks();
bitmasks = [];
}
// ── Send ──────────────────────────────────────────────────────────────────
private void SendMessage()
{
sendFeedback = null;
if (!TryParseHex32(sendIdHex, out uint id))
{ sendFeedback = "Invalid ID"; sendOk = false; return; }
byte[] data;
try
{
var hex = (sendDataHex ?? "").Replace(" ", "");
data = hex.Length > 0 ? Convert.FromHexString(hex) : [];
}
catch { sendFeedback = "Invalid hex data"; sendOk = false; return; }
if (data.Length > 8)
{ sendFeedback = "Max 8 bytes"; sendOk = false; return; }
var msg = new PcanMessage
{
ID = id,
MsgType = sendExtended ? MessageType.Extended : MessageType.Standard,
DLC = (byte)data.Length,
Data = new byte[8]
};
data.CopyTo(msg.Data, 0);
var result = CanService.Write(msg);
sendOk = result == PcanStatus.OK;
sendFeedback = sendOk ? "Sent!" : $"Error: {result}";
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<CanMessageDto> GetLatestRows()
{
lock (latestMessages)
return latestMessages.Values.OrderBy(m => m.Id).ToList();
}
private static string SignalsText(CanMessageDto m)
{
if (m.Signals is null || m.Signals.Count == 0) return "–";
return string.Join(", ", m.Signals.Select(kv => $"{kv.Key}={kv.Value:G4}"));
}
private static bool TryParseHex32(string? s, out uint value)
{
value = 0;
if (string.IsNullOrWhiteSpace(s)) return false;
var hex = s.Trim();
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
return uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value);
}
private static bool TryParseHex64(string? s, out ulong value)
{
value = 0;
if (string.IsNullOrWhiteSpace(s)) return false;
var hex = s.Trim();
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
return ulong.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value);
}
}

@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

@ -0,0 +1,346 @@
@page "/"
@rendermode InteractiveServer
@implements IDisposable
@using IOModuleTestBlazor.Models
@using IOModuleTestBlazor.Services
@using Peak.Can.Basic
@inject ICanService CanService
<PageTitle>IO Module Dashboard</PageTitle>
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>IO Module Dashboard</h1>
@* ── Connection Status ──────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-body py-2">
@if (CanService.IsConnected)
{
<div class="d-flex align-items-center gap-3">
<span class="badge bg-success">● Connected</span>
<span class="font-monospace fw-semibold">@CanService.ChannelName</span>
<span class="text-muted">@GetBitrateLabel(CanService.CurrentBitrate)</span>
</div>
}
else
{
<span class="badge bg-danger">● Disconnected</span>
<span class="text-muted ms-2">No CAN connection</span>
}
</div>
</div>
@* ── Push Button Status ─────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Push Button Status</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-4">
<div class="mb-2">
<div class="status-light @(button1Active ? "status-light-on" : "status-light-off")"></div>
</div>
<label>User Button (PC13)</label>
</div>
<div class="col-md-4">
<div class="mb-2">
<div class="status-light @(button2Active ? "status-light-on" : "status-light-off")"></div>
</div>
<label>Button 1 (CN9-29)</label>
</div>
<div class="col-md-4">
<div class="mb-2">
<div class="status-light @(button3Active ? "status-light-on" : "status-light-off")"></div>
</div>
<label>Button 2 (CN9-30)</label>
</div>
</div>
</div>
</div>
@* ── Termination Control ────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">CAN Hat Termination Control</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-md-6">
<div class="mb-2">
<div class="status-light @(_termOn ? "status-light-on" : "status-light-off")"></div>
</div>
<label class="d-block mb-2">TERM_ON (PB10)</label>
<button class="btn btn-sm @(_termOn ? "btn-success" : "btn-outline-secondary")"
@onclick="ToggleTermOn"
disabled="@(!CanService.IsConnected)">
@(_termOn ? "ON" : "OFF")
</button>
</div>
<div class="col-md-6">
<div class="mb-2">
<div class="status-light @(_termOff ? "status-light-on" : "status-light-off")"></div>
</div>
<label class="d-block mb-2">TERM_OFF (PB11)</label>
<button class="btn btn-sm @(_termOff ? "btn-success" : "btn-outline-secondary")"
@onclick="ToggleTermOff"
disabled="@(!CanService.IsConnected)">
@(_termOff ? "ON" : "OFF")
</button>
</div>
</div>
@if (!CanService.IsConnected)
{
<p class="text-muted text-center small mt-2 mb-0">Connect to CAN to control termination</p>
}
</div>
</div>
@* ── CAN Messages Table ──────────────────────────────────────────────────── *@
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">CAN Messages</h5>
<div class="d-flex gap-2">
<span class="badge bg-secondary">@latestMessages.Count message types</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearMessages">Clear</button>
</div>
</div>
<div class="card-body p-0">
@if (latestMessages.Count == 0)
{
<div class="p-3 text-center text-muted">
@if (CanService.IsConnected)
{
<p>Waiting for CAN messages...</p>
}
else
{
<p>Connect to CAN to see messages</p>
}
</div>
}
else
{
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-sm table-striped mb-0">
<thead class="table-dark sticky-top">
<tr>
<th>Timestamp</th>
<th>ID</th>
<th>DLC</th>
<th>Data</th>
<th>Signals</th>
</tr>
</thead>
<tbody>
@foreach (var msg in latestMessages.Values.OrderBy(m => m.Id))
{
<tr>
<td class="font-monospace small">@FormatTimestamp(msg.TimestampUs)</td>
<td class="font-monospace">0x@(msg.Id.ToString("X3"))</td>
<td>@msg.Dlc</td>
<td class="font-monospace">@FormatData(msg.Data)</td>
<td class="small">
@if (msg.Signals?.Count > 0)
{
@foreach (var signal in msg.Signals)
{
<span class="badge bg-info me-1">@signal.Key: @signal.Value.ToString("F2")</span>
}
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
</div>
</div>
</div>
</div>
<style>
.status-light {
width: 40px;
height: 40px;
border-radius: 50%;
margin: 0 auto;
border: 2px solid #ccc;
transition: all 0.3s ease;
}
.status-light-on {
background-color: #28a745;
border-color: #28a745;
box-shadow: 0 0 15px rgba(40, 167, 69, 0.7);
}
.status-light-off {
background-color: #6c757d;
border-color: #6c757d;
}
</style>
@code {
private Dictionary<uint, CanMessageDto> latestMessages = new();
private bool button1Active = false;
private bool button2Active = false;
private bool button3Active = false;
private bool _termOn = false;
private bool _termOff = false;
private Timer? _updateTimer;
protected override void OnInitialized()
{
// Poll for updates every 50ms for responsive UI (20 FPS)
_updateTimer = new Timer(UpdateUI, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(50));
}
private void UpdateUI(object? state)
{
InvokeAsync(() =>
{
// Get latest messages from CAN service
var newMessages = CanService.GetLatestMessages();
// Check if any messages have changed
bool hasChanges = false;
foreach (var (id, message) in newMessages)
{
if (!latestMessages.TryGetValue(id, out var existing) ||
!existing.Data.SequenceEqual(message.Data) ||
existing.TimestampUs != message.TimestampUs)
{
latestMessages[id] = message;
UpdateButtonStates(message);
hasChanges = true;
}
}
// Only trigger UI update if something actually changed
if (hasChanges)
{
StateHasChanged();
}
});
}
private void UpdateButtonStates(CanMessageDto message)
{
// STM32H7 Nucleo CAN Protocol Implementation
// Based on CAN message documentation v1.2
switch (message.Id)
{
case 0x210: // User Button State (onboard blue button)
if (message.Data.Length > 0)
{
// Use onboard button for Button 1 indicator
button1Active = message.Data[0] > 0;
}
break;
case 0x220: // CN9-29 Button State (Button 1 - PG0)
if (message.Data.Length >= 2)
{
// Byte 0: Toggle state (persistent)
// Byte 1: GPIO state (real-time press state)
// Use GPIO state for immediate visual feedback
button2Active = message.Data[1] > 0;
}
break;
case 0x230: // CN9-30 Button State (Button 2 - PG1)
if (message.Data.Length >= 2)
{
// Byte 0: Toggle state (persistent)
// Byte 1: GPIO state (real-time press state)
// Use GPIO state for immediate visual feedback
button3Active = message.Data[1] > 0;
}
break;
case 0x250: // Termination pin status — bit0=TERM_ON (PB10), bit1=TERM_OFF (PB11)
if (message.Data.Length > 0)
{
_termOn = (message.Data[0] & 0x01) != 0;
_termOff = (message.Data[0] & 0x02) != 0;
}
break;
}
// Alternative: Use toggle states instead of GPIO states for persistent indication
// Uncomment these lines if you want the lights to stay on after button release:
// case 0x220: // Button 1 Toggle State
// if (message.Data.Length > 0)
// button2Active = message.Data[0] > 0;
// break;
//
// case 0x230: // Button 2 Toggle State
// if (message.Data.Length > 0)
// button3Active = message.Data[0] > 0;
// break;
}
// ── Termination control ───────────────────────────────────────────────────
private void ToggleTermOn() { _termOn = !_termOn; SendTermination(); }
private void ToggleTermOff() { _termOff = !_termOff; SendTermination(); }
private void SendTermination()
{
var data = new byte[8];
data[0] = (byte)((_termOn ? 0x01 : 0x00) | (_termOff ? 0x02 : 0x00)); // bit0=TERM_ON, bit1=TERM_OFF
var msg = new PcanMessage(0x240, MessageType.Standard, 8, data, false);
CanService.Write(msg);
}
private void ClearMessages()
{
latestMessages.Clear();
StateHasChanged();
}
private string FormatTimestamp(ulong timestampUs)
{
var dt = DateTime.Now;
var ms = (timestampUs / 1000) % 1000;
return $"{dt:HH:mm:ss}.{ms:D3}";
}
private string FormatData(byte[] data)
{
return string.Join(" ", data.Select(b => b.ToString("X2")));
}
private string GetBitrateLabel(Peak.Can.Basic.Bitrate bitrate)
{
return bitrate switch
{
Peak.Can.Basic.Bitrate.Pcan1000 => "1000 kbps",
Peak.Can.Basic.Bitrate.Pcan800 => "800 kbps",
Peak.Can.Basic.Bitrate.Pcan500 => "500 kbps",
Peak.Can.Basic.Bitrate.Pcan250 => "250 kbps",
Peak.Can.Basic.Bitrate.Pcan125 => "125 kbps",
Peak.Can.Basic.Bitrate.Pcan100 => "100 kbps",
Peak.Can.Basic.Bitrate.Pcan50 => "50 kbps",
Peak.Can.Basic.Bitrate.Pcan20 => "20 kbps",
Peak.Can.Basic.Bitrate.Pcan10 => "10 kbps",
Peak.Can.Basic.Bitrate.Pcan5 => "5 kbps",
_ => bitrate.ToString()
};
}
public void Dispose()
{
_updateTimer?.Dispose();
}
}

@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

@ -0,0 +1,373 @@
@page "/relay-drum"
@rendermode InteractiveServer
@implements IDisposable
@using IOModuleTestBlazor.Services
@using Peak.Can.Basic
@inject ICanService CanService
<PageTitle>Relay Drum Machine</PageTitle>
<div class="container-fluid">
<h1 class="mb-3">Relay Drum Machine</h1>
@if (!CanService.IsConnected)
{
<div class="alert alert-warning mb-3">CAN not connected — connect on the Home page first.</div>
}
@* ── Transport ───────────────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-body">
<div class="d-flex align-items-center gap-4 flex-wrap">
<button class="btn @(_playing ? "btn-danger" : "btn-success") btn-lg px-4"
@onclick="TogglePlay"
disabled="@(!CanService.IsConnected)">
@(_playing ? "⏹ Stop" : "▶ Play")
</button>
<div class="d-flex align-items-center gap-2">
<label class="fw-semibold mb-0">BPM</label>
<input type="range" class="form-range" style="width:160px"
min="40" max="240" step="1"
@bind="_bpm" @bind:event="oninput" />
<span class="font-monospace fw-bold" style="min-width:3ch">@_bpm</span>
</div>
<div class="d-flex align-items-center gap-2">
<label class="fw-semibold mb-0">Pulse</label>
<input type="range" class="form-range" style="width:120px"
min="20" max="80" step="5"
@bind="_pulseDurationMs" @bind:event="oninput" />
<span class="font-monospace" style="min-width:5ch">@_pulseDurationMs ms</span>
</div>
<button class="btn btn-outline-secondary" @onclick="ClearPattern">Clear</button>
</div>
</div>
</div>
@* ── Presets ─────────────────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-header fw-semibold">Presets</div>
<div class="card-body d-flex gap-2 flex-wrap">
@foreach (var preset in _presets)
{
<button class="btn btn-outline-primary btn-sm" @onclick="() => LoadPreset(preset.Value)">
@preset.Key
</button>
}
</div>
</div>
@* ── Pattern grid ────────────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span class="fw-semibold">Pattern — @_barCount bar@(_barCount != 1 ? "s" : "") (@StepCount steps, 4/4 16th-note grid)</span>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-outline-secondary btn-sm"
@onclick="RemoveBar" disabled="@(_barCount <= 1)">− Bar</button>
<span class="font-monospace px-1">@_barCount</span>
<button class="btn btn-outline-secondary btn-sm"
@onclick="AddBar" disabled="@(_barCount >= 8)">+ Bar</button>
</div>
</div>
<div class="card-body">
@* Beat header — shown once above the first bar *@
<div class="beat-header">
<div class="bar-label-col"></div>
<div class="step-grid-col beat-header-grid">
@for (int b = 0; b < 4; b++)
{
<div class="beat-header-cell">@(b + 1)</div>
}
</div>
</div>
@* One row per bar *@
@for (int bar = 0; bar < _barCount; bar++)
{
var barIndex = bar;
<div class="bar-row @(barIndex == _currentBar ? "bar-row-active" : "")">
<div class="bar-label-col">
<span class="bar-label">B@(barIndex + 1)</span>
</div>
<div class="step-grid-col">
<div class="step-grid">
@for (int s = 0; s < 16; s++)
{
var globalStep = barIndex * 16 + s;
var localStep = s;
bool isGroupStart = localStep % 4 == 0;
<div class="step-cell @GetStepClass(globalStep) @(isGroupStart ? "beat-start" : "")"
@onclick="() => ToggleStep(globalStep)">
<span class="step-num">@(localStep + 1)</span>
</div>
}
</div>
</div>
</div>
}
</div>
</div>
@* ── Status bar ──────────────────────────────────────────────────────────── *@
<div class="card">
<div class="card-body py-2 d-flex align-items-center gap-3">
@if (_playing && _currentStep >= 0)
{
<span class="badge bg-success">● Playing</span>
<span class="font-monospace">Bar @(_currentBar + 1) · Beat @(_currentBeat + 1) · Step @(_currentStepInBar + 1)</span>
<span class="text-muted">(global step @(_currentStep + 1) / @StepCount)</span>
}
else
{
<span class="badge bg-secondary">■ Stopped</span>
}
<span class="text-muted ms-auto">@(_steps.Count(s => s)) / @StepCount steps active</span>
</div>
</div>
</div>
<style>
.beat-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.beat-header-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.beat-header-cell {
text-align: center;
font-size: 0.7rem;
font-weight: bold;
color: #6c757d;
padding-left: 4px;
}
.bar-label-col {
min-width: 36px;
flex-shrink: 0;
}
.bar-label {
font-size: 0.7rem;
font-weight: bold;
color: #6c757d;
}
.step-grid-col {
flex: 1;
min-width: 0;
}
.bar-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
border-radius: 6px;
padding: 2px 4px;
transition: background-color 0.1s;
}
.bar-row-active {
background-color: rgba(255, 193, 7, 0.08);
}
.step-grid {
display: grid;
grid-template-columns: repeat(16, 1fr);
gap: 4px;
}
.step-cell {
aspect-ratio: 1;
border: 2px solid #495057;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.1s, box-shadow 0.1s;
user-select: none;
min-width: 0;
}
.step-cell:hover { border-color: #0d6efd; }
.step-num {
font-size: 0.6rem;
font-weight: bold;
line-height: 1;
}
.beat-start { border-left: 2px solid #6c757d; }
/* States */
.step-off { background-color: #343a40; color: #6c757d; }
.step-on { background-color: #0d6efd; border-color: #0d6efd; color: white; }
.step-cursor { box-shadow: 0 0 0 2px #ffc107; border-color: #ffc107 !important; }
.step-cursor-on { background-color: #fd7e14; border-color: #ffc107 !important; color: white;
box-shadow: 0 0 10px rgba(253,126,20,0.8), 0 0 0 2px #ffc107; }
</style>
@code {
private const int StepsPerBar = 16;
private int _barCount = 1;
private int StepCount => _barCount * StepsPerBar;
private bool[] _steps = new bool[StepsPerBar];
private int _currentStep = -1;
private int _currentBar => _currentStep < 0 ? -1 : _currentStep / StepsPerBar;
private int _currentStepInBar => _currentStep < 0 ? -1 : _currentStep % StepsPerBar;
private int _currentBeat => _currentStep < 0 ? -1 : (_currentStep % StepsPerBar) / 4;
private bool _playing;
private int _bpm = 120;
private int _pulseDurationMs = 30;
private CancellationTokenSource? _cts;
private static readonly Dictionary<string, bool[]> _presets = new()
{
["Four-on-Floor"] = [true, false, false, false, true, false, false, false, true, false, false, false, true, false, false, false],
["Kick 1+3"] = [true, false, false, false, false, false, false, false, true, false, false, false, false, false, false, false],
["Snare 2+4"] = [false, false, false, false, true, false, false, false, false, false, false, false, true, false, false, false],
["Hi-Hat 8th"] = [true, false, true, false, true, false, true, false, true, false, true, false, true, false, true, false],
["Hi-Hat 16th"] = [true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true],
["Off-Beat"] = [false, false, true, false, false, false, true, false, false, false, true, false, false, false, true, false],
["Clave 3-2"] = [true, false, false, true, false, false, true, false, false, false, true, false, false, true, false, false],
};
private string GetStepClass(int step)
{
bool isCurrent = step == _currentStep;
bool isOn = _steps[step];
return (isCurrent, isOn) switch
{
(true, true) => "step-cursor-on",
(true, false) => "step-off step-cursor",
(false, true) => "step-on",
_ => "step-off",
};
}
private void ToggleStep(int step) => _steps[step] = !_steps[step];
private void ClearPattern()
{
Array.Clear(_steps);
StateHasChanged();
}
private void LoadPreset(bool[] pattern)
{
// Tile the 16-step preset across however many bars are active
var next = new bool[StepCount];
for (int i = 0; i < StepCount; i++)
next[i] = pattern[i % StepsPerBar];
_steps = next;
StateHasChanged();
}
private async Task AddBar()
{
await StopSequencer();
Array.Resize(ref _steps, (_barCount + 1) * StepsPerBar);
_barCount++;
}
private async Task RemoveBar()
{
if (_barCount <= 1) return;
await StopSequencer();
_barCount--;
Array.Resize(ref _steps, _barCount * StepsPerBar);
}
private async Task TogglePlay()
{
if (_playing)
await StopSequencer();
else
StartSequencer();
}
private void StartSequencer()
{
_cts?.Dispose();
_cts = new CancellationTokenSource();
_playing = true;
var steps = _steps; // snapshot so resize mid-play doesn't affect the running loop
_ = Task.Run(() => RunSequencer(steps, _cts.Token));
}
private async Task StopSequencer()
{
_cts?.Cancel();
// Yield briefly to let the background task react before we return
await Task.Delay(50);
}
private async Task RunSequencer(bool[] steps, CancellationToken ct)
{
int total = steps.Length;
try
{
while (!ct.IsCancellationRequested)
{
for (int step = 0; step < total && !ct.IsCancellationRequested; step++)
{
_currentStep = step;
try { await InvokeAsync(StateHasChanged); } catch { return; }
int stepMs = Math.Max(30, (int)(60_000.0 / _bpm / 4));
int pulseMs = Math.Min(_pulseDurationMs, stepMs / 2);
if (steps[step])
{
SendRelay(true);
await Task.Delay(pulseMs, ct);
SendRelay(false);
int rest = stepMs - pulseMs;
if (rest > 0) await Task.Delay(rest, ct);
}
else
{
await Task.Delay(stepMs, ct);
}
}
}
}
catch (OperationCanceledException) { }
finally
{
_currentStep = -1;
_playing = false;
SendRelay(false);
try { await InvokeAsync(StateHasChanged); } catch { }
}
}
private void SendRelay(bool on)
{
var data = new byte[8];
data[0] = on ? (byte)0x01 : (byte)0x02; // 0x01=TERM_ON(SET), 0x02=TERM_OFF(RESET)
CanService.Write(new PcanMessage(0x240, MessageType.Standard, 8, data, false));
}
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
}

@ -0,0 +1,230 @@
@page "/serial"
@rendermode InteractiveServer
@implements IDisposable
@using IOModuleTestBlazor.Services
@inject ISerialPortService SerialService
@inject IJSRuntime JS
<PageTitle>Serial Terminal</PageTitle>
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>Serial Terminal</h1>
@* ── Connection Bar ──────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
@if (SerialService.IsOpen)
{
<span class="badge bg-success">● Connected</span>
<span class="font-monospace fw-semibold">@SerialService.PortName</span>
<span class="text-muted">115200 baud</span>
}
else
{
<span class="badge bg-danger">● Disconnected</span>
}
<select class="form-select form-select-sm" style="width: auto;"
@bind="_selectedPort" disabled="@SerialService.IsOpen">
@if (!_availablePorts.Any())
{
<option value="">No ports found</option>
}
@foreach (var port in _availablePorts)
{
<option value="@port">@port</option>
}
</select>
<button class="btn btn-sm btn-outline-secondary" @onclick="RefreshPorts"
disabled="@SerialService.IsOpen">
Refresh
</button>
@if (SerialService.IsOpen)
{
<button class="btn btn-sm btn-danger" @onclick="Disconnect">Disconnect</button>
}
else
{
<button class="btn btn-sm btn-success" @onclick="Connect"
disabled="@string.IsNullOrEmpty(_selectedPort)">
Connect
</button>
}
@if (_connectError is not null)
{
<div class="w-100 mt-1">
<span class="badge bg-danger">Connect failed:</span>
<span class="text-danger small ms-1">@_connectError</span>
</div>
}
</div>
</div>
</div>
@* ── Terminal Output ─────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Output</h5>
<button class="btn btn-sm btn-outline-secondary" @onclick="ClearTerminal">Clear</button>
</div>
<div class="card-body p-0">
<pre id="terminal-output"
class="mb-0 p-3"
style="height: 400px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; font-size: 0.85rem;">@GetTerminalText()</pre>
</div>
</div>
@* ── Command Input ───────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-body">
<div class="d-flex gap-2">
<input class="form-control font-monospace"
placeholder="Enter command (e.g. r 0000 4)"
@bind="_commandInput"
@bind:event="oninput"
@onkeydown="OnKeyDown"
disabled="@(!SerialService.IsOpen)" />
<button class="btn btn-primary" @onclick="SendCommand"
disabled="@(!SerialService.IsOpen || string.IsNullOrWhiteSpace(_commandInput))">
Send
</button>
</div>
@* ── Quick Commands ──────────────────────────────────────────── *@
<div class="mt-2 d-flex gap-2 flex-wrap">
<span class="text-muted small align-self-center">Quick:</span>
@foreach (var (label, cmd) in _quickCommands)
{
<button class="btn btn-sm btn-outline-secondary font-monospace"
@onclick="() => SendQuickCommand(cmd)"
disabled="@(!SerialService.IsOpen)">
@label
</button>
}
</div>
</div>
</div>
</div>
</div>
</div>
@code {
private string _selectedPort = string.Empty;
private string _commandInput = string.Empty;
private string? _connectError;
private List<string> _availablePorts = new();
private List<string> _displayLines = new();
private IJSObjectReference? _jsModule;
private bool _scrollPending = false;
private static readonly (string Label, string Cmd)[] _quickCommands =
[
("?", "?"),
("r 0000 4", "r 0000 4"),
("r 0000 10", "r 0000 10"),
("w 0000 AB CD", "w 0000 AB CD"),
("w 0000 FF FF", "w 0000 FF FF"),
];
protected override void OnInitialized()
{
RefreshPorts();
SerialService.DataReceived += OnDataReceived;
_displayLines = SerialService.GetLines().ToList();
}
private void RefreshPorts()
{
_availablePorts = SerialService.GetPortNames().ToList();
if (_availablePorts.Count > 0 && string.IsNullOrEmpty(_selectedPort))
_selectedPort = _availablePorts[0];
}
private async Task Connect()
{
_connectError = null;
try
{
await Task.Run(() => SerialService.Open(_selectedPort, 115200));
}
catch (Exception ex)
{
_connectError = ex.Message;
}
}
private async Task Disconnect()
{
await Task.Run(() => SerialService.Close());
}
private void SendCommand()
{
if (string.IsNullOrWhiteSpace(_commandInput)) return;
SerialService.WriteLine(_commandInput.Trim());
_commandInput = string.Empty;
RefreshLines();
}
private void SendQuickCommand(string cmd)
{
SerialService.WriteLine(cmd);
RefreshLines();
}
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter") SendCommand();
}
private void OnDataReceived()
{
InvokeAsync(() =>
{
RefreshLines();
_scrollPending = true;
StateHasChanged();
});
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
_jsModule = await JS.InvokeAsync<IJSObjectReference>(
"import", "./Components/Pages/SerialTerminal.razor.js");
if (_scrollPending && _jsModule is not null)
{
_scrollPending = false;
await _jsModule.InvokeVoidAsync("scrollToBottom", "terminal-output");
}
}
private void RefreshLines()
=> _displayLines = SerialService.GetLines().ToList();
private void ClearTerminal()
{
SerialService.ClearLines();
_displayLines.Clear();
StateHasChanged();
}
private string GetTerminalText()
=> string.Join("\n", _displayLines);
public void Dispose()
{
SerialService.DataReceived -= OnDataReceived;
_jsModule?.DisposeAsync();
}
}

@ -0,0 +1,4 @@
export function scrollToBottom(elementId) {
const el = document.getElementById(elementId);
if (el) el.scrollTop = el.scrollHeight;
}

@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using IOModuleTestBlazor
@using IOModuleTestBlazor.Components
@using IOModuleTestBlazor.Components.Layout
@using IOModuleTestBlazor.Models
@using IOModuleTestBlazor.Services

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.4" />
<PackageReference Include="Peak.PCANBasic.NET" Version="4.10.1.968" />
<PackageReference Include="System.IO.Ports" Version="9.0.3" />
</ItemGroup>
</Project>

@ -0,0 +1,36 @@
namespace IOModuleTestBlazor.Models;
public record CanMessageDto(
uint Id,
byte[] Data,
int Dlc,
ulong TimestampUs,
bool IsExtended,
IReadOnlyDictionary<string, double>? Signals
);
public class CanFilter
{
public Guid Id { get; set; }
public uint MessageId { get; set; }
public uint Mask { get; set; } = 0x7FF;
public string? Description { get; set; }
}
public class CanBitmask
{
public Guid Id { get; set; }
public uint MessageId { get; set; }
public string SignalName { get; set; } = "";
public ulong DataMask { get; set; }
public int RightShift { get; set; }
public double Scale { get; set; } = 1.0;
public double Offset { get; set; }
public string? Unit { get; set; }
}
public class CanStatus
{
public bool IsConnected { get; set; }
public string ChannelName { get; set; } = "";
}

@ -0,0 +1,34 @@
using IOModuleTestBlazor;
using IOModuleTestBlazor.Components;
using IOModuleTestBlazor.Services;
var builder = WebApplication.CreateBuilder(args);
// ── CAN bus ───────────────────────────────────────────────────────────────────
builder.Services.AddSingleton<ICanService, CanService>();
builder.Services.AddHostedService<CanWorker>();
// ── Serial port ───────────────────────────────────────────────────────────────
builder.Services.AddSingleton<ISerialPortService, SerialPortService>();
// ── Blazor ────────────────────────────────────────────────────────────────────
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://go.microsoft.com/fwlink/?LinkID=208121. -->
<Project>
<PropertyGroup>
<DeleteExistingFiles>false</DeleteExistingFiles>
<ExcludeApp_Data>false</ExcludeApp_Data>
<LaunchSiteAfterPublish>true</LaunchSiteAfterPublish>
<LastUsedBuildConfiguration>Release</LastUsedBuildConfiguration>
<LastUsedPlatform>Any CPU</LastUsedPlatform>
<PublishProvider>FileSystem</PublishProvider>
<PublishUrl>bin\Release\net10.0\publish\</PublishUrl>
<WebPublishMethod>FileSystem</WebPublishMethod>
<_TargetId>Folder</_TargetId>
</PropertyGroup>
</Project>

@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5042",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7074;http://localhost:5042",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

@ -0,0 +1,204 @@
using Peak.Can.Basic;
using IOModuleTestBlazor.Models;
namespace IOModuleTestBlazor.Services;
public class CanService : ICanService
{
private PcanChannel _channel;
private readonly List<CanFilter> _filters = [];
private readonly List<CanBitmask> _bitmasks = [];
private readonly Dictionary<uint, CanMessageDto> _latestMessages = [];
private readonly object _lock = new();
public bool IsConnected { get; private set; }
public string ChannelName => IsConnected ? _channel.ToString() : "";
public Bitrate CurrentBitrate { get; private set; }
private (PcanChannel Channel, Bitrate Bitrate)? _pendingReinit;
public IReadOnlyDictionary<uint, CanMessageDto> GetLatestMessages()
{
lock (_lock) return new Dictionary<uint, CanMessageDto>(_latestMessages);
}
public void UpdateLatestMessage(CanMessageDto dto)
{
lock (_lock) _latestMessages[dto.Id] = dto;
}
// ── Filters ───────────────────────────────────────────────────────────────
public IReadOnlyList<CanFilter> Filters
{
get { lock (_lock) return _filters.ToList(); }
}
public CanFilter AddFilter(CanFilter filter)
{
lock (_lock) _filters.Add(filter);
return filter;
}
public bool RemoveFilter(Guid id)
{
lock (_lock) return _filters.RemoveAll(f => f.Id == id) > 0;
}
public void ClearFilters()
{
lock (_lock) _filters.Clear();
}
// ── Bitmasks ──────────────────────────────────────────────────────────────
public IReadOnlyList<CanBitmask> Bitmasks
{
get { lock (_lock) return _bitmasks.ToList(); }
}
public CanBitmask AddBitmask(CanBitmask bitmask)
{
lock (_lock) _bitmasks.Add(bitmask);
return bitmask;
}
public bool RemoveBitmask(Guid id)
{
lock (_lock) return _bitmasks.RemoveAll(b => b.Id == id) > 0;
}
public void ClearBitmasks()
{
lock (_lock) _bitmasks.Clear();
}
// ── Helpers ───────────────────────────────────────────────────────────────
public bool PassesFilter(uint messageId)
{
lock (_lock)
{
if (_filters.Count == 0) return true;
return _filters.Any(f => (messageId & f.Mask) == (f.MessageId & f.Mask));
}
}
public IReadOnlyDictionary<string, double> ExtractSignals(uint messageId, byte[] data)
{
List<CanBitmask> applicable;
lock (_lock)
applicable = _bitmasks.Where(b => b.MessageId == messageId).ToList();
if (applicable.Count == 0)
return new Dictionary<string, double>();
Span<byte> padded = stackalloc byte[8];
var len = Math.Min(data.Length, 8);
for (int i = 0; i < len; i++)
padded[i] = data[i];
ulong raw = 0;
for (int i = 0; i < 8; i++)
raw = (raw << 8) | padded[i];
var signals = new Dictionary<string, double>(applicable.Count);
foreach (var bm in applicable)
{
ulong masked = (raw & bm.DataMask) >> bm.RightShift;
signals[bm.SignalName] = masked * bm.Scale + bm.Offset;
}
return signals;
}
// ── PCAN passthrough ──────────────────────────────────────────────────────
public IReadOnlyList<PcanChannel> GetAvailableChannels()
{
var available = new List<PcanChannel>();
foreach (var ch in Enum.GetValues<PcanChannel>()
.Where(c => c.ToString().StartsWith("Usb", StringComparison.OrdinalIgnoreCase))
.OrderBy(c => c.ToString()))
{
var result = Api.GetValue(ch, PcanParameter.ChannelCondition, out uint condition);
if (result == PcanStatus.OK && condition != 0)
available.Add(ch);
}
return available;
}
public void RequestReinitialize(PcanChannel channel, Bitrate bitrate)
{
lock (_lock) _pendingReinit = (channel, bitrate);
}
public bool TryConsumePendingReinit(out PcanChannel channel, out Bitrate bitrate)
{
lock (_lock)
{
if (_pendingReinit.HasValue)
{
channel = _pendingReinit.Value.Channel;
bitrate = _pendingReinit.Value.Bitrate;
_pendingReinit = null;
return true;
}
channel = default;
bitrate = default;
return false;
}
}
public void Initialize(PcanChannel channel, Bitrate bitrate)
{
_channel = channel;
var result = Api.Initialize(channel, bitrate);
if (result != PcanStatus.OK)
throw new InvalidOperationException($"CAN init failed: {GetErrorText(result)}");
// Optimize PCAN settings for low latency
try
{
// Try to set receive event to 0 to disable buffering delays
Api.SetValue(channel, PcanParameter.ReceiveEvent, 0u);
}
catch (Exception)
{
// Buffer optimization failed but connection still works
}
IsConnected = true;
CurrentBitrate = bitrate;
}
public void Uninitialize()
{
Api.Uninitialize(_channel);
IsConnected = false;
}
public PcanStatus Read(out PcanMessage msg, out ulong timestamp)
=> Api.Read(_channel, out msg, out timestamp);
public PcanStatus Write(PcanMessage msg)
{
var status = Api.Write(_channel, msg);
if (status == PcanStatus.OK)
{
var timestampUs = (ulong)(DateTime.UtcNow.Ticks / 10);
var data = new byte[msg.DLC];
Array.Copy(msg.Data, data, msg.DLC);
UpdateLatestMessage(new CanMessageDto(
msg.ID, data, msg.DLC, timestampUs,
msg.MsgType == MessageType.Extended,
null));
}
return status;
}
private static string GetErrorText(PcanStatus status)
{
try { Api.GetErrorText(status, out var text); return text; }
catch (PcanBasicException) { return status.ToString(); }
}
}

@ -0,0 +1,53 @@
using Peak.Can.Basic;
using IOModuleTestBlazor.Models;
namespace IOModuleTestBlazor.Services;
public interface ICanService
{
bool IsConnected { get; }
string ChannelName { get; }
Bitrate CurrentBitrate { get; }
/// <summary>
/// Gets the latest received messages, indexed by CAN ID.
/// </summary>
IReadOnlyDictionary<uint, CanMessageDto> GetLatestMessages();
// ── Filters ───────────────────────────────────────────────────────────────
IReadOnlyList<CanFilter> Filters { get; }
CanFilter AddFilter(CanFilter filter);
bool RemoveFilter(Guid id);
void ClearFilters();
// ── Bitmasks ──────────────────────────────────────────────────────────────
IReadOnlyList<CanBitmask> Bitmasks { get; }
CanBitmask AddBitmask(CanBitmask bitmask);
bool RemoveBitmask(Guid id);
void ClearBitmasks();
// ── Helpers used by the worker ────────────────────────────────────────────
bool PassesFilter(uint messageId);
IReadOnlyDictionary<string, double> ExtractSignals(uint messageId, byte[] data);
void UpdateLatestMessage(CanMessageDto dto);
// ── Channel management ────────────────────────────────────────────────────
/// <summary>Returns all PCAN USB channels that are currently available.</summary>
IReadOnlyList<PcanChannel> GetAvailableChannels();
/// <summary>
/// Asks the worker to reinitialise on the next loop iteration.
/// Non-blocking — the actual reconnect happens asynchronously.
/// </summary>
void RequestReinitialize(PcanChannel channel, Bitrate bitrate);
/// <summary>Called by the worker to pick up a pending reinit request.</summary>
bool TryConsumePendingReinit(out PcanChannel channel, out Bitrate bitrate);
// ── PCAN passthrough ──────────────────────────────────────────────────────
void Initialize(PcanChannel channel, Bitrate bitrate);
void Uninitialize();
PcanStatus Read(out PcanMessage msg, out ulong timestamp);
PcanStatus Write(PcanMessage msg);
}

@ -0,0 +1,29 @@
namespace IOModuleTestBlazor.Services;
public interface ISerialPortService
{
bool IsOpen { get; }
string PortName { get; }
/// <summary>Returns available COM port names.</summary>
IReadOnlyList<string> GetPortNames();
/// <summary>Opens the specified port at the given baud rate.</summary>
void Open(string portName, int baudRate);
/// <summary>Closes the port if open.</summary>
void Close();
/// <summary>Sends a line to the port (appends \r\n).</summary>
void WriteLine(string command);
/// <summary>Returns buffered terminal lines (last 200, oldest first).
/// Each line is prefixed with "&gt; " (sent) or "&lt; " (received).</summary>
IReadOnlyList<string> GetLines();
/// <summary>Clears the line buffer.</summary>
void ClearLines();
/// <summary>Fired on the SerialPort receive thread when new lines arrive.</summary>
event Action? DataReceived;
}

@ -0,0 +1,143 @@
using System.IO.Ports;
namespace IOModuleTestBlazor.Services;
public sealed class SerialPortService : ISerialPortService, IDisposable
{
private readonly Lock _lock = new();
private SerialPort? _port;
private readonly List<string> _lines = new(200);
private string _receiveBuffer = string.Empty;
private const int MaxLines = 200;
public bool IsOpen => _port?.IsOpen == true;
public string PortName => _port?.PortName ?? string.Empty;
public event Action? DataReceived;
public IReadOnlyList<string> GetPortNames()
=> SerialPort.GetPortNames();
public void Open(string portName, int baudRate)
{
SerialPort newPort;
lock (_lock)
{
ClosePortUnsafe();
newPort = new SerialPort(portName, baudRate, Parity.None, 8, StopBits.One)
{
NewLine = "\r\n",
ReadTimeout = SerialPort.InfiniteTimeout,
WriteTimeout = 500,
Encoding = System.Text.Encoding.ASCII,
};
}
// Open outside the lock — USB VCP init can block ~1 s; don't hold _lock during that.
try
{
newPort.Open();
}
catch
{
newPort.Dispose();
throw;
}
lock (_lock)
{
_port = newPort;
_port.DataReceived += OnDataReceived;
}
}
public void Close()
{
lock (_lock)
ClosePortUnsafe();
}
// Must be called with _lock held. System.Threading.Lock is non-reentrant,
// so Close() cannot be called from Open() while the lock is already held.
private void ClosePortUnsafe()
{
if (_port is null) return;
_port.DataReceived -= OnDataReceived;
try { _port.Close(); } catch { /* ignore */ }
_port.Dispose();
_port = null;
}
public void WriteLine(string command)
{
SerialPort? port;
lock (_lock) { port = _port; }
if (port?.IsOpen != true) return;
try
{
port.WriteLine(command);
}
catch (Exception ex) when (ex is IOException or TimeoutException or InvalidOperationException or UnauthorizedAccessException)
{
// Device stopped responding or VCP disconnected — close so IsOpen reflects reality.
Close();
DataReceived?.Invoke();
return;
}
AppendLine($"> {command}");
}
public IReadOnlyList<string> GetLines()
{
lock (_lock)
return _lines.ToList();
}
public void ClearLines()
{
lock (_lock)
_lines.Clear();
}
private void OnDataReceived(object sender, SerialDataReceivedEventArgs e)
{
SerialPort? port;
lock (_lock) { port = _port; }
if (port is null) return;
try
{
string incoming = port.ReadExisting();
_receiveBuffer += incoming;
// Split on newlines, keep partial last line in buffer
var parts = _receiveBuffer.Split('\n');
for (int i = 0; i < parts.Length - 1; i++)
{
var line = parts[i].TrimEnd('\r');
if (line.Length > 0)
AppendLine($"< {line}");
}
_receiveBuffer = parts[^1];
}
catch { /* port closed mid-read */ }
DataReceived?.Invoke();
}
private void AppendLine(string line)
{
lock (_lock)
{
if (_lines.Count >= MaxLines)
_lines.RemoveAt(0);
_lines.Add(line);
}
}
public void Dispose() => Close();
}

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"IOModuleTestBlazor.CanWorker": "Debug"
}
},
"AllowedHosts": "*",
"CanOptions": {
"Channel": "",
"Bitrate": "Pcan500",
"Filters": [],
"Bitmasks": []
}
}

@ -0,0 +1,60 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,3 +1,92 @@
# IOModuleBlazorTest
# IO Module Blazor Dashboard
Blazor app with can interface
A Blazor Server web application for monitoring and controlling the STM32H723ZG Nucleo IO module over CAN bus and serial.
## What This App Does
- **Button Status** — Live indicators for the three push buttons (User/Button1/Button2) received via CAN
- **Termination Control** — Toggle TERM_ON and TERM_OFF outputs on the CAN hat (sends CAN `0x240`)
- **CAN Monitor** — Full CAN management: connect/disconnect, live message stream, filter and signal decoder, manual message sender
- **Serial Terminal** — Connect to the STM32's ST-Link VCP for NVMEM EEPROM read/write commands
## Requirements
- **.NET 10 SDK**
- **PEAK PCAN adapter** (e.g. PCAN-USB) with PCAN-Basic drivers installed
- STM32H723ZG Nucleo board running the [IOModuleBlazorTest firmware](../STM32Nucleo/)
## Running the App
```
cd IOModuleTestBlazor
dotnet run
```
Then open `https://localhost:5001` in a browser.
## Pages
| Page | Route | Description |
|------|-------|-------------|
| Home | `/` | Button status, termination control, CAN message list |
| CAN Monitor | `/can-monitor` | Full CAN management — connect, filters, signal decoder, send |
| Serial Terminal | `/serial` | ST-Link VCP terminal for NVMEM EEPROM commands |
## CAN Bus Setup
1. Connect the PCAN-USB adapter to the Nucleo CAN transceiver
2. Open the app and navigate to **CAN Monitor**
3. Select the PCAN channel and set bitrate to **500 kbps**
4. Click **Connect** — the app auto-scans for available PCAN USB channels
## Termination Control (Home page)
Two independent toggles map directly to the CAN hat's termination relay outputs:
| Button | CAN `0x240` bit | STM32 pin |
|--------|-----------------|-----------|
| TERM_ON | Bit 0 | PB10 (CN10-32) |
| TERM_OFF | Bit 1 | PB11 (CN10-34) |
Clicking a toggle immediately sends a `0x240` frame. Buttons are disabled when not connected.
## Serial Terminal (NVMEM Commands)
Connect to the Nucleo's ST-Link USB virtual COM port at **115200 baud**.
| Command | Description |
|---------|-------------|
| `r <addr> <len>` | Read `len` bytes (1–5) from EEPROM at hex address |
| `w <addr> <b0> [b1...]` | Write 1–5 bytes to EEPROM |
| `?` | Show help |
Example: `r 0000 4` reads 4 bytes at address 0x0000.
See [STM32 firmware docs/NVMEMCommands.md](../STM32Nucleo/docs/NVMEMCommands.md) for the full reference.
## Project Structure
```
IOModuleTestBlazor/
├── Components/
│ ├── Layout/
│ │ └── NavMenu.razor # Navigation sidebar
│ └── Pages/
│ ├── Home.razor # Dashboard: buttons + termination + messages
│ ├── CanMonitor.razor # Full CAN management UI
│ └── SerialTerminal.razor # NVMEM serial command terminal
├── Services/
│ ├── ICanService.cs / CanService.cs # PCAN adapter wrapper
│ └── ISerialPortService.cs / SerialPortService.cs # Serial port wrapper
├── Models/
│ └── CanModels.cs # CanMessageDto, CanFilter, CanBitmask
├── CanWorker.cs # Background CAN message reader
└── Program.cs # DI registration, middleware
```
## Dependencies
| Package | Purpose |
|---------|---------|
| `Peak.PCANBasic.NET` | PCAN hardware CAN adapter driver |
| `Microsoft.AspNetCore.SignalR.Client` | Real-time Blazor Server transport |

Loading…
Cancel
Save