|
|
|
|
|
@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();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|