Initial commit

This commit is contained in:
Twirpytherobot
2026-03-11 20:04:40 +00:00
commit 9383b944cc
73 changed files with 61306 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["IOModuleTestBlazor.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>
@@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>
@@ -0,0 +1,98 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
@@ -0,0 +1,30 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">IOModuleTestBlazor</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="can-monitor">
<span class="bi bi-activity-nav-menu" aria-hidden="true"></span> CAN Monitor
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="scott">
<span aria-hidden="true">💩</span> Scott click here
</NavLink>
</div>
</nav>
</div>
@@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}
@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>
@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}
@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}
@@ -0,0 +1,496 @@
@page "/can-monitor"
@rendermode InteractiveServer
@implements IDisposable
@using Peak.Can.Basic
@inject ICanService CanService
<PageTitle>CAN Monitor</PageTitle>
<h1>CAN Monitor</h1>
@* ── Status bar ─────────────────────────────────────────────────────────────── *@
<div class="d-flex align-items-center gap-3 mb-3">
@if (CanService.IsConnected)
{
<span class="badge bg-success fs-6">● Connected</span>
<span class="text-muted">Channel: <strong>@CanService.ChannelName</strong></span>
}
else
{
<span class="badge bg-danger fs-6">○ Not connected</span>
<span class="text-muted small">Check that a PCAN USB adapter is plugged in and CanOptions are set in appsettings.json.</span>
}
</div>
@* ── Live messages ──────────────────────────────────────────────────────────── *@
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center py-0 ps-0">
<ul class="nav nav-tabs border-0 mb-0">
<li class="nav-item">
<button class="nav-link rounded-0 @(msgTab == "stream" ? "active" : "")"
@onclick='() => msgTab = "stream"'>
Stream
<span class="badge bg-secondary ms-1">@messages.Count</span>
</button>
</li>
<li class="nav-item">
<button class="nav-link rounded-0 @(msgTab == "latest" ? "active" : "")"
@onclick='() => msgTab = "latest"'>
Latest
<span class="badge bg-secondary ms-1">@latestMessages.Count</span>
</button>
</li>
</ul>
<button class="btn btn-sm btn-outline-secondary me-2" @onclick="ClearMessages">Clear</button>
</div>
@if (msgTab == "stream")
{
<div style="max-height:320px; overflow-y:auto;">
<table class="table table-sm table-hover font-monospace mb-0">
<thead class="table-light sticky-top">
<tr>
<th>Timestamp (µs)</th>
<th>ID</th>
<th>DLC</th>
<th>Data</th>
<th>Signals</th>
</tr>
</thead>
<tbody>
@lock (messages)
{
foreach (var m in messages)
{
<tr>
<td>@m.TimestampUs</td>
<td>@($"0x{m.Id:X3}")</td>
<td>@m.Dlc</td>
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "")</td>
<td>@SignalsText(m)</td>
</tr>
}
}
</tbody>
</table>
</div>
}
else
{
<div style="max-height:320px; overflow-y:auto;">
<table class="table table-sm table-hover font-monospace mb-0">
<thead class="table-light sticky-top">
<tr>
<th>ID</th>
<th>DLC</th>
<th>Data</th>
<th>Signals</th>
<th>Timestamp (µs)</th>
<th class="text-end">Count</th>
</tr>
</thead>
<tbody>
@foreach (var m in GetLatestRows())
{
<tr>
<td>@($"0x{m.Id:X3}")</td>
<td>@m.Dlc</td>
<td>@(m.Data.Length > 0 ? BitConverter.ToString(m.Data).Replace("-", " ") : "")</td>
<td>@SignalsText(m)</td>
<td>@m.TimestampUs</td>
<td class="text-end">@updateCounts.GetValueOrDefault(m.Id)</td>
</tr>
}
</tbody>
</table>
</div>
}
</div>
@* ── Filters + Send (two columns) ──────────────────────────────────────────── *@
<div class="row g-3 mb-3">
@* Filters *@
<div class="col-lg-7">
<div class="card h-100">
<div class="card-header">
Filters
<span class="text-muted small ms-2">
@(filters.Count == 0 ? "(no filters — all messages pass)" : $"({filters.Count} active)")
</span>
</div>
<div class="card-body">
@if (filters.Count > 0)
{
<table class="table table-sm mb-2">
<thead><tr><th>ID</th><th>Mask</th><th>Description</th><th></th></tr></thead>
<tbody>
@foreach (var f in filters)
{
<tr>
<td class="font-monospace">@($"0x{f.MessageId:X3}")</td>
<td class="font-monospace">@($"0x{f.Mask:X3}")</td>
<td>@f.Description</td>
<td>
<button class="btn btn-sm btn-outline-danger py-0 px-2"
@onclick="() => RemoveFilter(f.Id)">×</button>
</td>
</tr>
}
</tbody>
</table>
}
<div class="row g-1 align-items-end">
<div class="col-3">
<label class="form-label small mb-0">ID (hex)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. 100" @bind="newFilterId" />
</div>
<div class="col-3">
<label class="form-label small mb-0">Mask (hex)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. 7FF" @bind="newFilterMask" />
</div>
<div class="col-4">
<label class="form-label small mb-0">Description</label>
<input class="form-control form-control-sm"
placeholder="optional" @bind="newFilterDesc" />
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary w-100" @onclick="AddFilter">Add</button>
</div>
</div>
@if (filterError != null)
{
<div class="text-danger small mt-1">@filterError</div>
}
@if (filters.Count > 0)
{
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearFilters">Clear all</button>
}
</div>
</div>
</div>
@* Send *@
<div class="col-lg-5">
<div class="card h-100">
<div class="card-header">Send Message</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label small mb-0">CAN ID (hex)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. 7FF" @bind="sendIdHex" />
</div>
<div class="mb-2">
<label class="form-label small mb-0">Data bytes (hex, space-separated)</label>
<input class="form-control form-control-sm font-monospace"
placeholder="e.g. DE AD BE EF" @bind="sendDataHex" />
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="extFrame" @bind="sendExtended" />
<label class="form-check-label small" for="extFrame">Extended frame (29-bit ID)</label>
</div>
<div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-success" @onclick="SendMessage">Send</button>
@if (sendFeedback != null)
{
<span class="small @(sendOk ? "text-success" : "text-danger")">@sendFeedback</span>
}
</div>
</div>
</div>
</div>
</div>
@* ── Bitmasks ───────────────────────────────────────────────────────────────── *@
<div class="card">
<div class="card-header">Signal Bitmasks</div>
<div class="card-body">
@if (bitmasks.Count == 0)
{
<p class="text-muted small mb-2">
No bitmasks configured. Add one to extract named signal values from a message's data bytes.<br />
<em>Formula: physicalValue = ((rawUint64 &amp; DataMask) &gt;&gt; RightShift) × Scale + Offset</em>
</p>
}
else
{
<table class="table table-sm mb-2">
<thead>
<tr>
<th>Msg ID</th><th>Signal</th><th>DataMask (64-bit)</th>
<th>Shift</th><th>Scale</th><th>Offset</th><th>Unit</th><th></th>
</tr>
</thead>
<tbody>
@foreach (var b in bitmasks)
{
<tr>
<td class="font-monospace">@($"0x{b.MessageId:X3}")</td>
<td>@b.SignalName</td>
<td class="font-monospace small">@($"0x{b.DataMask:X16}")</td>
<td>@b.RightShift</td>
<td>@b.Scale</td>
<td>@b.Offset</td>
<td>@b.Unit</td>
<td>
<button class="btn btn-sm btn-outline-danger py-0 px-2"
@onclick="() => RemoveBitmask(b.Id)">×</button>
</td>
</tr>
}
</tbody>
</table>
}
<div class="row g-1 align-items-end">
<div class="col-sm-2">
<label class="form-label small mb-0">Msg ID (hex)</label>
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 100" @bind="newBmMsgId" />
</div>
<div class="col-sm-2">
<label class="form-label small mb-0">Signal name</label>
<input class="form-control form-control-sm" placeholder="e.g. EngineRPM" @bind="newBmName" />
</div>
<div class="col-sm-3">
<label class="form-label small mb-0">DataMask (64-bit hex)</label>
<input class="form-control form-control-sm font-monospace" placeholder="e.g. 00FF000000000000" @bind="newBmMask" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Shift</label>
<input class="form-control form-control-sm" @bind="newBmShift" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Scale</label>
<input class="form-control form-control-sm" @bind="newBmScale" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Offset</label>
<input class="form-control form-control-sm" @bind="newBmOffset" />
</div>
<div class="col-sm-1">
<label class="form-label small mb-0">Unit</label>
<input class="form-control form-control-sm" placeholder="rpm" @bind="newBmUnit" />
</div>
<div class="col-sm-1">
<button class="btn btn-sm btn-primary w-100" @onclick="AddBitmask">Add</button>
</div>
</div>
@if (bitmaskError != null)
{
<div class="text-danger small mt-1">@bitmaskError</div>
}
@if (bitmasks.Count > 0)
{
<button class="btn btn-sm btn-outline-danger mt-2" @onclick="ClearBitmasks">Clear all</button>
}
</div>
</div>
@code {
// ── Data ──────────────────────────────────────────────────────────────────
private readonly List<CanMessageDto> messages = [];
private readonly Dictionary<uint, CanMessageDto> latestMessages = new();
private readonly Dictionary<uint, int> updateCounts = new();
private string msgTab = "stream";
private const int MaxMessages = 200;
private List<CanFilter> filters = [];
private List<CanBitmask> bitmasks = [];
// ── Filter form ───────────────────────────────────────────────────────────
private string newFilterId = "";
private string newFilterMask = "7FF";
private string newFilterDesc = "";
private string? filterError;
// ── Bitmask form ──────────────────────────────────────────────────────────
private string newBmMsgId = "";
private string newBmName = "";
private string newBmMask = "";
private int newBmShift = 0;
private double newBmScale = 1.0;
private double newBmOffset = 0.0;
private string newBmUnit = "";
private string? bitmaskError;
// ── Send form ─────────────────────────────────────────────────────────────
private string sendIdHex = "";
private string sendDataHex = "";
private bool sendExtended;
private string? sendFeedback;
private bool sendOk;
// ── Lifecycle ─────────────────────────────────────────────────────────────
protected override void OnInitialized()
{
filters = CanService.Filters.ToList();
bitmasks = CanService.Bitmasks.ToList();
CanService.MessageReceived += OnMessageReceived;
}
private void OnMessageReceived(CanMessageDto msg)
{
lock (messages)
{
messages.Insert(0, msg);
if (messages.Count > MaxMessages)
messages.RemoveAt(messages.Count - 1);
}
lock (latestMessages)
{
latestMessages[msg.Id] = msg;
updateCounts[msg.Id] = updateCounts.GetValueOrDefault(msg.Id) + 1;
}
InvokeAsync(StateHasChanged);
}
public void Dispose() => CanService.MessageReceived -= OnMessageReceived;
// ── Messages ──────────────────────────────────────────────────────────────
private void ClearMessages()
{
lock (messages) messages.Clear();
lock (latestMessages) { latestMessages.Clear(); updateCounts.Clear(); }
}
// ── Filters ───────────────────────────────────────────────────────────────
private void AddFilter()
{
filterError = null;
if (!TryParseHex32(newFilterId, out uint msgId))
{ filterError = "Invalid ID — enter a hex value e.g. 100"; return; }
if (!TryParseHex32(newFilterMask, out uint mask))
{ filterError = "Invalid mask — enter a hex value e.g. 7FF"; return; }
var filter = new CanFilter { MessageId = msgId, Mask = mask, Description = newFilterDesc };
CanService.AddFilter(filter);
filters = CanService.Filters.ToList();
newFilterId = ""; newFilterMask = "7FF"; newFilterDesc = "";
}
private void RemoveFilter(Guid id)
{
CanService.RemoveFilter(id);
filters = CanService.Filters.ToList();
}
private void ClearFilters()
{
CanService.ClearFilters();
filters = [];
}
// ── Bitmasks ──────────────────────────────────────────────────────────────
private void AddBitmask()
{
bitmaskError = null;
if (!TryParseHex32(newBmMsgId, out uint msgId))
{ bitmaskError = "Invalid Msg ID"; return; }
if (string.IsNullOrWhiteSpace(newBmName))
{ bitmaskError = "Signal name is required"; return; }
if (!TryParseHex64(newBmMask, out ulong mask))
{ bitmaskError = "Invalid 64-bit mask — enter hex e.g. 00FF000000000000"; return; }
var bitmask = new CanBitmask
{
MessageId = msgId,
SignalName = newBmName.Trim(),
DataMask = mask,
RightShift = newBmShift,
Scale = newBmScale,
Offset = newBmOffset,
Unit = string.IsNullOrWhiteSpace(newBmUnit) ? null : newBmUnit
};
CanService.AddBitmask(bitmask);
bitmasks = CanService.Bitmasks.ToList();
newBmMsgId = ""; newBmName = ""; newBmMask = "";
newBmShift = 0; newBmScale = 1.0; newBmOffset = 0.0; newBmUnit = "";
}
private void RemoveBitmask(Guid id)
{
CanService.RemoveBitmask(id);
bitmasks = CanService.Bitmasks.ToList();
}
private void ClearBitmasks()
{
CanService.ClearBitmasks();
bitmasks = [];
}
// ── Send ──────────────────────────────────────────────────────────────────
private void SendMessage()
{
sendFeedback = null;
if (!TryParseHex32(sendIdHex, out uint id))
{ sendFeedback = "Invalid ID"; sendOk = false; return; }
byte[] data;
try
{
var hex = (sendDataHex ?? "").Replace(" ", "");
data = hex.Length > 0 ? Convert.FromHexString(hex) : [];
}
catch { sendFeedback = "Invalid hex data"; sendOk = false; return; }
if (data.Length > 8)
{ sendFeedback = "Max 8 bytes"; sendOk = false; return; }
var msg = new PcanMessage
{
ID = id,
MsgType = sendExtended ? MessageType.Extended : MessageType.Standard,
DLC = (byte)data.Length,
Data = new byte[8]
};
data.CopyTo(msg.Data, 0);
var result = CanService.Write(msg);
sendOk = result == PcanStatus.OK;
sendFeedback = sendOk ? "Sent!" : $"Error: {result}";
}
// ── Helpers ───────────────────────────────────────────────────────────────
private List<CanMessageDto> GetLatestRows()
{
lock (latestMessages)
return latestMessages.Values.OrderBy(m => m.Id).ToList();
}
private static string SignalsText(CanMessageDto m)
{
if (m.Signals is null || m.Signals.Count == 0) return "";
return string.Join(", ", m.Signals.Select(kv => $"{kv.Key}={kv.Value:G4}"));
}
private static bool TryParseHex32(string? s, out uint value)
{
value = 0;
if (string.IsNullOrWhiteSpace(s)) return false;
var hex = s.Trim();
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
return uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value);
}
private static bool TryParseHex64(string? s, out ulong value)
{
value = 0;
if (string.IsNullOrWhiteSpace(s)) return false;
var hex = s.Trim();
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) hex = hex[2..];
return ulong.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out value);
}
}
@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
@@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
@@ -0,0 +1,90 @@
@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,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
@@ -0,0 +1,13 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using IOModuleTestBlazor
@using IOModuleTestBlazor.Components
@using IOModuleTestBlazor.Components.Layout
@using IOModuleTestBlazor.Models
@using IOModuleTestBlazor.Services