Initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
@page "/auth"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Auth</PageTitle>
|
||||
|
||||
<h1>You are authenticated</h1>
|
||||
|
||||
<AuthorizeView>
|
||||
Hello @context.User.Identity?.Name!
|
||||
</AuthorizeView>
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/config-manager"
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Configuration Manager</PageTitle>
|
||||
|
||||
<RobotNet10.CustomConfigurationEditor.Components.ConfigManager.ConfigManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,247 @@
|
||||
@page "/devices"
|
||||
@implements IAsyncDisposable
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
@using RobotNet10.RobotApp.Client.Services
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@using MudBlazor
|
||||
|
||||
@inject DeviceHubClient DeviceHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Device Diagnostics</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
|
||||
<MudPaper Class="pa-4 mb-4">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Justify="@Justify.SpaceBetween" Spacing="3">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Device Diagnostics</MudText>
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="3">
|
||||
<MudTextField @bind-Value="searchText" @bind-Value:after="OnFilterChanged" Placeholder="Search devices..." Class="flex-grow-1"
|
||||
Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Margin="@Margin.Dense" />
|
||||
<MudIconButton Color="Color.Primary" OnClick="RefreshDevices" Disabled="@(!DeviceHubClient.IsConnected || isLoading)" Icon="@Icons.Material.Filled.Refresh" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@* Loading State *@
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
|
||||
}
|
||||
|
||||
@* Device Count Summary *@
|
||||
<MudGrid Class="mb-4">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6">@devices.Count</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Total Devices</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">@devices.Count(d => d.IsConnected)</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Connected</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6" Color="Color.Error">@devices.Count(d => d.Status == DeviceStatus.Error)</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Errors</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6" Color="Color.Warning">@devices.Count(d => d.Status == DeviceStatus.Disconnected)</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Disconnected</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Devices List *@
|
||||
@if (filteredDevices != null && filteredDevices.Any())
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
@foreach (var device in filteredDevices)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="2">
|
||||
<DeviceCard Device="@device" />
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (!isLoading && (devices == null || !devices.Any()))
|
||||
{
|
||||
<MudPaper Class="pa-8 text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Devices" Size="Size.Large" Color="Color.Secondary" Class="mb-4" />
|
||||
<MudText Typo="Typo.h6">No devices found</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No devices are currently registered in the system.</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (!isLoading)
|
||||
{
|
||||
<MudPaper Class="pa-8 text-center">
|
||||
<MudText Typo="Typo.h6">No devices match the filter criteria</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private List<DeviceDto> devices = [];
|
||||
private List<DeviceDto> filteredDevices = [];
|
||||
private bool isLoading = false;
|
||||
private string searchText = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
DeviceHubClient.DeviceUpdated += OnDeviceUpdated;
|
||||
DeviceHubClient.DeviceStatusChanged += OnDeviceStatusChanged;
|
||||
DeviceHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
await DeviceHubClient.StartAsync();
|
||||
await LoadDevices();
|
||||
}
|
||||
|
||||
private async Task LoadDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
if (DeviceHubClient.IsConnected)
|
||||
{
|
||||
devices = (await DeviceHubClient.GetAllDevicesAsync()).ToList();
|
||||
ApplyFilters();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error loading devices: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshDevices()
|
||||
{
|
||||
await LoadDevices();
|
||||
}
|
||||
|
||||
private void OnDeviceUpdated(DeviceUpdateDto update)
|
||||
{
|
||||
if (devices == null) return;
|
||||
|
||||
var device = devices.FirstOrDefault(d => d.DeviceId == update.DeviceId);
|
||||
if (device != null)
|
||||
{
|
||||
if (update.Status.HasValue)
|
||||
device.Status = update.Status.Value;
|
||||
if (update.Properties != null)
|
||||
device.Properties = update.Properties;
|
||||
if (update.LastError != null)
|
||||
device.LastError = update.LastError;
|
||||
if (update.LastUpdateTime.HasValue)
|
||||
device.LastUpdateTime = update.LastUpdateTime.Value;
|
||||
if (update.LastConnectedTime.HasValue)
|
||||
device.LastConnectedTime = update.LastConnectedTime;
|
||||
if (update.LastDisconnectedTime.HasValue)
|
||||
device.LastDisconnectedTime = update.LastDisconnectedTime;
|
||||
if (update.ReconnectAttemptCount.HasValue)
|
||||
device.ReconnectAttemptCount = update.ReconnectAttemptCount.Value;
|
||||
|
||||
device.IsConnected = device.Status == DeviceStatus.Connected;
|
||||
ApplyFilters();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeviceStatusChanged(string deviceId, DeviceStatus status)
|
||||
{
|
||||
if (devices == null) return;
|
||||
|
||||
var device = devices.FirstOrDefault(d => d.DeviceId == deviceId);
|
||||
if (device != null)
|
||||
{
|
||||
device.Status = status;
|
||||
device.IsConnected = status == DeviceStatus.Connected;
|
||||
ApplyFilters();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
if (state == HubConnectionState.Connected)
|
||||
{
|
||||
_ = LoadDevices();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFilterChanged()
|
||||
{
|
||||
ApplyFilters();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
if (devices == null)
|
||||
{
|
||||
filteredDevices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var query = devices.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchText))
|
||||
{
|
||||
var searchLower = searchText.ToLowerInvariant();
|
||||
query = query.Where(d =>
|
||||
d.DeviceId.ToLowerInvariant().Contains(searchLower) ||
|
||||
d.DeviceName.ToLowerInvariant().Contains(searchLower) ||
|
||||
(d.Description != null && d.Description.ToLowerInvariant().Contains(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
filteredDevices = query.ToList();
|
||||
}
|
||||
|
||||
private string GetConnectionIcon()
|
||||
{
|
||||
return DeviceHubClient.ConnectionState switch
|
||||
{
|
||||
HubConnectionState.Connected => Icons.Material.Filled.CheckCircle,
|
||||
HubConnectionState.Connecting => Icons.Material.Filled.HourglassEmpty,
|
||||
HubConnectionState.Reconnecting => Icons.Material.Filled.Sync,
|
||||
_ => Icons.Material.Filled.Error
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetConnectionColor()
|
||||
{
|
||||
return DeviceHubClient.ConnectionState switch
|
||||
{
|
||||
HubConnectionState.Connected => Color.Success,
|
||||
HubConnectionState.Connecting => Color.Warning,
|
||||
HubConnectionState.Reconnecting => Color.Warning,
|
||||
_ => Color.Error
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
DeviceHubClient.DeviceUpdated -= OnDeviceUpdated;
|
||||
DeviceHubClient.DeviceStatusChanged -= OnDeviceStatusChanged;
|
||||
DeviceHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/devices/battery/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Battery - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<BatteryCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@page "/devices/cameraqr/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Camera QR - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<CameraQrCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@page "/devices/cia402servo/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>CiA402Servo Device - @DeviceId</PageTitle>
|
||||
|
||||
<RobotNet10.Components.DivContainer OverflowY="overflow-y-auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<CiA402ServoCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
</RobotNet10.Components.DivContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/devices/imu/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Inertial Measurement Unit - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<InertialMeasurementUnitCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/devices/lidar/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Lidar - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<LidarCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@page "/devices/modbustcp/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>ModbusTCP Device - @DeviceId</PageTitle>
|
||||
|
||||
<RobotNet10.Components.DivContainer OverflowY="overflow-y-auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<ModbusTcpCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
</RobotNet10.Components.DivContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@page "/devices/rfhandle/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>RF Handle - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<RfHandleCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
@page "/dock-station-config"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Services
|
||||
@using RobotNet10.RobotApp.Client.Dialogs
|
||||
@using RobotNet10.RobotApp.Shared.DockStation
|
||||
@inject DockStationConfigState State
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Dock Station Config</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Dock Station Configuration</MudText>
|
||||
|
||||
@if (!string.IsNullOrEmpty(State.ErrorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mb-3">
|
||||
@State.ErrorMessage
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudGrid>
|
||||
@* Left Panel - Config List *@
|
||||
<MudItem xs="12" md="5" lg="4">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||
<MudText Typo="Typo.h6">Stations</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="OpenCreateDialog" Disabled="State.IsSaving">
|
||||
New
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@if (State.IsLoading && State.Configs.Count == 0)
|
||||
{
|
||||
<MudProgressLinear Indeterminate />
|
||||
}
|
||||
else if (State.Configs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Tertiary" Align="Align.Center" Class="pa-4">
|
||||
No dock station configs found.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="DockStationConfigSummaryDto" Dense SelectedValueChanged="OnConfigSelected">
|
||||
@foreach (var config in State.Configs)
|
||||
{
|
||||
<MudListItem Value="config"
|
||||
Icon="@(config.IsActive ? Icons.Material.Filled.EvStation : Icons.Material.Outlined.EvStation)"
|
||||
IconColor="@(config.IsActive ? Color.Success : Color.Default)">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body1"><b>@config.StationId</b></MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary">
|
||||
@(string.IsNullOrEmpty(config.ConfigName) ? "No name" : config.ConfigName)
|
||||
· @config.MarkerEntryCount marker(s)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
@* Right Panel - Detail View *@
|
||||
<MudItem xs="12" md="7" lg="8">
|
||||
@if (State.SelectedConfig is not null)
|
||||
{
|
||||
var cfg = State.SelectedConfig;
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||
<MudText Typo="Typo.h6">@cfg.StationId</MudText>
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Color="@(cfg.IsActive ? Color.Success : Color.Default)">
|
||||
@(cfg.IsActive ? "Active" : "Inactive")
|
||||
</MudChip>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Primary"
|
||||
OnClick="OpenEditDialog" Disabled="State.IsSaving" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||
OnClick="OpenDeleteDialog" Disabled="State.IsSaving" />
|
||||
</MudStack>
|
||||
|
||||
@if (!string.IsNullOrEmpty(cfg.ConfigName))
|
||||
{
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-1">@cfg.ConfigName</MudText>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(cfg.Description))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Tertiary" Class="mb-3">@cfg.Description</MudText>
|
||||
}
|
||||
|
||||
<MudDivider Class="my-3" />
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Search Area</MudText>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">X: <b>@cfg.X.ToString("F3")</b> m</MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Y: <b>@cfg.Y.ToString("F3")</b> m</MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Yaw: <b>@cfg.Yaw.ToString("F4")</b> rad</MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Width: <b>@cfg.Width.ToString("F3")</b> m</MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Length: <b>@cfg.Length.ToString("F3")</b> m</MudText></MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudDivider Class="my-3" />
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Marker Entries (@cfg.MarkerEntries.Count)</MudText>
|
||||
<MudPaper Style="max-height: calc(100vh - 460px); overflow-y: auto; padding-right: 4px;" Elevation="0">
|
||||
@foreach (var entry in cfg.MarkerEntries.OrderBy(e => e.Priority))
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-2" Outlined>
|
||||
<MudGrid Spacing="1">
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Marker: <b>@entry.MarkerId</b></MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Type: <b>@entry.Type</b></MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Priority: <b>@entry.Priority</b></MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Device: <b>@(entry.DeviceId ?? "-")</b></MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Code: <b>@(entry.Code ?? "-")</b></MudText></MudItem>
|
||||
</MudGrid>
|
||||
@if (entry.ReferencePoints.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mt-1">
|
||||
Reference Points: @string.Join(", ", entry.ReferencePoints.Select(p => $"({p.X:F3}, {p.Y:F3})"))
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudDivider Class="my-3" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary">
|
||||
Created: @cfg.CreatedAt.ToString("yyyy-MM-dd HH:mm") | Updated: @cfg.UpdatedAt.ToString("yyyy-MM-dd HH:mm")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Class="pa-8 d-flex align-center justify-center" Elevation="0" Style="min-height:300px">
|
||||
<MudText Typo="Typo.body1" Color="Color.Tertiary">Select a dock station config to view details</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
State.OnStateChanged += StateHasChanged;
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
State.OnStateChanged -= StateHasChanged;
|
||||
}
|
||||
|
||||
private async Task OnConfigSelected(DockStationConfigSummaryDto? config)
|
||||
{
|
||||
if (config is not null)
|
||||
await State.SelectConfigAsync(config.Id);
|
||||
}
|
||||
|
||||
private async Task OpenCreateDialog()
|
||||
{
|
||||
var dialog = await DialogService.ShowAsync<DockStationConfigDialog>(
|
||||
"Create Dock Station Config",
|
||||
new DialogParameters { ["Config"] = null },
|
||||
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
|
||||
var result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled && result.Data is CreateDockStationConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.CreateConfigAsync(request);
|
||||
Snackbar.Add("Dock station config created.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog()
|
||||
{
|
||||
if (State.SelectedConfig is null) return;
|
||||
|
||||
var dialog = await DialogService.ShowAsync<DockStationConfigDialog>(
|
||||
"Edit Dock Station Config",
|
||||
new DialogParameters { ["Config"] = State.SelectedConfig },
|
||||
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
|
||||
var result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled && result.Data is UpdateDockStationConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.UpdateConfigAsync(State.SelectedConfig.Id, request);
|
||||
Snackbar.Add("Dock station config updated.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenDeleteDialog()
|
||||
{
|
||||
if (State.SelectedConfig is null) return;
|
||||
|
||||
var confirm = await DialogService.ShowMessageBoxAsync(
|
||||
"Confirm Delete",
|
||||
$"Are you sure you want to delete dock station config '{State.SelectedConfig.StationId}'?",
|
||||
yesText: "Delete", cancelText: "Cancel");
|
||||
|
||||
if (confirm == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.DeleteConfigAsync(State.SelectedConfig.Id);
|
||||
Snackbar.Add("Dock station config deleted.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@page "/layout-editor/{LevelId:guid}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Layout Editor</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.LayoutEditor.LayoutEditorComponent LevelId="@LevelId" />
|
||||
|
||||
<MudThemeProvider IsDarkMode/>
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid LevelId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/layout-manager"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Layout Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.LayoutManager.LayoutManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,556 @@
|
||||
@page "/localization"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Components.SLAM
|
||||
@using RobotNet10.RobotApp.Client.Dialogs
|
||||
@using RobotNet10.RobotApp.Client.Shared.SLAM
|
||||
@using RobotNet10.RobotApp.Shared.Enums
|
||||
@using RobotNet10.Shared.Detection
|
||||
@using RobotNet10.Shared.Geometry
|
||||
|
||||
<PageTitle>Localization</PageTitle>
|
||||
|
||||
<div class="h-100 w-100 d-flex flex-column overflow-hidden">
|
||||
<!-- Control Bar -->
|
||||
<div class="position-relative d-flex flex-row align-items-center gap-3 flex-wrap px-1" style="background-color: var(--mud-palette-background);">
|
||||
<!-- Map Name -->
|
||||
<div class="d-flex flex-row align-items-center gap-2" style="min-width: 300px;">
|
||||
<MudTextField @bind-Value="MapName"
|
||||
Label="Map Name" ShrinkLabel
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Disabled="@(CurrentState != SLAMState.Ready)"
|
||||
Style="max-width: 300px;" />
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="d-flex flex-row align-items-center gap-2">
|
||||
<MudButtonGroup Variant="Variant.Filled" Color="Color.Primary">
|
||||
@if (IsLocalizationActive)
|
||||
{
|
||||
@* Fit View Button - always visible when map is displayed *@
|
||||
<MudTooltip Text="Fit View">
|
||||
<MudIconButton Color="Color.Default" Variant="Variant.Outlined"
|
||||
Icon="@Icons.Material.Filled.FitScreen"
|
||||
OnClick="FitViewAsync" />
|
||||
</MudTooltip>
|
||||
|
||||
@* Localizing or InitializingLocalizing: Show Stop, Initial Pose, Marker Detect *@
|
||||
<MudTooltip Text="Stop">
|
||||
<MudIconButton Color="Color.Error" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.Stop"
|
||||
OnClick="StopLocalizationAsync" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Initialize Pose">
|
||||
<MudIconButton Color="Color.Secondary" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.MyLocation"
|
||||
OnClick="SetInitialPoseAsync" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@(_isDetecting ? "Stop Detect" : "Marker Detect")">
|
||||
<MudIconButton Color="@(_isDetecting? Color.Error: Color.Tertiary)" Variant="Variant.Filled"
|
||||
Icon="@(_isDetecting ? Icons.Material.Filled.StopCircle : Icons.Material.Filled.Search)"
|
||||
OnClick="ToggleMarkerDetectAsync"
|
||||
Disabled="@(CurrentState == SLAMState.Relocalizing)" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else if (IsScanMappingActive)
|
||||
{
|
||||
@* Fit View Button - always visible when map is displayed *@
|
||||
<MudTooltip Text="Fit View">
|
||||
<MudIconButton Color="Color.Default" Variant="Variant.Outlined"
|
||||
Icon="@Icons.Material.Filled.FitScreen"
|
||||
OnClick="FitViewAsync" />
|
||||
</MudTooltip>
|
||||
|
||||
@* ScanMapping or SavingMap: Show Save Map *@
|
||||
<MudTooltip Text="@(CurrentState == SLAMState.SavingMap ? "Saving..." : "Save Map")">
|
||||
<MudIconButton Color="Color.Success" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.Save"
|
||||
OnClick="SaveMapAsync"
|
||||
Disabled="@(CurrentState == SLAMState.SavingMap)" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Ready or other states: Show Localize and Scan Mapping *@
|
||||
<MudTooltip Text="Localize">
|
||||
<MudIconButton Color="Color.Primary" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.LocationOn"
|
||||
OnClick="StartLocalizationAsync"
|
||||
Disabled="@(CurrentState != SLAMState.Ready)" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Scan Mapping">
|
||||
<MudIconButton Color="Color.Primary" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.Map"
|
||||
OnClick="StartScanMappingAsync"
|
||||
Disabled="@(CurrentState != SLAMState.Ready || string.IsNullOrWhiteSpace(MapName))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudButtonGroup>
|
||||
</div>
|
||||
|
||||
@* Map Save Progress Indicator *@
|
||||
@if (CurrentState == SLAMState.SavingMap && _saveProgress >= 0)
|
||||
{
|
||||
<div class="d-flex flex-row align-items-center gap-2" style="min-width: 300px;">
|
||||
<MudProgressLinear Color="Color.Primary"
|
||||
Value="@_saveProgress"
|
||||
Class="flex-grow-1"
|
||||
Striped="true"
|
||||
Size="Size.Medium" />
|
||||
<span class="small fw-medium">@_saveProgress%</span>
|
||||
<span class="small">@(_workItemsCompleted)/@(_workItemsAdded)</span>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-grow-1"></div>
|
||||
<!-- State Indicator -->
|
||||
<div class="d-flex flex-row align-items-center gap-2">
|
||||
<span class="status-chip d-inline-flex align-items-center px-3 py-1 rounded-pill small fw-medium" style="background-color: @GetStateChipBackgroundColor(CurrentState); color: @GetStateChipTextColor(CurrentState);">
|
||||
@GetStateText(CurrentState ?? SLAMState.Idle)
|
||||
</span>
|
||||
</div>
|
||||
<MudOverlay Visible="@(!CartographerClient.IsConnected)" AutoClose="false" DarkBackground Absolute />
|
||||
</div>
|
||||
|
||||
<!-- Map Localization - Fill remaining space -->
|
||||
<div class="flex-grow-1 w-100" style="min-height: 0;">
|
||||
<MapLocalization @ref="MapLocalizationRef">
|
||||
<Elements>
|
||||
<MarkerDetectOverlay @ref="MarkerDetectOverlayRef" />
|
||||
</Elements>
|
||||
</MapLocalization>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private SLAMState? CurrentState { get; set; }
|
||||
private bool IsLocalizationActive => CurrentState == SLAMState.Localizing || CurrentState == SLAMState.Relocalizing;
|
||||
private bool IsScanMappingActive => CurrentState == SLAMState.ScanMapping || CurrentState == SLAMState.SavingMap;
|
||||
private MapLocalization? MapLocalizationRef { get; set; }
|
||||
private MarkerDetectOverlay? MarkerDetectOverlayRef { get; set; }
|
||||
|
||||
[Inject]
|
||||
private SLAMClient CartographerClient { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MarkerDetectorHubClient MarkerDetectorClient { get; set; } = null!;
|
||||
|
||||
// Marker detection state
|
||||
private Guid? _detectSessionId;
|
||||
private MarkersSearchRequest? _detectRequest;
|
||||
private System.Timers.Timer? _detectTimer;
|
||||
private bool _isDetecting;
|
||||
private string MapName = "";
|
||||
|
||||
// Map save progress state
|
||||
private int _saveProgress = -1;
|
||||
private int _workItemsAdded;
|
||||
private int _workItemsCompleted;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await CartographerClient.StartAsync();
|
||||
|
||||
// Subscribe to events
|
||||
CartographerClient.StateChanged += OnStateChanged;
|
||||
CartographerClient.MapSaveProgressChanged += OnMapSaveProgressChanged;
|
||||
|
||||
// Get initial state
|
||||
CurrentState = await CartographerClient.GetCurrentStateAsync();
|
||||
|
||||
// If currently in localization or scan mapping state, get the current map name
|
||||
if (CurrentState == SLAMState.Relocalizing ||
|
||||
CurrentState == SLAMState.Localizing ||
|
||||
CurrentState == SLAMState.ScanMapping)
|
||||
{
|
||||
var currentMap = await CartographerClient.GetCurrentMapAsync();
|
||||
if (!string.IsNullOrEmpty(currentMap))
|
||||
{
|
||||
MapName = currentMap;
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStateChanged(SLAMState state)
|
||||
{
|
||||
CurrentState = state;
|
||||
|
||||
// Reset progress when not saving
|
||||
if (state != SLAMState.SavingMap)
|
||||
{
|
||||
_saveProgress = -1;
|
||||
_workItemsAdded = 0;
|
||||
_workItemsCompleted = 0;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnMapSaveProgressChanged(int workItemsAdded, int workItemsCompleted, int percentComplete)
|
||||
{
|
||||
_workItemsAdded = workItemsAdded;
|
||||
_workItemsCompleted = workItemsCompleted;
|
||||
_saveProgress = percentComplete;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task StartLocalizationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CartographerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to server", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get list of available maps
|
||||
var maps = await CartographerClient.ListMapsAsync();
|
||||
if (maps == null || maps.Length == 0)
|
||||
{
|
||||
Snackbar.Add("No maps available. Please create a map first.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show dialog to select map
|
||||
var parameters = new DialogParameters<SelectMapDialog>
|
||||
{
|
||||
{ x => x.Maps, maps }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<SelectMapDialog>("Select Map for Localization", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled && result.Data is string mapName)
|
||||
{
|
||||
// Start localization with selected map
|
||||
var success = await CartographerClient.StartLocalizationAsync(mapName);
|
||||
if (success)
|
||||
{
|
||||
MapName = mapName;
|
||||
Snackbar.Add($"Started localization with map: {mapName}", Severity.Success);
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to start localization with map: {mapName}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to start localization: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopLocalizationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CartographerClient.StopLocalizationAsync();
|
||||
MapName = "";
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to stop localization: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartScanMappingAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CartographerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to server", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(MapName))
|
||||
{
|
||||
Snackbar.Add("Please enter a map name.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
var success = await CartographerClient.StartScanMappingAsync(MapName);
|
||||
if (success)
|
||||
Snackbar.Add("Scan mapping started", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("Failed to start scan mapping", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to start scan mapping: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveMapAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_saveProgress = 0;
|
||||
_workItemsAdded = 0;
|
||||
_workItemsCompleted = 0;
|
||||
StateHasChanged();
|
||||
|
||||
var mapPath = await CartographerClient.SaveMapAsync();
|
||||
if (!string.IsNullOrEmpty(mapPath))
|
||||
Snackbar.Add($"Map saved successfully: {mapPath}", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("Map save initiated. Monitor progress above.", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to save map: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetInitialPoseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pose = MapLocalizationRef?.GetGoalPoseDto();
|
||||
if (pose == null)
|
||||
{
|
||||
Snackbar.Add("No goal pose set. Right-click on map to set robot goal, then click Initial Pose.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
await CartographerClient.SetInitialPoseAsync(pose);
|
||||
Snackbar.Add("Initial pose set from goal.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to set initial pose: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FitViewAsync()
|
||||
{
|
||||
if (MapLocalizationRef != null)
|
||||
{
|
||||
await MapLocalizationRef.FitViewAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleMarkerDetectAsync()
|
||||
{
|
||||
if (_isDetecting)
|
||||
{
|
||||
await StopMarkerDetectAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await OpenMarkersSearchRequestDialogAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenMarkersSearchRequestDialogAsync()
|
||||
{
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<MarkersSearchRequestDialog>("Create Markers Search Request", options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result is not { Canceled: false, Data: MarkersSearchRequest request })
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Stop previous detection if running
|
||||
if (_isDetecting)
|
||||
await StopMarkerDetectAsync();
|
||||
|
||||
// Connect and create session
|
||||
await MarkerDetectorClient.StartAsync();
|
||||
var createResult = await MarkerDetectorClient.CreateSessionAsync(request);
|
||||
if (!createResult.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Failed to create session: {createResult.Message}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_detectSessionId = createResult.Data;
|
||||
_detectRequest = request;
|
||||
_isDetecting = true;
|
||||
|
||||
// Start polling for goal
|
||||
_detectTimer = new System.Timers.Timer(1000);
|
||||
_detectTimer.Elapsed += OnDetectTimerElapsed;
|
||||
_detectTimer.Start();
|
||||
|
||||
Snackbar.Add($"Marker detection started (session: {_detectSessionId})", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to start marker detection: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopMarkerDetectAsync()
|
||||
{
|
||||
if (_detectTimer != null)
|
||||
{
|
||||
_detectTimer.Stop();
|
||||
_detectTimer.Elapsed -= OnDetectTimerElapsed;
|
||||
_detectTimer.Dispose();
|
||||
_detectTimer = null;
|
||||
}
|
||||
|
||||
_isDetecting = false;
|
||||
_detectSessionId = null;
|
||||
_detectRequest = null;
|
||||
|
||||
MarkerDetectOverlayRef?.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
await MarkerDetectorClient.StopAsync();
|
||||
}
|
||||
catch { }
|
||||
|
||||
Snackbar.Add("Marker detection stopped.", Severity.Info);
|
||||
}
|
||||
|
||||
private async void OnDetectTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
if (!_detectSessionId.HasValue)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await MarkerDetectorClient.GetGoalAsync(_detectSessionId.Value);
|
||||
Console.WriteLine($"goal = [{result.Data.Position.X}, {result.Data.Position.Y}, {result.Data.Position.Z}] [{result.Data.Orientation.X}, {result.Data.Orientation.Y}, {result.Data.Orientation.Z}, {result.Data.Orientation.W}]");
|
||||
|
||||
if (result.IsSuccess && result.Data is Pose goal && (goal.Orientation.W != 0 || goal.Orientation.Z != 0))
|
||||
{
|
||||
var points = ComputeReferencePointsWorld(goal);
|
||||
await InvokeAsync(() => MarkerDetectOverlayRef?.Update(goal, points));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"reject MarkerDetectorClient.GetGoalAsync");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private List<(double X, double Y)> ComputeReferencePointsWorld(Pose goal)
|
||||
{
|
||||
var points = new List<(double X, double Y)>();
|
||||
if (_detectRequest is not MarkersSearchRequest req)
|
||||
return points;
|
||||
|
||||
var yaw = goal.Orientation.ToYawRadian();
|
||||
var cos = Math.Cos(yaw);
|
||||
var sin = Math.Sin(yaw);
|
||||
|
||||
foreach (var entry in req.MarkerSearchRequests)
|
||||
{
|
||||
foreach (var pt in entry.ReferencePoints)
|
||||
{
|
||||
var wx = goal.Position.X + pt.X * cos - pt.Y * sin;
|
||||
var wy = goal.Position.Y + pt.X * sin + pt.Y * cos;
|
||||
points.Add((wx, wy));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private string GetStateChipBackgroundColor(SLAMState? state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SLAMState.Ready => "#4caf50",
|
||||
SLAMState.Relocalizing => "#2196f3",
|
||||
SLAMState.Localizing => "#2196f3",
|
||||
SLAMState.ScanMapping => "#9c27b0",
|
||||
SLAMState.SavingMap => "#ff9800",
|
||||
SLAMState.Error => "#f44336",
|
||||
_ => "#9e9e9e"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetStateChipTextColor(SLAMState? state)
|
||||
{
|
||||
return "white";
|
||||
}
|
||||
|
||||
private string GetStateText(SLAMState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SLAMState.Idle => "Idle",
|
||||
SLAMState.Initializing => "Initializing",
|
||||
SLAMState.Ready => "Ready",
|
||||
SLAMState.Relocalizing => "Relocalizing",
|
||||
SLAMState.Localizing => "Localizing",
|
||||
SLAMState.ScanMapping => "Scan Mapping",
|
||||
SLAMState.SavingMap => "Saving Map",
|
||||
SLAMState.Error => "Error",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Unsubscribe from events
|
||||
CartographerClient.StateChanged -= OnStateChanged;
|
||||
CartographerClient.MapSaveProgressChanged -= OnMapSaveProgressChanged;
|
||||
|
||||
// Cleanup marker detection timer
|
||||
if (_detectTimer != null)
|
||||
{
|
||||
_detectTimer.Stop();
|
||||
_detectTimer.Elapsed -= OnDetectTimerElapsed;
|
||||
_detectTimer.Dispose();
|
||||
_detectTimer = null;
|
||||
}
|
||||
|
||||
// Stop marker detector client
|
||||
if (_isDetecting)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MarkerDetectorClient.StopAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
_isDetecting = false;
|
||||
_detectSessionId = null;
|
||||
_detectRequest = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
@page "/logs"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||
@using System.Text.Json.Serialization
|
||||
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
@inject IConfiguration Configuration
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Logs</PageTitle>
|
||||
|
||||
<div class="w-100 h-100 d-flex flex-column">
|
||||
<div class="d-flex flex-row align-items-center justify-content-between" style="border-bottom: 1px solid silver">
|
||||
<MudTextField Class="mt-1 ms-2" T="string" Value="FilterLog" Adornment="Adornment.End" ValueChanged="OnSearch" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
IconSize="Size.Medium" Variant="Variant.Outlined" Margin="Margin.Dense" AdornmentColor="Color.Secondary" Label="Search"></MudTextField>
|
||||
<MudSpacer />
|
||||
<div class="m-1 d-flex flex-row">
|
||||
<MudDatePicker Class="mx-4" Label="Date" Date="DateLog" DateChanged="OnDateChanged" MaxDate="DateTime.Today" Variant="Variant.Outlined" Color="Color.Primary"
|
||||
ShowToolbar="false" Margin="Margin.Dense" AdornmentColor="Color.Primary" />
|
||||
|
||||
<MudTooltip Text="Export">
|
||||
<MudFab Class="mt-2" Color="Color.Info" StartIcon="@Icons.Material.Filled.ImportExport" Size="Size.Small" OnClick="ExportLogs" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Refresh">
|
||||
<MudFab Class="mx-4 mt-2" StartIcon="@Icons.Material.Filled.Refresh" Color="Color.Primary" Size="Size.Small" OnClick="LoadLogs" />
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 mt-2 ms-2 position-relative" style="background-color: rgba(0, 0, 0, 0);">
|
||||
<MudOverlay Visible="IsLoading" DarkBackground="true" Absolute="true">
|
||||
<MudProgressCircular Color="Color.Info" Indeterminate="true" />
|
||||
</MudOverlay>
|
||||
<div class="h-100 w-100 position-relative">
|
||||
<div class="log-container" @ref="LogContainerRef">
|
||||
@if (ShowRawLog)
|
||||
{
|
||||
<div class="d-flex justify-content-center my-3">
|
||||
<div><MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ShowRawLog = false)">Normal</MudButton></div>
|
||||
</div>
|
||||
<big style="font-size: 14px;">
|
||||
@foreach (var log in ShowLogs)
|
||||
{
|
||||
@log <br />
|
||||
}
|
||||
</big>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (SearchLogs.Count < ShowLogs.Count)
|
||||
{
|
||||
<div class="d-flex justify-content-center my-3">
|
||||
<div><MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ShowRawLog = true)">Raw log</MudButton></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var log in SearchLogs)
|
||||
{
|
||||
<div class="log">
|
||||
<span class="log-head @log.BackgroundClass">
|
||||
@log.Time <span class="log-level">@log.Level</span>
|
||||
</span>
|
||||
<span>@log.Message</span>
|
||||
@if (log.HasException)
|
||||
{
|
||||
<br />
|
||||
<pre class="log-exception">
|
||||
@log.Exception
|
||||
</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.ScrollToBottom = (element) => {
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private DateTime DateLog = DateTime.Today;
|
||||
private bool IsLoading;
|
||||
private readonly List<string> ShowLogs = new();
|
||||
private readonly List<LoggerModel> SearchLogs = new();
|
||||
private ElementReference LogContainerRef { get; set; }
|
||||
private bool ShowRawLog { get; set; }
|
||||
private string? FilterLog { get; set; }
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
|
||||
await LoadLogs();
|
||||
}
|
||||
|
||||
private async Task LoadLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ShowLogs.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
var logs = await Http.GetFromJsonAsync<IEnumerable<string>>($"api/LogsManager?date={DateLog}");
|
||||
ShowLogs.AddRange(logs ?? []);
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
|
||||
await ReloadLogs();
|
||||
}
|
||||
catch (AccessTokenNotAvailableException ex)
|
||||
{
|
||||
ex.Redirect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadLogs()
|
||||
{
|
||||
IsLoading = true;
|
||||
SearchLogs.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
foreach (var line in ShowLogs.Where(log => string.IsNullOrEmpty(FilterLog) || log.Contains(FilterLog)).TakeLast(2000))
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = System.Text.Json.JsonSerializer.Deserialize<LoggerModel>(line);
|
||||
if (log is not null) SearchLogs.Add(log);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
await JSRuntime.InvokeVoidAsync("ScrollToBottom", LogContainerRef);
|
||||
}
|
||||
|
||||
private async Task OnSearch(string text)
|
||||
{
|
||||
FilterLog = text;
|
||||
await ReloadLogs();
|
||||
}
|
||||
|
||||
private async Task OnDateChanged(DateTime? date)
|
||||
{
|
||||
if (date is not null && date.HasValue)
|
||||
{
|
||||
DateLog = date.Value;
|
||||
await LoadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileContent = await Http.GetFromJsonAsync<IEnumerable<string>>($"api/LogsManager?date={DateLog}");
|
||||
var formattedContent = string.Join("\n", fileContent ?? []);
|
||||
var fileName = $"LogsManager_{DateLog.ToShortDateString()}.txt";
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, formattedContent, "text/plain");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải file: {ex.Message}", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
public class LoggerModel
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
public string? Time { get; set; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string? Level { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[JsonPropertyName("exception")]
|
||||
public string? Exception { get; set; }
|
||||
|
||||
public string ColorClass => Level switch
|
||||
{
|
||||
"WARN" => "text-warning",
|
||||
"INFO" => "text-info",
|
||||
"DEBUG" => "text-success",
|
||||
"ERROR" => "text-danger",
|
||||
"FATAL" => "text-secondary",
|
||||
_ => "text-muted",
|
||||
};
|
||||
|
||||
public string BackgroundClass => Level switch
|
||||
{
|
||||
"WARN" => "bg-warning text-dark",
|
||||
"INFO" => "bg-info text-dark",
|
||||
"DEBUG" => "bg-success text-white",
|
||||
"ERROR" => "bg-danger text-white",
|
||||
"FATAL" => "bg-secondary text-white",
|
||||
_ => "bg-dark text-white",
|
||||
};
|
||||
|
||||
public bool HasException => !string.IsNullOrEmpty(Exception);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.log-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log {
|
||||
word-wrap: break-word;
|
||||
line-height: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-logger {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.log-exception {
|
||||
line-height: 16px;
|
||||
margin-left: 30px;
|
||||
color: crimson;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
@page "/maps"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Shared.SLAM
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Components.SLAM
|
||||
@implements IAsyncDisposable
|
||||
@inject SLAMClient CartographerClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>Map Management</PageTitle>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<MudCard Elevation="2">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">Map Management</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="RefreshMapsAsync"
|
||||
Disabled="@(!CartographerClient.IsConnected || IsLoading)">
|
||||
Refresh
|
||||
</MudButton>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
|
||||
}
|
||||
else if (Maps.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary">
|
||||
No maps found. Create a map by starting scan mapping.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@Maps" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Created</MudTh>
|
||||
<MudTh>Resolution</MudTh>
|
||||
<MudTh>Size</MudTh>
|
||||
<MudTh>Trajectory Nodes</MudTh>
|
||||
<MudTh></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudText Typo="Typo.body1">@context.Name</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Created">
|
||||
<MudText Typo="Typo.body2">@context.CreatedDate.ToString("yyyy-MM-dd HH:mm")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Resolution">
|
||||
<MudText Typo="Typo.body2">@context.Resolution.ToString("F3") m/p</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Size">
|
||||
<MudText Typo="Typo.body2">@context.Width.ToString("F1") x @context.Height.ToString("F1") m</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Trajectory Nodes">
|
||||
<MudText Typo="Typo.body2">@context.TrajectoryNodeCount</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="View map details">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.OpenInNew"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
Href="@($"/map/{context.Name}")" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private List<MapInfoDto> Maps { get; set; } = new();
|
||||
private bool IsLoading { get; set; } = false;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await CartographerClient.StartAsync();
|
||||
await RefreshMapsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshMapsAsync()
|
||||
{
|
||||
if (!CartographerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to server", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
Maps = (await CartographerClient.ListMapsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load maps: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMapAsync(string mapName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await CartographerClient.StartLocalizationAsync(mapName);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Started localization with map: {mapName}", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to start localization with map: {mapName}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load map: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteMapAsync(string mapName)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["MapName"] = mapName
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<DeleteMapDialog>("Delete Map", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await CartographerClient.DeleteMapAsync(mapName);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Map deleted: {mapName}", Severity.Success);
|
||||
await RefreshMapsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to delete map: {mapName}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to delete map: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// CartographerClient is scoped, will be disposed by DI
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/missions"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Mission Manager</PageTitle>
|
||||
|
||||
<RobotNet10.ScriptEditor.InstanceMissionManager />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,133 @@
|
||||
@page "/motion/manualcontrol"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Components.Motion
|
||||
@using RobotNet10.RobotApp.Client.Shared.Motion
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>Manual Control</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
@* Disconnected Overlay *@
|
||||
<MudOverlay Visible="@(!isHubReady)" DarkBackground="true" ZIndex="9999" AutoClose="false">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Typo="Typo.h5" Color="Color.Primary">MotionHub Disconnected</MudText>
|
||||
<MudText Typo="Typo.body1">Waiting for connection...</MudText>
|
||||
</MudStack>
|
||||
</MudOverlay>
|
||||
|
||||
<MudStack Spacing="4">
|
||||
@* 4 Cards Grid *@
|
||||
<MudGrid>
|
||||
@* Card 1: Manual Control *@
|
||||
<MudItem xs="12" md="6">
|
||||
<ManualControlCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 2: Odometry (from OdometryService → XLOC) *@
|
||||
<MudItem xs="12" md="6">
|
||||
<OdometryCard Odometry="currentOdometry" IsHubReady="odometryHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 3: Lift Module *@
|
||||
<MudItem xs="12" md="6">
|
||||
<LiftModuleCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 4: Rotation Module *@
|
||||
<MudItem xs="12" md="6">
|
||||
<RotationModuleCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private MotionHubClient MotionHubClientInjected { get; set; } = null!;
|
||||
[Inject] private OdometryHubClient OdometryHubClientInjected { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private MotionHubClient motionHubClient = null!;
|
||||
private bool isHubReady = false;
|
||||
|
||||
private OdometryHubClient odometryHubClient = null!;
|
||||
private bool odometryHubReady = false;
|
||||
private OdometryDto? currentOdometry;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
motionHubClient = MotionHubClientInjected;
|
||||
odometryHubClient = OdometryHubClientInjected;
|
||||
|
||||
// Subscribe to connection state changes
|
||||
motionHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
odometryHubClient.ConnectionStateChanged += OnOdometryConnectionStateChanged;
|
||||
odometryHubClient.OdometryReceived += OnOdometryReceived;
|
||||
|
||||
try
|
||||
{
|
||||
await motionHubClient.StartAsync();
|
||||
isHubReady = motionHubClient.IsConnected;
|
||||
|
||||
// Wait a bit for connection to establish
|
||||
await Task.Delay(500);
|
||||
isHubReady = motionHubClient.IsConnected;
|
||||
|
||||
await odometryHubClient.StartAsync();
|
||||
odometryHubReady = odometryHubClient.IsConnected;
|
||||
|
||||
if (odometryHubReady)
|
||||
{
|
||||
currentOdometry = await odometryHubClient.GetCurrentOdometryAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error initializing: {ex.Message}", Severity.Error);
|
||||
isHubReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
isHubReady = state == HubConnectionState.Connected;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnOdometryConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
odometryHubReady = state == HubConnectionState.Connected;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnOdometryReceived(OdometryDto dto)
|
||||
{
|
||||
currentOdometry = dto;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (motionHubClient != null)
|
||||
{
|
||||
motionHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
}
|
||||
|
||||
if (odometryHubClient != null)
|
||||
{
|
||||
odometryHubClient.ConnectionStateChanged -= OnOdometryConnectionStateChanged;
|
||||
odometryHubClient.OdometryReceived -= OnOdometryReceived;
|
||||
}
|
||||
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
@page "/motion/navigation-monitor"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Shared.NavigationMonitor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using MudBlazor
|
||||
@using ApexCharts
|
||||
@using Color = MudBlazor.Color
|
||||
@using Size = MudBlazor.Size
|
||||
|
||||
<PageTitle>Navigation Monitor</PageTitle>
|
||||
<div style="height: 100%; width:100%; display: flex; flex-direction: column; overflow-x: hidden; overflow: auto" class="p-2">
|
||||
@* Connection status *@
|
||||
@if (!hubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||
Disconnected from Navigation Monitor hub. Reconnecting...
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* Controls *@
|
||||
<MudPaper Class="pa-3 mb-2" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudSwitch Value="telemetryEnabled" Color="Color.Primary"
|
||||
Label="Enable Telemetry" Disabled="@(!hubClient.IsConnected)"
|
||||
ValueChanged="@((bool v) => OnTelemetryToggle(v))" T="bool" />
|
||||
<MudSwitch Value="safetyStopEnabled" Color="Color.Error"
|
||||
Label="Enable Safety Stop" Disabled="@(!hubClient.IsConnected)"
|
||||
ValueChanged="@((bool v) => OnSafetyStopToggle(v))" T="bool" />
|
||||
<MudSpacer />
|
||||
@if (telemetryEnabled)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small">@($"{updateFrequencyHz:F0} Hz")</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small">Disabled</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* Safety Stop Latch Banner *@
|
||||
@if (safetyStopLatched)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="false" Class="mb-2" NoIcon="false">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>SAFETY STOP ACTIVE</strong> — @safetyStopReason
|
||||
</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Warning"
|
||||
OnClick="OnReleaseSafetyStop"
|
||||
Disabled="@(!hubClient.IsConnected)">
|
||||
Release Safety Stop
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* DockTo Telemetry - special panel when docking *@
|
||||
@if (telemetry.DockTo is not null)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3" Elevation="2" Style="border-left: 4px solid var(--mud-palette-info);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.GpsFixed" Color="Color.Info" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">Docking Telemetry</MudText>
|
||||
<MudSpacer />
|
||||
<MudChip T="string" Color="@GetDockPhaseColor()" Size="Size.Small">@telemetry.DockTo.Phase</MudChip>
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Outlined">@telemetry.DockTo.Direction</MudChip>
|
||||
</MudStack>
|
||||
@{
|
||||
var dock = telemetry.DockTo!;
|
||||
var svgBounds = GetDockSvgBounds(dock, telemetry.X, telemetry.Y);
|
||||
var vb = svgBounds;
|
||||
}
|
||||
<MudGrid>
|
||||
@* SVG Visualization - left side *@
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Docking Path</MudText>
|
||||
<div style="background: #1e1e2e; border-radius: 4px; padding: 8px; height: 100%;">
|
||||
<svg viewBox="@FormatViewBox(vb)"
|
||||
width="100%" height="280" preserveAspectRatio="xMidYMid meet"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@* Grid reference lines *@
|
||||
<line x1="@vb.MinX.ToString("F3")" y1="0" x2="@((vb.MinX + vb.Width).ToString("F3"))" y2="0"
|
||||
stroke="#444" stroke-width="@((vb.Width * 0.003).ToString("F4"))" stroke-dasharray="@((vb.Width * 0.01).ToString("F4"))" />
|
||||
<line x1="0" y1="@vb.MinY.ToString("F3")" x2="0" y2="@((vb.MinY + vb.Height).ToString("F3"))"
|
||||
stroke="#444" stroke-width="@((vb.Width * 0.003).ToString("F4"))" stroke-dasharray="@((vb.Width * 0.01).ToString("F4"))" />
|
||||
|
||||
@* Waypoints path *@
|
||||
@if (dock.Waypoints.Count >= 2)
|
||||
{
|
||||
var pathPoints = string.Join(" ", dock.Waypoints.Select(w => $"{w.X.ToString("F3")},{FlipY(w.Y).ToString("F3")}"));
|
||||
<polyline points="@pathPoints"
|
||||
fill="none" stroke="#5b9bd5" stroke-width="@((vb.Width * 0.008).ToString("F4"))"
|
||||
stroke-dasharray="@((vb.Width * 0.015).ToString("F4"))" stroke-linecap="round" opacity="0.8" />
|
||||
}
|
||||
|
||||
@* Distance to goal line *@
|
||||
<line x1="@telemetry.X.ToString("F3")" y1="@FlipY(telemetry.Y).ToString("F3")"
|
||||
x2="@dock.GoalX.ToString("F3")" y2="@FlipY(dock.GoalY).ToString("F3")"
|
||||
stroke="#888" stroke-width="@((vb.Width * 0.003).ToString("F4"))"
|
||||
stroke-dasharray="@((vb.Width * 0.008).ToString("F4"))" opacity="0.5" />
|
||||
|
||||
@* Start Node *@
|
||||
<circle cx="@dock.StartX.ToString("F3")" cy="@FlipY(dock.StartY).ToString("F3")"
|
||||
r="@((vb.Width * 0.025).ToString("F4"))" fill="#4caf50" stroke="#fff" stroke-width="@((vb.Width * 0.005).ToString("F4"))" />
|
||||
<text x="@dock.StartX.ToString("F3")" y="@((FlipY(dock.StartY) - vb.Width * 0.04).ToString("F3"))"
|
||||
text-anchor="middle" fill="#4caf50" font-size="@((vb.Width * 0.04).ToString("F4"))" font-weight="bold">Start</text>
|
||||
|
||||
@* Goal Node with direction arrow *@
|
||||
@{
|
||||
var goalSvgY = FlipY(dock.GoalY);
|
||||
var arrowLen = vb.Width * 0.06;
|
||||
var goalArrowX = dock.GoalX + arrowLen * Math.Cos(dock.GoalTheta);
|
||||
var goalArrowY = goalSvgY - arrowLen * Math.Sin(dock.GoalTheta);
|
||||
}
|
||||
<circle cx="@dock.GoalX.ToString("F3")" cy="@goalSvgY.ToString("F3")"
|
||||
r="@((vb.Width * 0.025).ToString("F4"))" fill="#f44336" stroke="#fff" stroke-width="@((vb.Width * 0.005).ToString("F4"))" />
|
||||
<line x1="@dock.GoalX.ToString("F3")" y1="@goalSvgY.ToString("F3")"
|
||||
x2="@goalArrowX.ToString("F3")" y2="@goalArrowY.ToString("F3")"
|
||||
stroke="#f44336" stroke-width="@((vb.Width * 0.008).ToString("F4"))" marker-end="url(#arrowGoal)" />
|
||||
<text x="@dock.GoalX.ToString("F3")" y="@((goalSvgY - vb.Width * 0.04).ToString("F3"))"
|
||||
text-anchor="middle" fill="#f44336" font-size="@((vb.Width * 0.04).ToString("F4"))" font-weight="bold">Goal</text>
|
||||
|
||||
@* Robot current position (triangle pointing in Theta direction) *@
|
||||
@{
|
||||
var rSvgY = FlipY(telemetry.Y);
|
||||
var rSize = vb.Width * 0.03;
|
||||
var rTheta = telemetry.Theta;
|
||||
// Triangle vertices: tip forward, two rear corners
|
||||
var tipX = telemetry.X + rSize * 1.5 * Math.Cos(rTheta);
|
||||
var tipY = rSvgY - rSize * 1.5 * Math.Sin(rTheta);
|
||||
var leftX = telemetry.X + rSize * Math.Cos(rTheta + 2.5);
|
||||
var leftY = rSvgY - rSize * Math.Sin(rTheta + 2.5);
|
||||
var rightX = telemetry.X + rSize * Math.Cos(rTheta - 2.5);
|
||||
var rightY = rSvgY - rSize * Math.Sin(rTheta - 2.5);
|
||||
}
|
||||
<polygon points="@($"{tipX.ToString("F3")},{tipY.ToString("F3")} {leftX.ToString("F3")},{leftY.ToString("F3")} {rightX.ToString("F3")},{rightY.ToString("F3")}")"
|
||||
fill="#2196f3" stroke="#fff" stroke-width="@((vb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@telemetry.X.ToString("F3")" y="@((rSvgY - vb.Width * 0.045).ToString("F3"))"
|
||||
text-anchor="middle" fill="#2196f3" font-size="@((vb.Width * 0.035).ToString("F4"))">Robot</text>
|
||||
|
||||
@* Arrow marker definition *@
|
||||
<defs>
|
||||
<marker id="arrowGoal" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f44336" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
@* Docking Info - right side *@
|
||||
<MudItem xs="12" md="5">
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mb-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Phase</td>
|
||||
<td class="text-right">
|
||||
<MudChip T="string" Color="@GetDockPhaseColor()" Size="Size.Small">@telemetry.DockTo.Phase</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Direction</td><td class="text-right"><strong>@telemetry.DockTo.Direction</strong></td></tr>
|
||||
<tr>
|
||||
<td>Fine Positioning Retries</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.DockTo.RetryCount > 0 ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.DockTo.RetryCount / @telemetry.DockTo.MaxRetries</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Waypoints</td><td class="text-right"><strong>@telemetry.DockTo.TotalWaypoints</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>Goal X</td><td class="text-right"><strong>@telemetry.DockTo.GoalX.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Goal Y</td><td class="text-right"><strong>@telemetry.DockTo.GoalY.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Goal Theta</td><td class="text-right"><strong>@((telemetry.DockTo.GoalTheta * 180 / Math.PI).ToString("F2"))°</strong></td></tr>
|
||||
<tr>
|
||||
<td>Distance to Goal</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.DistanceToGoal < 0.1 ? Color.Success : Color.Info)">
|
||||
<strong>@telemetry.DistanceToGoal.ToString("F3") m</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@* Telemetry Data Cards *@
|
||||
<MudGrid>
|
||||
@* Position *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Position</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>X</td><td class="text-right"><strong>@telemetry.X.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Y</td><td class="text-right"><strong>@telemetry.Y.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Theta</td><td class="text-right"><strong>@((telemetry.Theta * 180 / Math.PI).ToString("F2"))°</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Velocity *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Velocity</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>Linear</td><td class="text-right"><strong>@telemetry.LinearVelocity.ToString("F3") m/s</strong></td></tr>
|
||||
<tr><td>Angular</td><td class="text-right"><strong>@telemetry.AngularVelocity.ToString("F3") rad/s</strong></td></tr>
|
||||
<tr><td>Confidence</td><td class="text-right"><strong>@telemetry.ModelConfidence.ToString("F2")</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Navigation State *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Navigation</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>State</td>
|
||||
<td class="text-right">
|
||||
<MudChip T="string" Color="@GetNavStateColor()" Size="Size.Small">@telemetry.NavigationState</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Driving</td><td class="text-right"><strong>@(telemetry.Driving ? "Yes" : "No")</strong></td></tr>
|
||||
<tr><td>To Goal</td><td class="text-right"><strong>@telemetry.DistanceToGoal.ToString("F3") m</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Tracking (CTE) *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="1">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Tracking</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cross-Track Error</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.CrossTrackError > safetyConfig.MaxCrossTrackError ? Color.Error : Color.Default)">
|
||||
<strong>@telemetry.CrossTrackError.ToString("F3") m</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Heading Error</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.HeadingError) * 180 / Math.PI > safetyConfig.MaxHeadingError ? Color.Error : Color.Default)">
|
||||
<strong>@((telemetry.HeadingError * 180 / Math.PI).ToString("F2"))°</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Acceleration *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="1">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Acceleration</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Linear</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.LinearAcceleration) > safetyConfig.MaxLinearAcceleration ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.LinearAcceleration.ToString("F2") m/s²</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Angular</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.AngularAcceleration) > safetyConfig.MaxAngularVelocity ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.AngularAcceleration.ToString("F2") m/s²</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Telemetry Charts *@
|
||||
<MudText Typo="Typo.h6" Class="mt-2 mb-2">Telemetry Charts</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Cross-Track Error</MudText>
|
||||
<ApexChart @ref="_cteChart" TItem="TelemetryPoint" Options="_cteOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="CTE (m)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.Cte)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Velocity</MudText>
|
||||
<ApexChart @ref="_velocityChart" TItem="TelemetryPoint" Options="_velocityOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Linear (m/s)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.LinearVel)"
|
||||
OrderBy="p => p.X" />
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Angular (rad/s)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.AngularVel)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Heading Error</MudText>
|
||||
<ApexChart @ref="_headingChart" TItem="TelemetryPoint" Options="_headingOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Heading (deg)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.HeadingErrorDeg)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Navigation Path Visualization *@
|
||||
@if (_cachedWaypoints is { Count: >= 2 })
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3 mt-2" Elevation="2" Style="border-left: 4px solid var(--mud-palette-primary);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Navigation Path</MudText>
|
||||
<MudSpacer />
|
||||
<MudChip T="string" Color="Color.Info" Size="Size.Small">@_cachedWaypoints.Count waypoints</MudChip>
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Outlined">@telemetry.NavigationState</MudChip>
|
||||
</MudStack>
|
||||
@{
|
||||
var wp = _cachedWaypoints;
|
||||
var pvb = GetPathSvgBounds(wp, _robotTrail);
|
||||
var pStroke = (pvb.Width * 0.005).ToString("F4");
|
||||
var pThin = (pvb.Width * 0.003).ToString("F4");
|
||||
var pDash = (pvb.Width * 0.01).ToString("F4");
|
||||
}
|
||||
<div style="background: #1e1e2e; border-radius: 4px; padding: 8px;">
|
||||
<svg viewBox="@FormatViewBox(pvb)"
|
||||
width="100%" height="350" preserveAspectRatio="xMidYMid meet"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@* Grid reference lines *@
|
||||
<line x1="@pvb.MinX.ToString("F3")" y1="0" x2="@((pvb.MinX + pvb.Width).ToString("F3"))" y2="0"
|
||||
stroke="#444" stroke-width="@pThin" stroke-dasharray="@pDash" />
|
||||
<line x1="0" y1="@pvb.MinY.ToString("F3")" x2="0" y2="@((pvb.MinY + pvb.Height).ToString("F3"))"
|
||||
stroke="#444" stroke-width="@pThin" stroke-dasharray="@pDash" />
|
||||
|
||||
@* Waypoints path line *@
|
||||
@{
|
||||
var pathLine = string.Join(" ", wp.Select(w => $"{w.X.ToString("F3")},{FlipY(w.Y).ToString("F3")}"));
|
||||
}
|
||||
<polyline points="@pathLine"
|
||||
fill="none" stroke="#5b9bd5" stroke-width="@pStroke"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.7" />
|
||||
|
||||
@* Waypoint dots *@
|
||||
@for (int wi = 0; wi < wp.Count; wi++)
|
||||
{
|
||||
var wpt = wp[wi];
|
||||
var wColor = wpt.Direction == "BACKWARD" ? "#ff9800" : "#5b9bd5";
|
||||
var wRadius = (pvb.Width * 0.008).ToString("F4");
|
||||
<circle cx="@wpt.X.ToString("F3")" cy="@FlipY(wpt.Y).ToString("F3")"
|
||||
r="@wRadius" fill="@wColor" opacity="0.8" />
|
||||
}
|
||||
|
||||
@* Start waypoint *@
|
||||
@{
|
||||
var wpStart = wp[0];
|
||||
var startR = (pvb.Width * 0.02).ToString("F4");
|
||||
}
|
||||
<circle cx="@wpStart.X.ToString("F3")" cy="@FlipY(wpStart.Y).ToString("F3")"
|
||||
r="@startR" fill="#4caf50" stroke="#fff"
|
||||
stroke-width="@((pvb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@wpStart.X.ToString("F3")"
|
||||
y="@((FlipY(wpStart.Y) - pvb.Width * 0.035).ToString("F3"))"
|
||||
text-anchor="middle" fill="#4caf50"
|
||||
font-size="@((pvb.Width * 0.035).ToString("F4"))" font-weight="bold">Start</text>
|
||||
|
||||
@* Goal waypoint *@
|
||||
@{
|
||||
var wpGoal = wp[^1];
|
||||
var goalR = (pvb.Width * 0.02).ToString("F4");
|
||||
}
|
||||
<circle cx="@wpGoal.X.ToString("F3")" cy="@FlipY(wpGoal.Y).ToString("F3")"
|
||||
r="@goalR" fill="#f44336" stroke="#fff"
|
||||
stroke-width="@((pvb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@wpGoal.X.ToString("F3")"
|
||||
y="@((FlipY(wpGoal.Y) - pvb.Width * 0.035).ToString("F3"))"
|
||||
text-anchor="middle" fill="#f44336"
|
||||
font-size="@((pvb.Width * 0.035).ToString("F4"))" font-weight="bold">Goal</text>
|
||||
|
||||
@* Robot trail (breadcrumb) *@
|
||||
@if (_robotTrail.Count >= 2)
|
||||
{
|
||||
var trailLine = string.Join(" ", _robotTrail.Select(t => $"{t.X.ToString("F3")},{FlipY(t.Y).ToString("F3")}"));
|
||||
<polyline points="@trailLine"
|
||||
fill="none" stroke="#66bb6a" stroke-width="@pThin"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.6"
|
||||
stroke-dasharray="@((pvb.Width * 0.008).ToString("F4"))" />
|
||||
}
|
||||
|
||||
@* Legend *@
|
||||
@{
|
||||
var legendX = pvb.MinX + pvb.Width * 0.02;
|
||||
var legendY = pvb.MinY + pvb.Height * 0.06;
|
||||
var legendFs = (pvb.Width * 0.025).ToString("F4");
|
||||
var legendR = (pvb.Width * 0.008).ToString("F4");
|
||||
var legendStep = pvb.Height * 0.05;
|
||||
}
|
||||
<circle cx="@legendX.ToString("F3")" cy="@legendY.ToString("F3")" r="@legendR" fill="#5b9bd5" />
|
||||
<text x="@((legendX + pvb.Width * 0.02).ToString("F3"))" y="@((legendY + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Forward</text>
|
||||
<circle cx="@legendX.ToString("F3")" cy="@((legendY + legendStep).ToString("F3"))" r="@legendR" fill="#ff9800" />
|
||||
<text x="@((legendX + pvb.Width * 0.02).ToString("F3"))" y="@((legendY + legendStep + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Backward</text>
|
||||
<line x1="@legendX.ToString("F3")" y1="@((legendY + 2 * legendStep).ToString("F3"))"
|
||||
x2="@((legendX + pvb.Width * 0.03).ToString("F3"))" y2="@((legendY + 2 * legendStep).ToString("F3"))"
|
||||
stroke="#66bb6a" stroke-width="@pThin" stroke-dasharray="@((pvb.Width * 0.008).ToString("F4"))" />
|
||||
<text x="@((legendX + pvb.Width * 0.04).ToString("F3"))" y="@((legendY + 2 * legendStep + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Robot Trail</text>
|
||||
</svg>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@* Safety Configuration *@
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel>
|
||||
<TitleContent>
|
||||
<div class="d-flex flex-row">
|
||||
<MudText>Safety Configuration</MudText>
|
||||
<MudButton Class="ms-4" Variant="Variant.Text" Color="Color.Primary" OnClick="ApplySafetyConfig"
|
||||
Disabled="@(!hubClient.IsConnected)" Size="Size.Small">Apply</MudButton>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxLinearVelocity" Label="Max Linear Velocity (m/s)"
|
||||
Step="0.1" Min="0.1" Max="5.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxAngularVelocity" Label="Max Angular Velocity (rad/s)"
|
||||
Step="0.5" Min="0.5" Max="15.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxLinearAcceleration" Label="Max Linear Accel (m/s2)"
|
||||
Step="0.5" Min="0.5" Max="10.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxCrossTrackError" Label="Max CTE (m)"
|
||||
Step="0.05" Min="0.05" Max="2.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxHeadingError" Label="Max Heading Error (deg)"
|
||||
Step="5.0" Min="5.0" Max="180.0" Format="F1" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
|
||||
@* Safety Violations *@
|
||||
<MudPaper Class="pa-3 mt-3" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudText Typo="Typo.h6">Safety Violations</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="ClearViolations">Clear</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@if (violations.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No violations recorded.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
@foreach (var v in violations)
|
||||
{
|
||||
<MudAlert Severity="@(v.Severity == SafetyViolationSeverity.Critical ? Severity.Error : Severity.Warning)"
|
||||
Dense="true" Class="mb-1" NoIcon="false">
|
||||
<MudText Typo="Typo.caption">
|
||||
@DateTimeOffset.FromUnixTimeMilliseconds(v.TimestampMs).ToLocalTime().ToString("HH:mm:ss.fff")
|
||||
— @v.Message
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
</div>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private NavigationMonitorHubClient hubClient { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private NavigationTelemetryDto telemetry = new();
|
||||
private NavigationSafetyConfigDto safetyConfig = new();
|
||||
private List<NavigationSafetyViolationDto> violations = new();
|
||||
private bool telemetryEnabled;
|
||||
private bool safetyStopEnabled;
|
||||
private bool safetyStopLatched;
|
||||
private string safetyStopReason = "";
|
||||
private double updateFrequencyHz = 10;
|
||||
private const int MaxViolations = 100;
|
||||
|
||||
// UI render throttle: render at 2Hz
|
||||
private const int UiRefreshIntervalMs = 500;
|
||||
private System.Threading.Timer? _uiRefreshTimer;
|
||||
private volatile bool _telemetryDirty;
|
||||
|
||||
// Chart data & ApexCharts
|
||||
private List<TelemetryPoint> chartDataPoints = new();
|
||||
private const int MaxChartPoints = 120;
|
||||
private long chartStartTimeMs;
|
||||
private volatile bool _chartDirty;
|
||||
|
||||
// Path visualization: cached waypoints persist after navigation ends
|
||||
private List<WaypointDto> _cachedWaypoints = new();
|
||||
private List<(double X, double Y)> _robotTrail = new();
|
||||
private const int MaxTrailPoints = 120;
|
||||
|
||||
private ApexChart<TelemetryPoint>? _cteChart;
|
||||
private ApexChart<TelemetryPoint>? _velocityChart;
|
||||
private ApexChart<TelemetryPoint>? _headingChart;
|
||||
|
||||
private ApexChartOptions<TelemetryPoint> _cteOptions = new();
|
||||
private ApexChartOptions<TelemetryPoint> _velocityOptions = new();
|
||||
private ApexChartOptions<TelemetryPoint> _headingOptions = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
ConfigureChartOptions();
|
||||
|
||||
hubClient.TelemetryReceived += OnTelemetryReceived;
|
||||
hubClient.SafetyViolationReceived += OnSafetyViolationReceived;
|
||||
hubClient.MonitorStateChanged += OnMonitorStateChanged;
|
||||
hubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
_uiRefreshTimer = new System.Threading.Timer(OnUiRefreshTick, null, UiRefreshIntervalMs, UiRefreshIntervalMs);
|
||||
|
||||
try
|
||||
{
|
||||
await hubClient.StartAsync();
|
||||
var state = await hubClient.GetStateAsync();
|
||||
ApplyMonitorState(state);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTelemetryReceived(NavigationTelemetryDto dto)
|
||||
{
|
||||
telemetry = dto;
|
||||
|
||||
// Cache waypoints: update when new path arrives, persist after navigation ends
|
||||
if (dto.Waypoints is { Count: >= 2 })
|
||||
{
|
||||
// New waypoints = new navigation task → reset trail
|
||||
if (!WaypointsMatch(_cachedWaypoints, dto.Waypoints))
|
||||
{
|
||||
_cachedWaypoints = dto.Waypoints;
|
||||
_robotTrail.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Track robot trail (keep trail after navigation ends for visualization)
|
||||
if (dto.Driving)
|
||||
{
|
||||
_robotTrail.Add((dto.X, dto.Y));
|
||||
if (_robotTrail.Count > MaxTrailPoints)
|
||||
_robotTrail.RemoveAt(0);
|
||||
}
|
||||
|
||||
// Add chart data point every telemetry tick (now 2Hz from server)
|
||||
if (chartStartTimeMs == 0) chartStartTimeMs = dto.TimestampMs;
|
||||
chartDataPoints.Add(new TelemetryPoint
|
||||
{
|
||||
TimeSec = (dto.TimestampMs - chartStartTimeMs) / 1000.0,
|
||||
Cte = dto.CrossTrackError,
|
||||
LinearVel = dto.LinearVelocity,
|
||||
AngularVel = dto.AngularVelocity,
|
||||
HeadingErrorDeg = dto.HeadingError * 180 / Math.PI
|
||||
});
|
||||
if (chartDataPoints.Count > MaxChartPoints)
|
||||
chartDataPoints.RemoveRange(0, chartDataPoints.Count - MaxChartPoints);
|
||||
|
||||
_telemetryDirty = true;
|
||||
_chartDirty = true;
|
||||
}
|
||||
|
||||
private void OnUiRefreshTick(object? state)
|
||||
{
|
||||
if (!_telemetryDirty) return;
|
||||
_telemetryDirty = false;
|
||||
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
if (_chartDirty)
|
||||
{
|
||||
_chartDirty = false;
|
||||
try
|
||||
{
|
||||
if (_cteChart is not null) await _cteChart.UpdateSeriesAsync(true);
|
||||
if (_velocityChart is not null) await _velocityChart.UpdateSeriesAsync(true);
|
||||
if (_headingChart is not null) await _headingChart.UpdateSeriesAsync(true);
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnSafetyViolationReceived(NavigationSafetyViolationDto dto)
|
||||
{
|
||||
violations.Insert(0, dto);
|
||||
if (violations.Count > MaxViolations)
|
||||
violations.RemoveRange(MaxViolations, violations.Count - MaxViolations);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnMonitorStateChanged(NavigationMonitorStateDto state)
|
||||
{
|
||||
ApplyMonitorState(state);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void ApplyMonitorState(NavigationMonitorStateDto state)
|
||||
{
|
||||
telemetryEnabled = state.TelemetryEnabled;
|
||||
safetyStopEnabled = state.SafetyStopEnabled;
|
||||
safetyStopLatched = state.SafetyStopLatched;
|
||||
safetyStopReason = state.SafetyStopReason;
|
||||
updateFrequencyHz = state.UpdateFrequencyHz;
|
||||
safetyConfig = state.SafetyConfig;
|
||||
}
|
||||
|
||||
private async Task OnTelemetryToggle(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
telemetryEnabled = enabled;
|
||||
if (enabled)
|
||||
{
|
||||
// Reset chart history when re-enabling
|
||||
chartDataPoints.Clear();
|
||||
chartStartTimeMs = 0;
|
||||
_cachedWaypoints.Clear();
|
||||
_robotTrail.Clear();
|
||||
}
|
||||
await hubClient.SetTelemetryEnabledAsync(enabled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSafetyStopToggle(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
safetyStopEnabled = enabled;
|
||||
await hubClient.SetSafetyStopEnabledAsync(enabled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnReleaseSafetyStop()
|
||||
{
|
||||
try
|
||||
{
|
||||
await hubClient.ReleaseSafetyStopAsync();
|
||||
Snackbar.Add("Safety stop released", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySafetyConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
await hubClient.UpdateSafetyConfigAsync(safetyConfig);
|
||||
Snackbar.Add("Safety config updated", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearViolations()
|
||||
{
|
||||
violations.Clear();
|
||||
}
|
||||
|
||||
private MudBlazor.Color GetNavStateColor() => telemetry.NavigationState switch
|
||||
{
|
||||
"Moving" or "Rotating" => MudBlazor.Color.Info,
|
||||
"Docking" or "FinePositioning" => MudBlazor.Color.Tertiary,
|
||||
"Completed" => MudBlazor.Color.Success,
|
||||
"Error" or "Canceled" or "SafetyStop" => MudBlazor.Color.Error,
|
||||
"Paused" => MudBlazor.Color.Warning,
|
||||
_ => MudBlazor.Color.Default
|
||||
};
|
||||
|
||||
private MudBlazor.Color GetDockPhaseColor() => telemetry.DockTo?.Phase switch
|
||||
{
|
||||
"Approaching" => MudBlazor.Color.Info,
|
||||
"Aligning" => MudBlazor.Color.Warning,
|
||||
"Advancing" => MudBlazor.Color.Success,
|
||||
_ => MudBlazor.Color.Default
|
||||
};
|
||||
|
||||
// SVG helpers for docking visualization
|
||||
private record struct SvgBounds(double MinX, double MinY, double Width, double Height);
|
||||
|
||||
private SvgBounds GetDockSvgBounds(DockToTelemetryDto dock, double robotX, double robotY)
|
||||
{
|
||||
var xs = new List<double> { dock.StartX, dock.GoalX, robotX };
|
||||
var ys = new List<double> { dock.StartY, dock.GoalY, robotY };
|
||||
foreach (var w in dock.Waypoints) { xs.Add(w.X); ys.Add(w.Y); }
|
||||
|
||||
double minX = xs.Min(), maxX = xs.Max();
|
||||
double minY = ys.Min(), maxY = ys.Max();
|
||||
|
||||
// Ensure minimum size and add padding
|
||||
double rangeX = maxX - minX;
|
||||
double rangeY = maxY - minY;
|
||||
if (rangeX < 0.5) { minX -= 0.25; rangeX = 0.5; maxX = minX + rangeX; }
|
||||
if (rangeY < 0.5) { minY -= 0.25; rangeY = 0.5; maxY = minY + rangeY; }
|
||||
|
||||
double pad = Math.Max(rangeX, rangeY) * 0.15;
|
||||
// Flip Y: SVG Y-down, world Y-up. We flip individual Y coords with FlipY(),
|
||||
// so viewBox uses flipped min/max
|
||||
double svgMinX = minX - pad;
|
||||
double svgMinY = -(maxY + pad); // flipped
|
||||
double svgW = rangeX + 2 * pad;
|
||||
double svgH = rangeY + 2 * pad;
|
||||
|
||||
return new SvgBounds(svgMinX, svgMinY, svgW, svgH);
|
||||
}
|
||||
|
||||
private static string FormatViewBox(SvgBounds vb)
|
||||
=> $"{vb.MinX.ToString("F3")} {vb.MinY.ToString("F3")} {vb.Width.ToString("F3")} {vb.Height.ToString("F3")}";
|
||||
|
||||
private static double FlipY(double worldY) => -worldY;
|
||||
|
||||
// SVG helpers for navigation path visualization
|
||||
private static SvgBounds GetPathSvgBounds(List<WaypointDto> waypoints, List<(double X, double Y)> trail)
|
||||
{
|
||||
var xs = waypoints.Select(w => w.X).ToList();
|
||||
var ys = waypoints.Select(w => w.Y).ToList();
|
||||
foreach (var t in trail) { xs.Add(t.X); ys.Add(t.Y); }
|
||||
|
||||
double minX = xs.Min(), maxX = xs.Max();
|
||||
double minY = ys.Min(), maxY = ys.Max();
|
||||
|
||||
double rangeX = maxX - minX;
|
||||
double rangeY = maxY - minY;
|
||||
if (rangeX < 0.5) { minX -= 0.25; rangeX = 0.5; maxX = minX + rangeX; }
|
||||
if (rangeY < 0.5) { minY -= 0.25; rangeY = 0.5; maxY = minY + rangeY; }
|
||||
|
||||
double pad = Math.Max(rangeX, rangeY) * 0.15;
|
||||
double svgMinX = minX - pad;
|
||||
double svgMinY = -(maxY + pad);
|
||||
double svgW = rangeX + 2 * pad;
|
||||
double svgH = rangeY + 2 * pad;
|
||||
|
||||
return new SvgBounds(svgMinX, svgMinY, svgW, svgH);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick check if waypoints are the same path (compare first/last + count)
|
||||
/// </summary>
|
||||
private static bool WaypointsMatch(List<WaypointDto> a, List<WaypointDto> b)
|
||||
{
|
||||
if (a.Count != b.Count || a.Count == 0) return false;
|
||||
return Math.Abs(a[0].X - b[0].X) < 0.001
|
||||
&& Math.Abs(a[0].Y - b[0].Y) < 0.001
|
||||
&& Math.Abs(a[^1].X - b[^1].X) < 0.001
|
||||
&& Math.Abs(a[^1].Y - b[^1].Y) < 0.001;
|
||||
}
|
||||
|
||||
// ApexCharts configuration (following TelemetryChartPanel pattern)
|
||||
private void ConfigureChartOptions()
|
||||
{
|
||||
var axisColor = "#009933";
|
||||
|
||||
var baseGrid = () => new Grid
|
||||
{
|
||||
BorderColor = "#00993330",
|
||||
StrokeDashArray = 4,
|
||||
Xaxis = new GridXAxis { Lines = new Lines { Show = false } },
|
||||
Yaxis = new GridYAxis { Lines = new Lines { Show = true } }
|
||||
};
|
||||
|
||||
var baseChart = () => new Chart
|
||||
{
|
||||
ForeColor = axisColor,
|
||||
Animations = new Animations { Enabled = false },
|
||||
Toolbar = new Toolbar { Show = false },
|
||||
Zoom = new Zoom { Enabled = false }
|
||||
};
|
||||
|
||||
var baseXAxis = () => new XAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Time (s)", Style = new AxisTitleStyle { Color = axisColor } },
|
||||
Labels = new XAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return parseFloat(val).toFixed(0); }",
|
||||
Style = new AxisLabelStyle { Colors = axisColor }
|
||||
}
|
||||
};
|
||||
|
||||
var baseTooltip = () => new Tooltip
|
||||
{
|
||||
Shared = true,
|
||||
X = new TooltipX { Format = "0.1f" },
|
||||
Y = new TooltipY { Formatter = @"function(val) { return val.toFixed(4); }" }
|
||||
};
|
||||
|
||||
var yAxisStyle = new AxisTitleStyle { Color = axisColor };
|
||||
var yLabelStyle = new AxisLabelStyle { Colors = axisColor };
|
||||
|
||||
// CTE chart — blue
|
||||
_cteOptions.Chart = baseChart();
|
||||
_cteOptions.Grid = baseGrid();
|
||||
_cteOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_cteOptions.Xaxis = baseXAxis();
|
||||
_cteOptions.Colors = new List<string> { "#0D47A1" };
|
||||
_cteOptions.Tooltip = baseTooltip();
|
||||
_cteOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "CTE (m)", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3,
|
||||
Min = 0
|
||||
}
|
||||
};
|
||||
|
||||
// Velocity chart — green (linear) + purple (angular)
|
||||
_velocityOptions.Chart = baseChart();
|
||||
_velocityOptions.Grid = baseGrid();
|
||||
_velocityOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_velocityOptions.Xaxis = baseXAxis();
|
||||
_velocityOptions.Colors = new List<string> { "#1B5E20", "#7B1FA2" };
|
||||
_velocityOptions.Tooltip = baseTooltip();
|
||||
_velocityOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Velocity", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
|
||||
// Heading error chart — orange
|
||||
_headingOptions.Chart = baseChart();
|
||||
_headingOptions.Grid = baseGrid();
|
||||
_headingOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_headingOptions.Xaxis = baseXAxis();
|
||||
_headingOptions.Colors = new List<string> { "#E65100" };
|
||||
_headingOptions.Tooltip = baseTooltip();
|
||||
_headingOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Heading (deg)", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(2); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 2
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private class TelemetryPoint
|
||||
{
|
||||
public double TimeSec { get; set; }
|
||||
public double Cte { get; set; }
|
||||
public double LinearVel { get; set; }
|
||||
public double AngularVel { get; set; }
|
||||
public double HeadingErrorDeg { get; set; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_uiRefreshTimer is not null)
|
||||
await _uiRefreshTimer.DisposeAsync();
|
||||
hubClient.TelemetryReceived -= OnTelemetryReceived;
|
||||
hubClient.SafetyViolationReceived -= OnSafetyViolationReceived;
|
||||
hubClient.MonitorStateChanged -= OnMonitorStateChanged;
|
||||
hubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
await hubClient.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
@page "/motion/odometry"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Motion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>Odometry (XLOC)</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-3 mb-4">
|
||||
@* Header *@
|
||||
<MudPaper Class="pa-4 mb-3 odom-page-header" Elevation="0">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Primary" Style="font-size: 28px;" />
|
||||
<div>
|
||||
<MudText Typo="Typo.h6" Style="line-height: 1.2;">Odometry → XLOC</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">OdometryService → XlocIntegrationService · ~10 Hz</MudText>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudChip T="string"
|
||||
Color="@(OdometryHubClient.IsConnected ? Color.Success : Color.Default)"
|
||||
Variant="Variant.Filled"
|
||||
Size="Size.Small"
|
||||
Icon="@(OdometryHubClient.IsConnected ? Icons.Material.Filled.Link : Icons.Material.Filled.LinkOff)"
|
||||
Style="font-weight: 500;">
|
||||
@(OdometryHubClient.IsConnected ? "Connected" : "Disconnected")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@if (!OdometryHubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sync">
|
||||
Đang kết nối tới hub odometry...
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (lastOdom != null)
|
||||
{
|
||||
<MudGrid Spacing="3">
|
||||
@* Meta: frame_id, child_frame_id, stamp *@
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-meta">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Tag" Size="Size.Small" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.subtitle2">Header</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2" Wrap="Wrap.Wrap">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary">@lastOdom.FrameId</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary">@lastOdom.ChildFrameId</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary">@lastOdom.Timestamp.ToString("HH:mm:ss.fff")</MudChip>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Pose: Position + Orientation *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-position">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Place" Size="Size.Small" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.subtitle2">Position</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(m)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="X" Value="@Format(lastOdom.PositionX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Y" Value="@Format(lastOdom.PositionY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Z" Value="@Format(lastOdom.PositionZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-orientation">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Explore" Size="Size.Small" Color="Color.Secondary" />
|
||||
<MudText Typo="Typo.subtitle2">Orientation</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(yaw °)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2" Wrap="Wrap.Wrap" AlignItems="AlignItems.End">
|
||||
<MudTextField T="string" Label="Yaw" Value="@(FormatDegrees(YawDegrees))" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 100px;" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="pb-2">Q: (@Format(lastOdom.OrientationX), @Format(lastOdom.OrientationY), @Format(lastOdom.OrientationZ), @Format(lastOdom.OrientationW))</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Twist: Linear + Angular *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-linear">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Speed" Size="Size.Small" Color="Color.Info" />
|
||||
<MudText Typo="Typo.subtitle2">Linear velocity</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(m/s)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="Vx" Value="@Format(lastOdom.LinearVelocityX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Vy" Value="@Format(lastOdom.LinearVelocityY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Vz" Value="@Format(lastOdom.LinearVelocityZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-angular">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.RotateRight" Size="Size.Small" Color="Color.Warning" />
|
||||
<MudText Typo="Typo.subtitle2">Angular velocity</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(rad/s)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="ωx" Value="@Format(lastOdom.AngularVelocityX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="ωy" Value="@Format(lastOdom.AngularVelocityY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="ωz" Value="@Format(lastOdom.AngularVelocityZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-2" Elevation="0" Style="border-radius: 8px; background: var(--mud-palette-background-grey);">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Align="Align.Center">
|
||||
pose_position · pose_orientation · twist_linear · twist_angular → ToXlocOdometry()
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
else if (OdometryHubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.Schedule">
|
||||
Chưa nhận dữ liệu. Đang chờ broadcast từ server.
|
||||
</MudAlert>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
[Inject]
|
||||
private OdometryHubClient OdometryHubClient { get; set; } = null!;
|
||||
|
||||
private OdometryDto? lastOdom;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
OdometryHubClient.OdometryReceived += OnOdometryReceived;
|
||||
OdometryHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
await OdometryHubClient.StartAsync();
|
||||
if (OdometryHubClient.IsConnected)
|
||||
{
|
||||
lastOdom = await OdometryHubClient.GetCurrentOdometryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOdometryReceived(OdometryDto dto)
|
||||
{
|
||||
lastOdom = dto;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState _)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private double YawDegrees
|
||||
{
|
||||
get
|
||||
{
|
||||
if (lastOdom == null) return 0;
|
||||
var qw = lastOdom.OrientationW;
|
||||
var qz = lastOdom.OrientationZ;
|
||||
var qx = lastOdom.OrientationX;
|
||||
var qy = lastOdom.OrientationY;
|
||||
var yaw = Math.Atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz));
|
||||
return yaw * 180.0 / Math.PI;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Format(double value) => value.ToString("F4");
|
||||
private static string FormatDegrees(double value) => value.ToString("F2") + " °";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
OdometryHubClient.OdometryReceived -= OnOdometryReceived;
|
||||
OdometryHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
await OdometryHubClient.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Odometry page – header và card có màu */
|
||||
.odom-page-header {
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--mud-palette-primary);
|
||||
background-color: var(--mud-palette-surface);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Card chung: bo góc, viền trái 4px màu */
|
||||
.odom-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mud-palette-lines-default);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.odom-card-meta {
|
||||
border-left-color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.odom-card-position {
|
||||
border-left-color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.odom-card-orientation {
|
||||
border-left-color: var(--mud-palette-secondary);
|
||||
}
|
||||
|
||||
.odom-card-linear {
|
||||
border-left-color: var(--mud-palette-info);
|
||||
}
|
||||
|
||||
.odom-card-angular {
|
||||
border-left-color: var(--mud-palette-warning);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
@page "/plc/controller"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Plc
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>PLC Controller</PageTitle>
|
||||
|
||||
<div style="height: 100%; display: flex; flex-direction: column; overflow: hidden;">
|
||||
@* Sticky Header Section *@
|
||||
<div style="flex-shrink: 0; background: var(--mud-palette-background); z-index: 10;">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="py-3">
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.h5">PLC Controller Status</MudText>
|
||||
|
||||
@* Connection Status *@
|
||||
<MudAlert Severity="@(hubClient?.IsConnected == true ? Severity.Success : Severity.Warning)" Dense>
|
||||
@if (hubClient?.IsConnected == true)
|
||||
{
|
||||
<MudText>Connected to PlcControllerHub</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>Disconnected from PlcControllerHub</MudText>
|
||||
}
|
||||
</MudAlert>
|
||||
|
||||
@* Control Section *@
|
||||
<MudPaper Class="pa-3">
|
||||
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="EnableUpdate"
|
||||
Disabled="@(isUpdateEnabled || !isHubReady)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Size="Size.Small" Class="mr-1" />
|
||||
Enable Update
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
OnClick="DisableUpdate"
|
||||
Disabled="@(!isUpdateEnabled)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Stop" Size="Size.Small" Class="mr-1" />
|
||||
Disable Update
|
||||
</MudButton>
|
||||
|
||||
<MudChip T="string" Color="@(isUpdateEnabled? Color.Success: Color.Default)" Size="Size.Small">
|
||||
@(isUpdateEnabled ? "Updating (2Hz)" : "Stopped")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
|
||||
@* Scrollable Content Area *@
|
||||
<div style="flex: 1; overflow-y: auto; overflow-x: hidden;">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="py-3">
|
||||
@if (status != null)
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
@* System Status Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>System Status</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudChip T="string" Color="@(status.IsReady? Color.Success: Color.Error)" Size="Size.Small">
|
||||
@(status.IsReady ? "Ready" : "Not Ready")
|
||||
</MudChip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Peripheral Mode:</MudText>
|
||||
<MudChip T="string" Color="@GetModeColor(status.PeripheralMode)" Size="Size.Small">
|
||||
@status.PeripheralMode
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Safety Speed:</MudText>
|
||||
<MudChip T="string" Color="@GetSpeedColor(status.SafetySpeed)" Size="Size.Small">
|
||||
@status.SafetySpeed
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Stop State:</MudText>
|
||||
<MudChip T="string" Color="@GetStopStateColor(status.StopState)" Size="Size.Small">
|
||||
@status.StopState
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Lift State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Lift State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Lifted Up", status.LiftedUp, false))
|
||||
@StatusIndicator(("Lifted Down", status.LiftedDown, false))
|
||||
@StatusIndicator(("Lift Home", status.LiftHome, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* (Lift Module control removed; now use LiftModuleCard in Motion/ManualControl page) *@
|
||||
|
||||
@* Motor State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Motor State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Left Motor", status.LeftMotorReady, false))
|
||||
@StatusIndicator(("Right Motor", status.RightMotorReady, false))
|
||||
@StatusIndicator(("Lift Motor", status.LiftMotorReady, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Safety Sensors Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Safety Sensors</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Emergency", status.Emergency, true))
|
||||
@StatusIndicator(("Bumper", status.Bumper, true))
|
||||
@StatusIndicator(("Lidar Front", status.LidarFrontProtectField, true))
|
||||
@StatusIndicator(("Lidar Back", status.LidarBackProtectField, true))
|
||||
@StatusIndicator(("Lidar Tim", status.LidarFrontTimProtectField, true))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
|
||||
@* Button State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Button State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Start Button", status.ButtonStart, false))
|
||||
@StatusIndicator(("Stop Button", status.ButtonStop, true))
|
||||
@StatusIndicator(("Reset Button", status.ButtonReset, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Other State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Other State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Has Load", status.HasLoad, false))
|
||||
@StatusIndicator(("Charger Enabled", status.EnabledCharger, false))
|
||||
@StatusIndicator(("Charging", status.Charging, false))
|
||||
@StatusIndicator(("Muted Base", status.MutedBase, false))
|
||||
@StatusIndicator(("Muted Load", status.MutedLoad, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Command State Card - Các lệnh đã ghi xuống PLC *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Command State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">System State:</MudText>
|
||||
<MudChip T="string" Color="@GetSystemStateColor(status.CurrentSystemState)" Size="Size.Small">
|
||||
@status.CurrentSystemState
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Operation State:</MudText>
|
||||
<MudChip T="string" Color="@GetOperationStateColor(status.CurrentOperationState)" Size="Size.Small">
|
||||
@status.CurrentOperationState
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">RF Mode:</MudText>
|
||||
<MudChip T="string" Color="@GetRFModeColor(status.CurrentRFMode)" Size="Size.Small">
|
||||
@status.CurrentRFMode
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Command Values Card - Các giá trị điều khiển đã ghi *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Command Values</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Horizontal Load", status.SetHorizontalLoadValue, false))
|
||||
@StatusIndicator(("Muted Base (Set)", status.SetMutedBaseValue, false))
|
||||
@StatusIndicator(("Muted Load (Set)", status.SetMutedLoadValue, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Command Values Card - Các giá trị điều khiển đã ghi *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Command Values</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Color="Color.Primary" Size="Size.Small" />
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Charger Enable (Set)", status.SetEnableChargerValue, false))
|
||||
@StatusIndicator(("Has Load (Set)", status.SetHasLoadValue, false))
|
||||
@StatusIndicator(("RF E-Stop (Set)", status.SetRFEStopValue, true))
|
||||
@StatusIndicator(("Batter Low (Set)", status.SetBatteryLowValue, true))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private IServiceProvider ServiceProvider { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
[Inject] private HttpClient Http { get; set; } = null!;
|
||||
|
||||
private PlcControllerHubClient? hubClient;
|
||||
private PlcControllerStatusDto? status;
|
||||
private bool isLoading = false;
|
||||
private bool isHubReady = false;
|
||||
private bool isUpdateEnabled = false;
|
||||
private System.Threading.Timer? updateTimer;
|
||||
private bool _disposed = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
hubClient = ServiceProvider.GetService<PlcControllerHubClient>();
|
||||
if (hubClient is null)
|
||||
{
|
||||
isHubReady = false;
|
||||
return; // PLC hub client not registered (disconnected)
|
||||
}
|
||||
|
||||
// Subscribe to connection state changes
|
||||
hubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
try
|
||||
{
|
||||
await hubClient.StartAsync();
|
||||
isHubReady = hubClient.IsConnected;
|
||||
|
||||
// Wait a bit for connection to establish
|
||||
await Task.Delay(500);
|
||||
isHubReady = hubClient.IsConnected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error initializing: {ex.Message}", Severity.Error);
|
||||
isHubReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
isHubReady = state == HubConnectionState.Connected;
|
||||
|
||||
// Stop update if disconnected
|
||||
if (!isHubReady && isUpdateEnabled)
|
||||
{
|
||||
DisableUpdate();
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void EnableUpdate()
|
||||
{
|
||||
if (_disposed || isUpdateEnabled || !isHubReady)
|
||||
return;
|
||||
|
||||
isUpdateEnabled = true;
|
||||
|
||||
// Start timer at 2Hz (500ms)
|
||||
updateTimer = new System.Threading.Timer(
|
||||
async _ => await RefreshStatus(),
|
||||
null,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMilliseconds(500));
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void DisableUpdate()
|
||||
{
|
||||
if (!isUpdateEnabled)
|
||||
return;
|
||||
|
||||
isUpdateEnabled = false;
|
||||
|
||||
// Stop and dispose timer
|
||||
updateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
updateTimer?.Dispose();
|
||||
updateTimer = null;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RefreshStatus()
|
||||
{
|
||||
if (_disposed || isLoading || hubClient is null || !isHubReady)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
status = await hubClient.GetStatusAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error refreshing status: {ex.Message}", Severity.Error);
|
||||
isHubReady = false;
|
||||
DisableUpdate();
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetModeColor(string mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
"AUTOMATIC" => Color.Success,
|
||||
"MANUAL" => Color.Warning,
|
||||
"SERVICE" => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetSpeedColor(string speed)
|
||||
{
|
||||
return speed switch
|
||||
{
|
||||
"Very_Slow" => Color.Error,
|
||||
"Slow" => Color.Warning,
|
||||
"Normal" => Color.Default,
|
||||
"Medium" => Color.Info,
|
||||
"Optimal" => Color.Success,
|
||||
"Fast" => Color.Primary,
|
||||
"Very_Fast" => Color.Secondary,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetStopStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"None" => Color.Success,
|
||||
"EMC" => Color.Error,
|
||||
"Bumper" => Color.Error,
|
||||
"FrontProtective" => Color.Warning,
|
||||
"BackProtective" => Color.Warning,
|
||||
"TimProtective" => Color.Warning,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetSystemStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"INIT" => Color.Info,
|
||||
"IDLE" => Color.Default,
|
||||
"PAUSED" => Color.Warning,
|
||||
"PROCCESSING" => Color.Primary,
|
||||
"DOCKING" => Color.Secondary,
|
||||
"CHARGING" => Color.Tertiary,
|
||||
"MAINTENANCE" => Color.Warning,
|
||||
"MANUAL" => Color.Info,
|
||||
"OVERRIDE" => Color.Warning,
|
||||
"ERROR" => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetOperationStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"Move" => Color.Primary,
|
||||
"Lifting" => Color.Info,
|
||||
"LiftRotating" => Color.Secondary,
|
||||
"None" => Color.Default,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetRFModeColor(string mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
"Default" => Color.Success,
|
||||
"Maintenance" => Color.Warning,
|
||||
"Override" => Color.Error,
|
||||
"None" => Color.Default,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Stop update timer
|
||||
DisableUpdate();
|
||||
|
||||
// Unsubscribe from events
|
||||
if (hubClient != null)
|
||||
{
|
||||
hubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@* Status Indicator Component *@
|
||||
@code {
|
||||
private RenderFragment<(string Label, bool Value, bool DangerWhenTrue)> StatusIndicator => context =>
|
||||
@<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">@context.Label:</MudText>
|
||||
<MudChip T="bool"
|
||||
Color="@(context.Value ? (context.DangerWhenTrue ? Color.Error : Color.Success) : Color.Default)"
|
||||
Size="Size.Small">
|
||||
@(context.Value ? "ON" : "OFF")
|
||||
</MudChip>
|
||||
</MudStack>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/programming"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Script Editor</PageTitle>
|
||||
|
||||
<RobotNet10.ScriptEditor.ScriptEditor />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
@page "/station-manager/{LevelId:guid}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Station Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.StationManager.StationManagerComponent LayoutLevelId="LevelId" />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
public Guid LevelId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@page "/navigation/tuning"
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Navigation Tuning</PageTitle>
|
||||
|
||||
<AuthorizeView Roles="Distributor">
|
||||
<TuningDashboard />
|
||||
</AuthorizeView>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,18 @@
|
||||
@page "/vehicletypes/edit/{Id:guid}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Vehicle Editor</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.VehicleTypeManager.VehicleTypeEditComponent Id="@Id" />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/vehicle-manager"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Vehicle Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.VehicleTypeManager.VehicleTypeManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@attribute [Authorize]
|
||||
Reference in New Issue
Block a user