diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a145d88 --- /dev/null +++ b/CLAUDE.md @@ -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 ` and `w ` 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). diff --git a/IOModuleTestBlazor/CanWorker.cs b/IOModuleTestBlazor/CanWorker.cs index c060532..d837adb 100644 --- a/IOModuleTestBlazor/CanWorker.cs +++ b/IOModuleTestBlazor/CanWorker.cs @@ -5,8 +5,8 @@ using IOModuleTestBlazor.Services; namespace IOModuleTestBlazor; /// -/// Background service that initialises the PCAN adapter and streams CAN messages -/// to the rest of the app via . +/// Background service that initialises the PCAN adapter and stores CAN messages +/// in the CanService for polling by UI components. /// public class CanWorker( ILogger logger, @@ -65,39 +65,62 @@ public class CanWorker( continue; } - var result = canService.Read(out PcanMessage msg, out ulong timestamp); - - if (result == PcanStatus.OK) + // 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) { - if (!canService.PassesFilter(msg.ID)) - continue; + var result = canService.Read(out PcanMessage msg, out ulong timestamp); - var data = new byte[msg.DLC]; - for (int i = 0; i < msg.DLC; i++) - data[i] = msg.Data[i]; + if (result == PcanStatus.OK) + { + if (!canService.PassesFilter(msg.ID)) + { + logger.LogDebug("[FILTERED] ID=0x{Id:X3} blocked by filter", msg.ID); + messagesProcessed++; + continue; + } - var signals = canService.ExtractSignals(msg.ID, data); + var data = new byte[msg.DLC]; + for (int i = 0; i < msg.DLC; i++) + data[i] = msg.Data[i]; - canService.PublishMessage(new CanMessageDto( - Id: msg.ID, - Data: data, - Dlc: msg.DLC, - TimestampUs: timestamp, - IsExtended: msg.MsgType == MessageType.Extended, - Signals: signals.Count > 0 ? signals : null - )); + var signals = canService.ExtractSignals(msg.ID, data); - logger.LogDebug("[RX] ID=0x{Id:X3} DLC={Dlc} Data={Data}", - msg.ID, msg.DLC, Convert.ToHexString(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; + } } - else if (result == PcanStatus.ReceiveQueueEmpty) + + // Small yield to prevent 100% CPU usage when no messages + if (messagesProcessed == 0) { - await Task.Delay(1, stoppingToken); - } - else - { - logger.LogError("CAN read error: {Error}", GetErrorText(result)); - await Task.Delay(100, stoppingToken); + await Task.Yield(); } } } diff --git a/IOModuleTestBlazor/Components/Layout/NavMenu.razor b/IOModuleTestBlazor/Components/Layout/NavMenu.razor index b7460fd..ff5d662 100644 --- a/IOModuleTestBlazor/Components/Layout/NavMenu.razor +++ b/IOModuleTestBlazor/Components/Layout/NavMenu.razor @@ -21,10 +21,11 @@ + diff --git a/IOModuleTestBlazor/Components/Pages/CanMonitor.razor b/IOModuleTestBlazor/Components/Pages/CanMonitor.razor index 77b9ff5..85d1647 100644 --- a/IOModuleTestBlazor/Components/Pages/CanMonitor.razor +++ b/IOModuleTestBlazor/Components/Pages/CanMonitor.razor @@ -405,11 +405,15 @@ // ── Lifecycle ───────────────────────────────────────────────────────────── + private Timer? _updateTimer; + protected override void OnInitialized() { filters = CanService.Filters.ToList(); bitmasks = CanService.Bitmasks.ToList(); - CanService.MessageReceived += OnMessageReceived; + + // Start timer for polling CAN messages + _updateTimer = new Timer(UpdateMessages, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(100)); ScanChannels(); if (CanService.IsConnected) @@ -423,27 +427,44 @@ } } - private void OnMessageReceived(CanMessageDto msg) + private void UpdateMessages(object? state) { - lock (messages) + InvokeAsync(() => { - messages.Insert(0, msg); - if (messages.Count > MaxMessages) - messages.RemoveAt(messages.Count - 1); - } - lock (latestMessages) - { - if (!latestMessages.TryGetValue(msg.Id, out var existing) || - !msg.Data.SequenceEqual(existing.Data)) + var newMessages = CanService.GetLatestMessages(); + bool hasChanges = false; + + lock (latestMessages) { - latestMessages[msg.Id] = msg; - updateCounts[msg.Id] = updateCounts.GetValueOrDefault(msg.Id) + 1; + 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; + } + } } - } - InvokeAsync(StateHasChanged); + + if (hasChanges) + { + StateHasChanged(); + } + }); } - public void Dispose() => CanService.MessageReceived -= OnMessageReceived; + public void Dispose() => _updateTimer?.Dispose(); // ── Connection ──────────────────────────────────────────────────────────── diff --git a/IOModuleTestBlazor/Components/Pages/Home.razor b/IOModuleTestBlazor/Components/Pages/Home.razor index 9001e0b..326ebf5 100644 --- a/IOModuleTestBlazor/Components/Pages/Home.razor +++ b/IOModuleTestBlazor/Components/Pages/Home.razor @@ -1,7 +1,346 @@ @page "/" +@rendermode InteractiveServer +@implements IDisposable -Home +@using IOModuleTestBlazor.Models +@using IOModuleTestBlazor.Services +@using Peak.Can.Basic -

Hello, world!

+@inject ICanService CanService -Welcome to your new app. +IO Module Dashboard + +
+
+
+

IO Module Dashboard

+ + @* ── Connection Status ──────────────────────────────────────────────────── *@ +
+
+ @if (CanService.IsConnected) + { +
+ ● Connected + @CanService.ChannelName + @GetBitrateLabel(CanService.CurrentBitrate) +
+ } + else + { + ● Disconnected + No CAN connection + } +
+
+ + @* ── Push Button Status ─────────────────────────────────────────────────── *@ +
+
+
Push Button Status
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+
+ + @* ── Termination Control ────────────────────────────────────────────────── *@ +
+
+
CAN Hat Termination Control
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ + +
+
+ @if (!CanService.IsConnected) + { +

Connect to CAN to control termination

+ } +
+
+ + @* ── CAN Messages Table ──────────────────────────────────────────────────── *@ +
+
+
CAN Messages
+
+ @latestMessages.Count message types + +
+
+
+ @if (latestMessages.Count == 0) + { +
+ @if (CanService.IsConnected) + { +

Waiting for CAN messages...

+ } + else + { +

Connect to CAN to see messages

+ } +
+ } + else + { +
+ + + + + + + + + + + + @foreach (var msg in latestMessages.Values.OrderBy(m => m.Id)) + { + + + + + + + + } + +
TimestampIDDLCDataSignals
@FormatTimestamp(msg.TimestampUs)0x@(msg.Id.ToString("X3"))@msg.Dlc@FormatData(msg.Data) + @if (msg.Signals?.Count > 0) + { + @foreach (var signal in msg.Signals) + { + @signal.Key: @signal.Value.ToString("F2") + } + } +
+
+ } +
+
+
+
+
+ + + +@code { + private Dictionary 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(); + } +} diff --git a/IOModuleTestBlazor/Components/Pages/Scott.razor b/IOModuleTestBlazor/Components/Pages/Scott.razor deleted file mode 100644 index 0cce31e..0000000 --- a/IOModuleTestBlazor/Components/Pages/Scott.razor +++ /dev/null @@ -1,90 +0,0 @@ -@page "/scott" - -Scott - -
-
-
-
-
- 💩POO💩 -
-
-
-
-
- - diff --git a/IOModuleTestBlazor/Components/Pages/SerialTerminal.razor b/IOModuleTestBlazor/Components/Pages/SerialTerminal.razor new file mode 100644 index 0000000..022349d --- /dev/null +++ b/IOModuleTestBlazor/Components/Pages/SerialTerminal.razor @@ -0,0 +1,209 @@ +@page "/serial" +@rendermode InteractiveServer +@implements IDisposable + +@using IOModuleTestBlazor.Services +@inject ISerialPortService SerialService + +Serial Terminal + +
+
+
+

Serial Terminal

+ + @* ── Connection Bar ──────────────────────────────────────────────────── *@ +
+
+
+ + @if (SerialService.IsOpen) + { + ● Connected + @SerialService.PortName + 115200 baud + } + else + { + ● Disconnected + } + + + + + + @if (SerialService.IsOpen) + { + + } + else + { + + } + + @if (_connectError is not null) + { +
+ Connect failed: + @_connectError +
+ } +
+
+
+ + @* ── Terminal Output ─────────────────────────────────────────────────── *@ +
+
+
Output
+ +
+
+
@GetTerminalText()
+
+
+ + @* ── Command Input ───────────────────────────────────────────────────── *@ +
+
+
+ + +
+ + @* ── Quick Commands ──────────────────────────────────────────── *@ +
+ Quick: + @foreach (var (label, cmd) in _quickCommands) + { + + } +
+
+
+ +
+
+
+ +@code { + private string _selectedPort = string.Empty; + private string _commandInput = string.Empty; + private string? _connectError; + private List _availablePorts = new(); + private List _displayLines = new(); + + 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(); + StateHasChanged(); + }); + } + + private void RefreshLines() + => _displayLines = SerialService.GetLines().ToList(); + + private void ClearTerminal() + { + _displayLines.Clear(); + StateHasChanged(); + } + + private string GetTerminalText() + => string.Join("\n", _displayLines); + + public void Dispose() + => SerialService.DataReceived -= OnDataReceived; +} diff --git a/IOModuleTestBlazor/IOModuleTestBlazor.csproj b/IOModuleTestBlazor/IOModuleTestBlazor.csproj index 9f2a98b..9f04830 100644 --- a/IOModuleTestBlazor/IOModuleTestBlazor.csproj +++ b/IOModuleTestBlazor/IOModuleTestBlazor.csproj @@ -10,6 +10,7 @@ + diff --git a/IOModuleTestBlazor/Program.cs b/IOModuleTestBlazor/Program.cs index 4c87840..9b890ba 100644 --- a/IOModuleTestBlazor/Program.cs +++ b/IOModuleTestBlazor/Program.cs @@ -8,6 +8,9 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +// ── Serial port ─────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); + // ── Blazor ──────────────────────────────────────────────────────────────────── builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); diff --git a/IOModuleTestBlazor/Services/CanService.cs b/IOModuleTestBlazor/Services/CanService.cs index 767931d..a1f5cb3 100644 --- a/IOModuleTestBlazor/Services/CanService.cs +++ b/IOModuleTestBlazor/Services/CanService.cs @@ -8,6 +8,7 @@ public class CanService : ICanService private PcanChannel _channel; private readonly List _filters = []; private readonly List _bitmasks = []; + private readonly Dictionary _latestMessages = []; private readonly object _lock = new(); public bool IsConnected { get; private set; } @@ -16,9 +17,15 @@ public class CanService : ICanService private (PcanChannel Channel, Bitrate Bitrate)? _pendingReinit; - public event Action? MessageReceived; + public IReadOnlyDictionary GetLatestMessages() + { + lock (_lock) return new Dictionary(_latestMessages); + } - public void PublishMessage(CanMessageDto dto) => MessageReceived?.Invoke(dto); + public void UpdateLatestMessage(CanMessageDto dto) + { + lock (_lock) _latestMessages[dto.Id] = dto; + } // ── Filters ─────────────────────────────────────────────────────────────── @@ -148,6 +155,18 @@ public class CanService : ICanService 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; } @@ -162,7 +181,20 @@ public class CanService : ICanService => Api.Read(_channel, out msg, out timestamp); public PcanStatus Write(PcanMessage msg) - => Api.Write(_channel, 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) { diff --git a/IOModuleTestBlazor/Services/ICanService.cs b/IOModuleTestBlazor/Services/ICanService.cs index 6a1bf1d..f4929e5 100644 --- a/IOModuleTestBlazor/Services/ICanService.cs +++ b/IOModuleTestBlazor/Services/ICanService.cs @@ -10,10 +10,9 @@ public interface ICanService Bitrate CurrentBitrate { get; } /// - /// Raised on the worker thread each time a CAN message passes all filters. - /// Subscribers must marshal UI updates with InvokeAsync(StateHasChanged). + /// Gets the latest received messages, indexed by CAN ID. /// - event Action MessageReceived; + IReadOnlyDictionary GetLatestMessages(); // ── Filters ─────────────────────────────────────────────────────────────── IReadOnlyList Filters { get; } @@ -30,7 +29,7 @@ public interface ICanService // ── Helpers used by the worker ──────────────────────────────────────────── bool PassesFilter(uint messageId); IReadOnlyDictionary ExtractSignals(uint messageId, byte[] data); - void PublishMessage(CanMessageDto dto); + void UpdateLatestMessage(CanMessageDto dto); // ── Channel management ──────────────────────────────────────────────────── diff --git a/IOModuleTestBlazor/Services/ISerialPortService.cs b/IOModuleTestBlazor/Services/ISerialPortService.cs new file mode 100644 index 0000000..e0c8939 --- /dev/null +++ b/IOModuleTestBlazor/Services/ISerialPortService.cs @@ -0,0 +1,26 @@ +namespace IOModuleTestBlazor.Services; + +public interface ISerialPortService +{ + bool IsOpen { get; } + string PortName { get; } + + /// Returns available COM port names. + IReadOnlyList GetPortNames(); + + /// Opens the specified port at the given baud rate. + void Open(string portName, int baudRate); + + /// Closes the port if open. + void Close(); + + /// Sends a line to the port (appends \r\n). + void WriteLine(string command); + + /// Returns buffered terminal lines (last 200, oldest first). + /// Each line is prefixed with "> " (sent) or "< " (received). + IReadOnlyList GetLines(); + + /// Fired on the SerialPort receive thread when new lines arrive. + event Action? DataReceived; +} diff --git a/IOModuleTestBlazor/Services/SerialPortService.cs b/IOModuleTestBlazor/Services/SerialPortService.cs new file mode 100644 index 0000000..b033b4c --- /dev/null +++ b/IOModuleTestBlazor/Services/SerialPortService.cs @@ -0,0 +1,137 @@ +using System.IO.Ports; + +namespace IOModuleTestBlazor.Services; + +public sealed class SerialPortService : ISerialPortService, IDisposable +{ + private readonly Lock _lock = new(); + private SerialPort? _port; + private readonly List _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 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 GetLines() + { + lock (_lock) + return _lines.ToList(); + } + + 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(); +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..94b0f45 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# IO Module Blazor Dashboard + +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 ` | Read `len` bytes (1–5) from EEPROM at hex address | +| `w [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 |