This commit is contained in:
2026-02-02 10:00:26 +07:00
commit b9b2c6ef79
617 changed files with 133854 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
<div class="accordion-item level-@Level">
<div class="accordion-header" @onclick="OnToggle">
<span>@Title</span>
<span class="icon">@((IsOpen) ? "" : "+")</span>
</div>
<div class="accordion-content @(IsOpen ? "open" : "")">
@ChildContent
</div>
</div>
@code {
[Parameter] public string Title { get; set; } = "";
[Parameter] public bool IsOpen { get; set; }
[Parameter] public int Level { get; set; } = 0;
[Parameter] public EventCallback OnToggle { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}

View File

@@ -0,0 +1,24 @@
<!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 href="https://cdn.jsdelivr.net/npm/bootstrap-icons/font/bootstrap-icons.css" rel="stylesheet">
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["SickBlazorApp.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveServer" />
</head>
<body>
<Routes @rendermode="InteractiveServer" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -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">
@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>

View File

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

View File

@@ -0,0 +1,24 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">SickBlazorApp</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="sick">
<span class="bi bi-coin nav-icon"></span>Sick Monitor
</NavLink>
</div>
</nav>
</div>

View File

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

View File

@@ -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>
<button id="components-resume-button" class="components-pause-visible">
Resume
</button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please reload the page.
</p>
</div>
</dialog>

View File

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

View File

@@ -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 {
location.reload();
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

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

View File

@@ -0,0 +1,124 @@
@page "/"
<PageTitle>Home</PageTitle>
<div class="page-header">
<div class="page-title">BCG08-C1QM0371 | EcoLine</div>
<div class="page-subtitle"> WIRE DRAW ENCODERS</div>
</div>
<div class="product-container">
<div class="product-image">
<img src="Images/encoder.jpg" alt="BCG08-C1QM0371" />
</div>
<div class="product-info">
<AccordionItem Title="Tổng quan" IsOpen="@(OpenMainSection == "overview")"
OnToggle="@(() => ToggleSection("overview"))">
<p>
Bộ mã hóa kéo dây BCG08-C1QM0371 thuộc dòng EcoLine của SICK,
là thiết bị đo chiều dài và vị trí được thiết kế cho các ứng dụng công nghiệp.
Sản phẩm sử dụng cơ cấu kéo dây kết hợp encoder tuyệt đối,
cho phép đo chính xác vị trí tuyến tính trong các hệ thống chuyển động.
</p>
<p>
Thiết bị phù hợp lắp đặt trong các dây chuyền sản xuất, hệ thống nâng hạ,
máy công cụ và các ứng dụng tự động hóa yêu cầu độ tin cậy cao.
Với giao tiếp CANopen, BCG08-C1QM0371 dễ dàng tích hợp với PLC
và các hệ điều khiển công nghiệp phổ biến hiện nay.
</p>
</AccordionItem>
<AccordionItem Title="Thông số kỹ thuật" Level="0" IsOpen="@(OpenMainSection == "spec")"
OnToggle="@(() => ToggleSection("spec"))">
<AccordionItem Title="Hiệu năng" Level="1" IsOpen="IsPerformanceOpen"
OnToggle="@(() => IsPerformanceOpen = !IsPerformanceOpen)">
<ul>
<li>Phạm vi đo: 1m -> 3m </li>
<li>Bộ mã hoá: tuyệt đối</li>
<li>Độ phân giải: 0.01mm</li>
<li>Độ lặp lại: ≤ 0.2 mm</li>
<li>Độ tuyến tính: ≤ ± 2 mm</li>
<li>Độ trễ: ≤ 0.4 mm</li>
</ul>
</AccordionItem>
<AccordionItem Title="Giao tiếp" Level="1" IsOpen="IsCommunicationOpen"
OnToggle="@(() => IsCommunicationOpen = !IsCommunicationOpen)">
<ul>
<li>Giao thức truyền thông: Canopen</li>
</ul>
</AccordionItem>
<AccordionItem Title="Điện tử" Level="1" IsOpen="IsElectronicsOpen"
OnToggle="@(() => IsElectronicsOpen = !IsElectronicsOpen)">
<ul>
<li>Kiểu kết nối: đầu nối đực, M12 - 5 chân, chuẩn công nghiệp, dễ đấu PLC /CANopen</li>
<li>Điện áp cấp nguồn: 10V -> 30V</li>
<li>Công suất tiêu thụ: ≤ 1.5 W (khi không tải)</li>
</ul>
</AccordionItem>
<AccordionItem Title="Cơ khí" Level="1" IsOpen="IsMechanicalOpen"
OnToggle="@(() => IsMechanicalOpen = !IsMechanicalOpen)">
<ul>
<li>Khối lượng thiết bị: 0.37 kg</li>
<li>Vật liệu dây đo: Thép không gỉ bện mềm, chuẩn 1.4401/V4A</li>
<li>Đường kính dây thép đo: 0.55 mm</li>
<li>Khối lượng dây: 1.2 g/m</li>
<li>Vỏ & cơ cấu cuộn dây: Nhựa, Noryl</li>
<li>Lực lò xo kéo dây về: 3.3 N … 4.4 N</li>
<li>Chiều dài dây trên mỗi vòng quay: 230 mm</li>
<li>Tuổi thọ cơ cấu kéo dây: ~ 1,000,000 chu kỳ</li>
<li>Chiều dài dây thực tế: 3.2 m</li>
<li>Gia tốc của dây: 10m/s²</li>
<li>Tốc độ làm việc: 6 m/s</li>
<li>Encoder gắn bên trong: AHM36 CANopen, AHM36A-S3CC014x12, 1065999</li>
<li>Hộp kéo dây: MRA-G080-103D3, 5322778</li>
</ul>
</AccordionItem>
<AccordionItem Title="Dữ liệu môi trường" Level="1" IsOpen="IsEnvironmentOpen"
OnToggle="@(() => IsEnvironmentOpen = !IsEnvironmentOpen)">
<ul>
<li>Khả năng chống nhiễu điện từ: EN 61000-6-2, EN 61000-6-3</li>
<li>Cấp bảo vệ: IP50 (hộp kéo dây), IP66/IP67 (bộ mã hóa)</li>
<li>Nhiệt độ hoạt động: 30 °C ... +70 °C</li>
</ul>
</AccordionItem>
</AccordionItem>
<AccordionItem Title="Cấu hình" IsOpen="@(OpenMainSection == "config")"
OnToggle="@(() => ToggleSection("config"))">
<ul>
<li>Bitrate: 20k -> 1M bit/s</li>
<li>Node ID: 1 -> 127</li>
</ul>
</AccordionItem>
<AccordionItem Title="Tài liệu" IsOpen="@(OpenMainSection == "doc")"
OnToggle="@(() => ToggleSection("doc"))">
<ul>
<li><a href="https://www.sick.com/media/pdf/1/71/371/dataSheet_BCG08-C1QM0371_1068867_en.pdf" target="_blank">Datasheet PDF</a></li>
<li><a href="https://www.sick.com/media/docs/3/93/193/operating_instructions_ahs_ahm36_canopen_ahs_ahm36_canopen_inox_absolute_encoder_en_im0055193.pdf" target="_blank">Full Documentation</a></li>
</ul>
</AccordionItem>
</div>
<div class="go-to-monitor" style="margin-top: 2rem; text-align: right;">
<a href="/sick" class="btn btn-primary btn-lg">
Go to Monitor <i class="bi bi-arrow-right"></i>
</a>
</div>
</div>
@code {
string? OpenMainSection;
bool IsPerformanceOpen;
bool IsCommunicationOpen;
bool IsElectronicsOpen;
bool IsMechanicalOpen;
bool IsEnvironmentOpen;
void ToggleSection(string section)
{
OpenMainSection = OpenMainSection == section ? null : section;
}
}

View File

@@ -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>

View File

@@ -0,0 +1,293 @@
@page "/sick"
@implements IDisposable
@inject IOptions<CanBusOptions> CanOptions
@inject ICanBusService CanService
@inject IJSRuntime JS
<div class="encoder-container">
<div class="encoder-card">
<!-- HEADER -->
<div class="encoder-header">
<div class="encoder-title">Sick Encoder Monitor</div>
<div class="encoder-actions">
<button class="btn btn-start @( _starting ? "loading" : "" )"
disabled="@(_starting || _applying)"
@onclick="OnStartClicked">
@(_starting ? "Starting..." : "Start")
</button>
<button class="btn btn-stop @( _stopping ? "loading" : "" )"
disabled="@(_stopping || _applying)"
@onclick="OnStopClicked">
@(_stopping ? "Stopping..." : "Stop")
</button>
</div>
</div>
<!-- TABLE -->
<table class="encoder-table">
<thead>
<tr>
<th>CAN-ID</th>
<th>Length</th>
<th>Data</th>
<th>Position (mm)</th>
<th>Time</th>
</tr>
</thead>
<tbody>
@foreach (var f in _frames.Values.OrderBy(f => f.CobId))
{
<tr>
<td class="encoder-id">@($"0x{f.CobId:X3}")</td>
<td>@f.Length</td>
<td class="encoder-data">@f.DataHex</td>
<td class="encoder-pos">
@(f.PositionMm.HasValue? f.PositionMm.Value.ToString("F2") : "-")
</td>
<td class="encoder-time">@f.Timestamp.ToString("HH:mm:ss.fff")</td>
</tr>
}
</tbody>
</table>
<!-- STATUS -->
<div class="encoder-status @StateClass"> Node State: @_nodeState | Bitrate: @(CurrentBitrate / 1000) kbit/s | Node ID: @CurrentNodeId</div>
<div class="bitrate-group">
<label class="bitrate-label">Bitrate</label>
<select @bind="_selectedBitrate" class="bitrate-select">
<option value="1000000">1000 kbit/s</option>
<option value="800000">800 kbit/s</option>
<option value="500000">500 kbit/s</option>
<option value="250000">250 kbit/s</option>
<option value="125000">125 kbit/s</option>
<option value="100000">100 kbit/s</option>
<option value="50000">50 kbit/s</option>
<option value="20000">20 kbit/s</option>
</select>
<button class="btn btn-apply @( _applying ? "loading" : "" )"
disabled="@(_starting || _applying)"
@onclick="OnApplyClicked">
@(_applying ? "Applying..." : "Apply Bitrate")
</button>
<label class="nodeid-label">Node ID (1127)</label>
<input type="number"
min="1"
max="127"
@bind="_inputNodeId"
placeholder="@CurrentNodeId"
class="nodeid-input" />
<button class="btn btn-apply"
disabled="@(!_inputNodeId.HasValue)"
@onclick="OnApplyNodeIdClicked">
Apply Node ID
</button>
</div>
</div>
</div>
@code {
private PositionPdo? _current;
private CanNodeState _nodeState = CanNodeState.Unknown;
private int CurrentBitrate => CanService.CurrentBitrate;
private readonly Dictionary<uint, CanFrame> _frames = new();
private int _selectedBitrate;
private bool _bitrateApplied = false;
private bool _starting;
private bool _stopping;
private bool _applying;
private int? _inputNodeId;
private byte CurrentNodeId => CanService.CurrentNodeId;
protected override void OnInitialized()
{
CanService.PositionReceived += OnPositionReceived;
CanService.NodeStateChanged += OnNodeStateChanged;
CanService.FrameReceived += OnFrameReceived;
CanService.NodeIdChanged += OnNodeIdChanged;
_selectedBitrate = CanService.CurrentBitrate;
}
private async Task Start()
{
// ❗ LUÔN init lại để đảm bảo COB-ID & state sạch
await CanService.InitAsync();
// ❗ Reset node theo Node ID MỚI
CanService.SendNmtReset(CurrentNodeId);
await Task.Delay(800);
CanService.SendNmtStart(CurrentNodeId);
// ❗ Start read với Node ID MỚI
CanService.Start();
}
private Task Stop()
{
CanService.SendNmtStop(CurrentNodeId);
return Task.CompletedTask;
}
private void OnPositionReceived(object? sender, PositionPdo e)
{
_ = InvokeAsync(() =>
{
_current = e;
StateHasChanged();
});
}
public void Dispose()
{
CanService.PositionReceived -= OnPositionReceived;
CanService.NodeStateChanged -= OnNodeStateChanged;
CanService.FrameReceived -= OnFrameReceived;
CanService.NodeIdChanged -= OnNodeIdChanged;
CanService.Stop();
}
private void OnNodeStateChanged(object? sender, CanNodeState state)
{
_ = InvokeAsync(() =>
{
_nodeState = state;
StateHasChanged();
});
}
private string StateClass => _nodeState switch
{
CanNodeState.Operational => "state-op",
CanNodeState.PreOperational => "state-preop",
CanNodeState.Stopped => "state-stop",
CanNodeState.Timeout => "state-timeout",
_ => "state-unknown"
};
private void OnFrameReceived(object? sender, CanFrame frame)
{
_ = InvokeAsync(() =>
{
// mỗi CAN-ID chỉ giữ frame mới nhất
_frames[frame.CobId] = frame;
StateHasChanged();
});
}
private async Task ApplyBitrate()
{
await CanService.ApplyBitrateAsync(
CurrentNodeId,
_selectedBitrate
);
// UI chỉ báo PreOp / Stopped
_nodeState = CanNodeState.PreOperational;
}
private async Task OnApplyNodeIdClicked()
{
if (!_inputNodeId.HasValue)
return;
byte newNodeId = (byte)_inputNodeId.Value;
byte oldNodeId = CanService.CurrentNodeId;
await CanService.ApplyNodeIdAsync(oldNodeId, newNodeId);
_inputNodeId = null; // reset input
}
private async Task OnStartClicked()
{
if (_starting) return;
_starting = true;
StateHasChanged();
try
{
// ✅ Nếu CHƯA Apply bitrate → dùng bitrate hiện tại
if (!_bitrateApplied)
{
_selectedBitrate = CanService.CurrentBitrate;
}
await Start();
}
finally
{
_starting = false;
StateHasChanged();
}
}
private async Task OnStopClicked()
{
if (_stopping) return;
_stopping = true;
StateHasChanged();
try
{
await Stop();
}
finally
{
_stopping = false;
StateHasChanged();
}
}
private async Task OnApplyClicked()
{
if (_applying) return;
_applying = true;
StateHasChanged();
try
{
await ApplyBitrate();
// ✅ Đánh dấu đã Apply
_bitrateApplied = true;
}
finally
{
_applying = false;
StateHasChanged();
}
}
private void OnNodeIdChanged(object? sender, byte newNodeId)
{
_ = InvokeAsync(() =>
{
// 🔥 XÓA TOÀN BỘ FRAME CŨ
_frames.Clear();
// reset input
_inputNodeId = null;
StateHasChanged();
});
}
}

View File

@@ -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>

View File

@@ -0,0 +1,16 @@
@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 Microsoft.Extensions.Options
@using SickBlazorApp
@using SickBlazorApp.Components
@using SickBlazorApp.Components.Layout
@using SickBlazorApp.Options
@using SickBlazorApp.Models
@using SickBlazorApp.Services
@using SickBlazorApp.Services.Windows