Initial commit
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.Shared.Sensor
|
||||
@implements IAsyncDisposable
|
||||
@inject BatteryHubClient BatteryHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload battery data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadBatteryDataAsync"
|
||||
Disabled="@(!BatteryHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
|
||||
<!-- LEFT COLUMN : SVG BATTERY -->
|
||||
<MudItem xs="12" md="4">
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
|
||||
@* =================== SVG KHÔNG ĐỔI =================== *@
|
||||
<svg width="100%" height="100%" viewBox="0 0 200 300" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
|
||||
<defs>
|
||||
<linearGradient id="@($"healthGradient_{DeviceId}_{(int)BatteryData.Percentage}")" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:@GetHealthColor();stop-opacity:0.95" />
|
||||
<stop offset="50%" style="stop-color:@GetHealthColor();stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:@GetHealthColor();stop-opacity:0.75" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="40" y="20" width="120" height="200" rx="8" ry="8"
|
||||
fill="none" stroke="currentColor" stroke-width="4"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
|
||||
<rect x="80" y="0" width="40" height="20" rx="4" ry="4"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
|
||||
<clipPath id="batteryClip">
|
||||
<rect x="42" y="22" width="116" height="196" rx="6" ry="6" />
|
||||
</clipPath>
|
||||
|
||||
@* ==== Charge Level Fill ==== *@
|
||||
@{
|
||||
var clipHeight = 196.0;
|
||||
var clipY = 22.0;
|
||||
var clipBottom = clipY + clipHeight;
|
||||
var healthHeight = BatteryData.Percentage / 100.0 * clipHeight;
|
||||
var healthY = clipBottom - healthHeight;
|
||||
}
|
||||
|
||||
<g clip-path="url(#batteryClip)">
|
||||
<rect x="42" y="@healthY"
|
||||
width="116" height="@healthHeight"
|
||||
rx="6" ry="6"
|
||||
fill="@($"url(#healthGradient_{DeviceId}_{(int)BatteryData.Percentage})")"
|
||||
opacity="0.9" />
|
||||
|
||||
<ellipse cx="100" cy="@(healthY + 6)"
|
||||
rx="40" ry="8"
|
||||
fill="white" opacity="0.3">
|
||||
<animate attributeName="cy" dur="3s" repeatCount="indefinite"
|
||||
values="@(healthY + 6);@(healthY + 12);@(healthY + 6)" />
|
||||
<animate attributeName="opacity" dur="3s" repeatCount="indefinite"
|
||||
values="0.3;0.4;0.3" />
|
||||
</ellipse>
|
||||
</g>
|
||||
|
||||
<text x="100" y="140"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
font-size="48" font-weight="bold"
|
||||
fill="@GetHealthTextColor()">
|
||||
@((int)BatteryData.Percentage)%
|
||||
</text>
|
||||
|
||||
<text x="100" y="180"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
font-size="16"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-secondary);">
|
||||
Charge Level
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
<!-- RIGHT COLUMN -->
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
|
||||
<!-- HEALTH -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@GetHealthPercentage()%</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Health (SOH)</MudText>
|
||||
<MudProgressLinear Value="@GetHealthPercentage()" Color="@GetChargeLevelColor()" Class="mt-2" />
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- VOLTAGE -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.Voltage.ToString("F2") V</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Voltage</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- CURRENT -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4" Color="@GetCurrentColor()">@BatteryData.Current.ToString("F2") A</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Current</MudText>
|
||||
<MudChip T="string" Color="@(IsCharging()? Color.Success: Color.Default)" Size="Size.Small" Class="mt-2">
|
||||
@(IsCharging() ? "Charging" : "Discharging")
|
||||
</MudChip>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- TEMPERATURE -->
|
||||
@if (BatteryData.CellTemperature != null && BatteryData.CellTemperature.Length > 0)
|
||||
{
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.CellTemperature[0].ToString("F1") °C</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Temperature</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- REMAIN CAPACITY -->
|
||||
@if (!double.IsNaN(BatteryData.Charge))
|
||||
{
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.Charge.ToString("F2") Ah</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Remaining Capacity</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- FULL CAPACITY -->
|
||||
@if (!double.IsNaN(BatteryData.Capacity))
|
||||
{
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.Capacity.ToString("F2") Ah</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Full Capacity</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- TIMESTAMP -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Last Update: @BatteryData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
|
||||
private BatteryState BatteryData = new();
|
||||
|
||||
private bool IsLoading => !BatteryHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
=> await base.OnInitializedAsync();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
await ConnectAsync();
|
||||
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await BatteryHubClient.StartAsync();
|
||||
var deviceInfo = await BatteryHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
DeviceName = deviceInfo?.DeviceName ?? DeviceId;
|
||||
|
||||
BatteryData = await BatteryHubClient.GetBatteryDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await BatteryHubClient.StopAsync();
|
||||
BatteryData = new BatteryState();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadBatteryDataAsync()
|
||||
{
|
||||
if (!BatteryHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
BatteryData = await BatteryHubClient.GetBatteryDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload battery data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetChargeLevelColor() =>
|
||||
GetHealthPercentage() switch
|
||||
{
|
||||
>= 70 => Color.Success,
|
||||
>= 30 => Color.Warning,
|
||||
_ => Color.Error
|
||||
};
|
||||
|
||||
private Color GetCurrentColor() =>
|
||||
IsCharging() ? Color.Success : Color.Info;
|
||||
|
||||
private string GetHealthColor() =>
|
||||
BatteryData.Percentage switch
|
||||
{
|
||||
>= 70 => "#4caf50",
|
||||
>= 30 => "#ff9800",
|
||||
_ => "#f44336"
|
||||
};
|
||||
|
||||
private bool IsCharging() =>
|
||||
BatteryData.PowerSupplyStatus == BatteryState.PowerSupplyStatusCharging;
|
||||
|
||||
private double GetHealthPercentage()
|
||||
{
|
||||
return BatteryData.PowerSupplyHealth switch
|
||||
{
|
||||
BatteryState.PowerSupplyHealthGood => 100.0,
|
||||
BatteryState.PowerSupplyHealthOverheat => 50.0,
|
||||
BatteryState.PowerSupplyHealthDead => 0.0,
|
||||
BatteryState.PowerSupplyHealthOvervoltage => 30.0,
|
||||
BatteryState.PowerSupplyHealthUnspecifiedFailure => 20.0,
|
||||
BatteryState.PowerSupplyHealthCold => 40.0,
|
||||
_ => 0.0
|
||||
};
|
||||
}
|
||||
|
||||
private string GetHealthTextColor() =>
|
||||
"var(--mud-palette-text-primary)";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
=> await DisconnectAsync();
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
|
||||
@inject CameraQrHubClient CameraQrHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload Camera QR data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadCameraQrDataAsync"
|
||||
Disabled="@(!CameraQrHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;" Class="pa-2">
|
||||
<MudGrid Spacing="2">
|
||||
<!-- Left Column: Camera Frame Visualization -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary" Class="mb-2">Camera Frame</MudText>
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 640 480" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 400px; border: 2px solid var(--mud-palette-divider); border-radius: 4px;">
|
||||
<defs>
|
||||
<!-- Gradient cho camera frame -->
|
||||
<linearGradient id="@($"cameraGradient_{DeviceId}")" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#2a2a2a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<!-- Filter cho glow effect -->
|
||||
<filter id="@($"glow_{DeviceId}")">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Camera frame background -->
|
||||
<rect x="0" y="0" width="640" height="480" fill="url(@($"#cameraGradient_{DeviceId}"))" />
|
||||
|
||||
</svg>
|
||||
</div>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right Column: Data -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudGrid Spacing="1">
|
||||
<!-- Connection Status -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudGrid Spacing="1">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudChip T="string" Color="@(CameraQrData.IsConnected ? Color.Success : Color.Error)"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Filled">
|
||||
@(CameraQrData.IsConnected ? "Connected" : "Disconnected")
|
||||
</MudChip>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Detection Status -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary" Class="mb-1">Detection Status</MudText>
|
||||
<MudGrid Spacing="1">
|
||||
<MudItem xs="12">
|
||||
@if (CameraQrData.Codes is { Count: > 0 })
|
||||
{
|
||||
<MudTable Dense="true" Hover="true" Bordered="true" Elevation="0" Items="CameraQrData.Codes">
|
||||
<HeaderContent>
|
||||
<MudTh>Code</MudTh>
|
||||
<MudTh>X (m)</MudTh>
|
||||
<MudTh>Y (m)</MudTh>
|
||||
<MudTh>Z (m)</MudTh>
|
||||
<MudTh>Yaw (deg)</MudTh>
|
||||
<MudTh>Time</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate Context="kv">
|
||||
<MudTd>@kv.Key</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Position.X.ToString("F3")</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Position.Y.ToString("F3")</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Position.Z.ToString("F3")</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Orientation.ToYawDegrees().ToString("F1")</MudTd>
|
||||
<MudTd>@kv.Value.Header.Stamp.ToLocalTime().ToString("HH:mm:ss")</MudTd>
|
||||
</RowTemplate>
|
||||
<FooterContent>
|
||||
<MudTd ColSpan="6">
|
||||
Total: @CameraQrData.Codes.Count code(s)
|
||||
</MudTd>
|
||||
</FooterContent>
|
||||
</MudTable>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
No QR detected.
|
||||
</MudText>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private CameraQrDataDto CameraQrData = new();
|
||||
private bool IsLoading => !CameraQrHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CameraQrHubClient.StartAsync();
|
||||
|
||||
// Lấy DeviceName từ CameraQrHub
|
||||
var deviceInfo = await CameraQrHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
if (deviceInfo != null)
|
||||
{
|
||||
DeviceName = deviceInfo.DeviceName;
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceName = DeviceId; // Fallback to DeviceId if not found
|
||||
}
|
||||
|
||||
CameraQrData = await CameraQrHubClient.GetCameraQrDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CameraQrHubClient.StopAsync();
|
||||
CameraQrData = new CameraQrDataDto(); // Reset về giá trị mặc định
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadCameraQrDataAsync()
|
||||
{
|
||||
if (!CameraQrHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
CameraQrData = await CameraQrHubClient.GetCameraQrDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload Camera QR data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetConfidenceColor(double confidence)
|
||||
{
|
||||
if (confidence >= 0.9)
|
||||
{
|
||||
return Color.Success;
|
||||
}
|
||||
else if (confidence >= 0.7)
|
||||
{
|
||||
return Color.Info;
|
||||
}
|
||||
else if (confidence >= 0.5)
|
||||
{
|
||||
return Color.Warning;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Color.Error;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using MudBlazor
|
||||
|
||||
<MudCard Elevation="1" Class="mb-2">
|
||||
<MudCardContent Class="pa-2">
|
||||
@* Compact Header *@
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1" Class="mb-1">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@GetDeviceIcon()" Color="@GetStatusColor()" Size="Size.Small" />
|
||||
<div>
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 600; line-height: 1.2;">@Device.DeviceName</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="line-height: 1;">@Device.DeviceId</MudText>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetStatusColor()" Variant="Variant.Filled" Style="font-size: 0.7rem;">
|
||||
@Device.Status.ToString()
|
||||
</MudChip>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Settings"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Text"
|
||||
OnClick="NavigateToDeviceManagement"
|
||||
title="Manage Device" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@* Compact Info Grid - 2 columns *@
|
||||
<MudGrid Spacing="1" Class="mt-1">
|
||||
@* Device Type *@
|
||||
<MudItem xs="6">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Type:</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Style="font-size: 0.7rem; height: 20px;">@Device.DeviceType</MudChip>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Connection Status *@
|
||||
<MudItem xs="6">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Status:</MudText>
|
||||
<MudIcon Icon="@(Device.IsConnected ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.Cancel)"
|
||||
Color="@(Device.IsConnected ? Color.Success : Color.Error)"
|
||||
Size="Size.Small" Style="width: 16px; height: 16px;" />
|
||||
<MudText Typo="Typo.caption">@(Device.IsConnected ? "Yes" : "No")</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Last Update - Compact format *@
|
||||
<MudItem xs="12">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Updated:</MudText>
|
||||
<MudText Typo="Typo.caption">@Device.LastUpdateTime.ToString("MM-dd HH:mm:ss")</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Reconnect Attempts - Only show if > 0 *@
|
||||
@if (Device.ReconnectAttemptCount > 0)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Retries:</MudText>
|
||||
<MudChip T="int" Size="Size.Small" Color="Color.Warning" Style="font-size: 0.7rem; height: 20px;">@Device.ReconnectAttemptCount</MudChip>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@* Error Message - Compact *@
|
||||
@if (!string.IsNullOrWhiteSpace(Device.LastError))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-1" Style="padding: 4px 8px;">
|
||||
<MudText Typo="Typo.caption" Style="line-height: 1.2;">@Device.LastError</MudText>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* Properties - Collapsed by default, only show count *@
|
||||
@if (Device.Properties.Any())
|
||||
{
|
||||
<MudExpansionPanels Dense="true" Class="mt-1">
|
||||
<MudExpansionPanel Text="@($"Properties ({Device.Properties.Count})")" Icon="@Icons.Material.Filled.Info" Style="font-size: 0.75rem;">
|
||||
<MudSimpleTable Dense="true" Style="font-size: 0.7rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 4px;">Property</th>
|
||||
<th style="padding: 4px;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var prop in GetDisplayedProperties().Take(5))
|
||||
{
|
||||
<tr>
|
||||
<td style="padding: 2px 4px;">
|
||||
<MudText Typo="Typo.caption">@prop.DisplayName</MudText>
|
||||
</td>
|
||||
<td style="padding: 2px 4px;">
|
||||
@if (Device.Properties.TryGetValue(prop.Key, out var value))
|
||||
{
|
||||
<MudText Typo="Typo.caption">
|
||||
@FormatPropertyValue(value, prop)
|
||||
</MudText>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (Device.Properties.Count > 5)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 2px 4px; text-align: center;">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
... and @(Device.Properties.Count - 5) more
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public DeviceDto Device { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager NavigationManager { get; set; } = null!;
|
||||
|
||||
private void NavigateToDeviceManagement()
|
||||
{
|
||||
var deviceType = GetDeviceTypeRoute(Device.DeviceType);
|
||||
var deviceId = Uri.EscapeDataString(Device.DeviceId);
|
||||
NavigationManager.NavigateTo($"/devices/{deviceType}/{deviceId}");
|
||||
}
|
||||
|
||||
private string GetDeviceTypeRoute(DeviceType deviceType)
|
||||
{
|
||||
return deviceType switch
|
||||
{
|
||||
DeviceType.Lidar => "lidar",
|
||||
DeviceType.Imu => "imu",
|
||||
DeviceType.Battery => "battery",
|
||||
DeviceType.ModbusTcp => "modbustcp",
|
||||
DeviceType.RfHandle => "rfhandle",
|
||||
DeviceType.CameraQr => "cameraqr",
|
||||
DeviceType.CiA402Servo => "cia402servo",
|
||||
_ => deviceType.ToString().ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private string GetDeviceIcon()
|
||||
{
|
||||
return Device.DeviceType switch
|
||||
{
|
||||
DeviceType.Lidar => Icons.Material.Filled.Radar,
|
||||
DeviceType.Imu => Icons.Material.Filled.Explore,
|
||||
DeviceType.Battery => Icons.Material.Filled.BatteryFull,
|
||||
DeviceType.ModbusTcp => Icons.Material.Filled.Lan,
|
||||
DeviceType.RfHandle => Icons.Material.Filled.RadioButtonChecked,
|
||||
DeviceType.CameraQr => Icons.Material.Filled.QrCodeScanner,
|
||||
DeviceType.CiA402Servo => Icons.Material.Filled.Settings,
|
||||
_ => Icons.Material.Filled.Devices
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetStatusColor()
|
||||
{
|
||||
return Device.Status switch
|
||||
{
|
||||
DeviceStatus.Connected => Color.Success,
|
||||
DeviceStatus.Connecting => Color.Info,
|
||||
DeviceStatus.Disconnecting => Color.Warning,
|
||||
DeviceStatus.Disconnected => Color.Default,
|
||||
DeviceStatus.Reconnecting => Color.Warning,
|
||||
DeviceStatus.Error => Color.Error,
|
||||
DeviceStatus.Initializing => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<PropertyDescription> GetDisplayedProperties()
|
||||
{
|
||||
return Device.PropertyDescriptions
|
||||
.OrderBy(p => p.DisplayOrder)
|
||||
.ThenBy(p => p.Category ?? "")
|
||||
.ThenBy(p => p.DisplayName);
|
||||
}
|
||||
|
||||
private string FormatPropertyValue(string value, PropertyDescription prop)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "-";
|
||||
|
||||
if (prop.DataType == "number" && double.TryParse(value, out var numValue))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(prop.Format))
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.Format(prop.Format, numValue) + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@using RobotNet10.Shared.Sensor
|
||||
@using RobotNet10.Shared.Geometry
|
||||
@using RobotNet10.Shared.Numbers
|
||||
@using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion
|
||||
@using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion
|
||||
@implements IAsyncDisposable
|
||||
@inject InertialMeasurementUnitHubClient ImuHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload IMU data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadImuDataAsync"
|
||||
Disabled="@(!ImuHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
<!-- Left Column: IMU SVG với 3D visualization -->
|
||||
<MudItem xs="12" md="4">
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 300 300" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
|
||||
<defs>
|
||||
<!-- Gradients cho các trục -->
|
||||
<linearGradient id="@($"xAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#f44336;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#f44336;stop-opacity:0.4" />
|
||||
</linearGradient>
|
||||
<linearGradient id="@($"yAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4caf50;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#4caf50;stop-opacity:0.4" />
|
||||
</linearGradient>
|
||||
<linearGradient id="@($"zAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2196f3;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#2196f3;stop-opacity:0.4" />
|
||||
</linearGradient>
|
||||
<!-- Filter cho glow effect -->
|
||||
<filter id="@($"glow_{DeviceId}")">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<!-- Arrow markers -->
|
||||
<marker id="@($"arrowhead-red-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#f44336" />
|
||||
</marker>
|
||||
<marker id="@($"arrowhead-green-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#4caf50" />
|
||||
</marker>
|
||||
<marker id="@($"arrowhead-blue-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#2196f3" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle -->
|
||||
<circle cx="150" cy="150" r="120" fill="none" stroke="currentColor" stroke-width="2"
|
||||
opacity="0.1" style="color: var(--mud-palette-text-primary);" />
|
||||
|
||||
<!-- IMU Device (3D box representation) -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Device box với perspective -->
|
||||
<g transform="rotate(@(QuaternionToEuler(ImuData.Orientation).yaw * 180 / Math.PI), 0, 0)">
|
||||
<!-- Top face -->
|
||||
<polygon points="-30,-20 -10,-30 10,-30 30,-20 30,20 10,30 -10,30 -30,20"
|
||||
fill="var(--mud-palette-surface)"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.9"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
<!-- Front face -->
|
||||
<polygon points="-30,-20 30,-20 30,20 -30,20"
|
||||
fill="var(--mud-palette-surface)"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.7"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
<!-- Side face -->
|
||||
<polygon points="30,-20 10,-30 -10,-30 -30,-20 -30,20 -10,30 10,30 30,20"
|
||||
fill="var(--mud-palette-surface)"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.5"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
</g>
|
||||
|
||||
<!-- X Axis (Red) -->
|
||||
@{
|
||||
var accelX = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.X * 5));
|
||||
var accelXEnd = 80 + accelX;
|
||||
}
|
||||
<line x1="0" y1="0" x2="@accelXEnd" y2="0"
|
||||
stroke="#f44336"
|
||||
stroke-width="4"
|
||||
marker-end="@($"url(#arrowhead-red-{DeviceId})")"
|
||||
filter="@($"url(#glow_{DeviceId})")"
|
||||
opacity="0.8">
|
||||
<animate attributeName="x2"
|
||||
values="@(accelXEnd - 5);@(accelXEnd + 5);@(accelXEnd - 5)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
</line>
|
||||
<text x="@(accelXEnd + 10)" y="5" font-size="12" fill="#f44336" font-weight="bold">X</text>
|
||||
|
||||
<!-- Y Axis (Green) -->
|
||||
@{
|
||||
var accelY = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.Y * 5));
|
||||
var accelYEnd = 80 + accelY;
|
||||
}
|
||||
<line x1="0" y1="0" x2="0" y2="@(-accelYEnd)"
|
||||
stroke="#4caf50"
|
||||
stroke-width="4"
|
||||
marker-end="@($"url(#arrowhead-green-{DeviceId})")"
|
||||
filter="@($"url(#glow_{DeviceId})")"
|
||||
opacity="0.8">
|
||||
<animate attributeName="y2"
|
||||
values="@(-accelYEnd - 5);@(-accelYEnd + 5);@(-accelYEnd - 5)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
</line>
|
||||
<text x="5" y="@(-accelYEnd - 10)" font-size="12" fill="#4caf50" font-weight="bold">Y</text>
|
||||
|
||||
<!-- Z Axis (Blue) - represented as depth -->
|
||||
@{
|
||||
var accelZ = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.Z * 5));
|
||||
var zOffset = accelZ * 0.5;
|
||||
}
|
||||
<line x1="0" y1="0" x2="@zOffset" y2="@(80 + zOffset)"
|
||||
stroke="#2196f3"
|
||||
stroke-width="4"
|
||||
stroke-dasharray="5,5"
|
||||
marker-end="@($"url(#arrowhead-blue-{DeviceId})")"
|
||||
filter="@($"url(#glow_{DeviceId})")"
|
||||
opacity="0.8">
|
||||
<animate attributeName="x2"
|
||||
values="@(zOffset - 3);@(zOffset + 3);@(zOffset - 3)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="y2"
|
||||
values="@(80 + zOffset - 3);@(80 + zOffset + 3);@(80 + zOffset - 3)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
</line>
|
||||
<text x="@(zOffset + 5)" y="@(80 + zOffset + 15)" font-size="12" fill="#2196f3" font-weight="bold">Z</text>
|
||||
|
||||
<!-- Angular velocity indicators (circular arrows) -->
|
||||
@{
|
||||
var angularVel = Math.Sqrt(ImuData.AngularVelocity.X * ImuData.AngularVelocity.X +
|
||||
ImuData.AngularVelocity.Y * ImuData.AngularVelocity.Y +
|
||||
ImuData.AngularVelocity.Z * ImuData.AngularVelocity.Z);
|
||||
var angularVelNormalized = Math.Max(0, Math.Min(1, angularVel / 5.0));
|
||||
var rotationSpeed = angularVelNormalized * 360;
|
||||
}
|
||||
<circle cx="0" cy="0" r="60"
|
||||
fill="none"
|
||||
stroke="#ff9800"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="10,5"
|
||||
opacity="@(0.3 + angularVelNormalized * 0.5)"
|
||||
transform="rotate(@rotationSpeed)">
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
values="0;360"
|
||||
dur="@(Math.Max(0.5, 5 - angularVelNormalized * 4.5))s"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- Center point -->
|
||||
<circle cx="0" cy="0" r="5" fill="currentColor"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
</g>
|
||||
|
||||
<!-- Orientation text -->
|
||||
<text x="150" y="280"
|
||||
text-anchor="middle"
|
||||
font-size="14"
|
||||
font-weight="bold"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-primary);">
|
||||
Orientation
|
||||
</text>
|
||||
<text x="150" y="295"
|
||||
text-anchor="middle"
|
||||
font-size="12"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-secondary);">
|
||||
R:@(QuaternionToEuler(ImuData.Orientation).roll.ToString("F2")) P:@(QuaternionToEuler(ImuData.Orientation).pitch.ToString("F2")) Y:@(QuaternionToEuler(ImuData.Orientation).yaw.ToString("F2"))
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right Column: Data parameters -->
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
<!-- Status Row -->
|
||||
<MudItem xs="12">
|
||||
<MudGrid>
|
||||
<!-- Status fields removed - not available in Imu struct -->
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Acceleration -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Acceleration</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">m/s²</MudText>
|
||||
<MudText Typo="Typo.body1">X: <strong>@ImuData.LinearAcceleration.X.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Y: <strong>@ImuData.LinearAcceleration.Y.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Z: <strong>@ImuData.LinearAcceleration.Z.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Angular Velocity -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">Angular Velocity</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">rad/s</MudText>
|
||||
<MudText Typo="Typo.body1">Roll (X): <strong>@ImuData.AngularVelocity.X.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Pitch (Y): <strong>@ImuData.AngularVelocity.Y.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Yaw (Z): <strong>@ImuData.AngularVelocity.Z.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Orientation -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">Orientation</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">rad (Euler)</MudText>
|
||||
<MudText Typo="Typo.body1">Roll: <strong>@QuaternionToEuler(ImuData.Orientation).roll.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Pitch: <strong>@QuaternionToEuler(ImuData.Orientation).pitch.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Yaw: <strong>@QuaternionToEuler(ImuData.Orientation).yaw.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Quaternion -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Secondary">Quaternion</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">Unit quaternion</MudText>
|
||||
<MudText Typo="Typo.body1">W: <strong>@ImuData.Orientation.W.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">X: <strong>@ImuData.Orientation.X.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Y: <strong>@ImuData.Orientation.Y.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Z: <strong>@ImuData.Orientation.Z.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Last Update Time -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Last Update: @ImuData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<!-- Device Properties -->
|
||||
@if (DeviceProperties.Any() && PropertyDescriptions.Any())
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudExpansionPanels Dense="true" Class="mt-2">
|
||||
<MudExpansionPanel Text="@($"Device Properties ({DeviceProperties.Count})")"
|
||||
Icon="@Icons.Material.Filled.Info"
|
||||
Style="font-size: 0.875rem;">
|
||||
<MudSimpleTable Dense="true" Hover="true" Striped="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 8px; width: 30%;">Property</th>
|
||||
<th style="padding: 8px; width: 70%;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var prop in GetDisplayedProperties())
|
||||
{
|
||||
<tr>
|
||||
<td style="padding: 6px 8px;">
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 500;">
|
||||
@prop.DisplayName
|
||||
</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(prop.Description))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="font-size: 0.7rem;">
|
||||
@prop.Description
|
||||
</MudText>
|
||||
}
|
||||
</td>
|
||||
<td style="padding: 6px 8px;">
|
||||
@if (DeviceProperties.TryGetValue(prop.Key, out var value))
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@FormatPropertyValue(value, prop)
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private Imu ImuData = new();
|
||||
private Dictionary<string, string> DeviceProperties = new();
|
||||
private List<PropertyDescription> PropertyDescriptions = new();
|
||||
private bool IsLoading => !ImuHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ImuHubClient.StartAsync();
|
||||
|
||||
// Lấy DeviceName từ ImuHub
|
||||
var deviceInfo = await ImuHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
if (deviceInfo != null)
|
||||
{
|
||||
DeviceName = deviceInfo.DeviceName;
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceName = DeviceId; // Fallback to DeviceId if not found
|
||||
}
|
||||
|
||||
ImuData = await ImuHubClient.GetImuDataAsync(DeviceId);
|
||||
|
||||
// Lấy device properties và property descriptions
|
||||
var properties = await ImuHubClient.GetDevicePropertiesAsync(DeviceId);
|
||||
if (properties != null)
|
||||
{
|
||||
DeviceProperties = properties;
|
||||
}
|
||||
|
||||
var propDescriptions = await ImuHubClient.GetDevicePropertyDescriptionsAsync(DeviceId);
|
||||
if (propDescriptions != null)
|
||||
{
|
||||
PropertyDescriptions = propDescriptions;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ImuHubClient.StopAsync();
|
||||
ImuData = new Imu(); // Reset về giá trị mặc định
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadImuDataAsync()
|
||||
{
|
||||
if (!ImuHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
ImuData = await ImuHubClient.ReadAllDataAsync(DeviceId);
|
||||
|
||||
// Reload properties
|
||||
var properties = await ImuHubClient.GetDevicePropertiesAsync(DeviceId);
|
||||
if (properties != null)
|
||||
{
|
||||
DeviceProperties = properties;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload IMU data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert Quaternion sang Euler angles (Roll, Pitch, Yaw)
|
||||
/// </summary>
|
||||
private (double roll, double pitch, double yaw) QuaternionToEuler(QuaternionGeometry q)
|
||||
{
|
||||
// Roll (x-axis rotation)
|
||||
var sinr_cosp = 2 * (q.W * q.X + q.Y * q.Z);
|
||||
var cosr_cosp = 1 - 2 * (q.X * q.X + q.Y * q.Y);
|
||||
var roll = Math.Atan2(sinr_cosp, cosr_cosp);
|
||||
|
||||
// Pitch (y-axis rotation)
|
||||
var sinp = 2 * (q.W * q.Y - q.Z * q.X);
|
||||
double pitch;
|
||||
if (Math.Abs(sinp) >= 1)
|
||||
pitch = Math.CopySign(Math.PI / 2, sinp); // use 90 degrees if out of range
|
||||
else
|
||||
pitch = Math.Asin(sinp);
|
||||
|
||||
// Yaw (z-axis rotation)
|
||||
var siny_cosp = 2 * (q.W * q.Z + q.X * q.Y);
|
||||
var cosy_cosp = 1 - 2 * (q.Y * q.Y + q.Z * q.Z);
|
||||
var yaw = Math.Atan2(siny_cosp, cosy_cosp);
|
||||
|
||||
return (roll, pitch, yaw);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy danh sách properties đã sắp xếp để hiển thị
|
||||
/// </summary>
|
||||
private IEnumerable<PropertyDescription> GetDisplayedProperties()
|
||||
{
|
||||
return PropertyDescriptions
|
||||
.OrderBy(p => p.DisplayOrder)
|
||||
.ThenBy(p => p.Category ?? "")
|
||||
.ThenBy(p => p.DisplayName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format giá trị property theo DataType và Format
|
||||
/// </summary>
|
||||
private string FormatPropertyValue(string value, PropertyDescription prop)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "-";
|
||||
|
||||
if (prop.DataType == "number" && double.TryParse(value, out var numValue))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(prop.Format))
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.Format(prop.Format, numValue) + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.Shared.Sensor
|
||||
@implements IAsyncDisposable
|
||||
@inject LidarHubClient LidarHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Toggle auto reload (1s interval)">
|
||||
<MudIconButton Icon="@(AutoReloadEnabled ? Icons.Material.Filled.PauseCircle : Icons.Material.Filled.PlayCircle)"
|
||||
Color="@(AutoReloadEnabled ? Color.Success : Color.Default)"
|
||||
Size="Size.Small"
|
||||
OnClick="ToggleAutoReloadAsync"
|
||||
Disabled="@(!LidarHubClient.IsConnected)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Reload lidar data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadLidarDataAsync"
|
||||
Disabled="@(!LidarHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
<!-- Left Column: Lidar Radar Plot SVG -->
|
||||
<MudItem xs="12" md="8">
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 @SvgSize @SvgSize" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
|
||||
<defs>
|
||||
<!-- Gradient cho scan line -->
|
||||
<radialGradient id="@($"scanGradient_{DeviceId}")" cx="50%" cy="50%">
|
||||
<stop offset="0%" style="stop-color:#2196f3;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#2196f3;stop-opacity:0.2" />
|
||||
</radialGradient>
|
||||
<!-- Filter cho glow effect -->
|
||||
<filter id="@($"glow_{DeviceId}")">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background circles (range indicators) -->
|
||||
@foreach (var radius in RangeIndicatorRadii)
|
||||
{
|
||||
<circle cx="@SvgCenter" cy="@SvgCenter" r="@radius" fill="none" stroke="currentColor" stroke-width="1"
|
||||
opacity="0.2" style="color: var(--mud-palette-text-primary);" />
|
||||
}
|
||||
|
||||
<!-- Center point (Lidar position) -->
|
||||
<circle cx="@SvgCenter" cy="@SvgCenter" r="5" fill="#2196f3" />
|
||||
<circle cx="@SvgCenter" cy="@SvgCenter" r="8" fill="#2196f3" opacity="0.3">
|
||||
<animate attributeName="r" values="8;15;8" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.3;0;0.3" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- Grid lines (every 30 degrees) -->
|
||||
@foreach (var gridLine in GridLines)
|
||||
{
|
||||
<line x1="@SvgCenter" y1="@SvgCenter" x2="@gridLine.X" y2="@gridLine.Y"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.5"
|
||||
opacity="0.1"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
}
|
||||
|
||||
<!-- Scan points và biên dạng -->
|
||||
@if (ValidScanPoints.Count > 0)
|
||||
{
|
||||
var pathData = BuildScanPath();
|
||||
|
||||
<!-- Biên dạng (outline) - nối các điểm scan với đường mỏng -->
|
||||
<path d="@pathData"
|
||||
fill="none"
|
||||
stroke="#2196f3"
|
||||
stroke-width="1"
|
||||
opacity="0.8"
|
||||
filter="@($"url(#glow_{DeviceId})")" />
|
||||
|
||||
<!-- Fill area bên trong biên dạng -->
|
||||
<path d="@pathData"
|
||||
fill="url(@($"#scanGradient_{DeviceId}"))"
|
||||
opacity="0.3" />
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right Column: Data parameters -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudGrid>
|
||||
<!-- Status -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Status</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
Points: <strong>@LidarData.Ranges.Length</strong>
|
||||
</MudText>
|
||||
@if (LidarData.ScanTime > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
Frequency: <strong>@((1.0 / LidarData.ScanTime).ToString("F1")) Hz</strong>
|
||||
</MudText>
|
||||
}
|
||||
@if (LidarData.AngleIncrement > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
Resolution: <strong>@FormatAngleDegreesPrecise(LidarData.AngleIncrement)°</strong>
|
||||
</MudText>
|
||||
}
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Device Specifications -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">Specifications</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
Angle Range: <strong>@FormatAngleDegrees(LidarData.AngleMin)° to @FormatAngleDegrees(LidarData.AngleMax)°</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
FOV: <strong>@FormatAngleDegrees(LidarData.AngleMax - LidarData.AngleMin)°</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Range: <strong>@LidarData.RangeMin.ToString("F2") m to @LidarData.RangeMax.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Intensity: <strong>@(LidarData.Intensities != null && LidarData.Intensities.Length > 0 ? "Supported" : "Not Supported")</strong>
|
||||
</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Statistics -->
|
||||
@if (Statistics != null)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">Statistics</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
Avg Distance: <strong>@Statistics.AvgDistance.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Min Distance: <strong>@Statistics.MinDistance.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Max Distance: <strong>@Statistics.MaxDistance.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Avg Intensity: <strong>@Statistics.AvgIntensity.ToString("F1")</strong>
|
||||
</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- Last Update Time -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Last Update: @LidarData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss.fff")
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
#region Constants
|
||||
private const int SvgSize = 400;
|
||||
private const double SvgCenter = 200.0;
|
||||
private const double SvgMaxRadius = 150.0;
|
||||
private const double DefaultMaxDistanceM = 10.0;
|
||||
private const int GridLineStepDegrees = 30;
|
||||
|
||||
private static readonly int[] RangeIndicatorRadii = { 150, 100, 50 };
|
||||
#endregion
|
||||
|
||||
#region Parameters
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private LaserScan LidarData = new();
|
||||
private bool IsReloading = false;
|
||||
private bool AutoReloadEnabled = false;
|
||||
private System.Threading.PeriodicTimer? _autoReloadTimer;
|
||||
private readonly System.Threading.CancellationTokenSource _cancellationTokenSource = new();
|
||||
#endregion
|
||||
|
||||
#region Computed Properties
|
||||
private bool IsLoading => !LidarHubClient.IsConnected;
|
||||
|
||||
/// <summary>
|
||||
/// Tính AngleIncrement thực tế dựa trên số lượng Ranges để hiển thị đúng
|
||||
/// </summary>
|
||||
private double ActualAngleIncrement
|
||||
{
|
||||
get
|
||||
{
|
||||
if (LidarData.Ranges == null || LidarData.Ranges.Length == 0)
|
||||
return LidarData.AngleIncrement;
|
||||
|
||||
var angleSpan = LidarData.AngleMax - LidarData.AngleMin;
|
||||
if (angleSpan <= 0 || LidarData.Ranges.Length <= 1)
|
||||
return LidarData.AngleIncrement;
|
||||
|
||||
// Tính AngleIncrement thực tế dựa trên số lượng điểm
|
||||
return angleSpan / (LidarData.Ranges.Length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<ScanPoint> ValidScanPoints
|
||||
{
|
||||
get
|
||||
{
|
||||
if (LidarData.Ranges == null || LidarData.Ranges.Length == 0)
|
||||
return Array.Empty<ScanPoint>();
|
||||
|
||||
var points = new List<ScanPoint>();
|
||||
// Sử dụng ActualAngleIncrement để tính góc đúng cho hiển thị
|
||||
var angleIncrement = ActualAngleIncrement;
|
||||
|
||||
for (int i = 0; i < LidarData.Ranges.Length; i++)
|
||||
{
|
||||
var range = LidarData.Ranges[i];
|
||||
var angle = LidarData.AngleMin + i * angleIncrement;
|
||||
var isValid = !double.IsNaN(range) && !double.IsInfinity(range) &&
|
||||
range >= LidarData.RangeMin && range <= LidarData.RangeMax;
|
||||
var intensity = LidarData.Intensities != null && i < LidarData.Intensities.Length
|
||||
? LidarData.Intensities[i] : 0.0;
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
points.Add(new ScanPoint
|
||||
{
|
||||
AngleRad = angle,
|
||||
AngleDeg = angle * 180.0 / Math.PI,
|
||||
DistanceM = range,
|
||||
Intensity = intensity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return points.OrderBy(p => p.AngleDeg).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private double MaxDistanceM
|
||||
{
|
||||
get
|
||||
{
|
||||
var validPoints = ValidScanPoints;
|
||||
if (validPoints.Count == 0)
|
||||
return DefaultMaxDistanceM;
|
||||
|
||||
var max = validPoints.Max(p => p.DistanceM);
|
||||
return max > 0 ? max : DefaultMaxDistanceM;
|
||||
}
|
||||
}
|
||||
|
||||
private double Scale => SvgMaxRadius / MaxDistanceM;
|
||||
|
||||
private ScanStatistics? Statistics
|
||||
{
|
||||
get
|
||||
{
|
||||
var validPoints = ValidScanPoints;
|
||||
if (validPoints.Count == 0)
|
||||
return null;
|
||||
|
||||
return new ScanStatistics
|
||||
{
|
||||
AvgDistance = validPoints.Average(p => p.DistanceM),
|
||||
MinDistance = validPoints.Min(p => p.DistanceM),
|
||||
MaxDistance = validPoints.Max(p => p.DistanceM),
|
||||
AvgIntensity = validPoints.Average(p => p.Intensity)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private List<(double X, double Y)> GridLines
|
||||
{
|
||||
get
|
||||
{
|
||||
var lines = new List<(double X, double Y)>();
|
||||
for (int angle = 0; angle < 360; angle += GridLineStepDegrees)
|
||||
{
|
||||
var rad = angle * Math.PI / 180.0;
|
||||
var x = SvgCenter + Math.Cos(rad) * SvgMaxRadius;
|
||||
var y = SvgCenter + Math.Sin(rad) * SvgMaxRadius;
|
||||
lines.Add((x, y));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Lifecycle Methods
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
_cancellationTokenSource.Cancel();
|
||||
_cancellationTokenSource.Dispose();
|
||||
await DisconnectAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection Methods
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LidarHubClient.StartAsync();
|
||||
|
||||
var deviceInfo = await LidarHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
DeviceName = deviceInfo?.DeviceName ?? DeviceId;
|
||||
|
||||
LidarData = await LidarHubClient.GetLidarDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect xx: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LidarHubClient.StopAsync();
|
||||
LidarData = new LaserScan();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadLidarDataAsync()
|
||||
{
|
||||
if (!LidarHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
LidarData = await LidarHubClient.GetLidarDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
IsReloading = false;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload lidar data: {ex.Message}", Severity.Error);
|
||||
IsReloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled)
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await StartAutoReloadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled || !LidarHubClient.IsConnected)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = true;
|
||||
_autoReloadTimer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await _autoReloadTimer.WaitForNextTickAsync(_cancellationTokenSource.Token))
|
||||
{
|
||||
if (!_cancellationTokenSource.Token.IsCancellationRequested && LidarHubClient.IsConnected)
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await ReloadLidarDataAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// Expected when cancelling
|
||||
}
|
||||
});
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task StopAutoReloadAsync()
|
||||
{
|
||||
if (!AutoReloadEnabled)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = false;
|
||||
if (_autoReloadTimer != null)
|
||||
{
|
||||
_autoReloadTimer.Dispose();
|
||||
_autoReloadTimer = null;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
private string BuildScanPath()
|
||||
{
|
||||
var validPoints = ValidScanPoints;
|
||||
if (validPoints.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var pathBuilder = new System.Text.StringBuilder();
|
||||
|
||||
// Start from center
|
||||
pathBuilder.Append($"M {SvgCenter:F2} {SvgCenter:F2}");
|
||||
|
||||
// Connect to all scan points
|
||||
foreach (var point in validPoints)
|
||||
{
|
||||
var (x, y) = ConvertAngleToSvgCoordinates(point.AngleRad, point.DistanceM);
|
||||
pathBuilder.Append($" L {x:F2} {y:F2}");
|
||||
}
|
||||
|
||||
// Close path back to center
|
||||
pathBuilder.Append($" L {SvgCenter:F2} {SvgCenter:F2}");
|
||||
pathBuilder.Append(" Z");
|
||||
|
||||
return pathBuilder.ToString();
|
||||
}
|
||||
|
||||
private (double x, double y) ConvertAngleToSvgCoordinates(double angleRad, double distanceM)
|
||||
{
|
||||
// Convert from SICK coordinate system to SVG coordinate system
|
||||
// SICK: 0° = right, increases counter-clockwise
|
||||
// SVG: 0° = up, increases clockwise
|
||||
// Formula: x = center + cos(angle) * distance, y = center - sin(angle) * distance
|
||||
var scaledDistance = distanceM * Scale;
|
||||
var x = SvgCenter + Math.Cos(angleRad) * scaledDistance;
|
||||
var y = SvgCenter - Math.Sin(angleRad) * scaledDistance;
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
private string FormatAngleDegrees(double angleRad) => (angleRad * 180.0 / Math.PI).ToString("F1");
|
||||
|
||||
private string FormatAngleDegreesPrecise(double angleRad) => (angleRad * 180.0 / Math.PI).ToString("F3");
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
private class ScanPoint
|
||||
{
|
||||
public double AngleRad { get; set; }
|
||||
public double AngleDeg { get; set; }
|
||||
public double DistanceM { get; set; }
|
||||
public double Intensity { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
private class ScanStatistics
|
||||
{
|
||||
public double AvgDistance { get; set; }
|
||||
public double MinDistance { get; set; }
|
||||
public double MaxDistance { get; set; }
|
||||
public double AvgIntensity { get; set; }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@implements IAsyncDisposable
|
||||
@inject ModbusTcpHubClient ModbusTcpHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload Modbus data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadModbusDataAsync"
|
||||
Disabled="@(!ModbusTcpHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
<!-- Connection Info -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>IP:</strong> @ModbusData.IpAddress
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>Port:</strong> @ModbusData.Port
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>Slave ID:</strong> @ModbusData.SlaveId
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudChip T="string" Color="@(ModbusData.IsConnected ? Color.Success : Color.Error)"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Filled">
|
||||
@(ModbusData.IsConnected ? "Connected" : "Disconnected")
|
||||
</MudChip>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Holding Registers -->
|
||||
@if (ModbusData.HoldingRegisters != null && ModbusData.HoldingRegisters.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.HoldingRegisters)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">
|
||||
Holding Registers: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity registers)
|
||||
</MudText>
|
||||
<MudTable Items="@range.Values" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value (Decimal)</MudTh>
|
||||
<MudTh>Value (Hex)</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Decimal)">
|
||||
<strong>@context.Value</strong>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Hex)">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">0x@(context.Value.ToString("X4"))</MudText>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Input Registers -->
|
||||
@if (ModbusData.InputRegisters != null && ModbusData.InputRegisters.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.InputRegisters)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">
|
||||
Input Registers: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity registers)
|
||||
</MudText>
|
||||
<MudTable Items="@range.Values" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value (Decimal)</MudTh>
|
||||
<MudTh>Value (Hex)</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Decimal)">
|
||||
<strong>@context.Value</strong>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Hex)">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">0x@(context.Value.ToString("X4"))</MudText>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Coils -->
|
||||
@if (ModbusData.Coils != null && ModbusData.Coils.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.Coils)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">
|
||||
Coils: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity coils)
|
||||
</MudText>
|
||||
<MudTable Items="@range.BoolValues" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
<MudTh>Action</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Variant="Variant.Filled"
|
||||
Color="@(context.Value ? Color.Success : Color.Default)">
|
||||
@(context.Value ? "ON" : "OFF")
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Action">
|
||||
@{
|
||||
var coilKey = $"{range.StartAddress}_{context.Index}";
|
||||
var isWriting = WritingCoils.ContainsKey(coilKey) && WritingCoils[coilKey];
|
||||
}
|
||||
<MudSwitch Value="@context.Value"
|
||||
Disabled="@(!ModbusTcpHubClient.IsConnected || isWriting)"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
ValueChanged="@((bool value) => HandleCoilToggle(context.Address, value, coilKey))">
|
||||
</MudSwitch>
|
||||
@if (isWriting)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="ml-2" />
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Discrete Inputs -->
|
||||
@if (ModbusData.DiscreteInputs != null && ModbusData.DiscreteInputs.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.DiscreteInputs)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Warning">
|
||||
Discrete Inputs: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity inputs)
|
||||
</MudText>
|
||||
<MudTable Items="@range.BoolValues" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Variant="Variant.Filled"
|
||||
Color="@(context.Value ? Color.Success : Color.Default)">
|
||||
@(context.Value ? "ON" : "OFF")
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private ModbusTcpData ModbusData = new();
|
||||
private bool IsLoading => !ModbusTcpHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
private Dictionary<string, bool> WritingCoils = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ModbusTcpHubClient.StartAsync();
|
||||
|
||||
// Lấy DeviceName từ ModbusTcpHub
|
||||
var deviceInfo = await ModbusTcpHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
if (deviceInfo != null)
|
||||
{
|
||||
DeviceName = deviceInfo.DeviceName;
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceName = DeviceId; // Fallback to DeviceId if not found
|
||||
}
|
||||
|
||||
ModbusData = await ModbusTcpHubClient.GetModbusDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ModbusTcpHubClient.StopAsync();
|
||||
ModbusData = new ModbusTcpData(); // Reset về giá trị mặc định
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadModbusDataAsync()
|
||||
{
|
||||
if (!ModbusTcpHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
ModbusData = await ModbusTcpHubClient.GetModbusDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload Modbus data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCoilToggle(ushort address, bool value, string coilKey)
|
||||
{
|
||||
if (!ModbusTcpHubClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to Modbus device", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set writing state
|
||||
WritingCoils[coilKey] = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var success = await ModbusTcpHubClient.WriteCoilAsync(DeviceId, address, value);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Coil {address} set to {(value ? "ON" : "OFF")}", Severity.Success);
|
||||
|
||||
// Reload data để cập nhật giá trị mới nhất
|
||||
await ReloadModbusDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to write coil {address}", Severity.Error);
|
||||
// Reload để khôi phục giá trị cũ
|
||||
await ReloadModbusDataAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error writing coil {address}: {ex.Message}", Severity.Error);
|
||||
// Reload để khôi phục giá trị cũ
|
||||
await ReloadModbusDataAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
WritingCoils[coilKey] = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
|
||||
@inject RfHandleHubClient RfHandleHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard Class="pa-0">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.caption">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Toggle auto reload (1s interval)">
|
||||
<MudIconButton Icon="@(AutoReloadEnabled? Icons.Material.Filled.PauseCircle : Icons.Material.Filled.PlayCircle)"
|
||||
Color="@(AutoReloadEnabled ? Color.Success : Color.Default)"
|
||||
Size="Size.Small"
|
||||
OnClick="ToggleAutoReloadAsync"
|
||||
Disabled="@(!RfHandleHubClient.IsConnected)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Reload RfHandle data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Disabled="@(!RfHandleHubClient.IsConnected || IsReloading)"
|
||||
OnClick="ReloadRfHandleDataAsync" />
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
|
||||
<MudCardContent Class="pa-3">
|
||||
|
||||
<MudGrid>
|
||||
|
||||
<!-- LEFT PANEL (NO JOYSTICK FOR YNZDH) -->
|
||||
<MudItem xs="12" md="5">
|
||||
<MudPaper Elevation="1" Class="pa-3 d-flex flex-column">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Joystick</MudText>
|
||||
<MudItem xs="12">
|
||||
<!-- ================= LEFT: JOYSTICK ================= -->
|
||||
<svg width="400" height="400" viewBox="0 0 220 220">
|
||||
<!-- Background -->
|
||||
<circle cx="110" cy="110" r="95" fill="#2e2e2e" />
|
||||
|
||||
<!-- Deadzone -->
|
||||
<circle cx="110" cy="110" r="25" fill="#3f3f3f" />
|
||||
|
||||
<!-- Axis cross -->
|
||||
<line x1="110" y1="15" x2="110" y2="205"
|
||||
stroke="#444" stroke-width="2" />
|
||||
<line x1="15" y1="110" x2="205" y2="110"
|
||||
stroke="#444" stroke-width="2" />
|
||||
|
||||
<!-- Joystick knob -->
|
||||
<circle cx="@JoyX"
|
||||
cy="@JoyY"
|
||||
r="28"
|
||||
fill="@JoyColor"
|
||||
stroke="#111"
|
||||
stroke-width="3" />
|
||||
</svg>
|
||||
<!-- ================= RIGHT: AXES ================= -->
|
||||
<!-- LINEAR -->
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
|
||||
Linear
|
||||
</MudText>
|
||||
|
||||
<MudProgressLinear Value="@AxisToPercent(RfHandleData.Linear)"
|
||||
Color="Color.Info"
|
||||
Class="mb-1" />
|
||||
|
||||
<MudText Typo="Typo.caption" Class="mb-3">
|
||||
@AxisText(RfHandleData.Linear)
|
||||
</MudText>
|
||||
|
||||
<!-- ANGULAR -->
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
|
||||
Angular
|
||||
</MudText>
|
||||
|
||||
<MudProgressLinear Value="@AxisToPercent(RfHandleData.Angular)"
|
||||
Color="Color.Info"
|
||||
Class="mb-1" />
|
||||
|
||||
<MudText Typo="Typo.caption">
|
||||
@AxisText(RfHandleData.Angular)
|
||||
</MudText>
|
||||
|
||||
</MudItem>
|
||||
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- RIGHT PANELS -->
|
||||
<MudItem xs="12" md="7">
|
||||
<MudGrid Spacing="2">
|
||||
|
||||
<!-- STATUS PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Info">Status</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
||||
<MudChip T="string" Color="@(RfHandleData.Heartbeat != 0 ? Color.Success : Color.Default)"
|
||||
Variant="@(RfHandleData.Heartbeat != 0 ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Heartbeat</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.RemoteReady? Color.Warning: Color.Default)"
|
||||
Variant="@(RfHandleData.RemoteReady ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">RemoteReady</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.EStop? Color.Error: Color.Default)"
|
||||
Variant="@(RfHandleData.EStop ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">EStop</MudChip>
|
||||
</MudStack>
|
||||
|
||||
<MudText Class="mt-2">
|
||||
<b>Last Update:</b> @RfHandleData.LastUpdateTime.ToString("HH:mm:ss")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- SPEED PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Speed</MudText>
|
||||
|
||||
<MudProgressLinear Value="@RfHandleData.Speed" Color="Color.Info" Class="mt-2" />
|
||||
<MudText Typo="Typo.caption">Speed: @RfHandleData.Speed%</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- MOTION PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Warning">Motion</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
||||
<MudChip T="string" Color="@(RfHandleData.LiftUp? Color.Info: Color.Default)"
|
||||
Variant="@(RfHandleData.LiftUp ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">LiftUp</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.LiftDown? Color.Info: Color.Default)"
|
||||
Variant="@(RfHandleData.LiftDown ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">LiftDown</MudChip>
|
||||
|
||||
<MudChip T="string" Color="@(RfHandleData.RotateLeft? Color.Secondary: Color.Default)"
|
||||
Variant="@(RfHandleData.RotateLeft ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Rot L</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.RotateRight? Color.Secondary: Color.Default)"
|
||||
Variant="@(RfHandleData.RotateRight ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Rot R</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- MODE PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Mode & Flags</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
||||
<MudChip T="string" Color="Color.Info" Variant="Variant.Filled">
|
||||
Mode: @RfHandleData.Mode
|
||||
</MudChip>
|
||||
|
||||
<MudChip T="string" Color="@(RfHandleData.ModeSelect? Color.Warning: Color.Default)"
|
||||
Variant="@(RfHandleData.ModeSelect ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">ModeSelect</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.Enable? Color.Success: Color.Default)"
|
||||
Variant="@(RfHandleData.Enable ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Enable</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
// ===== Joystick visual helpers =====
|
||||
|
||||
private const double JoyRadius = 70f; // px
|
||||
private const double JoyCenter = 110f;
|
||||
|
||||
private double SafeLinear =>
|
||||
(!RfHandleData.RemoteReady || RfHandleData.EStop)
|
||||
? 0
|
||||
: Math.Clamp(RfHandleData.Linear, -1, 1);
|
||||
|
||||
private double SafeAngular =>
|
||||
(!RfHandleData.RemoteReady || RfHandleData.EStop)
|
||||
? 0
|
||||
: Math.Clamp(RfHandleData.Angular, -1, 1);
|
||||
|
||||
private double JoyX =>
|
||||
JoyCenter + SafeAngular * JoyRadius;
|
||||
|
||||
private double JoyY =>
|
||||
JoyCenter - SafeLinear * JoyRadius;
|
||||
|
||||
private string JoyColor =>
|
||||
(!RfHandleData.RemoteReady || RfHandleData.EStop)
|
||||
? "#666"
|
||||
: "#bdbdbd";
|
||||
|
||||
private RfHandleDataDto RfHandleData = new();
|
||||
private bool IsReloading = false;
|
||||
private bool AutoReloadEnabled = false;
|
||||
private string DeviceName = "";
|
||||
private System.Threading.PeriodicTimer? _autoReloadTimer;
|
||||
private readonly System.Threading.CancellationTokenSource _cancellationTokenSource = new();
|
||||
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
private bool IsLoading => !RfHandleHubClient.IsConnected;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await RfHandleHubClient.StartAsync();
|
||||
|
||||
var info = await RfHandleHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
DeviceName = info?.DeviceName ?? DeviceId;
|
||||
|
||||
RfHandleData = await RfHandleHubClient.GetRfHandleDataAsync(DeviceId);
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Connect failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadRfHandleDataAsync()
|
||||
{
|
||||
if (!RfHandleHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
RfHandleData = await RfHandleHubClient.GetRfHandleDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload RfHandle data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled)
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await StartAutoReloadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled || !RfHandleHubClient.IsConnected)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = true;
|
||||
_autoReloadTimer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(0.05));
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await _autoReloadTimer.WaitForNextTickAsync(_cancellationTokenSource.Token))
|
||||
{
|
||||
if (!_cancellationTokenSource.Token.IsCancellationRequested && RfHandleHubClient.IsConnected)
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await ReloadRfHandleDataAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// Expected when cancelling
|
||||
}
|
||||
});
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task StopAutoReloadAsync()
|
||||
{
|
||||
if (!AutoReloadEnabled)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = false;
|
||||
if (_autoReloadTimer != null)
|
||||
{
|
||||
_autoReloadTimer.Dispose();
|
||||
_autoReloadTimer = null;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
_cancellationTokenSource.Cancel();
|
||||
_cancellationTokenSource.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
await RfHandleHubClient.StopAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
private int AxisToPercent(double v)
|
||||
{
|
||||
// v ∈ [-1, +1] → [0, 100]
|
||||
return (int)Math.Clamp((v + 1) * 50f, 0, 100f);
|
||||
}
|
||||
|
||||
private string AxisText(double v)
|
||||
{
|
||||
return v.ToString("F2");
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user