This commit is contained in:
2026-06-10 20:52:27 +01:00
parent ad5fda71b0
commit ba40821bd5
14 changed files with 1024 additions and 146 deletions
@@ -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 ────────────────────────────────────────────────────────────
+342 -3
View File
@@ -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;
}