Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using RobotNet10.NavigationTune.Shared.Hubs;
using RobotNet10.NavigationTune.Shared.Interfaces;
namespace RobotNet10.NavigationTuneUI.Clients;
/// <summary>
/// SignalR client for Tuning Hub
/// </summary>
public class TuningHubClient : IAsyncDisposable
{
private readonly HubConnection _hubConnection;
private bool _disposed;
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
public HubConnectionState ConnectionState => _hubConnection.State;
// Events
public event Action<TelemetryUpdateDto>? TelemetryUpdated;
public event Action<TestStatusUpdateDto>? TestStatusUpdated;
public event Action<SafetyEventDto>? SafetyEventReceived;
public event Action<TestExecutionResult>? TestResultReceived;
public event Action<HubConnectionState>? ConnectionStateChanged;
public TuningHubClient(NavigationManager navigationManager)
{
var hubUrl = navigationManager.ToAbsoluteUri("/tuninghub");
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
})
.WithAutomaticReconnect()
.Build();
// Subscribe to server messages
_hubConnection.On<TelemetryUpdateDto>("ReceiveTelemetry", dto => TelemetryUpdated?.Invoke(dto));
_hubConnection.On<TestStatusUpdateDto>("ReceiveTestStatus", dto => TestStatusUpdated?.Invoke(dto));
_hubConnection.On<SafetyEventDto>("ReceiveSafetyEvent", dto => SafetyEventReceived?.Invoke(dto));
_hubConnection.On<TestExecutionResult>("ReceiveTestResult", result => TestResultReceived?.Invoke(result));
_hubConnection.Reconnecting += async (ex) =>
{
ConnectionStateChanged?.Invoke(_hubConnection.State);
await Task.CompletedTask;
};
_hubConnection.Reconnected += async (id) =>
{
ConnectionStateChanged?.Invoke(_hubConnection.State);
await Task.CompletedTask;
};
_hubConnection.Closed += async (ex) =>
{
ConnectionStateChanged?.Invoke(_hubConnection.State);
await Task.CompletedTask;
};
}
public async Task StartAsync()
{
if (_hubConnection.State == HubConnectionState.Disconnected)
{
await _hubConnection.StartAsync();
}
}
public async Task StopAsync()
{
if (_hubConnection.State != HubConnectionState.Disconnected)
{
await _hubConnection.StopAsync();
}
}
public async Task JoinTestSessionAsync(string testRunId)
{
await _hubConnection.InvokeAsync("JoinTestSession", testRunId);
}
public async Task LeaveTestSessionAsync(string testRunId)
{
await _hubConnection.InvokeAsync("LeaveTestSession", testRunId);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
await StopAsync();
await _hubConnection.DisposeAsync();
_disposed = true;
}
}

View File

@@ -0,0 +1,168 @@
@using RobotNet10.NavigationTune.Shared.Models
@using MudBlazor
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Test Metrics</MudText>
@if (Metrics != null)
{
<MudChip T="string"
Color="@(Metrics.PassedCriteria ? Color.Success : Color.Warning)"
Size="Size.Small">
@(Metrics.PassedCriteria ? "Passed" : "Failed")
</MudChip>
}
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
@if (Metrics == null)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">No metrics available</MudText>
}
else
{
<MudTabs Elevation="0">
<MudTabPanel Text="Overall Score">
<MudGrid Spacing="3" Class="mt-2">
<MudItem xs="12">
<MudText Typo="Typo.h5" Class="mb-3">Overall Score: @Metrics.OverallScore.ToString("F1")</MudText>
<MudProgressLinear Value="@Metrics.OverallScore"
Color="@GetScoreColor(Metrics.OverallScore)"
Class="mt-2"
Style="height: 30px;" />
</MudItem>
<MudItem xs="12" sm="4">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Tracking Score</MudText>
<MudText Typo="Typo.h6">@Metrics.TrackingScore.ToString("F1")</MudText>
<MudProgressLinear Value="@Metrics.TrackingScore"
Color="@GetScoreColor(Metrics.TrackingScore)"
Class="mt-2" />
</MudCard>
</MudItem>
<MudItem xs="12" sm="4">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Smoothness Score</MudText>
<MudText Typo="Typo.h6">@Metrics.SmoothnessScore.ToString("F1")</MudText>
<MudProgressLinear Value="@Metrics.SmoothnessScore"
Color="@GetScoreColor(Metrics.SmoothnessScore)"
Class="mt-2" />
</MudCard>
</MudItem>
<MudItem xs="12" sm="4">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Efficiency Score</MudText>
<MudText Typo="Typo.h6">@Metrics.EfficiencyScore.ToString("F1")</MudText>
<MudProgressLinear Value="@Metrics.EfficiencyScore"
Color="@GetScoreColor(Metrics.EfficiencyScore)"
Class="mt-2" />
</MudCard>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Tracking Accuracy">
<MudGrid Spacing="3" Class="mt-2">
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Cross-Track Error RMS (m)</MudText>
<MudText Typo="Typo.h6">@Metrics.CrossTrackErrorRMS.ToString("F4")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Cross-Track Error Peak (m)</MudText>
<MudText Typo="Typo.h6">@Metrics.CrossTrackErrorPeak.ToString("F4")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Heading Error RMS (rad)</MudText>
<MudText Typo="Typo.h6">@Metrics.HeadingErrorRMS.ToString("F4")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Heading Error Peak (rad)</MudText>
<MudText Typo="Typo.h6">@Metrics.HeadingErrorPeak.ToString("F4")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Goal Position Error (m)</MudText>
<MudText Typo="Typo.h6">@Metrics.GoalPositionError.ToString("F4")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Goal Heading Error (rad)</MudText>
<MudText Typo="Typo.h6">@Metrics.GoalHeadingError.ToString("F4")</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Smoothness">
<MudGrid Spacing="3" Class="mt-2">
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Velocity Std Dev (m/s)</MudText>
<MudText Typo="Typo.h6">@Metrics.VelocityStdDev.ToString("F4")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Acceleration Std Dev (m/s²)</MudText>
<MudText Typo="Typo.h6">@Metrics.AccelerationStdDev.ToString("F4")</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Efficiency">
<MudGrid Spacing="3" Class="mt-2">
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Path Length Ratio</MudText>
<MudText Typo="Typo.h6">@Metrics.PathLengthRatio.ToString("F4")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Completion Time (s)</MudText>
<MudText Typo="Typo.h6">@Metrics.CompletionTime.ToString("F2")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Average Speed (m/s)</MudText>
<MudText Typo="Typo.h6">@Metrics.AverageSpeed.ToString("F3")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-3">
<MudText Typo="Typo.caption" Color="Color.Secondary">Max Speed (m/s)</MudText>
<MudText Typo="Typo.h6">@Metrics.MaxSpeed.ToString("F3")</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudTabPanel>
</MudTabs>
}
</MudCardContent>
</MudCard>
@code {
[Parameter] public TestMetrics? Metrics { get; set; }
private Color GetScoreColor(double score)
{
return score switch
{
>= 80 => Color.Success,
>= 60 => Color.Warning,
_ => Color.Error
};
}
}

View File

@@ -0,0 +1,13 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Components
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-4" Style="height: calc(100vh - 120px); overflow-y: auto;">
<TestHistoryViewer @ref="TestHistoryViewerRef"
OnShowMetrics="OnShowMetrics" />
</MudContainer>
@code {
[Parameter] public EventCallback<TestMetrics?> OnShowMetrics { get; set; }
public TestHistoryViewer? TestHistoryViewerRef { get; private set; }
}

View File

@@ -0,0 +1,18 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Components
<MudContainer MaxWidth="MaxWidth.Large" Class="pa-4" Style="height: calc(100vh - 120px); overflow-y: auto;">
<ParameterSetManager ParameterSets="@ParameterSets"
OnParameterSetSelected="OnParameterSetSelected"
OnParameterSetCreated="OnParameterSetCreated"
OnParameterSetUpdated="OnParameterSetUpdated"
OnParameterSetDeleted="OnParameterSetDeleted" />
</MudContainer>
@code {
[Parameter] public List<NavigationParameterSet> ParameterSets { get; set; } = new();
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetSelected { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetCreated { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetUpdated { get; set; }
[Parameter] public EventCallback<Guid> OnParameterSetDeleted { get; set; }
}

View File

@@ -0,0 +1,5 @@
@using RobotNet10.NavigationTuneUI.Components
<MudContainer MaxWidth="MaxWidth.Medium" Class="pa-4" Style="height: calc(100vh - 120px); overflow-y: auto;">
<VelocityControl />
</MudContainer>

View File

@@ -0,0 +1,94 @@
@using Microsoft.AspNetCore.SignalR.Client
@using RobotNet10.NavigationTuneUI.Clients
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Components
<MudGrid Spacing="2" Style="overflow-y: auto; width: 100%; height: calc(100vh - 120px); padding: 8px;">
<!-- 1. Test Execution Control -->
<MudItem xs="12" md="6">
<TestExecutionControl HubClient="@HubClient"
AvailableScenarios="@Scenarios"
AvailableParameterSets="@ParameterSets"
OnStartTest="OnStartTest"
OnPauseTest="OnPauseTest"
OnResumeTest="OnResumeTest"
OnStopTest="OnStopTest"
OnEmergencyStop="OnEmergencyStop"
OnScenarioSelected="OnScenarioSelected" />
</MudItem>
<!-- 2. Parameter Configuration -->
<MudItem xs="12" md="6">
<ParameterTuningEditor ParameterSet="@CurrentParameterSet"
AvailableParameterSets="@ParameterSets"
OnSaveClicked="OnSaveParameters"
OnResetClicked="OnResetParameters"
OnParameterSetChanged="OnParameterSetChanged" />
</MudItem>
<!-- 3. Telemetry Charts (full width) -->
<MudItem xs="12">
<TelemetryChartPanel HubClient="@HubClient" TestRunId="@CurrentTestRunId" />
</MudItem>
<!-- 4. Real-Time Monitor -->
<MudItem xs="12" md="6">
<RealTimeMonitor HubClient="@HubClient" TestRunId="@CurrentTestRunId" />
</MudItem>
<!-- 5. Metrics Visualization -->
<MudItem xs="12" md="6">
<MetricsVisualization Metrics="@CurrentMetrics" />
</MudItem>
<!-- 6. Tuning Advisor -->
@if (TuningReport != null)
{
<MudItem xs="12">
<TuningAdvisorPanel Report="@TuningReport"
CurrentParameters="@CurrentParameterSet"
TestRunId="@_parsedTestRunId"
OnApplySuggestions="OnApplySuggestions"
OnSaveNewParameterSet="OnSaveNewParameterSet" />
</MudItem>
}
<!-- 7. Scenario Configuration -->
<MudItem xs="12">
<ScenarioEditor SelectedScenario="@SelectedScenario"
OnScenarioChanged="OnScenarioChanged"
OnScenarioCreated="OnScenarioCreated"
OnScenarioDeleted="OnScenarioDeleted" />
</MudItem>
</MudGrid>
@code {
[Parameter] public TuningHubClient? HubClient { get; set; }
[Parameter] public List<TestScenario> Scenarios { get; set; } = new();
[Parameter] public List<NavigationParameterSet> ParameterSets { get; set; } = new();
[Parameter] public NavigationParameterSet CurrentParameterSet { get; set; } = new();
[Parameter] public TestScenario? SelectedScenario { get; set; }
[Parameter] public TestMetrics? CurrentMetrics { get; set; }
[Parameter] public string? CurrentTestRunId { get; set; }
[Parameter] public EventCallback<(Guid ScenarioId, Guid ParameterSetId)> OnStartTest { get; set; }
[Parameter] public EventCallback OnPauseTest { get; set; }
[Parameter] public EventCallback OnResumeTest { get; set; }
[Parameter] public EventCallback OnStopTest { get; set; }
[Parameter] public EventCallback OnEmergencyStop { get; set; }
[Parameter] public EventCallback<TestScenario> OnScenarioSelected { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnSaveParameters { get; set; }
[Parameter] public EventCallback OnResetParameters { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetChanged { get; set; }
[Parameter] public EventCallback<TestScenario> OnScenarioChanged { get; set; }
[Parameter] public EventCallback<TestScenario> OnScenarioCreated { get; set; }
[Parameter] public EventCallback<Guid> OnScenarioDeleted { get; set; }
[Parameter] public TuningReport? TuningReport { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnApplySuggestions { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnSaveNewParameterSet { get; set; }
private Guid? _parsedTestRunId => Guid.TryParse(CurrentTestRunId, out var id) ? id : null;
}

View File

@@ -0,0 +1,360 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Services
@using RobotNet10.NavigationTuneUI.Helpers
@using MudBlazor
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
<MudCardHeader Style="box-sizing: border-box; flex-shrink: 0;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Parameter Sets Manager</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Manage your parameter configurations</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
Color="Color.Success"
OnClick="OpenCreateDialog">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-1" />
New Parameter Set
</MudButton>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
@if (ParameterSets.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense>
No parameter sets found. Click "New Parameter Set" to create one.
</MudAlert>
}
else
{
<MudSimpleTable Hover="true" Dense="true" FixedHeader="true" Style="height: 490px">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Created</th>
<th>Version</th>
<th>Default</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var paramSet in ParameterSets)
{
<tr>
<td>
<MudText Typo="Typo.body1">@paramSet.Name</MudText>
</td>
<td>
<MudText Typo="Typo.body2" Color="Color.Secondary">
@(string.IsNullOrEmpty(paramSet.Description) ? "-" : paramSet.Description)
</MudText>
</td>
<td>
<MudText Typo="Typo.body2">
@paramSet.CreatedAt.ToString("yyyy-MM-dd HH:mm")
</MudText>
</td>
<td>
<MudChip T="string" Size="Size.Small" Color="Color.Info">v@paramSet.Version</MudChip>
</td>
<td>
@if (paramSet.IsDefault)
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">Default</MudChip>
}
</td>
<td>
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
OnClick="@(() => OnSelectParameterSet(paramSet))"/>
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
OnClick="@(() => DuplicateParameterSet(paramSet))"/>
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => DeleteParameterSet(paramSet))"
Disabled="@paramSet.IsDefault" />
</MudStack>
</td>
</tr>
}
</tbody>
</MudSimpleTable>
}
</MudCardContent>
</MudCard>
<!-- Create/Edit Dialog -->
<MudDialog @bind-Visible="@_isCreateDialogVisible">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingParameterSet?.Id == Guid.Empty ? "Create New" : "Edit") Parameter Set</MudText>
</TitleContent>
<DialogContent>
<MudStack Spacing="3">
<MudTextField @bind-Value="@_editingParameterSet.Name"
Label="Name"
Variant="Variant.Outlined"
Required="true"
RequiredError="Name is required" />
<MudTextField @bind-Value="@_editingParameterSet.Description"
Label="Description"
Variant="Variant.Outlined"
Lines="3" />
<MudCheckBox @bind-Value="@_editingParameterSet.IsDefault"
Label="Set as default parameter set" />
</MudStack>
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _isCreateDialogVisible = false)">Cancel</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveParameterSetDialog">Save</MudButton>
</DialogActions>
</MudDialog>
<!-- Delete Confirmation Dialog -->
<MudDialog @bind-Visible="@_isDeleteDialogVisible">
<TitleContent>
<MudText Typo="Typo.h6">Delete Parameter Set</MudText>
</TitleContent>
<DialogContent>
<MudText>Are you sure you want to delete parameter set "@_parameterSetToDelete?.Name"?</MudText>
<MudAlert Severity="Severity.Warning" Class="mt-3">
This action cannot be undone.
</MudAlert>
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _isDeleteDialogVisible = false)">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="ConfirmDelete">Delete</MudButton>
</DialogActions>
</MudDialog>
@code {
[Parameter] public List<NavigationParameterSet> ParameterSets { get; set; } = new();
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetSelected { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetCreated { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetUpdated { get; set; }
[Parameter] public EventCallback<Guid> OnParameterSetDeleted { get; set; }
[Inject] private TuningApiService ApiService { get; set; } = null!;
[Inject] private ISnackbar Snackbar { get; set; } = null!;
private bool _isCreateDialogVisible = false;
private bool _isDeleteDialogVisible = false;
private NavigationParameterSet _editingParameterSet = new();
private NavigationParameterSet? _parameterSetToDelete;
private void OpenCreateDialog()
{
_editingParameterSet = new NavigationParameterSet
{
Id = Guid.Empty,
Name = $"Parameter Set {DateTime.Now:yyyyMMdd_HHmmss}",
Description = "",
IsDefault = false,
Version = 1
};
_isCreateDialogVisible = true;
StateHasChanged();
}
private async Task SaveParameterSetDialog()
{
if (string.IsNullOrWhiteSpace(_editingParameterSet.Name))
{
Snackbar.Add("Tên parameter set không được để trống", Severity.Warning);
return;
}
try
{
if (_editingParameterSet.Id == Guid.Empty)
{
// Create new
var created = await ApiService.CreateParameterSetAsync(_editingParameterSet);
await OnParameterSetCreated.InvokeAsync(created);
Snackbar.Add($"Đã tạo parameter set: {created.Name}", Severity.Success);
}
else
{
// Update existing
await ApiService.UpdateParameterSetAsync(_editingParameterSet.Id, _editingParameterSet);
await OnParameterSetUpdated.InvokeAsync(_editingParameterSet);
Snackbar.Add($"Đã cập nhật parameter set: {_editingParameterSet.Name}", Severity.Success);
}
_isCreateDialogVisible = false;
}
catch (ApiException ex)
{
Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task OnSelectParameterSet(NavigationParameterSet parameterSet)
{
try
{
if (OnParameterSetSelected.HasDelegate)
{
await OnParameterSetSelected.InvokeAsync(parameterSet);
}
else
{
Snackbar.Add("OnParameterSetSelected event không được bind", Severity.Warning);
}
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi chọn parameter set: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private void DuplicateParameterSet(NavigationParameterSet source)
{
try
{
_editingParameterSet = CloneParameterSet(source);
_editingParameterSet.Id = Guid.Empty; // New ID
_editingParameterSet.Name = $"{source.Name} (Copy)";
_editingParameterSet.Description = source.Description;
_editingParameterSet.CreatedAt = DateTime.UtcNow;
_editingParameterSet.UpdatedAt = null;
_editingParameterSet.IsDefault = false;
_isCreateDialogVisible = true;
InvokeAsync(StateHasChanged);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi duplicate parameter set: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private void DeleteParameterSet(NavigationParameterSet parameterSet)
{
try
{
_parameterSetToDelete = parameterSet;
_isDeleteDialogVisible = true;
InvokeAsync(StateHasChanged);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi xóa parameter set: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task ConfirmDelete()
{
if (_parameterSetToDelete == null) return;
try
{
await ApiService.DeleteParameterSetAsync(_parameterSetToDelete.Id);
await OnParameterSetDeleted.InvokeAsync(_parameterSetToDelete.Id);
Snackbar.Add($"Đã xóa parameter set: {_parameterSetToDelete.Name}", Severity.Success);
_isDeleteDialogVisible = false;
_parameterSetToDelete = null;
}
catch (ApiException ex)
{
Snackbar.Add($"Lỗi: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private NavigationParameterSet CloneParameterSet(NavigationParameterSet source)
{
return new NavigationParameterSet
{
Name = source.Name,
Description = source.Description,
ControllerType = source.ControllerType,
MovePidConfig = new PIDConfig
{
Kp = source.MovePidConfig.Kp,
Ki = source.MovePidConfig.Ki,
Kd = source.MovePidConfig.Kd
},
RotatePidConfig = new PIDConfig
{
Kp = source.RotatePidConfig.Kp,
Ki = source.RotatePidConfig.Ki,
Kd = source.RotatePidConfig.Kd
},
PurePursuitConfig = new PurePursuitConfig
{
LookaheadMin = source.PurePursuitConfig.LookaheadMin,
LookaheadMax = source.PurePursuitConfig.LookaheadMax,
Kdd = source.PurePursuitConfig.Kdd,
MaxAngularVelocity = source.PurePursuitConfig.MaxAngularVelocity,
ResolutionSplit = source.PurePursuitConfig.ResolutionSplit,
FinalApproachThreshold = source.PurePursuitConfig.FinalApproachThreshold,
HeadingTolerance = source.PurePursuitConfig.HeadingTolerance,
GoalRegionDistance = source.PurePursuitConfig.GoalRegionDistance,
KCurvature = source.PurePursuitConfig.KCurvature,
MinLookaheadTimeRatio = source.PurePursuitConfig.MinLookaheadTimeRatio,
MaxLookaheadTimeRatio = source.PurePursuitConfig.MaxLookaheadTimeRatio
},
StanleyConfig = new StanleyConfig
{
K = source.StanleyConfig.K,
Ks = source.StanleyConfig.Ks,
WheelBase = source.StanleyConfig.WheelBase,
MaxSteeringAngle = source.StanleyConfig.MaxSteeringAngle,
EnableCurvatureFeedforward = source.StanleyConfig.EnableCurvatureFeedforward,
KCurvatureFF = source.StanleyConfig.KCurvatureFF,
GoalTolerance = source.StanleyConfig.GoalTolerance,
HeadingTolerance = source.StanleyConfig.HeadingTolerance,
ResolutionSplit = source.StanleyConfig.ResolutionSplit,
GoalApproachDistance = source.StanleyConfig.GoalApproachDistance,
GoalGainMultiplier = source.StanleyConfig.GoalGainMultiplier,
LowSpeedThreshold = source.StanleyConfig.LowSpeedThreshold,
LowSpeedAngularGain = source.StanleyConfig.LowSpeedAngularGain
},
EstimatorConfig = new VelocityEstimatorConfig
{
MinBlendRatio = source.EstimatorConfig.MinBlendRatio,
MaxBlendRatio = source.EstimatorConfig.MaxBlendRatio,
DefaultBlendRatio = source.EstimatorConfig.DefaultBlendRatio,
GoodTrackingBlend = source.EstimatorConfig.GoodTrackingBlend,
ModerateTrackingBlend = source.EstimatorConfig.ModerateTrackingBlend,
PoorTrackingBlend = source.EstimatorConfig.PoorTrackingBlend,
GoodTrackingThreshold = source.EstimatorConfig.GoodTrackingThreshold,
ModerateTrackingThreshold = source.EstimatorConfig.ModerateTrackingThreshold,
ConfidenceDecayRate = source.EstimatorConfig.ConfidenceDecayRate,
MinConfidence = source.EstimatorConfig.MinConfidence
},
SignalConfig = new VelocitySignalProcessingConfig
{
AlphaFilter = source.SignalConfig.AlphaFilter,
NoiseThreshold = source.SignalConfig.NoiseThreshold
},
MotorDynamicsConfig = new MotorDynamicsConfig
{
Tau = source.MotorDynamicsConfig.Tau,
Delta = source.MotorDynamicsConfig.Delta
},
NavigationConfig = new NavigationConfig
{
MaxLinearVelocity = source.NavigationConfig.MaxLinearVelocity,
MinLinearVelocity = source.NavigationConfig.MinLinearVelocity,
MaxAngularVelocity = source.NavigationConfig.MaxAngularVelocity,
RotateAngularVelocity = source.NavigationConfig.RotateAngularVelocity,
ReachedRadius = source.NavigationConfig.ReachedRadius,
InitialRotationThreshold = source.NavigationConfig.InitialRotationThreshold,
Acceleration = source.NavigationConfig.Acceleration,
Deceleration = source.NavigationConfig.Deceleration
}
};
}
}

View File

@@ -0,0 +1,718 @@
@using RobotNet10.NavigationTune.Shared.Models
@using MudBlazor
<MudCard Style="width: 100%; height: 400px; ">
<MudCardHeader Class="pb-0" Style="flex-shrink: 0; box-sizing: border-box;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Parameter Configuration</MudText>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudSelect T="Guid"
Label="Parameter Set"
Variant="Variant.Outlined"
Dense="true"
Margin="Margin.Dense"
Value="@ParameterSet.Id"
ValueChanged="@HandleParameterSetSelectionChanged"
Style="width: 50px;">
<MudSelectItem Value="@Guid.Empty">New Parameter Set</MudSelectItem>
@foreach (var paramSet in AvailableParameterSets)
{
<MudSelectItem Value="@paramSet.Id">@paramSet.Name</MudSelectItem>
}
</MudSelect>
<MudSelect T="PathFollowingController" @bind-Value="@ParameterSet.ControllerType"
Label="Controller Type"
Variant="Variant.Outlined"
Dense="true"
Margin="Margin.Dense"
Style="min-width: 140px;">
<MudSelectItem T="PathFollowingController" Value="@PathFollowingController.PurePursuit">Pure Pursuit</MudSelectItem>
<MudSelectItem T="PathFollowingController" Value="@PathFollowingController.Stanley">Stanley</MudSelectItem>
</MudSelect>
</MudStack>
</CardHeaderContent>
<CardHeaderActions>
<div class="aligns-item-center mt-2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" OnClick="OnSave">Save</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="OnReset">Reset</MudButton>
</div>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Class="pt-0" Style="flex: 1; overflow: hidden; box-sizing: border-box;">
<MudTabs Elevation="0">
<MudTabPanel Text="PID Controllers">
<MudGrid Spacing="1" Style="overflow-y:auto; height: 200px">
<MudItem xs="12">
<MudText Typo="Typo.h6" Class="mb-1">Move PID</MudText>
<MudGrid>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.MovePidConfig.Kp"
Label="Kp"
Variant="Variant.Outlined"
Min="0"
Max="100"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.MovePidConfig.Ki"
Label="Ki"
Variant="Variant.Outlined"
Min="0"
Max="1"
Step="0.0001"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.MovePidConfig.Kd"
Label="Kd"
Variant="Variant.Outlined"
Min="0"
Max="10"
Margin="Margin.Dense" />
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.h6" Class="mb-1">Rotate PID</MudText>
<MudGrid>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.RotatePidConfig.Kp"
Label="Kp"
Variant="Variant.Outlined"
Min="0"
Max="100"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.RotatePidConfig.Ki"
Label="Ki"
Variant="Variant.Outlined"
Min="0"
Max="1"
Step="0.001"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.RotatePidConfig.Kd"
Label="Kd"
Variant="Variant.Outlined"
Min="0"
Max="10"
Margin="Margin.Dense" />
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Pure Pursuit">
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.LookaheadMin"
Label="Lookahead Min (m)"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.LookaheadMax"
Label="Lookahead Max (m)"
Variant="Variant.Outlined"
Min="0"
Max="10.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.Kdd"
Label="Kdd"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.MaxAngularVelocity"
Label="Max Angular Velocity (rad/s)"
Variant="Variant.Outlined"
Min="0"
Max="10.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.ResolutionSplit"
Label="Resolution Split (m)"
Variant="Variant.Outlined"
Min="0"
Max="0.5"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Distance between interpolated points
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.FinalApproachThreshold"
Label="Final Approach Threshold (m)"
Variant="Variant.Outlined"
Min="0"
Max="1.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Distance to goal to switch to final approach controller
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.HeadingTolerance"
Label="Heading Tolerance (deg)"
Variant="Variant.Outlined"
Min="0"
Max="15.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Heading accuracy for goal reached
</MudText>
</MudItem>
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">Adaptive lookahead</MudText>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.GoalRegionDistance"
Label="Goal Region Distance (m)"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Start reducing lookahead at this distance from goal
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.KCurvature"
Label="K Curvature"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Curvature sensitivity (higher = more reduction on curves)
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.MinLookaheadTimeRatio"
Label="Min Lookahead Time Ratio"
Variant="Variant.Outlined"
Min="0"
Max="1.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Min lookahead = velocity × this ratio (s)
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.PurePursuitConfig.MaxLookaheadTimeRatio"
Label="Max Lookahead Time Ratio"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Max lookahead = velocity × this ratio (s)
</MudText>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Velocity Estimator">
<MudGrid Spacing="1" Class="mt-1" Style="height: 200px; overflow-y:auto">
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Blend Ratios</MudText>
<MudGrid>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.MinBlendRatio"
Label="Min Blend"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.MaxBlendRatio"
Label="Max Blend"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.DefaultBlendRatio"
Label="Default Blend"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Tracking Thresholds</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.GoodTrackingThreshold"
Label="Good Threshold (m)"
Variant="Variant.Outlined"
Min="0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.ModerateTrackingThreshold"
Label="Moderate Threshold (m)"
Variant="Variant.Outlined"
Min="0"
Max="2.0"
Margin="Margin.Dense" />
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Tracking Blend Ratios</MudText>
<MudGrid>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.GoodTrackingBlend"
Label="Good Tracking Blend"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.ModerateTrackingBlend"
Label="Moderate Tracking Blend"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="4">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.PoorTrackingBlend"
Label="Poor Tracking Blend"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Confidence Settings</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.ConfidenceDecayRate"
Label="Confidence Decay Rate"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.EstimatorConfig.MinConfidence"
Label="Min Confidence"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
</MudGrid>
</MudItem>
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Signal Processing</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.SignalConfig.AlphaFilter"
Label="Alpha Filter"
Variant="Variant.Outlined"
Min="0.0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.SignalConfig.NoiseThreshold"
Label="Noise Threshold"
Variant="Variant.Outlined"
Min="0.0"
Max="5.0"
Margin="Margin.Dense" />
</MudItem>
</MudGrid>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Motor Dynamics">
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.MotorDynamicsConfig.Tau"
Label="Time Constant τ (s)"
Variant="Variant.Outlined"
Min="0"
Max="2.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Time to reach 63.2% of target velocity
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.MotorDynamicsConfig.Delta"
Label="Pure Delay δ (s)"
Variant="Variant.Outlined"
Min="0.0"
Max="0.5"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Delay before motor starts responding
</MudText>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Navigation Limits">
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.MaxLinearVelocity"
Label="Max Linear Velocity (m/s)"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.MinLinearVelocity"
Label="Min Linear Velocity (m/s)"
Variant="Variant.Outlined"
Min="0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.MaxAngularVelocity"
Label="Max Angular Velocity (rad/s)"
Variant="Variant.Outlined"
Min="0"
Max="10.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.RotateAngularVelocity"
Label="Rotate Angular Velocity (rad/s)"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.ReachedRadius"
Label="Reached Radius (m)"
Variant="Variant.Outlined"
Min="0"
Max="0.5"
Step="0.001"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Goal reached tolerance
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.InitialRotationThreshold"
Label="Initial Rotation Threshold (deg)"
Variant="Variant.Outlined"
Min="0"
Max="90.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
Min heading error to rotate in place before moving
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.Acceleration"
Label="Acceleration (m/s²)"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Step="0.1"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
How quickly the robot is allowed to reach target linear speed
</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.NavigationConfig.Deceleration"
Label="Deceleration (m/s²)"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Step="0.1"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">
How quickly the robot is allowed to slow down / stop
</MudText>
</MudItem>
</MudGrid>
</MudTabPanel>
<MudTabPanel Text="Stanley">
<MudGrid Spacing="1" Class="mt-1" Style="overflow-y:auto; height: 200px">
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">Chỉ áp dụng khi Controller Type = Stanley</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.K"
Label="K (cross-track gain)"
Variant="Variant.Outlined"
Min="0"
Max="10.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.Ks"
Label="Ks (softening, m/s)"
Variant="Variant.Outlined"
Min="0"
Max="1.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.WheelBase"
Label="Wheelbase (m)"
Variant="Variant.Outlined"
Min="0"
Max="3.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.MaxSteeringAngle"
Label="Max Steering Angle (rad)"
Variant="Variant.Outlined"
Min="0"
Max="1.5"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudSwitch T="bool" @bind-Value="@ParameterSet.StanleyConfig.EnableCurvatureFeedforward"
Label="Curvature feedforward"
Color="Color.Primary" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.KCurvatureFF"
Label="K Curvature FF"
Variant="Variant.Outlined"
Min="0"
Max="2.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.GoalTolerance"
Label="Goal Tolerance (m)"
Variant="Variant.Outlined"
Min="0"
Max="0.2"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.HeadingTolerance"
Label="Heading Tolerance (deg)"
Variant="Variant.Outlined"
Min="0"
Max="30.0"
Margin="Margin.Dense" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.ResolutionSplit"
Label="Resolution Split (m)"
Variant="Variant.Outlined"
Min="0"
Max="0.2"
Margin="Margin.Dense" />
</MudItem>
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">Goal approach</MudText>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.GoalApproachDistance"
Label="Goal Approach Distance (m)"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">Distance to start increasing K near goal</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.GoalGainMultiplier"
Label="Goal Gain Multiplier"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">K multiplier at goal</MudText>
</MudItem>
<MudText Typo="Typo.subtitle2" Class="mt-2 mb-1">Low speed control</MudText>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.LowSpeedThreshold"
Label="Low Speed Threshold (m/s)"
Variant="Variant.Outlined"
Min="0"
Max="2.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">Below this speed, direct angular control blends in</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double" @bind-Value="@ParameterSet.StanleyConfig.LowSpeedAngularGain"
Label="Low Speed Angular Gain"
Variant="Variant.Outlined"
Min="0"
Max="5.0"
Margin="Margin.Dense" />
<MudText Typo="Typo.caption" Color="Color.Secondary">Angular gain at low speed</MudText>
</MudItem>
</MudGrid>
</MudTabPanel>
</MudTabs>
</MudCardContent>
</MudCard>
@code {
[Parameter] public NavigationParameterSet ParameterSet { get; set; } = new();
[Parameter] public List<NavigationParameterSet> AvailableParameterSets { get; set; } = new();
[Parameter] public EventCallback<NavigationParameterSet> OnSaveClicked { get; set; }
[Parameter] public EventCallback OnResetClicked { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnParameterSetChanged { get; set; }
private NavigationParameterSet _originalParameterSet = new();
protected override void OnParametersSet()
{
if (ParameterSet != null)
{
_originalParameterSet = CloneParameterSet(ParameterSet);
}
}
private async Task HandleParameterSetSelectionChanged(Guid parameterSetId)
{
if (parameterSetId == Guid.Empty)
{
// New parameter set
var newSet = new NavigationParameterSet
{
Id = Guid.Empty,
Name = $"Parameter Set {DateTime.Now:yyyyMMdd_HHmmss}",
Description = "",
CreatedAt = DateTime.UtcNow
};
await OnParameterSetChanged.InvokeAsync(newSet);
}
else
{
// Load existing parameter set
var selected = AvailableParameterSets.FirstOrDefault(p => p.Id == parameterSetId);
if (selected != null)
{
await OnParameterSetChanged.InvokeAsync(selected);
}
}
}
private void OnSave()
{
OnSaveClicked.InvokeAsync(ParameterSet);
}
private void OnReset()
{
ParameterSet = CloneParameterSet(_originalParameterSet);
OnResetClicked.InvokeAsync();
}
private NavigationParameterSet CloneParameterSet(NavigationParameterSet source)
{
// Simple clone - in production, use proper deep cloning
return new NavigationParameterSet
{
Id = source.Id,
Name = source.Name,
Description = source.Description,
ControllerType = source.ControllerType,
MovePidConfig = new PIDConfig
{
Kp = source.MovePidConfig.Kp,
Ki = source.MovePidConfig.Ki,
Kd = source.MovePidConfig.Kd
},
RotatePidConfig = new PIDConfig
{
Kp = source.RotatePidConfig.Kp,
Ki = source.RotatePidConfig.Ki,
Kd = source.RotatePidConfig.Kd
},
PurePursuitConfig = new PurePursuitConfig
{
LookaheadMin = source.PurePursuitConfig.LookaheadMin,
LookaheadMax = source.PurePursuitConfig.LookaheadMax,
Kdd = source.PurePursuitConfig.Kdd,
MaxAngularVelocity = source.PurePursuitConfig.MaxAngularVelocity,
ResolutionSplit = source.PurePursuitConfig.ResolutionSplit,
FinalApproachThreshold = source.PurePursuitConfig.FinalApproachThreshold,
HeadingTolerance = source.PurePursuitConfig.HeadingTolerance,
GoalRegionDistance = source.PurePursuitConfig.GoalRegionDistance,
KCurvature = source.PurePursuitConfig.KCurvature,
MinLookaheadTimeRatio = source.PurePursuitConfig.MinLookaheadTimeRatio,
MaxLookaheadTimeRatio = source.PurePursuitConfig.MaxLookaheadTimeRatio
},
StanleyConfig = new StanleyConfig
{
K = source.StanleyConfig.K,
Ks = source.StanleyConfig.Ks,
WheelBase = source.StanleyConfig.WheelBase,
MaxSteeringAngle = source.StanleyConfig.MaxSteeringAngle,
EnableCurvatureFeedforward = source.StanleyConfig.EnableCurvatureFeedforward,
KCurvatureFF = source.StanleyConfig.KCurvatureFF,
GoalTolerance = source.StanleyConfig.GoalTolerance,
HeadingTolerance = source.StanleyConfig.HeadingTolerance,
ResolutionSplit = source.StanleyConfig.ResolutionSplit,
GoalApproachDistance = source.StanleyConfig.GoalApproachDistance,
GoalGainMultiplier = source.StanleyConfig.GoalGainMultiplier,
LowSpeedThreshold = source.StanleyConfig.LowSpeedThreshold,
LowSpeedAngularGain = source.StanleyConfig.LowSpeedAngularGain
},
EstimatorConfig = new VelocityEstimatorConfig
{
MinBlendRatio = source.EstimatorConfig.MinBlendRatio,
MaxBlendRatio = source.EstimatorConfig.MaxBlendRatio,
DefaultBlendRatio = source.EstimatorConfig.DefaultBlendRatio,
GoodTrackingThreshold = source.EstimatorConfig.GoodTrackingThreshold,
ModerateTrackingThreshold = source.EstimatorConfig.ModerateTrackingThreshold,
GoodTrackingBlend = source.EstimatorConfig.GoodTrackingBlend,
ModerateTrackingBlend = source.EstimatorConfig.ModerateTrackingBlend,
PoorTrackingBlend = source.EstimatorConfig.PoorTrackingBlend,
ConfidenceDecayRate = source.EstimatorConfig.ConfidenceDecayRate,
MinConfidence = source.EstimatorConfig.MinConfidence
},
SignalConfig = new VelocitySignalProcessingConfig
{
AlphaFilter = source.SignalConfig.AlphaFilter,
NoiseThreshold = source.SignalConfig.NoiseThreshold
},
MotorDynamicsConfig = new MotorDynamicsConfig
{
Tau = source.MotorDynamicsConfig.Tau,
Delta = source.MotorDynamicsConfig.Delta
},
NavigationConfig = new NavigationConfig
{
MaxLinearVelocity = source.NavigationConfig.MaxLinearVelocity,
MinLinearVelocity = source.NavigationConfig.MinLinearVelocity,
MaxAngularVelocity = source.NavigationConfig.MaxAngularVelocity,
RotateAngularVelocity = source.NavigationConfig.RotateAngularVelocity,
ReachedRadius = source.NavigationConfig.ReachedRadius,
InitialRotationThreshold = source.NavigationConfig.InitialRotationThreshold,
Acceleration = source.NavigationConfig.Acceleration,
Deceleration = source.NavigationConfig.Deceleration
}
};
}
}

View File

@@ -0,0 +1,635 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Helpers
@using MudBlazor
<MudCard Style="width: 100%; height: 442px; display: flex; flex-direction: column; overflow: hidden;">
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h6">Path Editor</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Create custom path with edges</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="AddEdge">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-1" />
Add Edge
</MudButton>
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="ClearPath" Color="Color.Warning">
<MudIcon Icon="@Icons.Material.Filled.Clear" Class="mr-1" />
Clear All
</MudButton>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow-x: auto; overflow-y: auto;">
<MudGrid Spacing="3">
<!-- Edges List -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle1" Class="mb-2">Edges (@Edges.Count)</MudText>
@if (Edges.Count == 0)
{
<MudAlert Severity="Severity.Info" Dense>
No edges. Click "Add Edge" to start creating a path.
</MudAlert>
}
else
{
<MudExpansionPanels>
@for (int i = 0; i < Edges.Count; i++)
{
var index = i;
var edge = Edges[index];
<MudExpansionPanel Text="@($"Edge {index + 1} (Degree {edge.Degree})")">
<MudGrid Spacing="2">
<!-- Degree Selection -->
<MudItem xs="12" sm="6">
<MudSelect T="int"
Label="Curve Degree"
Variant="Variant.Outlined"
Value="@edge.Degree"
ValueChanged="@(val => UpdateEdgeDegree(index, val))">
<MudSelectItem Value="1">1 - Linear (Straight Line)</MudSelectItem>
<MudSelectItem Value="2">2 - Quadratic Bezier</MudSelectItem>
<MudSelectItem Value="3">3 - Cubic Bezier</MudSelectItem>
</MudSelect>
</MudItem>
<!-- Direction Selection -->
<MudItem xs="12" sm="6">
<MudSelect T="RobotDirection"
Label="Movement Direction"
Variant="Variant.Outlined"
Value="@edge.Direction"
ValueChanged="@(val => UpdateEdgeDirection(index, val))">
<MudSelectItem Value="@RobotDirection.FORWARD">Forward</MudSelectItem>
<MudSelectItem Value="@RobotDirection.BACKWARD">Backward</MudSelectItem>
</MudSelect>
</MudItem>
<!-- Start Point -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Start Point</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudNumericField T="double"
Value="@edge.StartX"
ValueChanged="@(val => UpdateEdgeStartX(index, val))"
Label="Start X (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double"
Value="@edge.StartY"
ValueChanged="@(val => UpdateEdgeStartY(index, val))"
Label="Start Y (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
</MudGrid>
</MudItem>
<!-- End Point -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">End Point</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudNumericField T="double"
Value="@edge.EndX"
ValueChanged="@(val => UpdateEdgeEndX(index, val))"
Label="End X (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double"
Value="@edge.EndY"
ValueChanged="@(val => UpdateEdgeEndY(index, val))"
Label="End Y (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
</MudGrid>
</MudItem>
<!-- Control Point 1 (for Degree 2 and 3) -->
@if (edge.Degree >= 2)
{
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Control Point 1</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudNumericField T="double?"
Value="@edge.ControlPoint1X"
ValueChanged="@(val => UpdateEdgeControl1X(index, val))"
Label="CP1 X (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double?"
Value="@edge.ControlPoint1Y"
ValueChanged="@(val => UpdateEdgeControl1Y(index, val))"
Label="CP1 Y (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
</MudGrid>
</MudItem>
}
<!-- Control Point 2 (for Degree 3 only) -->
@if (edge.Degree == 3)
{
<MudItem xs="12">
<MudText Typo="Typo.subtitle2" Class="mb-2">Control Point 2</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudNumericField T="double?"
Value="@edge.ControlPoint2X"
ValueChanged="@(val => UpdateEdgeControl2X(index, val))"
Label="CP2 X (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
<MudItem xs="12" sm="6">
<MudNumericField T="double?"
Value="@edge.ControlPoint2Y"
ValueChanged="@(val => UpdateEdgeControl2Y(index, val))"
Label="CP2 Y (m)"
Variant="Variant.Outlined"
Step="0.1" />
</MudItem>
</MudGrid>
</MudItem>
}
<!-- Edge Actions -->
<MudItem xs="12">
<MudStack Row="true" Spacing="2">
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
Color="Color.Error"
OnClick="@(() => RemoveEdge(index))">
<MudIcon Icon="@Icons.Material.Filled.Delete" Class="mr-1" />
Delete Edge
</MudButton>
@if (index > 0)
{
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
OnClick="@(() => MoveEdgeUp(index))">
<MudIcon Icon="@Icons.Material.Filled.ArrowUpward" Class="mr-1" />
Move Up
</MudButton>
}
@if (index < Edges.Count - 1)
{
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
OnClick="@(() => MoveEdgeDown(index))">
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Class="mr-1" />
Move Down
</MudButton>
}
</MudStack>
</MudItem>
</MudGrid>
</MudExpansionPanel>
}
</MudExpansionPanels>
}
</MudItem>
<!-- Path Configuration -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle1" Class="mb-2">Path Configuration</MudText>
<MudStack Spacing="3">
<MudNumericField T="double"
Value="@Resolution"
ValueChanged="@(val => UpdateResolution(val))"
Label="Resolution (m)"
Variant="Variant.Outlined"
Step="0.01"
Min="0.01"
Max="0.5"
HelperText="Distance between interpolated points" />
<MudDivider />
<MudText Typo="Typo.body2" Class="mb-2">Path Statistics</MudText>
<MudAlert Severity="Severity.Info" Dense>
<MudText Typo="Typo.body2">
Total Edges: @Edges.Count<br/>
Estimated Path Length: @GetPathLength():F2 m<br/>
Estimated Points: ~@GetEstimatedPointCount()
</MudText>
</MudAlert>
</MudStack>
</MudItem>
<!-- Quick Actions -->
<MudItem xs="12" md="6">
<MudDivider Class="my-2" />
<MudText Typo="Typo.subtitle1" Class="mb-2">Quick Actions</MudText>
<MudStack Spacing="2">
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
OnClick="AddStraightLine">
<MudIcon Icon="@Icons.Material.Filled.ShowChart" Class="mr-1" />
Add Straight Line (5m)
</MudButton>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
OnClick="AddQuadraticCurve">
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="mr-1" />
Add Quadratic Curve
</MudButton>
<MudButton Variant="Variant.Outlined"
Size="Size.Small"
OnClick="AddCubicCurve">
<MudIcon Icon="@Icons.Material.Filled.Timeline" Class="mr-1" />
Add Cubic Curve
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
@code {
[Parameter] public CustomPathScenario? Scenario { get; set; }
[Parameter] public EventCallback<CustomPathScenario> OnScenarioChanged { get; set; }
private List<PathEdge> Edges { get; set; } = new();
private double Resolution { get; set; } = 0.05;
protected override void OnParametersSet()
{
if (Scenario != null)
{
Edges = new List<PathEdge>(Scenario.Edges);
Resolution = Scenario.Resolution;
}
else
{
Edges = new List<PathEdge>();
Resolution = 0.05;
}
}
protected override void OnAfterRender(bool firstRender)
{
if (firstRender && Scenario != null)
{
Edges = new List<PathEdge>(Scenario.Edges);
Resolution = Scenario.Resolution;
}
}
private void AddEdge()
{
double startX = 0, startY = 0, endX = 1, endY = 0;
// If there are existing edges, connect to the last edge's end
if (Edges.Count > 0)
{
var lastEdge = Edges[^1];
startX = lastEdge.EndX;
startY = lastEdge.EndY;
endX = startX + 1.0; // Default: 1m forward
endY = startY;
}
Edges.Add(new PathEdge
{
StartX = startX,
StartY = startY,
EndX = endX,
EndY = endY,
Degree = 1, // Default to linear
Direction = RobotDirection.FORWARD // Default to forward
});
UpdateScenario();
}
private void RemoveEdge(int index)
{
if (index >= 0 && index < Edges.Count)
{
Edges.RemoveAt(index);
UpdateScenario();
}
}
private void MoveEdgeUp(int index)
{
if (index > 0 && index < Edges.Count)
{
var temp = Edges[index];
Edges[index] = Edges[index - 1];
Edges[index - 1] = temp;
UpdateScenario();
}
}
private void MoveEdgeDown(int index)
{
if (index >= 0 && index < Edges.Count - 1)
{
var temp = Edges[index];
Edges[index] = Edges[index + 1];
Edges[index + 1] = temp;
UpdateScenario();
}
}
private void UpdateEdgeDegree(int index, int? value)
{
if (index >= 0 && index < Edges.Count && value.HasValue)
{
Edges[index].Degree = Math.Clamp(value.Value, 1, 3);
// Clear control points if degree is reduced
if (Edges[index].Degree < 2)
{
Edges[index].ControlPoint1X = null;
Edges[index].ControlPoint1Y = null;
}
if (Edges[index].Degree < 3)
{
Edges[index].ControlPoint2X = null;
Edges[index].ControlPoint2Y = null;
}
UpdateScenario();
}
}
private void UpdateEdgeDirection(int index, RobotDirection? value)
{
if (index >= 0 && index < Edges.Count && value.HasValue)
{
Edges[index].Direction = value.Value;
UpdateScenario();
}
}
private void UpdateEdgeStartX(int index, double? value)
{
if (index >= 0 && index < Edges.Count && value.HasValue)
{
Edges[index].StartX = value.Value;
UpdateScenario();
}
}
private void UpdateEdgeStartY(int index, double? value)
{
if (index >= 0 && index < Edges.Count && value.HasValue)
{
Edges[index].StartY = value.Value;
UpdateScenario();
}
}
private void UpdateEdgeEndX(int index, double? value)
{
if (index >= 0 && index < Edges.Count && value.HasValue)
{
Edges[index].EndX = value.Value;
UpdateScenario();
}
}
private void UpdateEdgeEndY(int index, double? value)
{
if (index >= 0 && index < Edges.Count && value.HasValue)
{
Edges[index].EndY = value.Value;
UpdateScenario();
}
}
private void UpdateEdgeControl1X(int index, double? value)
{
if (index >= 0 && index < Edges.Count)
{
Edges[index].ControlPoint1X = value;
UpdateScenario();
}
}
private void UpdateEdgeControl1Y(int index, double? value)
{
if (index >= 0 && index < Edges.Count)
{
Edges[index].ControlPoint1Y = value;
UpdateScenario();
}
}
private void UpdateEdgeControl2X(int index, double? value)
{
if (index >= 0 && index < Edges.Count)
{
Edges[index].ControlPoint2X = value;
UpdateScenario();
}
}
private void UpdateEdgeControl2Y(int index, double? value)
{
if (index >= 0 && index < Edges.Count)
{
Edges[index].ControlPoint2Y = value;
UpdateScenario();
}
}
private void UpdateResolution(double? value)
{
if (value.HasValue)
{
Resolution = value.Value;
UpdateScenario();
}
}
private void ClearPath()
{
Edges.Clear();
UpdateScenario();
}
private void AddStraightLine()
{
double startX = 0, startY = 0, endX = 5, endY = 0;
if (Edges.Count > 0)
{
var lastEdge = Edges[^1];
startX = lastEdge.EndX;
startY = lastEdge.EndY;
endX = startX + 5.0;
endY = startY;
}
Edges.Add(new PathEdge
{
StartX = startX,
StartY = startY,
EndX = endX,
EndY = endY,
Degree = 1,
Direction = RobotDirection.FORWARD
});
UpdateScenario();
}
private void AddQuadraticCurve()
{
double startX = 0, startY = 0, endX = 3, endY = 2;
double cp1X = 1.5, cp1Y = 0;
if (Edges.Count > 0)
{
var lastEdge = Edges[^1];
startX = lastEdge.EndX;
startY = lastEdge.EndY;
endX = startX + 3.0;
endY = startY + 2.0;
cp1X = startX + 1.5;
cp1Y = startY;
}
Edges.Add(new PathEdge
{
StartX = startX,
StartY = startY,
EndX = endX,
EndY = endY,
Degree = 2,
ControlPoint1X = cp1X,
ControlPoint1Y = cp1Y,
Direction = RobotDirection.FORWARD
});
UpdateScenario();
}
private void AddCubicCurve()
{
double startX = 0, startY = 0, endX = 4, endY = 2;
double cp1X = 1, cp1Y = 0;
double cp2X = 3, cp2Y = 2;
if (Edges.Count > 0)
{
var lastEdge = Edges[^1];
startX = lastEdge.EndX;
startY = lastEdge.EndY;
endX = startX + 4.0;
endY = startY + 2.0;
cp1X = startX + 1.0;
cp1Y = startY;
cp2X = startX + 3.0;
cp2Y = startY + 2.0;
}
Edges.Add(new PathEdge
{
StartX = startX,
StartY = startY,
EndX = endX,
EndY = endY,
Degree = 3,
ControlPoint1X = cp1X,
ControlPoint1Y = cp1Y,
ControlPoint2X = cp2X,
ControlPoint2Y = cp2Y,
Direction = RobotDirection.FORWARD
});
UpdateScenario();
}
private double GetPathLength()
{
if (Edges.Count == 0) return 0.0;
double totalLength = 0.0;
foreach (var edge in Edges)
{
if (edge.Degree == 1)
{
double dx = edge.EndX - edge.StartX;
double dy = edge.EndY - edge.StartY;
totalLength += Math.Sqrt(dx * dx + dy * dy);
}
else
{
// Approximate curve length
const int samples = 20;
double length = 0.0;
double prevX = edge.StartX, prevY = edge.StartY;
for (int i = 1; i <= samples; i++)
{
double t = 1.0 * i / samples;
double x, y;
if (edge.Degree == 2)
{
double oneMinusT = 1.0 - t;
x = oneMinusT * oneMinusT * edge.StartX +
2 * oneMinusT * t * (edge.ControlPoint1X ?? edge.StartX) +
t * t * edge.EndX;
y = oneMinusT * oneMinusT * edge.StartY +
2 * oneMinusT * t * (edge.ControlPoint1Y ?? edge.StartY) +
t * t * edge.EndY;
}
else // Degree 3
{
double oneMinusT = 1.0 - t;
double oneMinusT2 = oneMinusT * oneMinusT;
double oneMinusT3 = oneMinusT2 * oneMinusT;
double t2 = t * t;
double t3 = t2 * t;
x = oneMinusT3 * edge.StartX +
3 * oneMinusT2 * t * (edge.ControlPoint1X ?? edge.StartX) +
3 * oneMinusT * t2 * (edge.ControlPoint2X ?? edge.EndX) +
t3 * edge.EndX;
y = oneMinusT3 * edge.StartY +
3 * oneMinusT2 * t * (edge.ControlPoint1Y ?? edge.StartY) +
3 * oneMinusT * t2 * (edge.ControlPoint2Y ?? edge.EndY) +
t3 * edge.EndY;
}
double dx = x - prevX;
double dy = y - prevY;
length += Math.Sqrt(dx * dx + dy * dy);
prevX = x;
prevY = y;
}
totalLength += length;
}
}
return totalLength;
}
private int GetEstimatedPointCount()
{
if (Resolution <= 0) return 0;
return (int)(GetPathLength() / Resolution) + Edges.Count;
}
private void UpdateScenario()
{
if (Scenario != null)
{
Scenario.Edges = new List<PathEdge>(Edges);
Scenario.Resolution = Resolution;
OnScenarioChanged.InvokeAsync(Scenario);
}
}
}

View File

@@ -0,0 +1,226 @@
@using RobotNet10.NavigationTune.Shared.Hubs
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Clients
@using Microsoft.AspNetCore.SignalR.Client
@using MudBlazor
@implements IAsyncDisposable
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Real-Time Telemetry</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
@if (IsConnected)
{
<MudChip T="string" Color="Color.Success" Size="Size.Small">Connected</MudChip>
}
else
{
<MudChip T="string" Color="Color.Error" Size="Size.Small">Disconnected</MudChip>
}
</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Size="Size.Small"
OnClick="ClearData"
Disabled="@(!IsConnected)">
</MudIconButton>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
<MudGrid Spacing="1">
<!-- Robot Pose -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle1" Class="mb-2">Robot Pose</MudText>
<MudGrid>
<MudItem xs="12" sm="4">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">X (m)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.X.ToString("F3")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="4">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Y (m)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.Y.ToString("F3")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="4">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Theta (rad)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.Theta.ToString("F3")</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudItem>
<!-- Velocity -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle1" Class="mb-2">Velocity</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Linear (m/s)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.LinearVelocity.ToString("F3")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Angular (rad/s)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.AngularVelocity.ToString("F3")</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudItem>
<!-- Errors -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle1" Class="mb-2">Tracking Errors</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Cross-Track Error (m)</MudText>
<MudText Typo="Typo.h6" Color="@GetErrorColor(CurrentTelemetry?.CrossTrackError ?? 0)">
@(CurrentTelemetry?.CrossTrackError.ToString("F4") ?? "0.0000")
</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Heading Error (rad)</MudText>
<MudText Typo="Typo.h6" Color="@GetErrorColor(CurrentTelemetry?.HeadingError ?? 0)">
@(CurrentTelemetry?.HeadingError.ToString("F4") ?? "0.0000")
</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudItem>
<!-- Commands -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle1" Class="mb-2">Velocity Commands</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Linear Command (m/s)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.LinearVelocity.ToString("F3")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Angular Command (rad/s)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.AngularVelocity.ToString("F3")</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudItem>
<!-- Status -->
<MudItem xs="12">
<MudText Typo="Typo.subtitle1" Class="mb-2">Status</MudText>
<MudGrid>
<MudItem xs="12" sm="4">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Distance to Goal (m)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.DistanceToGoal.ToString("F3")</MudText>
</MudCard>
</MudItem>
<MudItem xs="12" sm="6">
<MudCard Elevation="0" Class="pa-2">
<MudText Typo="Typo.caption" Color="Color.Secondary">Distance to Goal (m)</MudText>
<MudText Typo="Typo.h6">@CurrentTelemetry?.DistanceToGoal.ToString("F3")</MudText>
</MudCard>
</MudItem>
</MudGrid>
</MudItem>
<!-- Data Count -->
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">
Total data points: @TelemetryHistory.Count
</MudText>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
@code {
[Parameter] public TuningHubClient? HubClient { get; set; }
[Parameter] public string? TestRunId { get; set; }
private TelemetryUpdateDto? CurrentTelemetry { get; set; }
private List<TelemetryUpdateDto> TelemetryHistory { get; set; } = new();
private bool IsConnected => HubClient?.IsConnected ?? false;
protected override async Task OnInitializedAsync()
{
if (HubClient != null)
{
HubClient.TelemetryUpdated += OnTelemetryUpdated;
HubClient.ConnectionStateChanged += OnConnectionStateChanged;
if (!string.IsNullOrEmpty(TestRunId))
{
await HubClient.JoinTestSessionAsync(TestRunId);
}
}
}
private void OnTelemetryUpdated(TelemetryUpdateDto telemetry)
{
CurrentTelemetry = telemetry;
TelemetryHistory.Add(telemetry);
// Keep only last 1000 points
if (TelemetryHistory.Count > 1000)
{
TelemetryHistory.RemoveAt(0);
}
InvokeAsync(StateHasChanged);
}
private void OnConnectionStateChanged(HubConnectionState state)
{
InvokeAsync(StateHasChanged);
}
private Color GetErrorColor(double error)
{
var absError = Math.Abs(error);
if (absError < 0.05f) return Color.Success;
if (absError < 0.15f) return Color.Warning;
return Color.Error;
}
private Color GetConfidenceColor(double confidence)
{
return confidence switch
{
>= 0.7 => Color.Success,
>= 0.4 => Color.Warning,
_ => Color.Error
};
}
private void ClearData()
{
TelemetryHistory.Clear();
CurrentTelemetry = null;
}
public async ValueTask DisposeAsync()
{
if (HubClient != null)
{
HubClient.TelemetryUpdated -= OnTelemetryUpdated;
HubClient.ConnectionStateChanged -= OnConnectionStateChanged;
if (!string.IsNullOrEmpty(TestRunId))
{
await HubClient.LeaveTestSessionAsync(TestRunId);
}
}
}
}

View File

@@ -0,0 +1,236 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Services
@using RobotNet10.NavigationTuneUI.Helpers
@using RobotNet10.NavigationTuneUI.Components
@using MudBlazor
@inject TuningApiService ApiService
@inject ISnackbar Snackbar
<MudCard Style="width: 100%; height: 600px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Scenario Configuration</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Custom path editor</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudButton Variant="Variant.Outlined" Size="Size.Small" Color="Color.Success" OnClick="CreateCustomPath">
<MudIcon Icon="@Icons.Material.Filled.Add" Class="mr-1" />
New Custom Path
</MudButton>
<MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="SaveScenario" Disabled="@(SelectedScenario == null)">
Save
</MudButton>
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="ResetScenario" Disabled="@(SelectedScenario == null)">
Reset
</MudButton>
@if (SelectedScenario != null && !SelectedScenario.IsDefault)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Error" OnClick="OpenDeleteDialog">
<MudIcon Icon="@Icons.Material.Filled.Delete" Class="mr-1" />
Delete
</MudButton>
}
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow-x: auto; overflow-y: auto; box-sizing: border-box;">
@if (SelectedScenario == null)
{
<MudAlert Severity="Severity.Info" Class="mt-2">
No scenario selected. Click "New Custom Path" to create one.
</MudAlert>
}
else
{
<MudGrid Spacing="2" Class="mb-3">
<MudItem xs="12" sm="6">
<MudTextField T="string" Label="Scenario name" Variant="Variant.Outlined"
Value="@SelectedScenario.Name"
ValueChanged="@(v => UpdateScenarioName(v))"
Immediate="true" />
</MudItem>
<MudItem xs="12" sm="6">
<MudTextField T="string" Label="Description" Variant="Variant.Outlined"
Value="@SelectedScenario.Description"
ValueChanged="@(v => UpdateScenarioDescription(v))"
Immediate="true" />
</MudItem>
</MudGrid>
<PathEditor Scenario="@GetCustomPathScenario()" OnScenarioChanged="HandleCustomPathChanged" />
}
</MudCardContent>
</MudCard>
<!-- Delete scenario confirmation -->
<MudDialog @bind-Visible="_isDeleteDialogVisible">
<TitleContent>
<MudText Typo="Typo.h6">Delete scenario</MudText>
</TitleContent>
<DialogContent>
<MudText>Are you sure you want to delete "@SelectedScenario?.Name"? This action cannot be undone.</MudText>
</DialogContent>
<DialogActions>
<MudButton OnClick="@(() => _isDeleteDialogVisible = false)">Cancel</MudButton>
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="ConfirmDeleteScenario">Delete</MudButton>
</DialogActions>
</MudDialog>
@code {
[Parameter] public TestScenario? SelectedScenario { get; set; }
[Parameter] public EventCallback<TestScenario> OnScenarioChanged { get; set; }
[Parameter] public EventCallback<Guid> OnScenarioDeleted { get; set; }
[Parameter] public EventCallback<TestScenario> OnScenarioCreated { get; set; }
private CustomPathScenario? _customPathScenario;
private bool _isDeleteDialogVisible;
private CustomPathScenario? GetCustomPathScenario()
{
if (SelectedScenario is CustomPathScenario custom)
return custom;
if (_customPathScenario == null)
{
var id = (SelectedScenario != null && SelectedScenario.Id != Guid.Empty) ? SelectedScenario.Id : Guid.Empty;
_customPathScenario = new CustomPathScenario
{
Id = id,
Name = SelectedScenario?.Name ?? "Custom Path",
Description = SelectedScenario?.Description ?? "User-defined path",
Type = TrajectoryType.Custom,
Edges = new List<PathEdge>(),
Resolution = 0.05
};
}
return _customPathScenario;
}
private void UpdateScenarioName(string? value)
{
if (SelectedScenario != null)
{
SelectedScenario.Name = value ?? string.Empty;
OnScenarioChanged.InvokeAsync(SelectedScenario);
}
}
private void UpdateScenarioDescription(string? value)
{
if (SelectedScenario != null)
{
SelectedScenario.Description = value ?? string.Empty;
OnScenarioChanged.InvokeAsync(SelectedScenario);
}
}
private async Task HandleCustomPathChanged(CustomPathScenario scenario)
{
SelectedScenario = scenario;
await OnScenarioChanged.InvokeAsync(scenario);
}
private async Task SaveScenario()
{
if (SelectedScenario == null) return;
var scenarioToSave = GetCustomPathScenario();
if (scenarioToSave == null) return;
// Sync base metadata
scenarioToSave.Name = SelectedScenario.Name;
scenarioToSave.Description = SelectedScenario.Description;
scenarioToSave.Id = SelectedScenario.Id;
scenarioToSave.IsDefault = SelectedScenario.IsDefault;
try
{
if (scenarioToSave.Id == Guid.Empty)
{
var created = await ApiService.CreateScenarioAsync(scenarioToSave);
SelectedScenario = created;
_customPathScenario = created as CustomPathScenario;
await OnScenarioChanged.InvokeAsync(created);
await OnScenarioCreated.InvokeAsync(created);
Snackbar.Add($"Created scenario: {created.Name}", Severity.Success);
}
else
{
await ApiService.UpdateScenarioAsync(scenarioToSave.Id, scenarioToSave);
SelectedScenario = scenarioToSave;
_customPathScenario = scenarioToSave;
Snackbar.Add($"Updated scenario: {scenarioToSave.Name}", Severity.Success);
}
}
catch (ApiException ex)
{
Snackbar.Add($"Cannot save scenario: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Error saving scenario: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private void ResetScenario()
{
InvokeAsync(StateHasChanged);
Snackbar.Add("Scenario reset to original values", Severity.Info);
}
private void OpenDeleteDialog()
{
if (SelectedScenario != null && !SelectedScenario.IsDefault)
_isDeleteDialogVisible = true;
}
private async Task ConfirmDeleteScenario()
{
if (SelectedScenario == null || SelectedScenario.IsDefault) return;
var idToDelete = SelectedScenario.Id;
try
{
await ApiService.DeleteScenarioAsync(idToDelete);
_isDeleteDialogVisible = false;
await OnScenarioDeleted.InvokeAsync(idToDelete);
Snackbar.Add("Scenario deleted", Severity.Success);
}
catch (ApiException ex)
{
Snackbar.Add($"Cannot delete scenario: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Error deleting scenario: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task CreateCustomPath()
{
var customPath = new CustomPathScenario
{
Id = Guid.Empty,
Name = $"Custom Path {DateTime.Now:HHmmss}",
Description = "User-defined custom path",
Type = TrajectoryType.Custom,
Edges = new List<PathEdge>
{
new PathEdge
{
StartX = 0,
StartY = 0,
EndX = 1,
EndY = 0,
Degree = 1
}
},
Resolution = 0.05
};
SelectedScenario = customPath;
_customPathScenario = customPath;
await OnScenarioChanged.InvokeAsync(customPath);
await OnScenarioCreated.InvokeAsync(customPath);
Snackbar.Add("New Custom Path created. Add edges to build your path.", Severity.Success);
}
}

View File

@@ -0,0 +1,534 @@
@using ApexCharts
@using RobotNet10.NavigationTune.Shared.Hubs
@using RobotNet10.NavigationTuneUI.Clients
@using System.Timers
@implements IAsyncDisposable
<MudCard Style="width: 100%;">
<MudCardHeader Style="flex-shrink: 0;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Telemetry Charts</MudText>
<MudText Typo="Typo.body2" Color="MudBlazor.Color.Secondary">
@(_dataPoints.Count) data points
</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudButton Variant="Variant.Text" Size="MudBlazor.Size.Small" OnClick="ClearData"
StartIcon="@Icons.Material.Filled.RestartAlt">
Clear
</MudButton>
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
<MudGrid Spacing="2">
<!-- Chart 1: Cross-Track Error -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle2" Class="mb-1">Cross-Track Error</MudText>
<ApexChart @ref="_cteChart"
TItem="ChartDataPoint"
Options="_cteOptions"
Height="280">
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="CTE (m)"
XValue="@(p => (decimal)p.ElapsedSeconds)"
YValue="@(p => (decimal)p.CrossTrackError)"
OrderBy="p => p.X" />
</ApexChart>
</MudItem>
<!-- Chart 2: Heading Error -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle2" Class="mb-1">Heading Error</MudText>
<ApexChart @ref="_headingChart"
TItem="ChartDataPoint"
Options="_headingOptions"
Height="280">
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="Heading Error (rad)"
XValue="@(p => (decimal)p.ElapsedSeconds)"
YValue="@(p => (decimal)p.HeadingError)"
OrderBy="p => p.X" />
</ApexChart>
</MudItem>
<!-- Chart 3: Linear Velocity (Actual vs Command) -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle2" Class="mb-1">Linear Velocity</MudText>
<ApexChart @ref="_linearVelChart"
TItem="ChartDataPoint"
Options="_linearVelOptions"
Height="280">
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="Actual (m/s)"
XValue="@(p => (decimal)p.ElapsedSeconds)"
YValue="@(p => (decimal)p.LinearVelocity)"
OrderBy="p => p.X" />
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="Command (m/s)"
XValue="@(p => (decimal)p.ElapsedSeconds)"
YValue="@(p => (decimal)p.CommandedLinearVelocity)"
OrderBy="p => p.X" />
</ApexChart>
</MudItem>
<!-- Chart 4: Angular Velocity (Actual vs Command) -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle2" Class="mb-1">Angular Velocity</MudText>
<ApexChart @ref="_angularVelChart"
TItem="ChartDataPoint"
Options="_angularVelOptions"
Height="280">
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="Actual (rad/s)"
XValue="@(p => (decimal)p.ElapsedSeconds)"
YValue="@(p => (decimal)p.AngularVelocity)"
OrderBy="p => p.X" />
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="Command (rad/s)"
XValue="@(p => (decimal)p.ElapsedSeconds)"
YValue="@(p => (decimal)p.CommandedAngularVelocity)"
OrderBy="p => p.X" />
</ApexChart>
</MudItem>
<!-- Chart 5: Distance to Goal -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle2" Class="mb-1">Distance to Goal</MudText>
<ApexChart @ref="_distanceChart"
TItem="ChartDataPoint"
Options="_distanceOptions"
Height="280">
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="Distance (m)"
XValue="@(p => (decimal)p.ElapsedSeconds)"
YValue="@(p => (decimal)p.DistanceToGoal)"
OrderBy="p => p.X" />
</ApexChart>
</MudItem>
<!-- Chart 6: Robot Trajectory (X-Y) -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle2" Class="mb-1">Robot Trajectory</MudText>
<ApexChart @ref="_trajectoryChart"
TItem="ChartDataPoint"
Options="_trajectoryOptions"
Height="280">
<ApexPointSeries TItem="ChartDataPoint"
Items="_dataPoints"
SeriesType="SeriesType.Line"
Name="Robot Path"
XValue="@(p => (decimal)p.X)"
YValue="@(p => (decimal)p.Y)"
OrderBy="p => p.X" />
</ApexChart>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
@code {
[Parameter] public TuningHubClient? HubClient { get; set; }
[Parameter] public string? TestRunId { get; set; }
private const int MaxDataPoints = 500;
private const int ChartUpdateIntervalMs = 250;
private ApexChart<ChartDataPoint>? _cteChart;
private ApexChart<ChartDataPoint>? _headingChart;
private ApexChart<ChartDataPoint>? _linearVelChart;
private ApexChart<ChartDataPoint>? _angularVelChart;
private ApexChart<ChartDataPoint>? _distanceChart;
private ApexChart<ChartDataPoint>? _trajectoryChart;
private ApexChartOptions<ChartDataPoint> _cteOptions = new();
private ApexChartOptions<ChartDataPoint> _headingOptions = new();
private ApexChartOptions<ChartDataPoint> _linearVelOptions = new();
private ApexChartOptions<ChartDataPoint> _angularVelOptions = new();
private ApexChartOptions<ChartDataPoint> _distanceOptions = new();
private ApexChartOptions<ChartDataPoint> _trajectoryOptions = new();
private List<ChartDataPoint> _dataPoints = new();
private long _firstTimestampMs = 0;
private string? _previousTestRunId;
private Timer? _updateTimer;
private bool _hasPendingData = false;
private bool _isDisposed = false;
protected override void OnInitialized()
{
ConfigureChartOptions();
StartUpdateTimer();
}
protected override async Task OnInitializedAsync()
{
if (HubClient != null)
HubClient.TelemetryUpdated += OnTelemetryUpdated;
}
protected override async Task OnParametersSetAsync()
{
if (TestRunId != _previousTestRunId)
{
_previousTestRunId = TestRunId;
ClearDataInternal();
}
}
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 = true,
Tools = new Tools
{
Zoom = true,
Zoomin = true,
Zoomout = true,
Pan = true,
Reset = true,
Download = false
}
},
Zoom = new Zoom { Enabled = true }
};
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(1); }",
Style = new AxisLabelStyle { Colors = axisColor }
}
};
var baseYAxisStyle = () => (
TitleStyle: new AxisTitleStyle { Color = axisColor },
LabelStyle: 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); }"
}
};
// Chart 1: Cross-Track Error — 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();
var cteYStyle = baseYAxisStyle();
_cteOptions.Yaxis = new List<YAxis>
{
new YAxis
{
Title = new AxisTitle { Text = "CTE (m)", Style = cteYStyle.TitleStyle },
Labels = new YAxisLabels
{
Formatter = @"function(val) { return val.toFixed(3); }",
Style = cteYStyle.LabelStyle
},
DecimalsInFloat = 3
}
};
// Chart 2: Heading Error — 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();
var headYStyle = baseYAxisStyle();
_headingOptions.Yaxis = new List<YAxis>
{
new YAxis
{
Title = new AxisTitle { Text = "Heading Error (rad)", Style = headYStyle.TitleStyle },
Labels = new YAxisLabels
{
Formatter = @"function(val) { return val.toFixed(3); }",
Style = headYStyle.LabelStyle
},
DecimalsInFloat = 3
}
};
// Chart 3: Linear Velocity — solid green (Actual), dashed (Command)
_linearVelOptions.Chart = baseChart();
_linearVelOptions.Grid = baseGrid();
_linearVelOptions.Stroke = new Stroke
{
Curve = Curve.Smooth,
Width = 3,
DashArray = new List<double> { 0, 6 }
};
_linearVelOptions.Xaxis = baseXAxis();
_linearVelOptions.Colors = new List<string> { "#1B5E20", "#388E3C" };
_linearVelOptions.Tooltip = baseTooltip();
var linVelYStyle = baseYAxisStyle();
_linearVelOptions.Yaxis = new List<YAxis>
{
new YAxis
{
Title = new AxisTitle { Text = "Linear Vel (m/s)", Style = linVelYStyle.TitleStyle },
Labels = new YAxisLabels
{
Formatter = @"function(val) { return val.toFixed(3); }",
Style = linVelYStyle.LabelStyle
},
DecimalsInFloat = 3
}
};
// Chart 4: Angular Velocity — solid purple (Actual), dashed (Command)
_angularVelOptions.Chart = baseChart();
_angularVelOptions.Grid = baseGrid();
_angularVelOptions.Stroke = new Stroke
{
Curve = Curve.Smooth,
Width = 3,
DashArray = new List<double> { 0, 6 }
};
_angularVelOptions.Xaxis = baseXAxis();
_angularVelOptions.Colors = new List<string> { "#4A148C", "#7B1FA2" };
_angularVelOptions.Tooltip = baseTooltip();
var angVelYStyle = baseYAxisStyle();
_angularVelOptions.Yaxis = new List<YAxis>
{
new YAxis
{
Title = new AxisTitle { Text = "Angular Vel (rad/s)", Style = angVelYStyle.TitleStyle },
Labels = new YAxisLabels
{
Formatter = @"function(val) { return val.toFixed(3); }",
Style = angVelYStyle.LabelStyle
},
DecimalsInFloat = 3
}
};
// Chart 5: Distance to Goal — red
_distanceOptions.Chart = baseChart();
_distanceOptions.Grid = baseGrid();
_distanceOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
_distanceOptions.Xaxis = baseXAxis();
_distanceOptions.Colors = new List<string> { "#B71C1C" };
_distanceOptions.Tooltip = baseTooltip();
var distYStyle = baseYAxisStyle();
_distanceOptions.Yaxis = new List<YAxis>
{
new YAxis
{
Title = new AxisTitle { Text = "Distance (m)", Style = distYStyle.TitleStyle },
Labels = new YAxisLabels
{
Formatter = @"function(val) { return val.toFixed(2); }",
Style = distYStyle.LabelStyle
},
DecimalsInFloat = 2,
Min = 0
}
};
// Chart 6: Robot Trajectory (X-Y) — teal path with markers
_trajectoryOptions.Chart = baseChart();
_trajectoryOptions.Grid = baseGrid();
_trajectoryOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
_trajectoryOptions.Xaxis = new XAxis
{
Title = new AxisTitle
{
Text = "X (m)",
Style = new AxisTitleStyle { Color = axisColor }
},
Labels = new XAxisLabels
{
Formatter = @"function(val) { return parseFloat(val).toFixed(2); }",
Style = new AxisLabelStyle { Colors = axisColor }
}
};
_trajectoryOptions.Colors = new List<string> { "#00695C" };
_trajectoryOptions.Tooltip = new Tooltip
{
Shared = true,
X = new TooltipX { Format = "0.3f" },
Y = new TooltipY
{
Formatter = @"function(val) { return val.toFixed(3); }"
}
};
_trajectoryOptions.Markers = new Markers
{
Size = 2,
Shape = ShapeEnum.Circle
};
var trajYStyle = baseYAxisStyle();
_trajectoryOptions.Yaxis = new List<YAxis>
{
new YAxis
{
Title = new AxisTitle { Text = "Y (m)", Style = trajYStyle.TitleStyle },
Labels = new YAxisLabels
{
Formatter = @"function(val) { return val.toFixed(3); }",
Style = trajYStyle.LabelStyle
},
DecimalsInFloat = 3
}
};
}
private void StartUpdateTimer()
{
_updateTimer = new Timer(ChartUpdateIntervalMs);
_updateTimer.Elapsed += async (s, e) => await OnTimerElapsed();
_updateTimer.AutoReset = true;
_updateTimer.Enabled = true;
}
private void OnTelemetryUpdated(TelemetryUpdateDto dto)
{
if (_isDisposed) return;
if (_firstTimestampMs == 0)
_firstTimestampMs = dto.TimestampMs;
var point = new ChartDataPoint
{
ElapsedSeconds = (dto.TimestampMs - _firstTimestampMs) / 1000.0,
X = dto.X,
Y = dto.Y,
Theta = dto.Theta,
CrossTrackError = dto.CrossTrackError,
HeadingError = dto.HeadingError,
LinearVelocity = dto.LinearVelocity,
AngularVelocity = dto.AngularVelocity,
CommandedLinearVelocity = dto.CommandedLinearVelocity,
CommandedAngularVelocity = dto.CommandedAngularVelocity,
DistanceToGoal = dto.DistanceToGoal
};
_dataPoints.Add(point);
// Trim to rolling buffer
if (_dataPoints.Count > MaxDataPoints)
_dataPoints.RemoveRange(0, _dataPoints.Count - MaxDataPoints);
_hasPendingData = true;
}
private async Task OnTimerElapsed()
{
if (_isDisposed || !_hasPendingData) return;
_hasPendingData = false;
try
{
await InvokeAsync(async () =>
{
if (_cteChart != null) await _cteChart.UpdateSeriesAsync(true);
if (_headingChart != null) await _headingChart.UpdateSeriesAsync(true);
if (_linearVelChart != null) await _linearVelChart.UpdateSeriesAsync(true);
if (_angularVelChart != null) await _angularVelChart.UpdateSeriesAsync(true);
if (_distanceChart != null) await _distanceChart.UpdateSeriesAsync(true);
if (_trajectoryChart != null) await _trajectoryChart.UpdateSeriesAsync(true);
StateHasChanged();
});
}
catch (ObjectDisposedException) { }
}
private async Task ClearData()
{
ClearDataInternal();
try
{
if (_cteChart != null) await _cteChart.UpdateSeriesAsync(true);
if (_headingChart != null) await _headingChart.UpdateSeriesAsync(true);
if (_linearVelChart != null) await _linearVelChart.UpdateSeriesAsync(true);
if (_angularVelChart != null) await _angularVelChart.UpdateSeriesAsync(true);
if (_distanceChart != null) await _distanceChart.UpdateSeriesAsync(true);
if (_trajectoryChart != null) await _trajectoryChart.UpdateSeriesAsync(true);
StateHasChanged();
}
catch (ObjectDisposedException) { }
}
private void ClearDataInternal()
{
_dataPoints.Clear();
_firstTimestampMs = 0;
_hasPendingData = false;
}
public async ValueTask DisposeAsync()
{
_isDisposed = true;
if (_updateTimer != null)
{
_updateTimer.Stop();
_updateTimer.Dispose();
_updateTimer = null;
}
if (HubClient != null)
HubClient.TelemetryUpdated -= OnTelemetryUpdated;
}
private class ChartDataPoint
{
public double ElapsedSeconds { get; set; }
public double X { get; set; }
public double Y { get; set; }
public double Theta { get; set; }
public double CrossTrackError { get; set; }
public double HeadingError { get; set; }
public double LinearVelocity { get; set; }
public double AngularVelocity { get; set; }
public double CommandedLinearVelocity { get; set; }
public double CommandedAngularVelocity { get; set; }
public double DistanceToGoal { get; set; }
}
}

View File

@@ -0,0 +1,225 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTune.Shared.Hubs
@using RobotNet10.NavigationTuneUI.Clients
@using MudBlazor
<MudCard Style="width: 100%; height: 400px;">
<MudCardHeader Style="box-sizing: border-box; flex-shrink: 0;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Test Execution Control</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
@if (CurrentStatus != null)
{
<MudChip T="string"
Color="@GetStatusColor(CurrentStatus.Status)"
Size="Size.Small">
@CurrentStatus.Status
</MudChip>
}
</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
<MudStack Spacing="3">
<!-- Scenario Selection -->
<MudItem xs="12">
<MudSelect T="Guid"
Label="Test Scenario"
Variant="Variant.Outlined"
Value="@SelectedScenarioId"
ValueChanged="@OnScenarioIdChanged">
@foreach (var scenario in AvailableScenarios)
{
<MudSelectItem Value="@scenario.Id">@scenario.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<!-- Parameter Set Selection -->
<MudItem xs="12">
<MudSelect T="Guid"
Label="Parameter Set"
Variant="Variant.Outlined"
@bind-Value="SelectedParameterSetId">
@foreach (var paramSet in AvailableParameterSets)
{
<MudSelectItem Value="@paramSet.Id">@paramSet.Name</MudSelectItem>
}
</MudSelect>
</MudItem>
<!-- Control Buttons -->
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center">
<MudButton Variant="Variant.Filled"
Color="Color.Success"
OnClick="StartTest"
Disabled="@(!CanStartTest)">
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-2" />
Start
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Warning"
OnClick="PauseTest"
Disabled="@(!CanPauseTest)">
<MudIcon Icon="@Icons.Material.Filled.Pause" Class="mr-2" />
Pause
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Info"
OnClick="ResumeTest"
Disabled="@(!CanResumeTest)">
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-2" />
Resume
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Default"
OnClick="StopTest">
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-2" />
Stop
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Error"
OnClick="EmergencyStop"
Disabled="@(!CanEmergencyStop)">
<MudIcon Icon="@Icons.Material.Filled.Emergency" Class="mr-2" />
EMC Stop
</MudButton>
</MudStack>
<!-- Progress -->
@if (CurrentStatus != null)
{
<MudItem xs="12">
<MudText Typo="Typo.body2" Color="Color.Secondary">Progress</MudText>
<MudProgressLinear Value="@(CurrentStatus.ProgressPercent * 100)"
Color="Color.Primary"
Class="mt-2" />
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mt-1">
@(CurrentStatus.ProgressPercent.ToString("P1"))
</MudText>
</MudItem>
}
<!-- Status Message -->
@if (!string.IsNullOrEmpty(CurrentStatus?.Message))
{
<MudAlert Severity="@GetStatusSeverity(CurrentStatus.Status)" Dense>
@CurrentStatus.Message
</MudAlert>
}
</MudStack>
</MudCardContent>
</MudCard>
@code {
[Parameter] public TuningHubClient? HubClient { get; set; }
[Parameter] public List<TestScenario> AvailableScenarios { get; set; } = new();
[Parameter] public List<NavigationParameterSet> AvailableParameterSets { get; set; } = new();
[Parameter] public EventCallback<(Guid ScenarioId, Guid ParameterSetId)> OnStartTest { get; set; }
[Parameter] public EventCallback OnPauseTest { get; set; }
[Parameter] public EventCallback OnResumeTest { get; set; }
[Parameter] public EventCallback OnStopTest { get; set; }
[Parameter] public EventCallback OnEmergencyStop { get; set; }
[Parameter] public EventCallback<TestScenario> OnScenarioSelected { get; set; }
private Guid SelectedScenarioId { get; set; } = Guid.Empty;
private Guid SelectedParameterSetId { get; set; } = Guid.Empty;
private TestStatusUpdateDto? CurrentStatus { get; set; }
private bool CanStartTest => SelectedScenarioId != Guid.Empty
&& SelectedParameterSetId != Guid.Empty
&& (CurrentStatus == null || CurrentStatus.Status == TestStatus.Completed
|| CurrentStatus.Status == TestStatus.Error
|| CurrentStatus.Status == TestStatus.Aborted);
private bool CanPauseTest => CurrentStatus?.Status == TestStatus.Running;
private bool CanResumeTest => CurrentStatus?.Status == TestStatus.Paused;
private bool CanStopTest => CurrentStatus?.Status == TestStatus.Running
|| CurrentStatus?.Status == TestStatus.Paused;
private bool CanEmergencyStop => CurrentStatus?.Status == TestStatus.Running
|| CurrentStatus?.Status == TestStatus.Paused;
protected override async Task OnInitializedAsync()
{
if (HubClient != null)
{
HubClient.TestStatusUpdated += OnTestStatusUpdated;
}
}
private void OnTestStatusUpdated(TestStatusUpdateDto status)
{
CurrentStatus = status;
InvokeAsync(StateHasChanged);
}
private async Task StartTest()
{
if (SelectedScenarioId != Guid.Empty && SelectedParameterSetId != Guid.Empty)
{
await OnStartTest.InvokeAsync((SelectedScenarioId, SelectedParameterSetId));
}
}
private async Task PauseTest()
{
await OnPauseTest.InvokeAsync();
}
private async Task ResumeTest()
{
await OnResumeTest.InvokeAsync();
}
private async Task StopTest()
{
await OnStopTest.InvokeAsync();
}
private async Task EmergencyStop()
{
await OnEmergencyStop.InvokeAsync();
}
private async Task OnScenarioIdChanged(Guid scenarioId)
{
SelectedScenarioId = scenarioId;
var scenario = AvailableScenarios.FirstOrDefault(s => s.Id == scenarioId);
if (scenario != null)
{
await OnScenarioSelected.InvokeAsync(scenario);
}
}
private Color GetStatusColor(TestStatus status)
{
return status switch
{
TestStatus.Running => Color.Success,
TestStatus.Paused => Color.Warning,
TestStatus.Completed => Color.Info,
TestStatus.Error => Color.Error,
TestStatus.EmergencyStopped => Color.Error,
TestStatus.Aborted => Color.Default,
_ => Color.Default
};
}
private Severity GetStatusSeverity(TestStatus status)
{
return status switch
{
TestStatus.Running => Severity.Success,
TestStatus.Paused => Severity.Warning,
TestStatus.Completed => Severity.Info,
TestStatus.Error => Severity.Error,
TestStatus.EmergencyStopped => Severity.Error,
_ => Severity.Info
};
}
}

View File

@@ -0,0 +1,309 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Services
@using RobotNet10.NavigationTuneUI.Helpers
@using MudBlazor
@inject TuningApiService ApiService
<MudCard Style="width: 100%; min-height: 400px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
<MudCardHeader Style="box-sizing: border-box; flex-shrink: 0;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Lịch sử test</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Danh sách các lần chạy test đã lưu</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
Size="Size.Small"
OnClick="LoadTestRunsAsync"
title="Làm mới" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow: auto; box-sizing: border-box;">
@if (_loading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-2" />
}
@if (_error != null)
{
<MudAlert Severity="Severity.Error" Dense Class="mb-2">@_error</MudAlert>
}
@if (!_loading && _totalCount == 0)
{
<MudAlert Severity="Severity.Info" Dense>
Chưa có lịch sử test. Chạy test từ "Test Execution Control" để lưu kết quả.
</MudAlert>
}
else if (!_loading)
{
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2">Hiển thị: </MudText>
<MudSelect T="int" Value="_pageSize" ValueChanged="OnPageSizeChanged" Style="max-width: 80px;" Dense="true">
<MudSelectItem T="int" Value="10">10</MudSelectItem>
<MudSelectItem T="int" Value="20">20</MudSelectItem>
<MudSelectItem T="int" Value="50">50</MudSelectItem>
</MudSelect>
<MudText Typo="Typo.body2">@((_currentPage - 1) * _pageSize + 1)-@(Math.Min(_currentPage * _pageSize, _totalCount)) / @_totalCount</MudText>
</MudStack>
<MudButton Variant="Variant.Filled"
Color="Color.Error"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.Delete"
Disabled="@(_selectedRuns.Count == 0)"
OnClick="DeleteSelectedAsync">
Xóa đã chọn (@_selectedRuns.Count)
</MudButton>
</MudStack>
<MudTable T="TestRunDto"
Items="@TestRuns"
Hover="true"
Dense="true"
FixedHeader="true"
MultiSelection="true"
@bind-SelectedItems="_selectedRuns"
Style="min-height: 320px"
Breakpoint="Breakpoint.Sm">
<HeaderContent>
<MudTh>Thời gian</MudTh>
<MudTh>Scenario</MudTh>
<MudTh>Parameter set</MudTh>
<MudTh>Trạng thái</MudTh>
<MudTh>Thời lượng</MudTh>
<MudTh>Điểm</MudTh>
<MudTh Style="width: 90px;">Thao tác</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Thời gian"><MudText Typo="Typo.body2">@context.StartTime.ToString("yyyy-MM-dd HH:mm:ss")</MudText></MudTd>
<MudTd DataLabel="Scenario"><MudText Typo="Typo.body2">@(context.ScenarioName ?? context.ScenarioId.ToString("N")[..8])</MudText></MudTd>
<MudTd DataLabel="Parameter set"><MudText Typo="Typo.body2">@(context.ParameterSetName ?? context.ParameterSetId.ToString("N")[..8])</MudText></MudTd>
<MudTd DataLabel="Trạng thái"><MudChip T="string" Size="Size.Small" Color="@GetStatusColor(context.Status)">@GetStatusText(context.Status)</MudChip></MudTd>
<MudTd DataLabel="Thời lượng"><MudText Typo="Typo.body2">@(context.Duration.ToString("F1"))s</MudText></MudTd>
<MudTd DataLabel="Điểm">
@if (context.Metrics != null)
{
<MudText Typo="Typo.body2">@(context.Metrics.OverallScore.ToString("F1"))</MudText>
}
else
{
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
}
</MudTd>
<MudTd DataLabel="Thao tác">
<MudStack Row="true" Spacing="1">
<MudIconButton Icon="@Icons.Material.Filled.Visibility" Size="Size.Small" OnClick="@(() => ViewDetail(context))" title="Xem chi tiết" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error" OnClick="@(() => DeleteRun(context))" title="Xóa" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
@if (_totalPages > 1)
{
<MudStack Row="true" Justify="Justify.Center" Class="mt-2">
<MudPagination Count="@_totalPages" Selected="@_currentPage" SelectedChanged="OnPageChanged" />
</MudStack>
}
</MudStack>
}
</MudCardContent>
</MudCard>
@* Dialog chi tiết test run *@
<MudDialog @bind-Visible="_detailDialogOpen" Options="@(new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true })">
<TitleContent>
<MudText Typo="Typo.h6">Chi tiết test</MudText>
</TitleContent>
<DialogContent>
@if (_selectedRun != null)
{
<MudStack Spacing="2">
<MudText Typo="Typo.body2"><strong>Thời gian:</strong> @_selectedRun.StartTime.ToString("yyyy-MM-dd HH:mm:ss")</MudText>
<MudText Typo="Typo.body2"><strong>Scenario:</strong> @(_selectedRun.ScenarioName ?? "-")</MudText>
<MudText Typo="Typo.body2"><strong>Parameter set:</strong> @(_selectedRun.ParameterSetName ?? "-")</MudText>
<MudText Typo="Typo.body2"><strong>Trạng thái:</strong> @GetStatusText(_selectedRun.Status)</MudText>
<MudText Typo="Typo.body2"><strong>Thời lượng:</strong> @(_selectedRun.Duration.ToString("F1"))s</MudText>
@if (!string.IsNullOrEmpty(_selectedRun.ErrorMessage))
{
<MudAlert Severity="Severity.Error" Dense>@_selectedRun.ErrorMessage</MudAlert>
}
@if (_selectedRun.Metrics != null)
{
<MudDivider />
<MudText Typo="Typo.subtitle1">Metrics</MudText>
<MudGrid Spacing="2">
<MudItem xs="6"><MudText Typo="Typo.body2">Overall: @(_selectedRun.Metrics.OverallScore.ToString("F1"))</MudText></MudItem>
<MudItem xs="6"><MudText Typo="Typo.body2">Tracking: @(_selectedRun.Metrics.TrackingScore.ToString("F1"))</MudText></MudItem>
<MudItem xs="6"><MudText Typo="Typo.body2">Smoothness: @(_selectedRun.Metrics.SmoothnessScore.ToString("F1"))</MudText></MudItem>
<MudItem xs="6"><MudText Typo="Typo.body2">Efficiency: @(_selectedRun.Metrics.EfficiencyScore.ToString("F1"))</MudText></MudItem>
</MudGrid>
}
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseDetailDialog">Đóng</MudButton>
@if (_selectedRun != null && _selectedRun.Metrics != null)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ShowMetricsInDashboard">
Hiển thị trong Metrics
</MudButton>
}
</DialogActions>
</MudDialog>
@code {
[Parameter] public EventCallback<TestMetrics?> OnShowMetrics { get; set; }
private List<TestRunDto> TestRuns { get; set; } = new();
private int _totalCount;
private int _currentPage = 1;
private int _pageSize = 20;
private int _totalPages => _pageSize > 0 ? (int)Math.Ceiling(1.0 * _totalCount / _pageSize) : 0;
private HashSet<TestRunDto> _selectedRuns { get; set; } = new();
private bool _loading;
private string? _error;
private bool _detailDialogOpen;
private TestRunDto? _selectedRun;
protected override async Task OnInitializedAsync()
{
await LoadPageAsync(1);
}
private async Task LoadPageAsync(int page)
{
_loading = true;
_error = null;
try
{
var skip = (page - 1) * _pageSize;
var paged = await ApiService.GetTestRunsPagedAsync(skip, _pageSize);
TestRuns = paged.Items ?? new List<TestRunDto>();
_totalCount = paged.TotalCount;
_currentPage = _totalCount > 0 ? Math.Clamp(page, 1, _totalPages) : 1;
_selectedRuns.Clear();
if (TestRuns.Count == 0 && page > 1)
await LoadPageAsync(page - 1);
}
catch (Exception ex)
{
_error = ErrorHelper.GetErrorMessage(ex);
}
finally
{
_loading = false;
StateHasChanged();
}
}
private async Task OnPageChanged(int page)
{
_currentPage = page;
await LoadPageAsync(page);
}
private async Task OnPageSizeChanged(int newSize)
{
_pageSize = newSize;
_currentPage = 1;
await LoadPageAsync(1);
}
private async Task LoadTestRunsAsync()
{
await LoadPageAsync(_currentPage);
}
private async Task DeleteSelectedAsync()
{
if (_selectedRuns.Count == 0) return;
try
{
await ApiService.DeleteTestRunsAsync(_selectedRuns.Select(r => r.Id));
_selectedRuns.Clear();
await LoadPageAsync(_currentPage);
}
catch (Exception ex)
{
_error = ErrorHelper.GetErrorMessage(ex);
StateHasChanged();
}
}
private void ViewDetail(TestRunDto run)
{
_selectedRun = run;
_detailDialogOpen = true;
}
private void CloseDetailDialog()
{
_detailDialogOpen = false;
_selectedRun = null;
}
private async Task ShowMetricsInDashboard()
{
if (_selectedRun?.Metrics != null)
{
await OnShowMetrics.InvokeAsync(_selectedRun.Metrics);
CloseDetailDialog();
}
}
private async Task DeleteRun(TestRunDto run)
{
try
{
await ApiService.DeleteTestRunAsync(run.Id);
_selectedRuns.Remove(run);
TestRuns.Remove(run);
_totalCount--;
if (TestRuns.Count == 0 && _currentPage > 1)
await LoadPageAsync(_currentPage - 1);
else
StateHasChanged();
}
catch (Exception ex)
{
_error = ErrorHelper.GetErrorMessage(ex);
StateHasChanged();
}
}
private static Color GetStatusColor(TestStatus status)
{
return status switch
{
TestStatus.Completed => Color.Success,
TestStatus.Running => Color.Info,
TestStatus.Paused => Color.Warning,
TestStatus.Aborted => Color.Default,
TestStatus.Error => Color.Error,
TestStatus.EmergencyStopped => Color.Error,
_ => Color.Default
};
}
private static string GetStatusText(TestStatus status)
{
return status switch
{
TestStatus.Preparing => "Chuẩn bị",
TestStatus.Running => "Đang chạy",
TestStatus.Paused => "Tạm dừng",
TestStatus.Completed => "Hoàn thành",
TestStatus.Aborted => "Đã dừng",
TestStatus.Error => "Lỗi",
TestStatus.EmergencyStopped => "Dừng khẩn",
_ => status.ToString()
};
}
/// <summary>
/// Gọi từ parent khi test mới hoàn thành để refresh danh sách.
/// </summary>
public async Task RefreshAsync()
{
await LoadPageAsync(_currentPage);
}
}

View File

@@ -0,0 +1,390 @@
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTuneUI.Services
@using RobotNet10.NavigationTuneUI.Helpers
@inject TuningApiService ApiService
@inject ISnackbar Snackbar
<MudCard Style="width: 100%; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoFixHigh" />
<MudText Typo="Typo.h6">Tuning Advisor</MudText>
@if (Report != null && Report.HasCriticalIssues)
{
<MudChip T="string" Color="Color.Error" Size="Size.Small">Critical</MudChip>
}
else if (Report != null && Report.HighPriorityCount > 0)
{
<MudChip T="string" Color="Color.Warning" Size="Size.Small">@Report.HighPriorityCount High</MudChip>
}
else if (Report != null && Report.Suggestions.Count > 0)
{
<MudChip T="string" Color="Color.Info" Size="Size.Small">@Report.Suggestions.Count Suggestions</MudChip>
}
</MudStack>
</CardHeaderContent>
<CardHeaderActions>
@if (Report != null)
{
<MudTooltip Text="Re-analyze">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
OnClick="HandleReAnalyze"
Disabled="@_isProcessing" />
</MudTooltip>
}
</CardHeaderActions>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow-y: auto; overflow-x: hidden; box-sizing: border-box;">
@if (Report == null)
{
<MudText Typo="Typo.body2" Color="Color.Secondary">
Chưa có dữ liệu phân tích. Chạy test để nhận gợi ý tuning tự động.
</MudText>
}
else
{
@* Overall Assessment *@
<MudAlert Severity="@GetOverallSeverity()" Dense="true" Class="mb-3">
@Report.OverallAssessment
</MudAlert>
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
Controller: @Report.ControllerType | Samples: @Report.TelemetrySamplesAnalyzed | @Report.GeneratedAt.ToString("HH:mm:ss")
</MudText>
@* Detected Patterns *@
@if (Report.DetectedPatterns.Count > 0)
{
<MudExpansionPanels Elevation="0" Class="mb-3">
<MudExpansionPanel Text="@($"Detected Patterns ({Report.DetectedPatterns.Count})")"
Expanded="false"
Dense="true">
@foreach (var pattern in Report.DetectedPatterns.OrderByDescending(p => p.Severity))
{
<MudAlert Severity="@GetPatternSeverity(pattern.Severity)"
Dense="true" Class="mb-1" NoIcon="false">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudChip T="string" Size="Size.Small"
Color="@GetPatternChipColor(pattern.Severity)">
@pattern.Category
</MudChip>
<MudText Typo="Typo.body2">@pattern.Description</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">
(Severity: @pattern.Severity.ToString("F2"))
</MudText>
</MudStack>
</MudAlert>
}
</MudExpansionPanel>
</MudExpansionPanels>
}
@* Suggestions Table *@
@if (Report.Suggestions.Count > 0)
{
<MudText Typo="Typo.subtitle2" Class="mb-2">Suggestions</MudText>
<MudTable Items="@Report.Suggestions"
Dense="true"
Hover="true"
Bordered="false"
Striped="true"
Elevation="0"
T="TuningSuggestion">
<HeaderContent>
<MudTh Style="width: 40px;"></MudTh>
<MudTh>Priority</MudTh>
<MudTh>Parameter</MudTh>
<MudTh Style="text-align: right;">Current</MudTh>
<MudTh Style="text-align: center;"></MudTh>
<MudTh Style="text-align: right;">Suggested</MudTh>
<MudTh Style="text-align: right;">Change</MudTh>
<MudTh Style="text-align: center;">Confidence</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudCheckBox T="bool"
Value="@IsSelected(context)"
ValueChanged="@(v => ToggleSelection(context, v))"
Dense="true"
Color="Color.Primary" />
</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small" Color="@GetPriorityColor(context.Priority)">
@context.Priority
</MudChip>
</MudTd>
<MudTd>
<MudTooltip Text="@context.Reason">
<MudText Typo="Typo.body2">@context.ParameterDisplayName</MudText>
</MudTooltip>
</MudTd>
<MudTd Style="text-align: right;">
<MudText Typo="Typo.body2">@context.CurrentValue.ToString("F4")</MudText>
</MudTd>
<MudTd Style="text-align: center;">
<MudIcon Icon="@Icons.Material.Filled.ArrowForward" Size="Size.Small" />
</MudTd>
<MudTd Style="text-align: right;">
<MudText Typo="Typo.body2" Color="Color.Primary">
<b>@context.SuggestedValue.ToString("F4")</b>
</MudText>
</MudTd>
<MudTd Style="text-align: right;">
<MudText Typo="Typo.body2"
Color="@(context.ChangePercent > 0 ? Color.Success : Color.Error)">
@(context.ChangePercent > 0 ? "+" : "")@context.ChangePercent.ToString("F1")%
</MudText>
</MudTd>
<MudTd Style="text-align: center;">
<MudProgressLinear Value="@(context.Confidence * 100)"
Color="@GetConfidenceColor(context.Confidence)"
Style="width: 60px; height: 8px; display: inline-block;" />
<MudText Typo="Typo.caption">@((context.Confidence * 100).ToString("F0"))%</MudText>
</MudTd>
</RowTemplate>
<ChildRowContent>
<MudGrid Class="pa-2">
<MudItem xs="12" sm="6">
<MudText Typo="Typo.caption" Color="Color.Secondary">Reason</MudText>
<MudText Typo="Typo.body2">@context.Reason</MudText>
</MudItem>
<MudItem xs="12" sm="6">
<MudText Typo="Typo.caption" Color="Color.Secondary">Expected Impact</MudText>
<MudText Typo="Typo.body2">@context.ExpectedImpact</MudText>
</MudItem>
@if (context.RelatedPatterns.Count > 0)
{
<MudItem xs="12">
<MudText Typo="Typo.caption" Color="Color.Secondary">Related Patterns</MudText>
<MudStack Row="true" Spacing="1">
@foreach (var pattern in context.RelatedPatterns)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined">@pattern</MudChip>
}
</MudStack>
</MudItem>
}
</MudGrid>
</ChildRowContent>
</MudTable>
@* Action Buttons *@
<MudStack Row="true" Spacing="2" Class="mt-3" Justify="Justify.FlexEnd">
<MudButton Variant="Variant.Text"
Size="Size.Small"
OnClick="SelectAll">
Select All
</MudButton>
<MudButton Variant="Variant.Text"
Size="Size.Small"
OnClick="DeselectAll">
Deselect All
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Preview"
OnClick="HandleApplySelected"
Disabled="@(_selectedIds.Count == 0 || _isProcessing)"
Size="Size.Small">
Apply Selected (Preview)
</MudButton>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Save"
OnClick="HandleApplyAndSave"
Disabled="@(_selectedIds.Count == 0 || _isProcessing)"
Size="Size.Small">
Apply & Save as New
</MudButton>
</MudStack>
}
else
{
<MudAlert Severity="Severity.Success" Dense="true">
Không phát hiện vấn đề. Tham số hiện tại hoạt động tốt.
</MudAlert>
}
}
@if (_isProcessing)
{
<MudProgressLinear Indeterminate="true" Color="Color.Primary" Class="mt-2" />
}
</MudCardContent>
</MudCard>
@code {
[Parameter] public TuningReport? Report { get; set; }
[Parameter] public NavigationParameterSet? CurrentParameters { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnApplySuggestions { get; set; }
[Parameter] public EventCallback<NavigationParameterSet> OnSaveNewParameterSet { get; set; }
[Parameter] public Guid? TestRunId { get; set; }
private HashSet<Guid> _selectedIds = new();
private bool _isProcessing;
protected override void OnParametersSet()
{
// Auto-select high+ priority suggestions when new report arrives
if (Report != null && _selectedIds.Count == 0)
{
_selectedIds = Report.Suggestions
.Where(s => s.Priority >= SuggestionPriority.High)
.Select(s => s.Id)
.ToHashSet();
}
}
private bool IsSelected(TuningSuggestion suggestion) => _selectedIds.Contains(suggestion.Id);
private void ToggleSelection(TuningSuggestion suggestion, bool selected)
{
if (selected)
_selectedIds.Add(suggestion.Id);
else
_selectedIds.Remove(suggestion.Id);
}
private void SelectAll()
{
if (Report != null)
_selectedIds = Report.Suggestions.Select(s => s.Id).ToHashSet();
}
private void DeselectAll() => _selectedIds.Clear();
private async Task HandleApplySelected()
{
if (Report == null || CurrentParameters == null || _selectedIds.Count == 0)
return;
_isProcessing = true;
try
{
var modified = await ApiService.ApplySuggestionsAsync(
CurrentParameters.Id, _selectedIds.ToList(), Report);
if (modified != null)
{
await OnApplySuggestions.InvokeAsync(modified);
Snackbar.Add($"Đã áp dụng {_selectedIds.Count} gợi ý (preview)", Severity.Success);
}
}
catch (ApiException ex)
{
Snackbar.Add($"Lỗi áp dụng gợi ý: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
finally
{
_isProcessing = false;
}
}
private async Task HandleApplyAndSave()
{
if (Report == null || CurrentParameters == null || _selectedIds.Count == 0)
return;
_isProcessing = true;
try
{
var saved = await ApiService.ApplyAndSaveSuggestionsAsync(
CurrentParameters.Id, _selectedIds.ToList(), Report);
if (saved != null)
{
await OnSaveNewParameterSet.InvokeAsync(saved);
Snackbar.Add($"Đã lưu bộ thông số mới: {saved.Name}", Severity.Success);
}
}
catch (ApiException ex)
{
Snackbar.Add($"Lỗi lưu bộ thông số: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
finally
{
_isProcessing = false;
}
}
private async Task HandleReAnalyze()
{
if (TestRunId == null)
return;
_isProcessing = true;
try
{
var newReport = await ApiService.AnalyzeTestRunAsync(TestRunId.Value);
if (newReport != null)
{
Report = newReport;
_selectedIds = newReport.Suggestions
.Where(s => s.Priority >= SuggestionPriority.High)
.Select(s => s.Id)
.ToHashSet();
Snackbar.Add("Phân tích lại hoàn tất", Severity.Success);
}
}
catch (ApiException ex)
{
Snackbar.Add($"Lỗi phân tích: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
finally
{
_isProcessing = false;
}
}
private Severity GetOverallSeverity()
{
if (Report == null) return Severity.Info;
if (Report.HasCriticalIssues) return Severity.Error;
if (Report.HighPriorityCount > 0) return Severity.Warning;
if (Report.Suggestions.Count > 0) return Severity.Info;
return Severity.Success;
}
private static Severity GetPatternSeverity(double severity)
{
if (severity >= 0.7) return Severity.Error;
if (severity >= 0.4) return Severity.Warning;
return Severity.Info;
}
private static Color GetPatternChipColor(double severity)
{
if (severity >= 0.7) return Color.Error;
if (severity >= 0.4) return Color.Warning;
return Color.Info;
}
private static Color GetPriorityColor(SuggestionPriority priority) => priority switch
{
SuggestionPriority.Critical => Color.Error,
SuggestionPriority.High => Color.Warning,
SuggestionPriority.Medium => Color.Info,
_ => Color.Default
};
private static Color GetConfidenceColor(double confidence)
{
if (confidence >= 0.7) return Color.Success;
if (confidence >= 0.4) return Color.Warning;
return Color.Error;
}
}

View File

@@ -0,0 +1,612 @@
@using Microsoft.AspNetCore.SignalR.Client
@using RobotNet10.NavigationTuneUI.Clients
@using RobotNet10.NavigationTune.Shared.Models
@using RobotNet10.NavigationTune.Shared.Hubs
@using RobotNet10.NavigationTune.Shared.Interfaces
@using RobotNet10.NavigationTuneUI.Components
@using RobotNet10.NavigationTuneUI.Components.Pages
@using RobotNet10.NavigationTuneUI.Services
@using RobotNet10.NavigationTuneUI.Helpers
@inject TuningHubClient TuningHub
@inject TuningApiService ApiService
@inject NavigationManager Navigation
@inject ISnackbar Snackbar
<PageTitle>Navigation Tuning</PageTitle>
<MudToolBar Dense="true" Class="mud-theme-primary" Style="flex-shrink: 0;">
<MudText Typo="Typo.h6" Class="mr-4" Style="color: inherit;">Navigation Tuning</MudText>
<MudDivider Vertical="true" FlexItem="true" Class="mx-2" />
<MudButton Variant="@(CurrentPage == "test" ? Variant.Filled : Variant.Text)"
Color="@(CurrentPage == "test" ? Color.Dark : Color.Inherit)"
OnClick='@(() => CurrentPage = "test")'
StartIcon="@Icons.Material.Filled.Science"
Size="Size.Small"
Class="mr-1">
Test
</MudButton>
<MudButton Variant="@(CurrentPage == "manager" ? Variant.Filled : Variant.Text)"
Color="@(CurrentPage == "manager" ? Color.Dark : Color.Inherit)"
OnClick='@(() => CurrentPage = "manager")'
StartIcon="@Icons.Material.Filled.Settings"
Size="Size.Small"
Class="mr-1">
Manager
</MudButton>
<MudButton Variant="@(CurrentPage == "history" ? Variant.Filled : Variant.Text)"
Color="@(CurrentPage == "history" ? Color.Dark : Color.Inherit)"
OnClick='@(() => CurrentPage = "history")'
StartIcon="@Icons.Material.Filled.History"
Size="Size.Small"
Class="mr-1">
History
</MudButton>
<MudButton Variant="@(CurrentPage == "motor" ? Variant.Filled : Variant.Text)"
Color="@(CurrentPage == "motor" ? Color.Dark : Color.Inherit)"
OnClick='@(() => CurrentPage = "motor")'
StartIcon="@Icons.Material.Filled.Speed"
Size="Size.Small">
Motor Test
</MudButton>
</MudToolBar>
@if (IsLoading)
{
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
}
else
{
@switch (CurrentPage)
{
case "test":
<TestPage HubClient="@TuningHub"
Scenarios="@Scenarios"
ParameterSets="@ParameterSets"
CurrentParameterSet="@CurrentParameterSet"
SelectedScenario="@SelectedScenario"
CurrentMetrics="@CurrentMetrics"
CurrentTestRunId="@CurrentTestRunId"
TuningReport="@CurrentTuningReport"
OnStartTest="HandleStartTest"
OnPauseTest="HandlePauseTest"
OnResumeTest="HandleResumeTest"
OnStopTest="HandleStopTest"
OnEmergencyStop="HandleEmergencyStop"
OnScenarioSelected="HandleScenarioSelected"
OnSaveParameters="HandleSaveParameters"
OnResetParameters="HandleResetParameters"
OnParameterSetChanged="HandleParameterSetChanged"
OnScenarioChanged="HandleScenarioChanged"
OnScenarioCreated="HandleScenarioCreated"
OnScenarioDeleted="HandleScenarioDeleted"
OnApplySuggestions="HandleApplySuggestions"
OnSaveNewParameterSet="HandleSaveNewParameterSet" />
break;
case "manager":
<ManagerPage ParameterSets="@ParameterSets"
OnParameterSetSelected="HandleParameterSetSelected"
OnParameterSetCreated="HandleParameterSetCreated"
OnParameterSetUpdated="HandleParameterSetUpdated"
OnParameterSetDeleted="HandleParameterSetDeleted" />
break;
case "history":
<HistoryPage @ref="_historyPage"
OnShowMetrics="HandleShowMetricsFromHistory" />
break;
case "motor":
<MotorTestPage />
break;
}
}
@code {
private string CurrentPage { get; set; } = "test";
private List<TestScenario> Scenarios { get; set; } = new();
private List<NavigationParameterSet> ParameterSets { get; set; } = new();
private NavigationParameterSet CurrentParameterSet { get; set; } = new();
private TestScenario? SelectedScenario { get; set; }
private TestMetrics? CurrentMetrics { get; set; }
private string? CurrentTestRunId { get; set; }
private TuningReport? CurrentTuningReport { get; set; }
private bool IsLoading { get; set; } = true;
private bool IsTestRunning { get; set; } = false;
private HistoryPage? _historyPage;
protected override async Task OnInitializedAsync()
{
try
{
// Start SignalR connection
await TuningHub.StartAsync();
TuningHub.TestResultReceived += OnTestResultReceived;
TuningHub.TestStatusUpdated += OnTestStatusUpdated;
// Load data from API
await LoadDataAsync();
}
catch (ApiException ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khởi tạo hệ thống: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
finally
{
IsLoading = false;
}
}
private async Task LoadDataAsync()
{
try
{
var scenariosTask = ApiService.GetScenariosAsync();
var parameterSetsTask = ApiService.GetParameterSetsAsync();
await Task.WhenAll(scenariosTask, parameterSetsTask);
Scenarios = await scenariosTask;
ParameterSets = await parameterSetsTask;
// If no parameter sets, load default preset
if (ParameterSets.Count == 0)
{
var defaultPreset = await ApiService.GetDefaultPresetAsync();
ParameterSets.Add(defaultPreset);
}
// Set current parameter set to first one or default
if (ParameterSets.Count > 0)
{
CurrentParameterSet = ParameterSets[0];
}
// Set selected scenario to first one
if (Scenarios.Count > 0)
{
SelectedScenario = Scenarios[0];
}
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể tải dữ liệu: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi tải dữ liệu: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task HandleStartTest((Guid ScenarioId, Guid ParameterSetId) args)
{
try
{
IsTestRunning = true;
// Leave previous test session if any
if (!string.IsNullOrEmpty(CurrentTestRunId))
{
await TuningHub.LeaveTestSessionAsync(CurrentTestRunId);
}
// Generate testRunId and join SignalR group BEFORE starting test so real-time telemetry is received
var testRunId = Guid.NewGuid();
CurrentTestRunId = testRunId.ToString();
await TuningHub.JoinTestSessionAsync(CurrentTestRunId);
var connectionId = TuningHub.ConnectionState == HubConnectionState.Connected
? TuningHub.GetHashCode().ToString() // Simple connection identifier
: null;
var result = await ApiService.ExecuteTestAsync(args.ScenarioId, args.ParameterSetId, testRunId, connectionId);
// Keep CurrentTestRunId in sync with result (server may return same id)
CurrentTestRunId = result.TestRunId.ToString();
var scenarioName = Scenarios.FirstOrDefault(s => s.Id == args.ScenarioId)?.Name ?? "Unknown";
Snackbar.Add($"Đã bắt đầu test: {scenarioName} (ID: {result.TestRunId})", Severity.Success);
}
catch (ApiException ex)
{
IsTestRunning = false;
Snackbar.Add($"Không thể bắt đầu test: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
IsTestRunning = false;
Snackbar.Add($"Lỗi khi bắt đầu test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task HandlePauseTest()
{
try
{
if (string.IsNullOrEmpty(CurrentTestRunId))
return;
await ApiService.PauseTestAsync(CurrentTestRunId);
Snackbar.Add("Test đã được tạm dừng", Severity.Warning);
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể tạm dừng test: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi tạm dừng test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task HandleResumeTest()
{
try
{
if (string.IsNullOrEmpty(CurrentTestRunId))
return;
await ApiService.ResumeTestAsync(CurrentTestRunId);
Snackbar.Add("Test đã được tiếp tục", Severity.Info);
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể tiếp tục test: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi tiếp tục test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task HandleStopTest()
{
try
{
if (string.IsNullOrEmpty(CurrentTestRunId))
return;
await ApiService.StopTestAsync(CurrentTestRunId);
IsTestRunning = false;
Snackbar.Add("Test đã được dừng", Severity.Info);
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể dừng test: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi dừng test: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task HandleEmergencyStop()
{
try
{
if (string.IsNullOrEmpty(CurrentTestRunId))
return;
await ApiService.EmergencyStopAsync(CurrentTestRunId);
IsTestRunning = false;
Snackbar.Add("Dừng khẩn cấp đã được kích hoạt!", Severity.Error);
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể kích hoạt dừng khẩn cấp: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi kích hoạt dừng khẩn cấp: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task HandleSaveParameters(NavigationParameterSet parameterSet)
{
try
{
// Ensure all required fields are set
if (parameterSet == null)
{
Snackbar.Add("Parameter set không hợp lệ", Severity.Error);
return;
}
// Ensure all config objects are initialized
parameterSet.MovePidConfig ??= new PIDConfig();
parameterSet.RotatePidConfig ??= new PIDConfig();
parameterSet.PurePursuitConfig ??= new PurePursuitConfig();
parameterSet.StanleyConfig ??= new StanleyConfig();
parameterSet.EstimatorConfig ??= new VelocityEstimatorConfig();
parameterSet.SignalConfig ??= new VelocitySignalProcessingConfig();
parameterSet.MotorDynamicsConfig ??= new MotorDynamicsConfig();
parameterSet.NavigationConfig ??= new NavigationConfig();
// Validate first
var validation = await ApiService.ValidateParameterSetAsync(parameterSet);
if (!validation.IsValid)
{
var errorList = validation.Errors?.Any() == true
? string.Join("\n", validation.Errors)
: "Có lỗi validation không xác định";
Snackbar.Add($"Thông số không hợp lệ:\n{errorList}", Severity.Error);
return;
}
// Check if name is provided for new parameter set
if (parameterSet.Id == Guid.Empty && string.IsNullOrWhiteSpace(parameterSet.Name))
{
Snackbar.Add("Vui lòng nhập tên cho bộ thông số mới", Severity.Warning);
return;
}
if (parameterSet.Id == Guid.Empty)
{
// Create new - ensure name is set
if (string.IsNullOrWhiteSpace(parameterSet.Name))
{
parameterSet.Name = $"Parameter Set {DateTime.Now:yyyyMMdd_HHmmss}";
}
var created = await ApiService.CreateParameterSetAsync(parameterSet);
CurrentParameterSet = CloneParameterSet(created);
ParameterSets.Add(created);
Snackbar.Add($"Đã tạo bộ thông số mới: {created.Name}", Severity.Success);
}
else
{
// Update existing - ensure ID is preserved
var clonedSet = CloneParameterSet(parameterSet);
clonedSet.Id = parameterSet.Id; // Ensure ID is preserved
clonedSet.CreatedAt = parameterSet.CreatedAt; // Preserve original creation date
clonedSet.Version = parameterSet.Version; // Preserve version
await ApiService.UpdateParameterSetAsync(parameterSet.Id, clonedSet);
CurrentParameterSet = CloneParameterSet(clonedSet);
// Update in list
var index = ParameterSets.FindIndex(p => p.Id == parameterSet.Id);
if (index >= 0)
ParameterSets[index] = CloneParameterSet(clonedSet);
Snackbar.Add($"Đã cập nhật bộ thông số: {parameterSet.Name}", Severity.Success);
}
// Reload parameter sets
ParameterSets = await ApiService.GetParameterSetsAsync();
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể lưu thông số: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi khi lưu thông số: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private void HandleResetParameters()
{
CurrentParameterSet = new NavigationParameterSet();
Snackbar.Add("Đã đặt lại thông số về mặc định", Severity.Info);
}
private async Task HandleParameterSetSelected(NavigationParameterSet parameterSet)
{
CurrentParameterSet = CloneParameterSet(parameterSet);
await InvokeAsync(StateHasChanged);
}
private async Task HandleParameterSetCreated(NavigationParameterSet parameterSet)
{
ParameterSets = await ApiService.GetParameterSetsAsync();
CurrentParameterSet = parameterSet;
await InvokeAsync(StateHasChanged);
}
private async Task HandleParameterSetUpdated(NavigationParameterSet parameterSet)
{
ParameterSets = await ApiService.GetParameterSetsAsync();
var index = ParameterSets.FindIndex(p => p.Id == parameterSet.Id);
if (index >= 0)
{
ParameterSets[index] = parameterSet;
}
if (CurrentParameterSet.Id == parameterSet.Id)
{
CurrentParameterSet = CloneParameterSet(parameterSet);
}
await InvokeAsync(StateHasChanged);
}
private async Task HandleParameterSetDeleted(Guid parameterSetId)
{
ParameterSets = await ApiService.GetParameterSetsAsync();
if (CurrentParameterSet.Id == parameterSetId)
{
CurrentParameterSet = ParameterSets.Count > 0 ? ParameterSets[0] : new NavigationParameterSet();
}
await InvokeAsync(StateHasChanged);
}
private async Task HandleParameterSetChanged(NavigationParameterSet parameterSet)
{
CurrentParameterSet = CloneParameterSet(parameterSet);
await InvokeAsync(StateHasChanged);
}
private async Task HandleApplySuggestions(NavigationParameterSet modified)
{
// Apply suggestions as preview: update current parameter set in UI without saving
CurrentParameterSet = CloneParameterSet(modified);
Snackbar.Add("Đã áp dụng gợi ý vào tham số hiện tại (chưa lưu)", Severity.Info);
await InvokeAsync(StateHasChanged);
}
private async Task HandleSaveNewParameterSet(NavigationParameterSet saved)
{
// New parameter set was saved by the API; reload list and switch to it
ParameterSets = await ApiService.GetParameterSetsAsync();
CurrentParameterSet = CloneParameterSet(saved);
Snackbar.Add($"Đã lưu bộ thông số mới: {saved.Name}", Severity.Success);
await InvokeAsync(StateHasChanged);
}
private NavigationParameterSet CloneParameterSet(NavigationParameterSet source)
{
return new NavigationParameterSet
{
Id = source.Id,
Name = source.Name,
Description = source.Description,
CreatedAt = source.CreatedAt,
UpdatedAt = source.UpdatedAt,
IsDefault = source.IsDefault,
Version = source.Version,
ControllerType = source.ControllerType,
MovePidConfig = new PIDConfig
{
Kp = source.MovePidConfig.Kp,
Ki = source.MovePidConfig.Ki,
Kd = source.MovePidConfig.Kd
},
RotatePidConfig = new PIDConfig
{
Kp = source.RotatePidConfig.Kp,
Ki = source.RotatePidConfig.Ki,
Kd = source.RotatePidConfig.Kd
},
PurePursuitConfig = new PurePursuitConfig
{
LookaheadMin = source.PurePursuitConfig.LookaheadMin,
LookaheadMax = source.PurePursuitConfig.LookaheadMax,
Kdd = source.PurePursuitConfig.Kdd,
MaxAngularVelocity = source.PurePursuitConfig.MaxAngularVelocity,
ResolutionSplit = source.PurePursuitConfig.ResolutionSplit,
FinalApproachThreshold = source.PurePursuitConfig.FinalApproachThreshold,
HeadingTolerance = source.PurePursuitConfig.HeadingTolerance,
GoalRegionDistance = source.PurePursuitConfig.GoalRegionDistance,
KCurvature = source.PurePursuitConfig.KCurvature,
MinLookaheadTimeRatio = source.PurePursuitConfig.MinLookaheadTimeRatio,
MaxLookaheadTimeRatio = source.PurePursuitConfig.MaxLookaheadTimeRatio
},
StanleyConfig = new StanleyConfig
{
K = source.StanleyConfig.K,
Ks = source.StanleyConfig.Ks,
WheelBase = source.StanleyConfig.WheelBase,
MaxSteeringAngle = source.StanleyConfig.MaxSteeringAngle,
EnableCurvatureFeedforward = source.StanleyConfig.EnableCurvatureFeedforward,
KCurvatureFF = source.StanleyConfig.KCurvatureFF,
GoalTolerance = source.StanleyConfig.GoalTolerance,
HeadingTolerance = source.StanleyConfig.HeadingTolerance,
ResolutionSplit = source.StanleyConfig.ResolutionSplit,
GoalApproachDistance = source.StanleyConfig.GoalApproachDistance,
GoalGainMultiplier = source.StanleyConfig.GoalGainMultiplier,
LowSpeedThreshold = source.StanleyConfig.LowSpeedThreshold,
LowSpeedAngularGain = source.StanleyConfig.LowSpeedAngularGain
},
EstimatorConfig = new VelocityEstimatorConfig
{
MinBlendRatio = source.EstimatorConfig.MinBlendRatio,
MaxBlendRatio = source.EstimatorConfig.MaxBlendRatio,
DefaultBlendRatio = source.EstimatorConfig.DefaultBlendRatio,
GoodTrackingBlend = source.EstimatorConfig.GoodTrackingBlend,
ModerateTrackingBlend = source.EstimatorConfig.ModerateTrackingBlend,
PoorTrackingBlend = source.EstimatorConfig.PoorTrackingBlend,
GoodTrackingThreshold = source.EstimatorConfig.GoodTrackingThreshold,
ModerateTrackingThreshold = source.EstimatorConfig.ModerateTrackingThreshold,
ConfidenceDecayRate = source.EstimatorConfig.ConfidenceDecayRate,
MinConfidence = source.EstimatorConfig.MinConfidence
},
SignalConfig = new VelocitySignalProcessingConfig
{
AlphaFilter = source.SignalConfig.AlphaFilter,
NoiseThreshold = source.SignalConfig.NoiseThreshold
},
MotorDynamicsConfig = new MotorDynamicsConfig
{
Tau = source.MotorDynamicsConfig.Tau,
Delta = source.MotorDynamicsConfig.Delta
},
NavigationConfig = new NavigationConfig
{
MaxLinearVelocity = source.NavigationConfig.MaxLinearVelocity,
MinLinearVelocity = source.NavigationConfig.MinLinearVelocity,
MaxAngularVelocity = source.NavigationConfig.MaxAngularVelocity,
RotateAngularVelocity = source.NavigationConfig.RotateAngularVelocity,
ReachedRadius = source.NavigationConfig.ReachedRadius,
InitialRotationThreshold = source.NavigationConfig.InitialRotationThreshold,
Acceleration = source.NavigationConfig.Acceleration,
Deceleration = source.NavigationConfig.Deceleration
}
};
}
private async void OnTestResultReceived(TestExecutionResult result)
{
CurrentMetrics = result.Metrics;
CurrentTestRunId = result.TestRunId.ToString();
CurrentTuningReport = result.TuningReport;
IsTestRunning = false;
if (_historyPage?.TestHistoryViewerRef != null)
_ = _historyPage.TestHistoryViewerRef.RefreshAsync();
await InvokeAsync(StateHasChanged);
}
private void OnTestStatusUpdated(TestStatusUpdateDto status)
{
if (status.Status == TestStatus.Completed || status.Status == TestStatus.Aborted ||
status.Status == TestStatus.Error || status.Status == TestStatus.EmergencyStopped)
{
IsTestRunning = false;
}
InvokeAsync(StateHasChanged);
}
private async Task HandleScenarioChanged(TestScenario scenario)
{
SelectedScenario = scenario;
// Update in scenarios list
var index = Scenarios.FindIndex(s => s.Id == scenario.Id);
if (index >= 0)
{
Scenarios[index] = scenario;
}
await InvokeAsync(StateHasChanged);
}
private async Task HandleScenarioSelected(TestScenario scenario)
{
SelectedScenario = scenario;
await InvokeAsync(StateHasChanged);
}
private async Task HandleScenarioCreated(TestScenario scenario)
{
// Add new scenario to list
if (!Scenarios.Any(s => s.Id == scenario.Id))
{
Scenarios.Add(scenario);
}
SelectedScenario = scenario;
await InvokeAsync(StateHasChanged);
}
private async Task HandleScenarioDeleted(Guid scenarioId)
{
Scenarios.RemoveAll(s => s.Id == scenarioId);
if (SelectedScenario?.Id == scenarioId)
SelectedScenario = Scenarios.FirstOrDefault();
await InvokeAsync(StateHasChanged);
}
private void HandleShowMetricsFromHistory(TestMetrics? metrics)
{
CurrentMetrics = metrics;
CurrentPage = "test"; // Switch to test page to show metrics
StateHasChanged();
}
}

View File

@@ -0,0 +1,310 @@
@using RobotNet10.NavigationTuneUI.Services
@using RobotNet10.NavigationTuneUI.Helpers
@using MudBlazor
@inject TuningApiService ApiService
@inject ISnackbar Snackbar
<MudCard Style="width: 100%; height: 675px; display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;">
<MudCardHeader Style="flex-shrink: 0; box-sizing: border-box;">
<CardHeaderContent>
<MudText Typo="Typo.h6">Manual Velocity Control</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">Direct robot control</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Style="flex: 1; overflow-x: auto; overflow-y: auto; box-sizing: border-box;">
<MudGrid Spacing="3">
<!-- Linear Velocity Control (Vertical Slider) -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle1" Class="mb-3">Linear Velocity (m/s)</MudText>
<MudStack AlignItems="@AlignItems.Center" Spacing="2">
<MudText Typo="Typo.body2" Color="Color.Secondary">Forward</MudText>
<!-- Vertical Slider Container -->
<div style="height: 300px; display: flex; align-items: center; justify-content: center; position: relative;">
<MudSlider T="double"
Value="@LinearVelocity"
ValueChanged="@OnLinearVelocityChanged"
Min="@(-MaxLinearVelocity)"
Max="@MaxLinearVelocity"
Step="0.01"
Vertical="true"
Immediate="true"
Class="vertical-slider align-content-center"
Size="Size.Large"
ValueLabel=" true"
Style="width: 300px"
ValueLabelFormat="@($"{LinearVelocity:F2} m/s")">
</MudSlider>
</div>
<MudText Typo="Typo.body2" Color="Color.Secondary">Backward</MudText>
<!-- Linear Velocity Display and Reset -->
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center" Justify="@Justify.Center">
<MudNumericField T="double"
Value="@LinearVelocity"
ValueChanged="@OnLinearVelocityChanged"
Label="Linear (m/s)"
Variant="Variant.Outlined"
Min="@(-MaxLinearVelocity)"
Max="@MaxLinearVelocity"
Step="0.01"
Immediate="true"
Margin="Margin.Dense"
Style="width: 120px;" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Class="mt-1"
OnClick="ResetLinearVelocity">
Reset
</MudButton>
</MudStack>
</MudStack>
</MudItem>
<!-- Angular Velocity Control (Horizontal Slider) -->
<MudItem xs="12" md="6">
<MudText Typo="Typo.subtitle1" Class="mb-3">Angular Velocity (rad/s)</MudText>
<MudStack AlignItems="@AlignItems.Center" Spacing="2">
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center" Justify="@Justify.Center" Style="width: 100%;">
<MudText Typo="Typo.body2" Color="Color.Secondary">Left</MudText>
<!-- Horizontal Slider -->
<MudSlider T="double"
Value="@AngularVelocity"
ValueChanged="@OnAngularVelocityChanged"
Min="@(-MaxAngularVelocity)"
Max="@MaxAngularVelocity"
Step="0.01"
Size="Size.Large"
Immediate="true"
Class="horizontal-slider"
ValueLabel=" true"
Style="flex: 1; max-width: 300px;"
ValueLabelFormat="@($"{AngularVelocity:F2} rad/s")">
</MudSlider>
<MudText Typo="Typo.body2" Color="Color.Secondary">Right</MudText>
</MudStack>
<!-- Angular Velocity Display and Reset -->
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center" Justify="@Justify.Center">
<MudNumericField T="double"
Value="@AngularVelocity"
ValueChanged="@OnAngularVelocityChanged"
Label="Angular (rad/s)"
Variant="Variant.Outlined"
Min="@(-MaxAngularVelocity)"
Max="@MaxAngularVelocity"
Step="0.01"
Immediate="true"
Margin="Margin.Dense"
Style="width: 120px;" />
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Class="mt-1"
OnClick="ResetAngularVelocity">
Reset
</MudButton>
</MudStack>
</MudStack>
</MudItem>
<!-- Stop Button -->
<MudItem xs="12">
<MudStack Row="true" AlignItems="@AlignItems.Center" Justify="@Justify.Center" Spacing="2">
<MudButton Variant="Variant.Filled"
Color="Color.Error"
Size="Size.Large"
StartIcon="@Icons.Material.Filled.Stop"
OnClick="StopAllVelocities"
Style="min-width: 150px;">
Stop All
</MudButton>
</MudStack>
</MudItem>
<!-- Current Velocity Display -->
<MudItem xs="12">
<MudDivider Class="my-2" />
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="text-center">
Current: Linear = @CurrentLinear.ToString("F2") m/s, Angular = @CurrentAngular.ToString("F2") rad/s
</MudText>
</MudItem>
</MudGrid>
</MudCardContent>
</MudCard>
@code {
private double LinearVelocity { get; set; } = 0.0;
private double AngularVelocity { get; set; } = 0.0;
private double CurrentLinear { get; set; } = 0.0;
private double CurrentAngular { get; set; } = 0.0;
private const double MaxLinearVelocity = 1.5; // m/s
private const double MaxAngularVelocity = 3.0; // rad/s
private PeriodicTimer? _velocityUpdateTimer;
private CancellationTokenSource? _timersCts;
private Task? _velocityUpdateLoopTask;
private bool _isUpdating = false;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
_timersCts = new CancellationTokenSource();
// 1) Periodically update displayed current velocity (2Hz)
_velocityUpdateTimer = new PeriodicTimer(TimeSpan.FromMilliseconds(500));
_velocityUpdateLoopTask = RunVelocityUpdateLoopAsync(_timersCts.Token);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (!firstRender) return;
}
private async Task OnLinearVelocityChanged(double value)
{
if (_isUpdating) return;
LinearVelocity = value;
await SendVelocityCommand();
}
private async Task OnAngularVelocityChanged(double value)
{
if (_isUpdating) return;
AngularVelocity = value;
await SendVelocityCommand();
}
private async Task SendVelocityCommand()
{
try
{
await ApiService.SetVelocityAsync(LinearVelocity, AngularVelocity);
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể gửi lệnh tốc độ: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi gửi lệnh tốc độ: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task RunVelocityUpdateLoopAsync(CancellationToken ct)
{
try
{
if (_velocityUpdateTimer == null) return;
// Initial update immediately
await UpdateCurrentVelocity();
while (await _velocityUpdateTimer.WaitForNextTickAsync(ct))
{
await UpdateCurrentVelocity();
}
}
catch (OperationCanceledException)
{
// expected on dispose
}
catch
{
// Silently fail - connection might not be available
}
}
private async Task ResetLinearVelocity()
{
_isUpdating = true;
LinearVelocity = 0.0;
_isUpdating = false;
await SendVelocityCommand();
Snackbar.Add("Đã reset tốc độ tịnh tiến về 0", Severity.Info);
}
private async Task ResetAngularVelocity()
{
_isUpdating = true;
AngularVelocity = 0.0;
_isUpdating = false;
await SendVelocityCommand();
Snackbar.Add("Đã reset tốc độ xoay về 0", Severity.Info);
}
private async Task StopAllVelocities()
{
_isUpdating = true;
LinearVelocity = 0.0;
AngularVelocity = 0.0;
_isUpdating = false;
try
{
await ApiService.StopVelocityAsync();
Snackbar.Add("Đã dừng tất cả tốc độ", Severity.Success);
}
catch (ApiException ex)
{
Snackbar.Add($"Không thể dừng robot: {ex.Message}", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Lỗi dừng robot: {ErrorHelper.GetErrorMessage(ex)}", Severity.Error);
}
}
private async Task UpdateCurrentVelocity()
{
try
{
var (linear, angular) = await ApiService.GetCurrentVelocityAsync();
CurrentLinear = linear;
CurrentAngular = angular;
await InvokeAsync(StateHasChanged);
}
catch
{
// Silently fail - connection might not be available
}
}
public async ValueTask DisposeAsync()
{
// Stop timers/loops first so we don't keep sending commands after disposal.
try
{
_timersCts?.Cancel();
}
catch { }
try
{
if (_velocityUpdateLoopTask != null)
await _velocityUpdateLoopTask;
}
catch { }
try { _velocityUpdateTimer?.Dispose(); } catch { }
try { _timersCts?.Dispose(); } catch { }
// Stop velocities when component is disposed (navigate away, refresh, etc.)
try
{
await ApiService.StopVelocityAsync();
}
catch
{
// Ignore errors on dispose
}
}
}

View File

@@ -0,0 +1,20 @@
namespace RobotNet10.NavigationTuneUI.Helpers;
/// <summary>
/// Custom exception for API errors with meaningful messages
/// </summary>
public class ApiException : Exception
{
public int? StatusCode { get; }
public ApiException(string message, int? statusCode = null) : base(message)
{
StatusCode = statusCode;
}
public ApiException(string message, Exception innerException, int? statusCode = null)
: base(message, innerException)
{
StatusCode = statusCode;
}
}

View File

@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace RobotNet10.NavigationTuneUI.Helpers;
/// <summary>
/// Helper class for extracting meaningful error messages from HTTP responses
/// </summary>
public static class ErrorHelper
{
/// <summary>
/// Extract error message from HTTP response
/// </summary>
public static async Task<string> GetErrorMessageAsync(HttpResponseMessage response)
{
try
{
// Try to read as JSON error object
var errorObject = await response.Content.ReadFromJsonAsync<JsonElement>();
// Check for common error formats
if (errorObject.ValueKind == JsonValueKind.Object)
{
// Format: { error: "message" }
if (errorObject.TryGetProperty("error", out var errorProp) &&
errorProp.ValueKind == JsonValueKind.String)
{
var errorMessage = errorProp.GetString();
// If there's a details field, append it
if (errorObject.TryGetProperty("details", out var detailsProp) &&
detailsProp.ValueKind == JsonValueKind.String)
{
var details = detailsProp.GetString();
if (!string.IsNullOrWhiteSpace(details))
{
return $"{errorMessage}: {details}";
}
}
// If there's an errors array (validation errors), append them
if (errorObject.TryGetProperty("errors", out var errorsProp) &&
errorsProp.ValueKind == JsonValueKind.Array)
{
var errors = errorsProp.EnumerateArray()
.Where(e => e.ValueKind == JsonValueKind.String)
.Select(e => e.GetString())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList();
if (errors.Count > 0)
{
return $"{errorMessage}\n{string.Join("\n", errors)}";
}
}
return errorMessage ?? GetDefaultErrorMessage(response.StatusCode);
}
// Format: { message: "message" }
if (errorObject.TryGetProperty("message", out var messageProp) &&
messageProp.ValueKind == JsonValueKind.String)
{
return messageProp.GetString() ?? GetDefaultErrorMessage(response.StatusCode);
}
}
// Try to read as plain text
var textContent = await response.Content.ReadAsStringAsync();
if (!string.IsNullOrWhiteSpace(textContent))
{
return textContent;
}
}
catch
{
// If parsing fails, fall back to status code message
}
return GetDefaultErrorMessage(response.StatusCode);
}
/// <summary>
/// Get default error message based on HTTP status code
/// </summary>
private static string GetDefaultErrorMessage(HttpStatusCode statusCode)
{
return statusCode switch
{
HttpStatusCode.BadRequest => "Yêu cầu không hợp lệ. Vui lòng kiểm tra lại dữ liệu đầu vào.",
HttpStatusCode.Unauthorized => "Bạn chưa được xác thực. Vui lòng đăng nhập lại.",
HttpStatusCode.Forbidden => "Bạn không có quyền thực hiện thao tác này.",
HttpStatusCode.NotFound => "Không tìm thấy tài nguyên yêu cầu.",
HttpStatusCode.Conflict => "Xung đột dữ liệu. Có thể tài nguyên đã tồn tại.",
HttpStatusCode.InternalServerError => "Lỗi máy chủ. Vui lòng thử lại sau.",
HttpStatusCode.ServiceUnavailable => "Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.",
HttpStatusCode.RequestTimeout => "Yêu cầu quá thời gian chờ. Vui lòng thử lại.",
_ => $"Lỗi không xác định ({(int)statusCode} {statusCode})"
};
}
/// <summary>
/// Extract error message from exception
/// </summary>
public static string GetErrorMessage(Exception ex)
{
// For HttpRequestException, try to extract more meaningful message
if (ex is HttpRequestException httpEx)
{
// Check if it's a connection error
if (httpEx.Message.Contains("connection") || httpEx.Message.Contains("refused"))
{
return "Không thể kết nối đến máy chủ. Vui lòng kiểm tra kết nối mạng.";
}
// Check if it's a timeout
if (httpEx.Message.Contains("timeout"))
{
return "Yêu cầu quá thời gian chờ. Vui lòng thử lại.";
}
return httpEx.Message;
}
// For TaskCanceledException (often timeout)
if (ex is TaskCanceledException)
{
return "Yêu cầu quá thời gian chờ. Vui lòng thử lại.";
}
// For JsonException
if (ex is JsonException)
{
return "Lỗi xử lý dữ liệu từ máy chủ. Vui lòng thử lại.";
}
// For InvalidOperationException
if (ex is InvalidOperationException)
{
return ex.Message;
}
// Default: return exception message
return ex.Message;
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.Json" Version="10.0.3" />
<PackageReference Include="Blazor-ApexCharts" Version="4.0.0" />
<PackageReference Include="MudBlazor" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Shared\RobotNet10.NavigationTune.Shared\RobotNet10.NavigationTune.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,2 @@
@using Microsoft.AspNetCore.Components.Web
@using MudBlazor