WIP
This commit is contained in:
@@ -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).
|
||||
@@ -5,8 +5,8 @@ using IOModuleTestBlazor.Services;
|
||||
namespace IOModuleTestBlazor;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that initialises the PCAN adapter and streams CAN messages
|
||||
/// to the rest of the app via <see cref="ICanService.MessageReceived"/>.
|
||||
/// 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,
|
||||
@@ -65,39 +65,62 @@ public class CanWorker(
|
||||
continue;
|
||||
}
|
||||
|
||||
var result = canService.Read(out PcanMessage msg, out ulong timestamp);
|
||||
// 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
|
||||
|
||||
if (result == PcanStatus.OK)
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="scott">
|
||||
<span aria-hidden="true">💩</span> Scott click here
|
||||
<NavLink class="nav-link" href="serial">
|
||||
<span class="bi bi-terminal-nav-menu" aria-hidden="true"></span> Serial Terminal
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,7 +1,346 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
@using IOModuleTestBlazor.Models
|
||||
@using IOModuleTestBlazor.Services
|
||||
@using Peak.Can.Basic
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
@inject ICanService CanService
|
||||
|
||||
Welcome to your new app.
|
||||
<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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
@page "/scott"
|
||||
|
||||
<PageTitle>Scott</PageTitle>
|
||||
|
||||
<div class="scott-wrapper">
|
||||
<div class="poo-orbit">
|
||||
<div class="poo-spin">
|
||||
<div class="poo-wobble">
|
||||
<div class="poo-pulse">
|
||||
<span class="poo-text">💩POO💩</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scott-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(ellipse at center, #1a0033 0%, #000 100%);
|
||||
border-radius: 12px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.poo-orbit {
|
||||
animation: orbit 3s linear infinite;
|
||||
}
|
||||
|
||||
.poo-spin {
|
||||
animation: spin 0.4s linear infinite;
|
||||
}
|
||||
|
||||
.poo-wobble {
|
||||
animation: wobble 0.7s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.poo-pulse {
|
||||
animation: pulse 0.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.poo-text {
|
||||
font-size: clamp(4rem, 12vw, 9rem);
|
||||
font-weight: 900;
|
||||
font-family: Impact, sans-serif;
|
||||
letter-spacing: 0.05em;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#ff0000, #ff7700, #ffff00,
|
||||
#00ff00, #0000ff, #8b00ff,
|
||||
#ff0000
|
||||
);
|
||||
background-size: 300% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: rainbow 0.8s linear infinite;
|
||||
filter: drop-shadow(0 0 18px #ff00ff) drop-shadow(0 0 40px #00ffff);
|
||||
display: inline-block;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@@keyframes orbit {
|
||||
0% { transform: rotate(0deg) translateX(40px) rotate(0deg); }
|
||||
100% { transform: rotate(360deg) translateX(40px) rotate(-360deg); }
|
||||
}
|
||||
|
||||
@@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@keyframes wobble {
|
||||
from { transform: skewX(-15deg) scaleY(0.9); }
|
||||
to { transform: skewX(15deg) scaleY(1.15); }
|
||||
}
|
||||
|
||||
@@keyframes pulse {
|
||||
from { transform: scale(0.85); }
|
||||
to { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
@@keyframes rainbow {
|
||||
0% { background-position: 0% center; }
|
||||
100% { background-position: 300% center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,209 @@
|
||||
@page "/serial"
|
||||
@rendermode InteractiveServer
|
||||
@implements IDisposable
|
||||
|
||||
@using IOModuleTestBlazor.Services
|
||||
@inject ISerialPortService SerialService
|
||||
|
||||
<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 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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,9 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddSingleton<ICanService, CanService>();
|
||||
builder.Services.AddHostedService<CanWorker>();
|
||||
|
||||
// ── Serial port ───────────────────────────────────────────────────────────────
|
||||
builder.Services.AddSingleton<ISerialPortService, SerialPortService>();
|
||||
|
||||
// ── Blazor ────────────────────────────────────────────────────────────────────
|
||||
builder.Services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
|
||||
@@ -8,6 +8,7 @@ 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; }
|
||||
@@ -16,9 +17,15 @@ public class CanService : ICanService
|
||||
|
||||
private (PcanChannel Channel, Bitrate Bitrate)? _pendingReinit;
|
||||
|
||||
public event Action<CanMessageDto>? MessageReceived;
|
||||
public IReadOnlyDictionary<uint, CanMessageDto> GetLatestMessages()
|
||||
{
|
||||
lock (_lock) return new Dictionary<uint, CanMessageDto>(_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)
|
||||
{
|
||||
|
||||
@@ -10,10 +10,9 @@ public interface ICanService
|
||||
Bitrate CurrentBitrate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
event Action<CanMessageDto> MessageReceived;
|
||||
IReadOnlyDictionary<uint, CanMessageDto> GetLatestMessages();
|
||||
|
||||
// ── Filters ───────────────────────────────────────────────────────────────
|
||||
IReadOnlyList<CanFilter> Filters { get; }
|
||||
@@ -30,7 +29,7 @@ public interface ICanService
|
||||
// ── Helpers used by the worker ────────────────────────────────────────────
|
||||
bool PassesFilter(uint messageId);
|
||||
IReadOnlyDictionary<string, double> ExtractSignals(uint messageId, byte[] data);
|
||||
void PublishMessage(CanMessageDto dto);
|
||||
void UpdateLatestMessage(CanMessageDto dto);
|
||||
|
||||
// ── Channel management ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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 "> " (sent) or "< " (received).</summary>
|
||||
IReadOnlyList<string> GetLines();
|
||||
|
||||
/// <summary>Fired on the SerialPort receive thread when new lines arrive.</summary>
|
||||
event Action? DataReceived;
|
||||
}
|
||||
@@ -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<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();
|
||||
}
|
||||
|
||||
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,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 <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 |
|
||||
Reference in New Issue
Block a user