You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
346 lines
14 KiB
346 lines
14 KiB
@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(); |
|
} |
|
}
|
|
|