Initial commit

This commit is contained in:
2026-07-03 16:37:12 +07:00
commit 63b8c1ea8b
1931 changed files with 640587 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
@page "/auth"
@rendermode InteractiveWebAssemblyNoPrerender
<PageTitle>Auth</PageTitle>
<h1>You are authenticated</h1>
<AuthorizeView>
Hello @context.User.Identity?.Name!
</AuthorizeView>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
&middot; @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);
}
}
}
}

View File

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

View File

@@ -0,0 +1,11 @@
@page "/layout-manager"
@rendermode InteractiveWebAssemblyNoPrerender
<PageTitle>Layout Manager</PageTitle>
<RobotNet10.MapEditor.Components.LayoutManager.LayoutManagerComponent />
<MudThemeProvider IsDarkMode />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
@page "/missions"
@rendermode InteractiveWebAssemblyNoPrerender
<PageTitle>Mission Manager</PageTitle>
<RobotNet10.ScriptEditor.InstanceMissionManager />
<MudThemeProvider IsDarkMode />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />

View File

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

View File

@@ -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> &mdash; @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"))&deg;</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"))&deg;</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"))&deg;</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&sup2;</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&sup2;</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")
&mdash; @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();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
@page "/programming"
@rendermode InteractiveWebAssemblyNoPrerender
<PageTitle>Script Editor</PageTitle>
<RobotNet10.ScriptEditor.ScriptEditor />
<MudThemeProvider IsDarkMode />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
@page "/vehicle-manager"
@rendermode InteractiveWebAssemblyNoPrerender
<PageTitle>Vehicle Manager</PageTitle>
<RobotNet10.MapEditor.Components.VehicleTypeManager.VehicleTypeManagerComponent />
<MudThemeProvider IsDarkMode />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />

View File

@@ -0,0 +1,3 @@
@using Microsoft.AspNetCore.Authorization
@attribute [Authorize]