Initial commit
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với BatteryHub
|
||||
/// </summary>
|
||||
public class BatteryHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public BatteryHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices/battery");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
return 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<BatteryState> GetBatteryDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<BatteryState?>("GetBatteryData", deviceId);
|
||||
return result ?? new BatteryState();
|
||||
}
|
||||
|
||||
public async Task<DeviceInfoDto?> GetDeviceInfoAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceInfoDto?>("GetDeviceInfo", deviceId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với CameraQrHub
|
||||
/// </summary>
|
||||
public class CameraQrHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public CameraQrHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices/cameraqr");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
return 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<CameraQrDataDto> GetCameraQrDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<CameraQrDataDto?>("GetCameraQrData", deviceId);
|
||||
return result ?? new CameraQrDataDto();
|
||||
}
|
||||
|
||||
public async Task<DeviceInfoDto?> GetDeviceInfoAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceInfoDto?>("GetDeviceInfo", deviceId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với CiA402ServoHub
|
||||
/// </summary>
|
||||
public class CiA402ServoHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public CiA402ServoHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices/cia402servo");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
return 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<CiA402ServoDataDto> GetServoDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<CiA402ServoDataDto?>("GetServoData", deviceId);
|
||||
return result ?? new CiA402ServoDataDto();
|
||||
}
|
||||
|
||||
public async Task<DeviceInfoDto?> GetDeviceInfoAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceInfoDto?>("GetDeviceInfo", deviceId);
|
||||
}
|
||||
|
||||
// State Machine Control Methods
|
||||
public async Task EnableOperationAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("EnableOperationAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task DisableOperationAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("DisableOperationAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task QuickStopAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("QuickStopAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task FaultResetAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("FaultResetAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task ShutdownAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("ShutdownAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task SwitchOnAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SwitchOnAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task EnableAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("EnableAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task DisableAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("DisableAsync", deviceId);
|
||||
}
|
||||
|
||||
// Operation Mode
|
||||
public async Task SetOperationModeAsync(string deviceId, string mode)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetOperationModeAsync", deviceId, mode);
|
||||
}
|
||||
|
||||
// Position Control
|
||||
public async Task SetTargetPositionAsync(string deviceId, int position)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetTargetPositionAsync", deviceId, position);
|
||||
}
|
||||
|
||||
public async Task MoveToPositionAsync(string deviceId, int position, uint velocity = 1000, uint acceleration = 5000, uint deceleration = 5000)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("MoveToPositionAsync", deviceId, position, velocity, acceleration, deceleration);
|
||||
}
|
||||
|
||||
// Velocity Control
|
||||
public async Task SetTargetVelocityAsync(string deviceId, int velocity)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetTargetVelocityAsync", deviceId, velocity);
|
||||
}
|
||||
|
||||
public async Task TargetVelocityAsync(string deviceId, int targetVelocity, uint acceleration = 5000, uint deceleration = 5000)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("TargetVelocityAsync", deviceId, targetVelocity, acceleration, deceleration);
|
||||
}
|
||||
|
||||
public async Task ProfileVelocityAsync(string deviceId, int targetVelocity, uint acceleration = 5000, uint deceleration = 5000)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("ProfileVelocityAsync", deviceId, targetVelocity, acceleration, deceleration);
|
||||
}
|
||||
|
||||
// Torque Control
|
||||
public async Task SetTargetTorqueAsync(string deviceId, short torque)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetTargetTorqueAsync", deviceId, torque);
|
||||
}
|
||||
|
||||
public async Task RunTorqueAsync(string deviceId, short torque)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("RunTorqueAsync", deviceId, torque);
|
||||
}
|
||||
|
||||
// Profile Settings
|
||||
public async Task SetProfileAccelerationAsync(string deviceId, uint acceleration)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetProfileAccelerationAsync", deviceId, acceleration);
|
||||
}
|
||||
|
||||
public async Task SetProfileDecelerationAsync(string deviceId, uint deceleration)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetProfileDecelerationAsync", deviceId, deceleration);
|
||||
}
|
||||
|
||||
public async Task SetProfileVelocityAsync(string deviceId, uint velocity)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetProfileVelocityAsync", deviceId, velocity);
|
||||
}
|
||||
|
||||
public async Task SetProfileSpeedAsync(string deviceId, uint velocity)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetProfileSpeedAsync", deviceId, velocity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ghi cùng lúc cả 3 profile (speed, acceleration, deceleration) xuống drive.
|
||||
/// </summary>
|
||||
public async Task SetProfileSettingsAsync(string deviceId, uint profileSpeed, uint profileAcceleration, uint profileDeceleration)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetProfileSettingsAsync", deviceId, profileSpeed, profileAcceleration, profileDeceleration);
|
||||
}
|
||||
|
||||
// Homing
|
||||
public async Task SetHomingMethodAsync(string deviceId, byte method)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetHomingMethodAsync", deviceId, method);
|
||||
}
|
||||
|
||||
public async Task SetHomingSpeedAsync(string deviceId, int speed)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetHomingSpeedAsync", deviceId, speed);
|
||||
}
|
||||
|
||||
public async Task SetHomingOffsetAsync(string deviceId, int offset)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetHomingOffsetAsync", deviceId, offset);
|
||||
}
|
||||
|
||||
public async Task StartHomingAsync(string deviceId, byte method, int speed)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("StartHomingAsync", deviceId, method, speed);
|
||||
}
|
||||
|
||||
/// <summary>Đọc lại homing method từ drive để kiểm tra đã ghi xuống chưa.</summary>
|
||||
public async Task<byte> GetHomingMethodAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<byte>("GetHomingMethodAsync", deviceId);
|
||||
}
|
||||
|
||||
/// <summary>Đọc lại homing speed từ drive để kiểm tra đã ghi xuống chưa.</summary>
|
||||
public async Task<int> GetHomingSpeedAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<int>("GetHomingSpeedAsync", deviceId);
|
||||
}
|
||||
|
||||
/// <summary>Đọc lại homing offset từ drive để kiểm tra đã ghi xuống chưa.</summary>
|
||||
public async Task<int> GetHomingOffsetAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<int>("GetHomingOffsetAsync", deviceId);
|
||||
}
|
||||
|
||||
// Position Control - Additional Methods
|
||||
public async Task StartPositionMoveAsync(string deviceId)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("StartPositionMoveAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task WaitUntilAtTargetAsync(string deviceId, int tolerance = 100, bool useStatusword = true, int checkIntervalMs = 10)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("WaitUntilAtTargetAsync", deviceId, tolerance, useStatusword, checkIntervalMs);
|
||||
}
|
||||
|
||||
// Error and Status Information
|
||||
public async Task<bool> IsInFaultStateAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("IsInFaultStateAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<byte> GetErrorRegisterAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<byte>("GetErrorRegisterAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<ushort[]> GetErrorHistoryAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<ushort[]>("GetErrorHistoryAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<ushort> GetLatestErrorCodeAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<ushort>("GetLatestErrorCodeAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<bool> TryFaultResetAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("TryFaultResetAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<bool> IsEnabledAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("IsEnabledAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<bool> IsReadyAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("IsReadyAsync", deviceId);
|
||||
}
|
||||
|
||||
// Statusword & Controlword
|
||||
public async Task<ushort> GetStatuswordAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<ushort>("GetStatuswordAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task SetControlwordAsync(string deviceId, ushort controlwordValue)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetControlwordAsync", deviceId, controlwordValue);
|
||||
}
|
||||
|
||||
public async Task<string> GetStateAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<string>("GetStateAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<string> GetOperationModeAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<string>("GetOperationModeAsync", deviceId);
|
||||
}
|
||||
|
||||
// Position, Velocity, Torque
|
||||
public async Task<int> GetActualPositionAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<int>("GetActualPositionAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<int> GetActualVelocityAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<int>("GetActualVelocityAsync", deviceId);
|
||||
}
|
||||
|
||||
public async Task<short> GetActualTorqueAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<short>("GetActualTorqueAsync", deviceId);
|
||||
}
|
||||
|
||||
// Target Position Checking
|
||||
public async Task<bool> IsAtTargetAsync(string deviceId, int tolerance = 100, bool useStatusword = true)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("IsAtTarget", deviceId, tolerance, useStatusword);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với InertialMeasurementUnitHub
|
||||
/// </summary>
|
||||
public class InertialMeasurementUnitHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public InertialMeasurementUnitHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices/inertialmeasurementunit");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
return 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<Imu> GetImuDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<Imu?>("GetImuData", deviceId);
|
||||
return result ?? new Imu();
|
||||
}
|
||||
|
||||
public async Task<Imu> ReadAllDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<Imu?>("ReadAllData", deviceId);
|
||||
return result ?? new Imu();
|
||||
}
|
||||
|
||||
public async Task<DeviceInfoDto?> GetDeviceInfoAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceInfoDto?>("GetDeviceInfo", deviceId);
|
||||
}
|
||||
|
||||
public async Task<Dictionary<string, string>?> GetDevicePropertiesAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<Dictionary<string, string>?>("GetDeviceProperties", deviceId);
|
||||
}
|
||||
|
||||
public async Task<List<PropertyDescription>?> GetDevicePropertyDescriptionsAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<List<PropertyDescription>?>("GetDevicePropertyDescriptions", deviceId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.Shared.Sensor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với LidarHub
|
||||
/// </summary>
|
||||
public class LidarHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public LidarHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices/lidar");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
return 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<LaserScan> GetLidarDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<LaserScan?>("GetLidarData", deviceId);
|
||||
return result ?? new LaserScan();
|
||||
}
|
||||
|
||||
public async Task<DeviceInfoDto?> GetDeviceInfoAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceInfoDto?>("GetDeviceInfo", deviceId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.Shared;
|
||||
using RobotNet10.Shared.Detection;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
public class MarkerDetectorHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public MarkerDetectorHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("hubs/marker-detect");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
return 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<MessageResult<Guid>> CreateSessionAsync(MarkersSearchRequest request)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<MessageResult<Guid>>("CreateSession", request);
|
||||
}
|
||||
|
||||
public async Task<MessageResult<Pose>> GetGoalAsync(Guid sessionId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<MessageResult<Pose>>("GetGoal", sessionId);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
using RobotNet10.Shared;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với ModbusTcpHub
|
||||
/// </summary>
|
||||
public class ModbusTcpHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public ModbusTcpHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices/modbustcp");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
return 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<ModbusTcpData> GetModbusDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<ModbusTcpData?>("GetModbusData", deviceId);
|
||||
return result ?? new ModbusTcpData();
|
||||
}
|
||||
|
||||
public async Task<DeviceInfoDto?> GetDeviceInfoAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceInfoDto?>("GetDeviceInfo", deviceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ghi một coil vào device
|
||||
/// </summary>
|
||||
/// <param name="deviceId">Device ID</param>
|
||||
/// <param name="address">Địa chỉ coil</param>
|
||||
/// <param name="value">Giá trị cần ghi (true/false)</param>
|
||||
/// <returns>True nếu thành công, false nếu thất bại</returns>
|
||||
public async Task<bool> WriteCoilAsync(string deviceId, ushort address, bool value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("WriteCoil", deviceId, address, value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Motion;
|
||||
using RobotNet10.RobotApp.Client.Shared.Modules;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client thống nhất để kết nối với MotionHub
|
||||
/// Bao gồm: ManualControl, Odometry, LiftModule, RotationModule
|
||||
/// </summary>
|
||||
public class MotionHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when odometry pose changes
|
||||
/// </summary>
|
||||
public event Action<OdometryDto>? PoseUpdated;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when connection state changes
|
||||
/// </summary>
|
||||
public event Action<HubConnectionState>? ConnectionStateChanged;
|
||||
|
||||
public MotionHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/motion");
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
// Subscribe to pose updates from server
|
||||
_hubConnection.On<OdometryDto>("PoseUpdated", dto => PoseUpdated?.Invoke(dto));
|
||||
|
||||
_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;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect to hub
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect from hub
|
||||
/// </summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#region Manual Control
|
||||
|
||||
/// <summary>
|
||||
/// Get current status
|
||||
/// </summary>
|
||||
public async Task<ManualControlStatusDto> GetStatusAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<ManualControlStatusDto>("GetStatus");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enable manual control
|
||||
/// </summary>
|
||||
public async Task EnableAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("Enable");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disable manual control
|
||||
/// </summary>
|
||||
public async Task DisableAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("Disable");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Odometry
|
||||
|
||||
/// <summary>
|
||||
/// Get current odometry pose
|
||||
/// </summary>
|
||||
public async Task<OdometryDto> GetCurrentPoseAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<OdometryDto>("GetCurrentPose");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset odometry to initial position
|
||||
/// </summary>
|
||||
public async Task ResetOdometryAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("ResetOdometry");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset odometry to a specific pose
|
||||
/// </summary>
|
||||
public async Task ResetOdometryPoseAsync(OdometryDto dto)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("ResetOdometryPose", dto);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Lift Module
|
||||
|
||||
/// <summary>
|
||||
/// Get lift module status
|
||||
/// </summary>
|
||||
public async Task<LiftModuleStatusDto> GetLiftStatusAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<LiftModuleStatusDto>("GetLiftStatus");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current lift position
|
||||
/// </summary>
|
||||
public async Task<int> GetLiftCurrentPositionAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<int>("GetLiftCurrentPosition");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lift up
|
||||
/// </summary>
|
||||
public async Task LiftUpAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("LiftUp");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lift down
|
||||
/// </summary>
|
||||
public async Task LiftDownAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("LiftDown");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop lift movement (when moving up/down)
|
||||
/// </summary>
|
||||
public async Task LiftStopAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("LiftStop");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Move lift to position
|
||||
/// </summary>
|
||||
public async Task LiftToPositionAsync(int position)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("LiftToPosition", position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if lift module is homed
|
||||
/// </summary>
|
||||
public async Task<bool> IsLiftHomedAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("IsLiftHomed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual homing for lift module
|
||||
/// </summary>
|
||||
public async Task LiftHomeAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("LiftHome");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rotation Module
|
||||
|
||||
/// <summary>
|
||||
/// Get rotation module status
|
||||
/// </summary>
|
||||
public async Task<RotationModuleStatusDto> GetRotationStatusAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<RotationModuleStatusDto>("GetRotationStatus");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current rotation angle
|
||||
/// </summary>
|
||||
public async Task<double> GetRotationCurrentAngleAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<double>("GetRotationCurrentAngle");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotate to absolute angle
|
||||
/// </summary>
|
||||
public async Task RotateToAngleAsync(double angleDegrees)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("RotateToAngle", angleDegrees);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotate relative offset
|
||||
/// </summary>
|
||||
public async Task RotateOffsetAsync(double angleOffsetDegrees)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("RotateOffset", angleOffsetDegrees);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if rotation module is homed
|
||||
/// </summary>
|
||||
public async Task<bool> IsRotationHomedAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("IsRotationHomed");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Shared.NavigationMonitor;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
public class NavigationMonitorHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public event Action<NavigationTelemetryDto>? TelemetryReceived;
|
||||
public event Action<NavigationSafetyViolationDto>? SafetyViolationReceived;
|
||||
public event Action<NavigationMonitorStateDto>? MonitorStateChanged;
|
||||
public event Action<HubConnectionState>? ConnectionStateChanged;
|
||||
|
||||
public NavigationMonitorHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/motion/navigation-monitor");
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<NavigationTelemetryDto>("ReceiveTelemetry", dto => TelemetryReceived?.Invoke(dto));
|
||||
_hubConnection.On<NavigationSafetyViolationDto>("ReceiveSafetyViolation", dto => SafetyViolationReceived?.Invoke(dto));
|
||||
_hubConnection.On<NavigationMonitorStateDto>("ReceiveMonitorState", dto => MonitorStateChanged?.Invoke(dto));
|
||||
|
||||
_hubConnection.Reconnecting += async (_) =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += async (_) =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
// Re-subscribe after reconnect
|
||||
await _hubConnection.InvokeAsync("Subscribe");
|
||||
};
|
||||
|
||||
_hubConnection.Closed += async (_) =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
await _hubConnection.InvokeAsync("Subscribe");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
try { await _hubConnection.InvokeAsync("Unsubscribe"); } catch { }
|
||||
await _hubConnection.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetTelemetryEnabledAsync(bool enabled)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetTelemetryEnabled", enabled);
|
||||
}
|
||||
|
||||
public async Task SetSafetyStopEnabledAsync(bool enabled)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetSafetyStopEnabled", enabled);
|
||||
}
|
||||
|
||||
public async Task UpdateSafetyConfigAsync(NavigationSafetyConfigDto config)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("UpdateSafetyConfig", config);
|
||||
}
|
||||
|
||||
public async Task ReleaseSafetyStopAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("ReleaseSafetyStop");
|
||||
}
|
||||
|
||||
public async Task<NavigationMonitorStateDto> GetStateAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<NavigationMonitorStateDto>("GetState");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Motion;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client kết nối tới OdometryHub - hiển thị odom (pose + velocity) realtime
|
||||
/// </summary>
|
||||
public class OdometryHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Sự kiện nhận odometry mới (broadcast từ server ~10 Hz)
|
||||
/// </summary>
|
||||
public event Action<OdometryDto>? OdometryReceived;
|
||||
|
||||
/// <summary>
|
||||
/// Trạng thái kết nối thay đổi
|
||||
/// </summary>
|
||||
public event Action<HubConnectionState>? ConnectionStateChanged;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public OdometryHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/odometry");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<OdometryDto>("ReceiveOdometry", dto => OdometryReceived?.Invoke(dto));
|
||||
|
||||
_hubConnection.Reconnecting += _ =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
_hubConnection.Reconnected += _ =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
_hubConnection.Closed += _ =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy odometry hiện tại (on-demand)
|
||||
/// </summary>
|
||||
public async Task<OdometryDto?> GetCurrentOdometryAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<OdometryDto?>("GetCurrentOdometry");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Plc;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với PlcControllerHub (read-only)
|
||||
/// </summary>
|
||||
public class PlcControllerHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when connection state changes
|
||||
/// </summary>
|
||||
public event Action<HubConnectionState>? ConnectionStateChanged;
|
||||
|
||||
public PlcControllerHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/plc/controller");
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_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;
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connect to hub
|
||||
/// </summary>
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnect from hub
|
||||
/// </summary>
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current status
|
||||
/// </summary>
|
||||
public async Task<PlcControllerStatusDto> GetStatusAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<PlcControllerStatusDto>("GetStatus");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
public class RfHandleHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public RfHandleHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices/rfhandle");
|
||||
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnecting += async (ex) =>
|
||||
{
|
||||
// optionally notify UI
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
_hubConnection.Reconnected += async (id) =>
|
||||
{
|
||||
// optionally notify UI
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
_hubConnection.Closed += async (ex) =>
|
||||
{
|
||||
// optionally notify UI
|
||||
await Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
// CONNECT / DISCONNECT
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
await _hubConnection.StopAsync();
|
||||
}
|
||||
|
||||
// SERVER CALLS (RPC)
|
||||
public async Task<RfHandleDataDto> GetRfHandleDataAsync(string deviceId)
|
||||
{
|
||||
var result = await _hubConnection.InvokeAsync<RfHandleDataDto?>("GetRfHandleData", deviceId);
|
||||
return result ?? new RfHandleDataDto();
|
||||
}
|
||||
|
||||
public async Task<DeviceInfoDto?> GetDeviceInfoAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceInfoDto?>("GetDeviceInfo", deviceId);
|
||||
}
|
||||
|
||||
// DISPOSE
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
// stop but don't throw
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
}
|
||||
catch { }
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.SLAM;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
using RobotNet10.Shared.Numbers;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Clients;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với CartographerHub
|
||||
/// </summary>
|
||||
public class SLAMClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
// Events từ server
|
||||
public event Action<SLAMState>? StateChanged;
|
||||
public event Action<int, int, int>? MapSaveProgressChanged;
|
||||
public event Action<string, bool>? IsProcessingChanged;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
// Cached state
|
||||
public SLAMState? CurrentState { get; private set; }
|
||||
public PoseDto? CurrentPose { get; private set; }
|
||||
|
||||
public SLAMClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/slam");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
// Subscribe to server events
|
||||
_hubConnection.On<SLAMState>("OnStateChanged", state =>
|
||||
{
|
||||
CurrentState = state;
|
||||
StateChanged?.Invoke(state);
|
||||
});
|
||||
|
||||
_hubConnection.On<int, int, int>("OnMapSaveProgress", (workItemsAdded, workItemsCompleted, percentComplete) =>
|
||||
{
|
||||
MapSaveProgressChanged?.Invoke(workItemsAdded, workItemsCompleted, percentComplete);
|
||||
});
|
||||
|
||||
_hubConnection.On<string, bool>("OnMapProcessingChanged", (mapName, isProcessing) =>
|
||||
{
|
||||
IsProcessingChanged?.Invoke(mapName, isProcessing);
|
||||
});
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
// Refresh state after reconnect
|
||||
_ = RefreshStateAsync();
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
// Get initial state
|
||||
await RefreshStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh current state from server
|
||||
/// </summary>
|
||||
public async Task RefreshStateAsync()
|
||||
{
|
||||
if (IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
CurrentState = await _hubConnection.InvokeAsync<SLAMState>("GetCurrentState");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Server method invocations
|
||||
|
||||
public async Task<SLAMState> GetCurrentStateAsync()
|
||||
{
|
||||
var state = await _hubConnection.InvokeAsync<SLAMState>("GetCurrentState");
|
||||
CurrentState = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy tên map hiện tại đang được sử dụng
|
||||
/// </summary>
|
||||
public async Task<string?> GetCurrentMapAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<string?>("GetCurrentMap");
|
||||
}
|
||||
|
||||
public async Task<bool> StartLocalizationAsync(string mapName, PoseDto? initialPose = null)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("StartLocalization", mapName, initialPose);
|
||||
}
|
||||
|
||||
public async Task StopLocalizationAsync()
|
||||
{
|
||||
await _hubConnection.InvokeAsync("StopLocalization");
|
||||
}
|
||||
|
||||
public async Task<bool> StartScanMappingAsync(string mapName)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("StartScanMapping", mapName);
|
||||
}
|
||||
|
||||
public async Task<string?> SaveMapAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<string?>("SaveMap");
|
||||
}
|
||||
|
||||
public async Task<MapInfoDto[]> ListMapsAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<MapInfoDto[]>("ListMaps");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy thông tin map và đăng ký nhận cập nhật trạng thái xử lý
|
||||
/// </summary>
|
||||
/// <param name="mapName">Tên map</param>
|
||||
/// <returns>MapInfoDto với trạng thái IsProcessing</returns>
|
||||
public async Task<MapInfoDto?> GetMapInfoAndSubscribeProcessingAsync(string mapName)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<MapInfoDto?>("GetMapInfoAndSubscribeProcessing", mapName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hủy đăng ký nhận cập nhật trạng thái xử lý
|
||||
/// </summary>
|
||||
/// <param name="mapName">Tên map</param>
|
||||
public async Task UnsubscribeMapProcessingAsync(string mapName)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("UnsubscribeMapProcessing", mapName);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteMapAsync(string mapName)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("DeleteMap", mapName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Transform map origin to a new pose
|
||||
/// </summary>
|
||||
/// <param name="mapName">Name of the map to transform</param>
|
||||
/// <param name="newOrigin">New origin pose (position and orientation)</param>
|
||||
/// <returns>True if transform was successful</returns>
|
||||
public async Task<bool> TransformMapOriginAsync(string mapName, PoseDto newOrigin)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("TransformMapOrigin", mapName, newOrigin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rerender map image files (PNG, JPG, PGM) with custom OccupancyGridConfiguration.
|
||||
/// This is an async operation - returns true if processing started successfully.
|
||||
/// Monitor IsProcessingChanged event for completion notification.
|
||||
/// </summary>
|
||||
/// <param name="mapName">Name of the map to rerender</param>
|
||||
/// <param name="config">Custom occupancy grid configuration</param>
|
||||
/// <returns>True if rerender was started successfully</returns>
|
||||
public async Task<bool> RerenderMapWithConfigAsync(string mapName, OccupancyGridConfigurationDto config)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<bool>("RerenderMapWithConfig", mapName, config);
|
||||
}
|
||||
|
||||
public async Task SetInitialPoseAsync(PoseDto pose)
|
||||
{
|
||||
await _hubConnection.InvokeAsync("SetInitialPose", pose);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy occupancy grid mới hơn thời gian chỉ định
|
||||
/// </summary>
|
||||
/// <param name="since">Thời gian để so sánh</param>
|
||||
/// <returns>OccupancyGridDto nếu có update mới hơn, null nếu không có</returns>
|
||||
public async Task<OccupancyGridDto?> GetOccupancyGridAsync(DateTime since)
|
||||
{
|
||||
var grid = await _hubConnection.InvokeAsync<OccupancyGridDto?>("GetOccupancyGrid", since);
|
||||
return grid;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy pose hiện tại của robot
|
||||
/// </summary>
|
||||
/// <returns>PoseDto nếu có pose, null nếu không có</returns>
|
||||
public async Task<PoseDto?> GetCurrentPoseAsync()
|
||||
{
|
||||
var pose = await _hubConnection.InvokeAsync<PoseDto?>("GetCurrentPose");
|
||||
if (pose != null)
|
||||
{
|
||||
CurrentPose = pose;
|
||||
}
|
||||
return pose;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy sample point cloud từ tất cả lidar devices (trong global frame)
|
||||
/// </summary>
|
||||
/// <returns>Danh sách Point32 trong global frame</returns>
|
||||
public async Task<RobotNet10.Shared.Numbers.Vector3[]> GetSamplePointCloudAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<RobotNet10.Shared.Numbers.Vector3[]>("GetSamplePointCloud") ?? [];
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.Shared.Sensor
|
||||
@implements IAsyncDisposable
|
||||
@inject BatteryHubClient BatteryHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload battery data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadBatteryDataAsync"
|
||||
Disabled="@(!BatteryHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
|
||||
<!-- LEFT COLUMN : SVG BATTERY -->
|
||||
<MudItem xs="12" md="4">
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
|
||||
@* =================== SVG KHÔNG ĐỔI =================== *@
|
||||
<svg width="100%" height="100%" viewBox="0 0 200 300" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
|
||||
<defs>
|
||||
<linearGradient id="@($"healthGradient_{DeviceId}_{(int)BatteryData.Percentage}")" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:@GetHealthColor();stop-opacity:0.95" />
|
||||
<stop offset="50%" style="stop-color:@GetHealthColor();stop-opacity:0.85" />
|
||||
<stop offset="100%" style="stop-color:@GetHealthColor();stop-opacity:0.75" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect x="40" y="20" width="120" height="200" rx="8" ry="8"
|
||||
fill="none" stroke="currentColor" stroke-width="4"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
|
||||
<rect x="80" y="0" width="40" height="20" rx="4" ry="4"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
|
||||
<clipPath id="batteryClip">
|
||||
<rect x="42" y="22" width="116" height="196" rx="6" ry="6" />
|
||||
</clipPath>
|
||||
|
||||
@* ==== Charge Level Fill ==== *@
|
||||
@{
|
||||
var clipHeight = 196.0;
|
||||
var clipY = 22.0;
|
||||
var clipBottom = clipY + clipHeight;
|
||||
var healthHeight = BatteryData.Percentage / 100.0 * clipHeight;
|
||||
var healthY = clipBottom - healthHeight;
|
||||
}
|
||||
|
||||
<g clip-path="url(#batteryClip)">
|
||||
<rect x="42" y="@healthY"
|
||||
width="116" height="@healthHeight"
|
||||
rx="6" ry="6"
|
||||
fill="@($"url(#healthGradient_{DeviceId}_{(int)BatteryData.Percentage})")"
|
||||
opacity="0.9" />
|
||||
|
||||
<ellipse cx="100" cy="@(healthY + 6)"
|
||||
rx="40" ry="8"
|
||||
fill="white" opacity="0.3">
|
||||
<animate attributeName="cy" dur="3s" repeatCount="indefinite"
|
||||
values="@(healthY + 6);@(healthY + 12);@(healthY + 6)" />
|
||||
<animate attributeName="opacity" dur="3s" repeatCount="indefinite"
|
||||
values="0.3;0.4;0.3" />
|
||||
</ellipse>
|
||||
</g>
|
||||
|
||||
<text x="100" y="140"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
font-size="48" font-weight="bold"
|
||||
fill="@GetHealthTextColor()">
|
||||
@((int)BatteryData.Percentage)%
|
||||
</text>
|
||||
|
||||
<text x="100" y="180"
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
font-size="16"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-secondary);">
|
||||
Charge Level
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
<!-- RIGHT COLUMN -->
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
|
||||
<!-- HEALTH -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@GetHealthPercentage()%</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Health (SOH)</MudText>
|
||||
<MudProgressLinear Value="@GetHealthPercentage()" Color="@GetChargeLevelColor()" Class="mt-2" />
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- VOLTAGE -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.Voltage.ToString("F2") V</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Voltage</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- CURRENT -->
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4" Color="@GetCurrentColor()">@BatteryData.Current.ToString("F2") A</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Current</MudText>
|
||||
<MudChip T="string" Color="@(IsCharging()? Color.Success: Color.Default)" Size="Size.Small" Class="mt-2">
|
||||
@(IsCharging() ? "Charging" : "Discharging")
|
||||
</MudChip>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- TEMPERATURE -->
|
||||
@if (BatteryData.CellTemperature != null && BatteryData.CellTemperature.Length > 0)
|
||||
{
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.CellTemperature[0].ToString("F1") °C</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Temperature</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- REMAIN CAPACITY -->
|
||||
@if (!double.IsNaN(BatteryData.Charge))
|
||||
{
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.Charge.ToString("F2") Ah</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Remaining Capacity</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- FULL CAPACITY -->
|
||||
@if (!double.IsNaN(BatteryData.Capacity))
|
||||
{
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h4">@BatteryData.Capacity.ToString("F2") Ah</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Full Capacity</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- TIMESTAMP -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Last Update: @BatteryData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
|
||||
private BatteryState BatteryData = new();
|
||||
|
||||
private bool IsLoading => !BatteryHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
=> await base.OnInitializedAsync();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
await ConnectAsync();
|
||||
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await BatteryHubClient.StartAsync();
|
||||
var deviceInfo = await BatteryHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
DeviceName = deviceInfo?.DeviceName ?? DeviceId;
|
||||
|
||||
BatteryData = await BatteryHubClient.GetBatteryDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await BatteryHubClient.StopAsync();
|
||||
BatteryData = new BatteryState();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadBatteryDataAsync()
|
||||
{
|
||||
if (!BatteryHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
BatteryData = await BatteryHubClient.GetBatteryDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload battery data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetChargeLevelColor() =>
|
||||
GetHealthPercentage() switch
|
||||
{
|
||||
>= 70 => Color.Success,
|
||||
>= 30 => Color.Warning,
|
||||
_ => Color.Error
|
||||
};
|
||||
|
||||
private Color GetCurrentColor() =>
|
||||
IsCharging() ? Color.Success : Color.Info;
|
||||
|
||||
private string GetHealthColor() =>
|
||||
BatteryData.Percentage switch
|
||||
{
|
||||
>= 70 => "#4caf50",
|
||||
>= 30 => "#ff9800",
|
||||
_ => "#f44336"
|
||||
};
|
||||
|
||||
private bool IsCharging() =>
|
||||
BatteryData.PowerSupplyStatus == BatteryState.PowerSupplyStatusCharging;
|
||||
|
||||
private double GetHealthPercentage()
|
||||
{
|
||||
return BatteryData.PowerSupplyHealth switch
|
||||
{
|
||||
BatteryState.PowerSupplyHealthGood => 100.0,
|
||||
BatteryState.PowerSupplyHealthOverheat => 50.0,
|
||||
BatteryState.PowerSupplyHealthDead => 0.0,
|
||||
BatteryState.PowerSupplyHealthOvervoltage => 30.0,
|
||||
BatteryState.PowerSupplyHealthUnspecifiedFailure => 20.0,
|
||||
BatteryState.PowerSupplyHealthCold => 40.0,
|
||||
_ => 0.0
|
||||
};
|
||||
}
|
||||
|
||||
private string GetHealthTextColor() =>
|
||||
"var(--mud-palette-text-primary)";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
=> await DisconnectAsync();
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
|
||||
@inject CameraQrHubClient CameraQrHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload Camera QR data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadCameraQrDataAsync"
|
||||
Disabled="@(!CameraQrHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;" Class="pa-2">
|
||||
<MudGrid Spacing="2">
|
||||
<!-- Left Column: Camera Frame Visualization -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary" Class="mb-2">Camera Frame</MudText>
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 640 480" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 400px; border: 2px solid var(--mud-palette-divider); border-radius: 4px;">
|
||||
<defs>
|
||||
<!-- Gradient cho camera frame -->
|
||||
<linearGradient id="@($"cameraGradient_{DeviceId}")" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#2a2a2a;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#1a1a1a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<!-- Filter cho glow effect -->
|
||||
<filter id="@($"glow_{DeviceId}")">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Camera frame background -->
|
||||
<rect x="0" y="0" width="640" height="480" fill="url(@($"#cameraGradient_{DeviceId}"))" />
|
||||
|
||||
</svg>
|
||||
</div>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right Column: Data -->
|
||||
<MudItem xs="12" md="6">
|
||||
<MudGrid Spacing="1">
|
||||
<!-- Connection Status -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudGrid Spacing="1">
|
||||
<MudItem xs="12" sm="6">
|
||||
<MudChip T="string" Color="@(CameraQrData.IsConnected ? Color.Success : Color.Error)"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Filled">
|
||||
@(CameraQrData.IsConnected ? "Connected" : "Disconnected")
|
||||
</MudChip>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Detection Status -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary" Class="mb-1">Detection Status</MudText>
|
||||
<MudGrid Spacing="1">
|
||||
<MudItem xs="12">
|
||||
@if (CameraQrData.Codes is { Count: > 0 })
|
||||
{
|
||||
<MudTable Dense="true" Hover="true" Bordered="true" Elevation="0" Items="CameraQrData.Codes">
|
||||
<HeaderContent>
|
||||
<MudTh>Code</MudTh>
|
||||
<MudTh>X (m)</MudTh>
|
||||
<MudTh>Y (m)</MudTh>
|
||||
<MudTh>Z (m)</MudTh>
|
||||
<MudTh>Yaw (deg)</MudTh>
|
||||
<MudTh>Time</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate Context="kv">
|
||||
<MudTd>@kv.Key</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Position.X.ToString("F3")</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Position.Y.ToString("F3")</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Position.Z.ToString("F3")</MudTd>
|
||||
<MudTd>@kv.Value.Pose.Orientation.ToYawDegrees().ToString("F1")</MudTd>
|
||||
<MudTd>@kv.Value.Header.Stamp.ToLocalTime().ToString("HH:mm:ss")</MudTd>
|
||||
</RowTemplate>
|
||||
<FooterContent>
|
||||
<MudTd ColSpan="6">
|
||||
Total: @CameraQrData.Codes.Count code(s)
|
||||
</MudTd>
|
||||
</FooterContent>
|
||||
</MudTable>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">
|
||||
No QR detected.
|
||||
</MudText>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private CameraQrDataDto CameraQrData = new();
|
||||
private bool IsLoading => !CameraQrHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CameraQrHubClient.StartAsync();
|
||||
|
||||
// Lấy DeviceName từ CameraQrHub
|
||||
var deviceInfo = await CameraQrHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
if (deviceInfo != null)
|
||||
{
|
||||
DeviceName = deviceInfo.DeviceName;
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceName = DeviceId; // Fallback to DeviceId if not found
|
||||
}
|
||||
|
||||
CameraQrData = await CameraQrHubClient.GetCameraQrDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CameraQrHubClient.StopAsync();
|
||||
CameraQrData = new CameraQrDataDto(); // Reset về giá trị mặc định
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadCameraQrDataAsync()
|
||||
{
|
||||
if (!CameraQrHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
CameraQrData = await CameraQrHubClient.GetCameraQrDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload Camera QR data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetConfidenceColor(double confidence)
|
||||
{
|
||||
if (confidence >= 0.9)
|
||||
{
|
||||
return Color.Success;
|
||||
}
|
||||
else if (confidence >= 0.7)
|
||||
{
|
||||
return Color.Info;
|
||||
}
|
||||
else if (confidence >= 0.5)
|
||||
{
|
||||
return Color.Warning;
|
||||
}
|
||||
else
|
||||
{
|
||||
return Color.Error;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,215 @@
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using MudBlazor
|
||||
|
||||
<MudCard Elevation="1" Class="mb-2">
|
||||
<MudCardContent Class="pa-2">
|
||||
@* Compact Header *@
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1" Class="mb-1">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@GetDeviceIcon()" Color="@GetStatusColor()" Size="Size.Small" />
|
||||
<div>
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 600; line-height: 1.2;">@Device.DeviceName</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="line-height: 1;">@Device.DeviceId</MudText>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudChip T="string" Size="Size.Small" Color="@GetStatusColor()" Variant="Variant.Filled" Style="font-size: 0.7rem;">
|
||||
@Device.Status.ToString()
|
||||
</MudChip>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Settings"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Variant="Variant.Text"
|
||||
OnClick="NavigateToDeviceManagement"
|
||||
title="Manage Device" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@* Compact Info Grid - 2 columns *@
|
||||
<MudGrid Spacing="1" Class="mt-1">
|
||||
@* Device Type *@
|
||||
<MudItem xs="6">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Type:</MudText>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Style="font-size: 0.7rem; height: 20px;">@Device.DeviceType</MudChip>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Connection Status *@
|
||||
<MudItem xs="6">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Status:</MudText>
|
||||
<MudIcon Icon="@(Device.IsConnected ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.Cancel)"
|
||||
Color="@(Device.IsConnected ? Color.Success : Color.Error)"
|
||||
Size="Size.Small" Style="width: 16px; height: 16px;" />
|
||||
<MudText Typo="Typo.caption">@(Device.IsConnected ? "Yes" : "No")</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Last Update - Compact format *@
|
||||
<MudItem xs="12">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Updated:</MudText>
|
||||
<MudText Typo="Typo.caption">@Device.LastUpdateTime.ToString("MM-dd HH:mm:ss")</MudText>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
@* Reconnect Attempts - Only show if > 0 *@
|
||||
@if (Device.ReconnectAttemptCount > 0)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="min-width: 60px;">Retries:</MudText>
|
||||
<MudChip T="int" Size="Size.Small" Color="Color.Warning" Style="font-size: 0.7rem; height: 20px;">@Device.ReconnectAttemptCount</MudChip>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
|
||||
@* Error Message - Compact *@
|
||||
@if (!string.IsNullOrWhiteSpace(Device.LastError))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="true" Class="mt-1" Style="padding: 4px 8px;">
|
||||
<MudText Typo="Typo.caption" Style="line-height: 1.2;">@Device.LastError</MudText>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* Properties - Collapsed by default, only show count *@
|
||||
@if (Device.Properties.Any())
|
||||
{
|
||||
<MudExpansionPanels Dense="true" Class="mt-1">
|
||||
<MudExpansionPanel Text="@($"Properties ({Device.Properties.Count})")" Icon="@Icons.Material.Filled.Info" Style="font-size: 0.75rem;">
|
||||
<MudSimpleTable Dense="true" Style="font-size: 0.7rem;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 4px;">Property</th>
|
||||
<th style="padding: 4px;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var prop in GetDisplayedProperties().Take(5))
|
||||
{
|
||||
<tr>
|
||||
<td style="padding: 2px 4px;">
|
||||
<MudText Typo="Typo.caption">@prop.DisplayName</MudText>
|
||||
</td>
|
||||
<td style="padding: 2px 4px;">
|
||||
@if (Device.Properties.TryGetValue(prop.Key, out var value))
|
||||
{
|
||||
<MudText Typo="Typo.caption">
|
||||
@FormatPropertyValue(value, prop)
|
||||
</MudText>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (Device.Properties.Count > 5)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 2px 4px; text-align: center;">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
... and @(Device.Properties.Count - 5) more
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public DeviceDto Device { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private NavigationManager NavigationManager { get; set; } = null!;
|
||||
|
||||
private void NavigateToDeviceManagement()
|
||||
{
|
||||
var deviceType = GetDeviceTypeRoute(Device.DeviceType);
|
||||
var deviceId = Uri.EscapeDataString(Device.DeviceId);
|
||||
NavigationManager.NavigateTo($"/devices/{deviceType}/{deviceId}");
|
||||
}
|
||||
|
||||
private string GetDeviceTypeRoute(DeviceType deviceType)
|
||||
{
|
||||
return deviceType switch
|
||||
{
|
||||
DeviceType.Lidar => "lidar",
|
||||
DeviceType.Imu => "imu",
|
||||
DeviceType.Battery => "battery",
|
||||
DeviceType.ModbusTcp => "modbustcp",
|
||||
DeviceType.RfHandle => "rfhandle",
|
||||
DeviceType.CameraQr => "cameraqr",
|
||||
DeviceType.CiA402Servo => "cia402servo",
|
||||
_ => deviceType.ToString().ToLowerInvariant()
|
||||
};
|
||||
}
|
||||
|
||||
private string GetDeviceIcon()
|
||||
{
|
||||
return Device.DeviceType switch
|
||||
{
|
||||
DeviceType.Lidar => Icons.Material.Filled.Radar,
|
||||
DeviceType.Imu => Icons.Material.Filled.Explore,
|
||||
DeviceType.Battery => Icons.Material.Filled.BatteryFull,
|
||||
DeviceType.ModbusTcp => Icons.Material.Filled.Lan,
|
||||
DeviceType.RfHandle => Icons.Material.Filled.RadioButtonChecked,
|
||||
DeviceType.CameraQr => Icons.Material.Filled.QrCodeScanner,
|
||||
DeviceType.CiA402Servo => Icons.Material.Filled.Settings,
|
||||
_ => Icons.Material.Filled.Devices
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetStatusColor()
|
||||
{
|
||||
return Device.Status switch
|
||||
{
|
||||
DeviceStatus.Connected => Color.Success,
|
||||
DeviceStatus.Connecting => Color.Info,
|
||||
DeviceStatus.Disconnecting => Color.Warning,
|
||||
DeviceStatus.Disconnected => Color.Default,
|
||||
DeviceStatus.Reconnecting => Color.Warning,
|
||||
DeviceStatus.Error => Color.Error,
|
||||
DeviceStatus.Initializing => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private IEnumerable<PropertyDescription> GetDisplayedProperties()
|
||||
{
|
||||
return Device.PropertyDescriptions
|
||||
.OrderBy(p => p.DisplayOrder)
|
||||
.ThenBy(p => p.Category ?? "")
|
||||
.ThenBy(p => p.DisplayName);
|
||||
}
|
||||
|
||||
private string FormatPropertyValue(string value, PropertyDescription prop)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "-";
|
||||
|
||||
if (prop.DataType == "number" && double.TryParse(value, out var numValue))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(prop.Format))
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.Format(prop.Format, numValue) + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@using RobotNet10.Shared.Sensor
|
||||
@using RobotNet10.Shared.Geometry
|
||||
@using RobotNet10.Shared.Numbers
|
||||
@using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion
|
||||
@using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion
|
||||
@implements IAsyncDisposable
|
||||
@inject InertialMeasurementUnitHubClient ImuHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload IMU data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadImuDataAsync"
|
||||
Disabled="@(!ImuHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
<!-- Left Column: IMU SVG với 3D visualization -->
|
||||
<MudItem xs="12" md="4">
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 300 300" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
|
||||
<defs>
|
||||
<!-- Gradients cho các trục -->
|
||||
<linearGradient id="@($"xAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#f44336;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#f44336;stop-opacity:0.4" />
|
||||
</linearGradient>
|
||||
<linearGradient id="@($"yAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4caf50;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#4caf50;stop-opacity:0.4" />
|
||||
</linearGradient>
|
||||
<linearGradient id="@($"zAxisGradient_{DeviceId}")" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#2196f3;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#2196f3;stop-opacity:0.4" />
|
||||
</linearGradient>
|
||||
<!-- Filter cho glow effect -->
|
||||
<filter id="@($"glow_{DeviceId}")">
|
||||
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<!-- Arrow markers -->
|
||||
<marker id="@($"arrowhead-red-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#f44336" />
|
||||
</marker>
|
||||
<marker id="@($"arrowhead-green-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#4caf50" />
|
||||
</marker>
|
||||
<marker id="@($"arrowhead-blue-{DeviceId}")" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
|
||||
<polygon points="0 0, 10 3, 0 6" fill="#2196f3" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Background circle -->
|
||||
<circle cx="150" cy="150" r="120" fill="none" stroke="currentColor" stroke-width="2"
|
||||
opacity="0.1" style="color: var(--mud-palette-text-primary);" />
|
||||
|
||||
<!-- IMU Device (3D box representation) -->
|
||||
<g transform="translate(150, 150)">
|
||||
<!-- Device box với perspective -->
|
||||
<g transform="rotate(@(QuaternionToEuler(ImuData.Orientation).yaw * 180 / Math.PI), 0, 0)">
|
||||
<!-- Top face -->
|
||||
<polygon points="-30,-20 -10,-30 10,-30 30,-20 30,20 10,30 -10,30 -30,20"
|
||||
fill="var(--mud-palette-surface)"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.9"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
<!-- Front face -->
|
||||
<polygon points="-30,-20 30,-20 30,20 -30,20"
|
||||
fill="var(--mud-palette-surface)"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.7"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
<!-- Side face -->
|
||||
<polygon points="30,-20 10,-30 -10,-30 -30,-20 -30,20 -10,30 10,30 30,20"
|
||||
fill="var(--mud-palette-surface)"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
opacity="0.5"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
</g>
|
||||
|
||||
<!-- X Axis (Red) -->
|
||||
@{
|
||||
var accelX = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.X * 5));
|
||||
var accelXEnd = 80 + accelX;
|
||||
}
|
||||
<line x1="0" y1="0" x2="@accelXEnd" y2="0"
|
||||
stroke="#f44336"
|
||||
stroke-width="4"
|
||||
marker-end="@($"url(#arrowhead-red-{DeviceId})")"
|
||||
filter="@($"url(#glow_{DeviceId})")"
|
||||
opacity="0.8">
|
||||
<animate attributeName="x2"
|
||||
values="@(accelXEnd - 5);@(accelXEnd + 5);@(accelXEnd - 5)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
</line>
|
||||
<text x="@(accelXEnd + 10)" y="5" font-size="12" fill="#f44336" font-weight="bold">X</text>
|
||||
|
||||
<!-- Y Axis (Green) -->
|
||||
@{
|
||||
var accelY = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.Y * 5));
|
||||
var accelYEnd = 80 + accelY;
|
||||
}
|
||||
<line x1="0" y1="0" x2="0" y2="@(-accelYEnd)"
|
||||
stroke="#4caf50"
|
||||
stroke-width="4"
|
||||
marker-end="@($"url(#arrowhead-green-{DeviceId})")"
|
||||
filter="@($"url(#glow_{DeviceId})")"
|
||||
opacity="0.8">
|
||||
<animate attributeName="y2"
|
||||
values="@(-accelYEnd - 5);@(-accelYEnd + 5);@(-accelYEnd - 5)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
</line>
|
||||
<text x="5" y="@(-accelYEnd - 10)" font-size="12" fill="#4caf50" font-weight="bold">Y</text>
|
||||
|
||||
<!-- Z Axis (Blue) - represented as depth -->
|
||||
@{
|
||||
var accelZ = Math.Max(-50, Math.Min(50, ImuData.LinearAcceleration.Z * 5));
|
||||
var zOffset = accelZ * 0.5;
|
||||
}
|
||||
<line x1="0" y1="0" x2="@zOffset" y2="@(80 + zOffset)"
|
||||
stroke="#2196f3"
|
||||
stroke-width="4"
|
||||
stroke-dasharray="5,5"
|
||||
marker-end="@($"url(#arrowhead-blue-{DeviceId})")"
|
||||
filter="@($"url(#glow_{DeviceId})")"
|
||||
opacity="0.8">
|
||||
<animate attributeName="x2"
|
||||
values="@(zOffset - 3);@(zOffset + 3);@(zOffset - 3)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
<animate attributeName="y2"
|
||||
values="@(80 + zOffset - 3);@(80 + zOffset + 3);@(80 + zOffset - 3)"
|
||||
dur="1s"
|
||||
repeatCount="indefinite" />
|
||||
</line>
|
||||
<text x="@(zOffset + 5)" y="@(80 + zOffset + 15)" font-size="12" fill="#2196f3" font-weight="bold">Z</text>
|
||||
|
||||
<!-- Angular velocity indicators (circular arrows) -->
|
||||
@{
|
||||
var angularVel = Math.Sqrt(ImuData.AngularVelocity.X * ImuData.AngularVelocity.X +
|
||||
ImuData.AngularVelocity.Y * ImuData.AngularVelocity.Y +
|
||||
ImuData.AngularVelocity.Z * ImuData.AngularVelocity.Z);
|
||||
var angularVelNormalized = Math.Max(0, Math.Min(1, angularVel / 5.0));
|
||||
var rotationSpeed = angularVelNormalized * 360;
|
||||
}
|
||||
<circle cx="0" cy="0" r="60"
|
||||
fill="none"
|
||||
stroke="#ff9800"
|
||||
stroke-width="3"
|
||||
stroke-dasharray="10,5"
|
||||
opacity="@(0.3 + angularVelNormalized * 0.5)"
|
||||
transform="rotate(@rotationSpeed)">
|
||||
<animateTransform attributeName="transform"
|
||||
type="rotate"
|
||||
values="0;360"
|
||||
dur="@(Math.Max(0.5, 5 - angularVelNormalized * 4.5))s"
|
||||
repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- Center point -->
|
||||
<circle cx="0" cy="0" r="5" fill="currentColor"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
</g>
|
||||
|
||||
<!-- Orientation text -->
|
||||
<text x="150" y="280"
|
||||
text-anchor="middle"
|
||||
font-size="14"
|
||||
font-weight="bold"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-primary);">
|
||||
Orientation
|
||||
</text>
|
||||
<text x="150" y="295"
|
||||
text-anchor="middle"
|
||||
font-size="12"
|
||||
fill="currentColor"
|
||||
style="color: var(--mud-palette-text-secondary);">
|
||||
R:@(QuaternionToEuler(ImuData.Orientation).roll.ToString("F2")) P:@(QuaternionToEuler(ImuData.Orientation).pitch.ToString("F2")) Y:@(QuaternionToEuler(ImuData.Orientation).yaw.ToString("F2"))
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right Column: Data parameters -->
|
||||
<MudItem xs="12" md="8">
|
||||
<MudGrid>
|
||||
<!-- Status Row -->
|
||||
<MudItem xs="12">
|
||||
<MudGrid>
|
||||
<!-- Status fields removed - not available in Imu struct -->
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
<!-- Acceleration -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Acceleration</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">m/s²</MudText>
|
||||
<MudText Typo="Typo.body1">X: <strong>@ImuData.LinearAcceleration.X.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Y: <strong>@ImuData.LinearAcceleration.Y.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Z: <strong>@ImuData.LinearAcceleration.Z.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Angular Velocity -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">Angular Velocity</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">rad/s</MudText>
|
||||
<MudText Typo="Typo.body1">Roll (X): <strong>@ImuData.AngularVelocity.X.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Pitch (Y): <strong>@ImuData.AngularVelocity.Y.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Yaw (Z): <strong>@ImuData.AngularVelocity.Z.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Orientation -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">Orientation</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">rad (Euler)</MudText>
|
||||
<MudText Typo="Typo.body1">Roll: <strong>@QuaternionToEuler(ImuData.Orientation).roll.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Pitch: <strong>@QuaternionToEuler(ImuData.Orientation).pitch.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Yaw: <strong>@QuaternionToEuler(ImuData.Orientation).yaw.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Quaternion -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Secondary">Quaternion</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">Unit quaternion</MudText>
|
||||
<MudText Typo="Typo.body1">W: <strong>@ImuData.Orientation.W.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">X: <strong>@ImuData.Orientation.X.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Y: <strong>@ImuData.Orientation.Y.ToString("F3")</strong></MudText>
|
||||
<MudText Typo="Typo.body1">Z: <strong>@ImuData.Orientation.Z.ToString("F3")</strong></MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Last Update Time -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Last Update: @ImuData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
</MudText>
|
||||
</MudItem>
|
||||
|
||||
<!-- Device Properties -->
|
||||
@if (DeviceProperties.Any() && PropertyDescriptions.Any())
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudExpansionPanels Dense="true" Class="mt-2">
|
||||
<MudExpansionPanel Text="@($"Device Properties ({DeviceProperties.Count})")"
|
||||
Icon="@Icons.Material.Filled.Info"
|
||||
Style="font-size: 0.875rem;">
|
||||
<MudSimpleTable Dense="true" Hover="true" Striped="true">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding: 8px; width: 30%;">Property</th>
|
||||
<th style="padding: 8px; width: 70%;">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var prop in GetDisplayedProperties())
|
||||
{
|
||||
<tr>
|
||||
<td style="padding: 6px 8px;">
|
||||
<MudText Typo="Typo.body2" Style="font-weight: 500;">
|
||||
@prop.DisplayName
|
||||
</MudText>
|
||||
@if (!string.IsNullOrWhiteSpace(prop.Description))
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Style="font-size: 0.7rem;">
|
||||
@prop.Description
|
||||
</MudText>
|
||||
}
|
||||
</td>
|
||||
<td style="padding: 6px 8px;">
|
||||
@if (DeviceProperties.TryGetValue(prop.Key, out var value))
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@FormatPropertyValue(value, prop)
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private Imu ImuData = new();
|
||||
private Dictionary<string, string> DeviceProperties = new();
|
||||
private List<PropertyDescription> PropertyDescriptions = new();
|
||||
private bool IsLoading => !ImuHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ImuHubClient.StartAsync();
|
||||
|
||||
// Lấy DeviceName từ ImuHub
|
||||
var deviceInfo = await ImuHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
if (deviceInfo != null)
|
||||
{
|
||||
DeviceName = deviceInfo.DeviceName;
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceName = DeviceId; // Fallback to DeviceId if not found
|
||||
}
|
||||
|
||||
ImuData = await ImuHubClient.GetImuDataAsync(DeviceId);
|
||||
|
||||
// Lấy device properties và property descriptions
|
||||
var properties = await ImuHubClient.GetDevicePropertiesAsync(DeviceId);
|
||||
if (properties != null)
|
||||
{
|
||||
DeviceProperties = properties;
|
||||
}
|
||||
|
||||
var propDescriptions = await ImuHubClient.GetDevicePropertyDescriptionsAsync(DeviceId);
|
||||
if (propDescriptions != null)
|
||||
{
|
||||
PropertyDescriptions = propDescriptions;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ImuHubClient.StopAsync();
|
||||
ImuData = new Imu(); // Reset về giá trị mặc định
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadImuDataAsync()
|
||||
{
|
||||
if (!ImuHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
ImuData = await ImuHubClient.ReadAllDataAsync(DeviceId);
|
||||
|
||||
// Reload properties
|
||||
var properties = await ImuHubClient.GetDevicePropertiesAsync(DeviceId);
|
||||
if (properties != null)
|
||||
{
|
||||
DeviceProperties = properties;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload IMU data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert Quaternion sang Euler angles (Roll, Pitch, Yaw)
|
||||
/// </summary>
|
||||
private (double roll, double pitch, double yaw) QuaternionToEuler(QuaternionGeometry q)
|
||||
{
|
||||
// Roll (x-axis rotation)
|
||||
var sinr_cosp = 2 * (q.W * q.X + q.Y * q.Z);
|
||||
var cosr_cosp = 1 - 2 * (q.X * q.X + q.Y * q.Y);
|
||||
var roll = Math.Atan2(sinr_cosp, cosr_cosp);
|
||||
|
||||
// Pitch (y-axis rotation)
|
||||
var sinp = 2 * (q.W * q.Y - q.Z * q.X);
|
||||
double pitch;
|
||||
if (Math.Abs(sinp) >= 1)
|
||||
pitch = Math.CopySign(Math.PI / 2, sinp); // use 90 degrees if out of range
|
||||
else
|
||||
pitch = Math.Asin(sinp);
|
||||
|
||||
// Yaw (z-axis rotation)
|
||||
var siny_cosp = 2 * (q.W * q.Z + q.X * q.Y);
|
||||
var cosy_cosp = 1 - 2 * (q.Y * q.Y + q.Z * q.Z);
|
||||
var yaw = Math.Atan2(siny_cosp, cosy_cosp);
|
||||
|
||||
return (roll, pitch, yaw);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lấy danh sách properties đã sắp xếp để hiển thị
|
||||
/// </summary>
|
||||
private IEnumerable<PropertyDescription> GetDisplayedProperties()
|
||||
{
|
||||
return PropertyDescriptions
|
||||
.OrderBy(p => p.DisplayOrder)
|
||||
.ThenBy(p => p.Category ?? "")
|
||||
.ThenBy(p => p.DisplayName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Format giá trị property theo DataType và Format
|
||||
/// </summary>
|
||||
private string FormatPropertyValue(string value, PropertyDescription prop)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "-";
|
||||
|
||||
if (prop.DataType == "number" && double.TryParse(value, out var numValue))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(prop.Format))
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.Format(prop.Format, numValue) + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
|
||||
return value + (prop.Unit != null ? $" {prop.Unit}" : "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.Shared.Sensor
|
||||
@implements IAsyncDisposable
|
||||
@inject LidarHubClient LidarHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Toggle auto reload (1s interval)">
|
||||
<MudIconButton Icon="@(AutoReloadEnabled ? Icons.Material.Filled.PauseCircle : Icons.Material.Filled.PlayCircle)"
|
||||
Color="@(AutoReloadEnabled ? Color.Success : Color.Default)"
|
||||
Size="Size.Small"
|
||||
OnClick="ToggleAutoReloadAsync"
|
||||
Disabled="@(!LidarHubClient.IsConnected)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Reload lidar data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadLidarDataAsync"
|
||||
Disabled="@(!LidarHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
<!-- Left Column: Lidar Radar Plot SVG -->
|
||||
<MudItem xs="12" md="8">
|
||||
<div class="d-flex flex-column align-center justify-center" style="height: 100%; width: 100%; position: relative;">
|
||||
<svg width="100%" height="100%" viewBox="0 0 @SvgSize @SvgSize" preserveAspectRatio="xMidYMid meet" xmlns="http://www.w3.org/2000/svg" style="max-height: 100%;">
|
||||
<defs>
|
||||
<!-- Gradient cho scan line -->
|
||||
<radialGradient id="@($"scanGradient_{DeviceId}")" cx="50%" cy="50%">
|
||||
<stop offset="0%" style="stop-color:#2196f3;stop-opacity:0.8" />
|
||||
<stop offset="100%" style="stop-color:#2196f3;stop-opacity:0.2" />
|
||||
</radialGradient>
|
||||
<!-- Filter cho glow effect -->
|
||||
<filter id="@($"glow_{DeviceId}")">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background circles (range indicators) -->
|
||||
@foreach (var radius in RangeIndicatorRadii)
|
||||
{
|
||||
<circle cx="@SvgCenter" cy="@SvgCenter" r="@radius" fill="none" stroke="currentColor" stroke-width="1"
|
||||
opacity="0.2" style="color: var(--mud-palette-text-primary);" />
|
||||
}
|
||||
|
||||
<!-- Center point (Lidar position) -->
|
||||
<circle cx="@SvgCenter" cy="@SvgCenter" r="5" fill="#2196f3" />
|
||||
<circle cx="@SvgCenter" cy="@SvgCenter" r="8" fill="#2196f3" opacity="0.3">
|
||||
<animate attributeName="r" values="8;15;8" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.3;0;0.3" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- Grid lines (every 30 degrees) -->
|
||||
@foreach (var gridLine in GridLines)
|
||||
{
|
||||
<line x1="@SvgCenter" y1="@SvgCenter" x2="@gridLine.X" y2="@gridLine.Y"
|
||||
stroke="currentColor"
|
||||
stroke-width="0.5"
|
||||
opacity="0.1"
|
||||
style="color: var(--mud-palette-text-primary);" />
|
||||
}
|
||||
|
||||
<!-- Scan points và biên dạng -->
|
||||
@if (ValidScanPoints.Count > 0)
|
||||
{
|
||||
var pathData = BuildScanPath();
|
||||
|
||||
<!-- Biên dạng (outline) - nối các điểm scan với đường mỏng -->
|
||||
<path d="@pathData"
|
||||
fill="none"
|
||||
stroke="#2196f3"
|
||||
stroke-width="1"
|
||||
opacity="0.8"
|
||||
filter="@($"url(#glow_{DeviceId})")" />
|
||||
|
||||
<!-- Fill area bên trong biên dạng -->
|
||||
<path d="@pathData"
|
||||
fill="url(@($"#scanGradient_{DeviceId}"))"
|
||||
opacity="0.3" />
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
<!-- Right Column: Data parameters -->
|
||||
<MudItem xs="12" md="4">
|
||||
<MudGrid>
|
||||
<!-- Status -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Status</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
Points: <strong>@LidarData.Ranges.Length</strong>
|
||||
</MudText>
|
||||
@if (LidarData.ScanTime > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
Frequency: <strong>@((1.0 / LidarData.ScanTime).ToString("F1")) Hz</strong>
|
||||
</MudText>
|
||||
}
|
||||
@if (LidarData.AngleIncrement > 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1">
|
||||
Resolution: <strong>@FormatAngleDegreesPrecise(LidarData.AngleIncrement)°</strong>
|
||||
</MudText>
|
||||
}
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Device Specifications -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">Specifications</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
Angle Range: <strong>@FormatAngleDegrees(LidarData.AngleMin)° to @FormatAngleDegrees(LidarData.AngleMax)°</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
FOV: <strong>@FormatAngleDegrees(LidarData.AngleMax - LidarData.AngleMin)°</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Range: <strong>@LidarData.RangeMin.ToString("F2") m to @LidarData.RangeMax.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Intensity: <strong>@(LidarData.Intensities != null && LidarData.Intensities.Length > 0 ? "Supported" : "Not Supported")</strong>
|
||||
</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Statistics -->
|
||||
@if (Statistics != null)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">Statistics</MudText>
|
||||
<MudText Typo="Typo.body1" Class="mt-2">
|
||||
Avg Distance: <strong>@Statistics.AvgDistance.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Min Distance: <strong>@Statistics.MinDistance.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Max Distance: <strong>@Statistics.MaxDistance.ToString("F2") m</strong>
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body1">
|
||||
Avg Intensity: <strong>@Statistics.AvgIntensity.ToString("F1")</strong>
|
||||
</MudText>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
<!-- Last Update Time -->
|
||||
<MudItem xs="12">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Last Update: @LidarData.Header.Stamp.ToString("yyyy-MM-dd HH:mm:ss.fff")
|
||||
</MudText>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
#region Constants
|
||||
private const int SvgSize = 400;
|
||||
private const double SvgCenter = 200.0;
|
||||
private const double SvgMaxRadius = 150.0;
|
||||
private const double DefaultMaxDistanceM = 10.0;
|
||||
private const int GridLineStepDegrees = 30;
|
||||
|
||||
private static readonly int[] RangeIndicatorRadii = { 150, 100, 50 };
|
||||
#endregion
|
||||
|
||||
#region Parameters
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
#endregion
|
||||
|
||||
#region Private Fields
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private LaserScan LidarData = new();
|
||||
private bool IsReloading = false;
|
||||
private bool AutoReloadEnabled = false;
|
||||
private System.Threading.PeriodicTimer? _autoReloadTimer;
|
||||
private readonly System.Threading.CancellationTokenSource _cancellationTokenSource = new();
|
||||
#endregion
|
||||
|
||||
#region Computed Properties
|
||||
private bool IsLoading => !LidarHubClient.IsConnected;
|
||||
|
||||
/// <summary>
|
||||
/// Tính AngleIncrement thực tế dựa trên số lượng Ranges để hiển thị đúng
|
||||
/// </summary>
|
||||
private double ActualAngleIncrement
|
||||
{
|
||||
get
|
||||
{
|
||||
if (LidarData.Ranges == null || LidarData.Ranges.Length == 0)
|
||||
return LidarData.AngleIncrement;
|
||||
|
||||
var angleSpan = LidarData.AngleMax - LidarData.AngleMin;
|
||||
if (angleSpan <= 0 || LidarData.Ranges.Length <= 1)
|
||||
return LidarData.AngleIncrement;
|
||||
|
||||
// Tính AngleIncrement thực tế dựa trên số lượng điểm
|
||||
return angleSpan / (LidarData.Ranges.Length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<ScanPoint> ValidScanPoints
|
||||
{
|
||||
get
|
||||
{
|
||||
if (LidarData.Ranges == null || LidarData.Ranges.Length == 0)
|
||||
return Array.Empty<ScanPoint>();
|
||||
|
||||
var points = new List<ScanPoint>();
|
||||
// Sử dụng ActualAngleIncrement để tính góc đúng cho hiển thị
|
||||
var angleIncrement = ActualAngleIncrement;
|
||||
|
||||
for (int i = 0; i < LidarData.Ranges.Length; i++)
|
||||
{
|
||||
var range = LidarData.Ranges[i];
|
||||
var angle = LidarData.AngleMin + i * angleIncrement;
|
||||
var isValid = !double.IsNaN(range) && !double.IsInfinity(range) &&
|
||||
range >= LidarData.RangeMin && range <= LidarData.RangeMax;
|
||||
var intensity = LidarData.Intensities != null && i < LidarData.Intensities.Length
|
||||
? LidarData.Intensities[i] : 0.0;
|
||||
|
||||
if (isValid)
|
||||
{
|
||||
points.Add(new ScanPoint
|
||||
{
|
||||
AngleRad = angle,
|
||||
AngleDeg = angle * 180.0 / Math.PI,
|
||||
DistanceM = range,
|
||||
Intensity = intensity
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return points.OrderBy(p => p.AngleDeg).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private double MaxDistanceM
|
||||
{
|
||||
get
|
||||
{
|
||||
var validPoints = ValidScanPoints;
|
||||
if (validPoints.Count == 0)
|
||||
return DefaultMaxDistanceM;
|
||||
|
||||
var max = validPoints.Max(p => p.DistanceM);
|
||||
return max > 0 ? max : DefaultMaxDistanceM;
|
||||
}
|
||||
}
|
||||
|
||||
private double Scale => SvgMaxRadius / MaxDistanceM;
|
||||
|
||||
private ScanStatistics? Statistics
|
||||
{
|
||||
get
|
||||
{
|
||||
var validPoints = ValidScanPoints;
|
||||
if (validPoints.Count == 0)
|
||||
return null;
|
||||
|
||||
return new ScanStatistics
|
||||
{
|
||||
AvgDistance = validPoints.Average(p => p.DistanceM),
|
||||
MinDistance = validPoints.Min(p => p.DistanceM),
|
||||
MaxDistance = validPoints.Max(p => p.DistanceM),
|
||||
AvgIntensity = validPoints.Average(p => p.Intensity)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private List<(double X, double Y)> GridLines
|
||||
{
|
||||
get
|
||||
{
|
||||
var lines = new List<(double X, double Y)>();
|
||||
for (int angle = 0; angle < 360; angle += GridLineStepDegrees)
|
||||
{
|
||||
var rad = angle * Math.PI / 180.0;
|
||||
var x = SvgCenter + Math.Cos(rad) * SvgMaxRadius;
|
||||
var y = SvgCenter + Math.Sin(rad) * SvgMaxRadius;
|
||||
lines.Add((x, y));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Lifecycle Methods
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
_cancellationTokenSource.Cancel();
|
||||
_cancellationTokenSource.Dispose();
|
||||
await DisconnectAsync();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Connection Methods
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LidarHubClient.StartAsync();
|
||||
|
||||
var deviceInfo = await LidarHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
DeviceName = deviceInfo?.DeviceName ?? DeviceId;
|
||||
|
||||
LidarData = await LidarHubClient.GetLidarDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect xx: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LidarHubClient.StopAsync();
|
||||
LidarData = new LaserScan();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadLidarDataAsync()
|
||||
{
|
||||
if (!LidarHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
LidarData = await LidarHubClient.GetLidarDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
IsReloading = false;
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload lidar data: {ex.Message}", Severity.Error);
|
||||
IsReloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled)
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await StartAutoReloadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled || !LidarHubClient.IsConnected)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = true;
|
||||
_autoReloadTimer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(1));
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await _autoReloadTimer.WaitForNextTickAsync(_cancellationTokenSource.Token))
|
||||
{
|
||||
if (!_cancellationTokenSource.Token.IsCancellationRequested && LidarHubClient.IsConnected)
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await ReloadLidarDataAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// Expected when cancelling
|
||||
}
|
||||
});
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task StopAutoReloadAsync()
|
||||
{
|
||||
if (!AutoReloadEnabled)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = false;
|
||||
if (_autoReloadTimer != null)
|
||||
{
|
||||
_autoReloadTimer.Dispose();
|
||||
_autoReloadTimer = null;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
private string BuildScanPath()
|
||||
{
|
||||
var validPoints = ValidScanPoints;
|
||||
if (validPoints.Count == 0)
|
||||
return string.Empty;
|
||||
|
||||
var pathBuilder = new System.Text.StringBuilder();
|
||||
|
||||
// Start from center
|
||||
pathBuilder.Append($"M {SvgCenter:F2} {SvgCenter:F2}");
|
||||
|
||||
// Connect to all scan points
|
||||
foreach (var point in validPoints)
|
||||
{
|
||||
var (x, y) = ConvertAngleToSvgCoordinates(point.AngleRad, point.DistanceM);
|
||||
pathBuilder.Append($" L {x:F2} {y:F2}");
|
||||
}
|
||||
|
||||
// Close path back to center
|
||||
pathBuilder.Append($" L {SvgCenter:F2} {SvgCenter:F2}");
|
||||
pathBuilder.Append(" Z");
|
||||
|
||||
return pathBuilder.ToString();
|
||||
}
|
||||
|
||||
private (double x, double y) ConvertAngleToSvgCoordinates(double angleRad, double distanceM)
|
||||
{
|
||||
// Convert from SICK coordinate system to SVG coordinate system
|
||||
// SICK: 0° = right, increases counter-clockwise
|
||||
// SVG: 0° = up, increases clockwise
|
||||
// Formula: x = center + cos(angle) * distance, y = center - sin(angle) * distance
|
||||
var scaledDistance = distanceM * Scale;
|
||||
var x = SvgCenter + Math.Cos(angleRad) * scaledDistance;
|
||||
var y = SvgCenter - Math.Sin(angleRad) * scaledDistance;
|
||||
return (x, y);
|
||||
}
|
||||
|
||||
private string FormatAngleDegrees(double angleRad) => (angleRad * 180.0 / Math.PI).ToString("F1");
|
||||
|
||||
private string FormatAngleDegreesPrecise(double angleRad) => (angleRad * 180.0 / Math.PI).ToString("F3");
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
private class ScanPoint
|
||||
{
|
||||
public double AngleRad { get; set; }
|
||||
public double AngleDeg { get; set; }
|
||||
public double DistanceM { get; set; }
|
||||
public double Intensity { get; set; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Helper Classes
|
||||
private class ScanStatistics
|
||||
{
|
||||
public double AvgDistance { get; set; }
|
||||
public double MinDistance { get; set; }
|
||||
public double MaxDistance { get; set; }
|
||||
public double AvgIntensity { get; set; }
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
@using MudBlazor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@implements IAsyncDisposable
|
||||
@inject ModbusTcpHubClient ModbusTcpHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard>
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Reload Modbus data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="ReloadModbusDataAsync"
|
||||
Disabled="@(!ModbusTcpHubClient.IsConnected || IsReloading)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Style="position: relative;">
|
||||
<MudGrid>
|
||||
<!-- Connection Info -->
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>IP:</strong> @ModbusData.IpAddress
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>Port:</strong> @ModbusData.Port
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>Slave ID:</strong> @ModbusData.SlaveId
|
||||
</MudText>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="3">
|
||||
<MudChip T="string" Color="@(ModbusData.IsConnected ? Color.Success : Color.Error)"
|
||||
Size="Size.Small"
|
||||
Variant="Variant.Filled">
|
||||
@(ModbusData.IsConnected ? "Connected" : "Disconnected")
|
||||
</MudChip>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<!-- Holding Registers -->
|
||||
@if (ModbusData.HoldingRegisters != null && ModbusData.HoldingRegisters.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.HoldingRegisters)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">
|
||||
Holding Registers: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity registers)
|
||||
</MudText>
|
||||
<MudTable Items="@range.Values" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value (Decimal)</MudTh>
|
||||
<MudTh>Value (Hex)</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Decimal)">
|
||||
<strong>@context.Value</strong>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Hex)">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">0x@(context.Value.ToString("X4"))</MudText>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Input Registers -->
|
||||
@if (ModbusData.InputRegisters != null && ModbusData.InputRegisters.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.InputRegisters)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">
|
||||
Input Registers: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity registers)
|
||||
</MudText>
|
||||
<MudTable Items="@range.Values" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value (Decimal)</MudTh>
|
||||
<MudTh>Value (Hex)</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Decimal)">
|
||||
<strong>@context.Value</strong>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value (Hex)">
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">0x@(context.Value.ToString("X4"))</MudText>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Coils -->
|
||||
@if (ModbusData.Coils != null && ModbusData.Coils.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.Coils)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">
|
||||
Coils: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity coils)
|
||||
</MudText>
|
||||
<MudTable Items="@range.BoolValues" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
<MudTh>Action</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Variant="Variant.Filled"
|
||||
Color="@(context.Value ? Color.Success : Color.Default)">
|
||||
@(context.Value ? "ON" : "OFF")
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Action">
|
||||
@{
|
||||
var coilKey = $"{range.StartAddress}_{context.Index}";
|
||||
var isWriting = WritingCoils.ContainsKey(coilKey) && WritingCoils[coilKey];
|
||||
}
|
||||
<MudSwitch Value="@context.Value"
|
||||
Disabled="@(!ModbusTcpHubClient.IsConnected || isWriting)"
|
||||
Color="Color.Success"
|
||||
Size="Size.Small"
|
||||
ValueChanged="@((bool value) => HandleCoilToggle(context.Address, value, coilKey))">
|
||||
</MudSwitch>
|
||||
@if (isWriting)
|
||||
{
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" Class="ml-2" />
|
||||
}
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Discrete Inputs -->
|
||||
@if (ModbusData.DiscreteInputs != null && ModbusData.DiscreteInputs.Length > 0)
|
||||
{
|
||||
@foreach (var range in ModbusData.DiscreteInputs)
|
||||
{
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="pa-4">
|
||||
<MudText Typo="Typo.h6" Color="Color.Warning">
|
||||
Discrete Inputs: @range.Name
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-2">
|
||||
Address: @range.StartAddress - @(range.StartAddress + range.Quantity - 1) (@range.Quantity inputs)
|
||||
</MudText>
|
||||
<MudTable Items="@range.BoolValues" Hover="true" Dense="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Address</MudTh>
|
||||
<MudTh>Index</MudTh>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Value</MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Address">@context.Address</MudTd>
|
||||
<MudTd DataLabel="Index">@context.Index</MudTd>
|
||||
<MudTd DataLabel="Name">
|
||||
@if (!string.IsNullOrEmpty(context.Name))
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Text" Color="Color.Info">
|
||||
@context.Name
|
||||
</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">-</MudText>
|
||||
}
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Value">
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Variant="Variant.Filled"
|
||||
Color="@(context.Value ? Color.Success : Color.Default)">
|
||||
@(context.Value ? "ON" : "OFF")
|
||||
</MudChip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
}
|
||||
</MudGrid>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
|
||||
private string DeviceName { get; set; } = string.Empty;
|
||||
private ModbusTcpData ModbusData = new();
|
||||
private bool IsLoading => !ModbusTcpHubClient.IsConnected;
|
||||
private bool IsReloading = false;
|
||||
private Dictionary<string, bool> WritingCoils = new();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await base.OnInitializedAsync();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && !string.IsNullOrEmpty(DeviceId))
|
||||
{
|
||||
await ConnectAsync();
|
||||
}
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ModbusTcpHubClient.StartAsync();
|
||||
|
||||
// Lấy DeviceName từ ModbusTcpHub
|
||||
var deviceInfo = await ModbusTcpHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
if (deviceInfo != null)
|
||||
{
|
||||
DeviceName = deviceInfo.DeviceName;
|
||||
}
|
||||
else
|
||||
{
|
||||
DeviceName = DeviceId; // Fallback to DeviceId if not found
|
||||
}
|
||||
|
||||
ModbusData = await ModbusTcpHubClient.GetModbusDataAsync(DeviceId);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await ModbusTcpHubClient.StopAsync();
|
||||
ModbusData = new ModbusTcpData(); // Reset về giá trị mặc định
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to disconnect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadModbusDataAsync()
|
||||
{
|
||||
if (!ModbusTcpHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
ModbusData = await ModbusTcpHubClient.GetModbusDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload Modbus data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleCoilToggle(ushort address, bool value, string coilKey)
|
||||
{
|
||||
if (!ModbusTcpHubClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to Modbus device", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set writing state
|
||||
WritingCoils[coilKey] = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var success = await ModbusTcpHubClient.WriteCoilAsync(DeviceId, address, value);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Coil {address} set to {(value ? "ON" : "OFF")}", Severity.Success);
|
||||
|
||||
// Reload data để cập nhật giá trị mới nhất
|
||||
await ReloadModbusDataAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to write coil {address}", Severity.Error);
|
||||
// Reload để khôi phục giá trị cũ
|
||||
await ReloadModbusDataAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error writing coil {address}: {ex.Message}", Severity.Error);
|
||||
// Reload để khôi phục giá trị cũ
|
||||
await ReloadModbusDataAsync();
|
||||
}
|
||||
finally
|
||||
{
|
||||
WritingCoils[coilKey] = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
|
||||
@inject RfHandleHubClient RfHandleHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<MudCard Class="pa-0">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">@DeviceName</MudText>
|
||||
<MudText Typo="Typo.caption">@DeviceId</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudTooltip Text="Toggle auto reload (1s interval)">
|
||||
<MudIconButton Icon="@(AutoReloadEnabled? Icons.Material.Filled.PauseCircle : Icons.Material.Filled.PlayCircle)"
|
||||
Color="@(AutoReloadEnabled ? Color.Success : Color.Default)"
|
||||
Size="Size.Small"
|
||||
OnClick="ToggleAutoReloadAsync"
|
||||
Disabled="@(!RfHandleHubClient.IsConnected)">
|
||||
</MudIconButton>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Reload RfHandle data">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Disabled="@(!RfHandleHubClient.IsConnected || IsReloading)"
|
||||
OnClick="ReloadRfHandleDataAsync" />
|
||||
</MudTooltip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
|
||||
<MudCardContent Class="pa-3">
|
||||
|
||||
<MudGrid>
|
||||
|
||||
<!-- LEFT PANEL (NO JOYSTICK FOR YNZDH) -->
|
||||
<MudItem xs="12" md="5">
|
||||
<MudPaper Elevation="1" Class="pa-3 d-flex flex-column">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Joystick</MudText>
|
||||
<MudItem xs="12">
|
||||
<!-- ================= LEFT: JOYSTICK ================= -->
|
||||
<svg width="400" height="400" viewBox="0 0 220 220">
|
||||
<!-- Background -->
|
||||
<circle cx="110" cy="110" r="95" fill="#2e2e2e" />
|
||||
|
||||
<!-- Deadzone -->
|
||||
<circle cx="110" cy="110" r="25" fill="#3f3f3f" />
|
||||
|
||||
<!-- Axis cross -->
|
||||
<line x1="110" y1="15" x2="110" y2="205"
|
||||
stroke="#444" stroke-width="2" />
|
||||
<line x1="15" y1="110" x2="205" y2="110"
|
||||
stroke="#444" stroke-width="2" />
|
||||
|
||||
<!-- Joystick knob -->
|
||||
<circle cx="@JoyX"
|
||||
cy="@JoyY"
|
||||
r="28"
|
||||
fill="@JoyColor"
|
||||
stroke="#111"
|
||||
stroke-width="3" />
|
||||
</svg>
|
||||
<!-- ================= RIGHT: AXES ================= -->
|
||||
<!-- LINEAR -->
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
|
||||
Linear
|
||||
</MudText>
|
||||
|
||||
<MudProgressLinear Value="@AxisToPercent(RfHandleData.Linear)"
|
||||
Color="Color.Info"
|
||||
Class="mb-1" />
|
||||
|
||||
<MudText Typo="Typo.caption" Class="mb-3">
|
||||
@AxisText(RfHandleData.Linear)
|
||||
</MudText>
|
||||
|
||||
<!-- ANGULAR -->
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">
|
||||
Angular
|
||||
</MudText>
|
||||
|
||||
<MudProgressLinear Value="@AxisToPercent(RfHandleData.Angular)"
|
||||
Color="Color.Info"
|
||||
Class="mb-1" />
|
||||
|
||||
<MudText Typo="Typo.caption">
|
||||
@AxisText(RfHandleData.Angular)
|
||||
</MudText>
|
||||
|
||||
</MudItem>
|
||||
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- RIGHT PANELS -->
|
||||
<MudItem xs="12" md="7">
|
||||
<MudGrid Spacing="2">
|
||||
|
||||
<!-- STATUS PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Info">Status</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
||||
<MudChip T="string" Color="@(RfHandleData.Heartbeat != 0 ? Color.Success : Color.Default)"
|
||||
Variant="@(RfHandleData.Heartbeat != 0 ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Heartbeat</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.RemoteReady? Color.Warning: Color.Default)"
|
||||
Variant="@(RfHandleData.RemoteReady ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">RemoteReady</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.EStop? Color.Error: Color.Default)"
|
||||
Variant="@(RfHandleData.EStop ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">EStop</MudChip>
|
||||
</MudStack>
|
||||
|
||||
<MudText Class="mt-2">
|
||||
<b>Last Update:</b> @RfHandleData.LastUpdateTime.ToString("HH:mm:ss")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- SPEED PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Speed</MudText>
|
||||
|
||||
<MudProgressLinear Value="@RfHandleData.Speed" Color="Color.Info" Class="mt-2" />
|
||||
<MudText Typo="Typo.caption">Speed: @RfHandleData.Speed%</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- MOTION PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Warning">Motion</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
||||
<MudChip T="string" Color="@(RfHandleData.LiftUp? Color.Info: Color.Default)"
|
||||
Variant="@(RfHandleData.LiftUp ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">LiftUp</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.LiftDown? Color.Info: Color.Default)"
|
||||
Variant="@(RfHandleData.LiftDown ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">LiftDown</MudChip>
|
||||
|
||||
<MudChip T="string" Color="@(RfHandleData.RotateLeft? Color.Secondary: Color.Default)"
|
||||
Variant="@(RfHandleData.RotateLeft ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Rot L</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.RotateRight? Color.Secondary: Color.Default)"
|
||||
Variant="@(RfHandleData.RotateRight ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Rot R</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
<!-- MODE PANEL -->
|
||||
<MudItem xs="12">
|
||||
<MudPaper Elevation="1" Class="pa-2">
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">Mode & Flags</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="2" Class="mt-2">
|
||||
<MudChip T="string" Color="Color.Info" Variant="Variant.Filled">
|
||||
Mode: @RfHandleData.Mode
|
||||
</MudChip>
|
||||
|
||||
<MudChip T="string" Color="@(RfHandleData.ModeSelect? Color.Warning: Color.Default)"
|
||||
Variant="@(RfHandleData.ModeSelect ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">ModeSelect</MudChip>
|
||||
<MudChip T="string" Color="@(RfHandleData.Enable? Color.Success: Color.Default)"
|
||||
Variant="@(RfHandleData.Enable ? Variant.Filled : Variant.Outlined)"
|
||||
Size="Size.Small">Enable</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
</MudItem>
|
||||
|
||||
</MudGrid>
|
||||
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudOverlay Visible="@IsLoading" Absolute="true">
|
||||
<MudProgressCircular Indeterminate="true" />
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
// ===== Joystick visual helpers =====
|
||||
|
||||
private const double JoyRadius = 70f; // px
|
||||
private const double JoyCenter = 110f;
|
||||
|
||||
private double SafeLinear =>
|
||||
(!RfHandleData.RemoteReady || RfHandleData.EStop)
|
||||
? 0
|
||||
: Math.Clamp(RfHandleData.Linear, -1, 1);
|
||||
|
||||
private double SafeAngular =>
|
||||
(!RfHandleData.RemoteReady || RfHandleData.EStop)
|
||||
? 0
|
||||
: Math.Clamp(RfHandleData.Angular, -1, 1);
|
||||
|
||||
private double JoyX =>
|
||||
JoyCenter + SafeAngular * JoyRadius;
|
||||
|
||||
private double JoyY =>
|
||||
JoyCenter - SafeLinear * JoyRadius;
|
||||
|
||||
private string JoyColor =>
|
||||
(!RfHandleData.RemoteReady || RfHandleData.EStop)
|
||||
? "#666"
|
||||
: "#bdbdbd";
|
||||
|
||||
private RfHandleDataDto RfHandleData = new();
|
||||
private bool IsReloading = false;
|
||||
private bool AutoReloadEnabled = false;
|
||||
private string DeviceName = "";
|
||||
private System.Threading.PeriodicTimer? _autoReloadTimer;
|
||||
private readonly System.Threading.CancellationTokenSource _cancellationTokenSource = new();
|
||||
|
||||
[Parameter] public string DeviceId { get; set; } = "";
|
||||
|
||||
private bool IsLoading => !RfHandleHubClient.IsConnected;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await RfHandleHubClient.StartAsync();
|
||||
|
||||
var info = await RfHandleHubClient.GetDeviceInfoAsync(DeviceId);
|
||||
DeviceName = info?.DeviceName ?? DeviceId;
|
||||
|
||||
RfHandleData = await RfHandleHubClient.GetRfHandleDataAsync(DeviceId);
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Connect failed: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadRfHandleDataAsync()
|
||||
{
|
||||
if (!RfHandleHubClient.IsConnected || IsReloading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
IsReloading = true;
|
||||
StateHasChanged();
|
||||
|
||||
RfHandleData = await RfHandleHubClient.GetRfHandleDataAsync(DeviceId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to reload RfHandle data: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsReloading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled)
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await StartAutoReloadAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartAutoReloadAsync()
|
||||
{
|
||||
if (AutoReloadEnabled || !RfHandleHubClient.IsConnected)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = true;
|
||||
_autoReloadTimer = new System.Threading.PeriodicTimer(TimeSpan.FromSeconds(0.05));
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
while (await _autoReloadTimer.WaitForNextTickAsync(_cancellationTokenSource.Token))
|
||||
{
|
||||
if (!_cancellationTokenSource.Token.IsCancellationRequested && RfHandleHubClient.IsConnected)
|
||||
{
|
||||
await InvokeAsync(async () =>
|
||||
{
|
||||
await ReloadRfHandleDataAsync();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
// Expected when cancelling
|
||||
}
|
||||
});
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task StopAutoReloadAsync()
|
||||
{
|
||||
if (!AutoReloadEnabled)
|
||||
return;
|
||||
|
||||
AutoReloadEnabled = false;
|
||||
if (_autoReloadTimer != null)
|
||||
{
|
||||
_autoReloadTimer.Dispose();
|
||||
_autoReloadTimer = null;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await StopAutoReloadAsync();
|
||||
_cancellationTokenSource.Cancel();
|
||||
_cancellationTokenSource.Dispose();
|
||||
|
||||
try
|
||||
{
|
||||
await RfHandleHubClient.StopAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
private int AxisToPercent(double v)
|
||||
{
|
||||
// v ∈ [-1, +1] → [0, 100]
|
||||
return (int)Math.Clamp((v + 1) * 50f, 0, 100f);
|
||||
}
|
||||
|
||||
private string AxisText(double v)
|
||||
{
|
||||
return v.ToString("F2");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Modules
|
||||
@using MudBlazor
|
||||
|
||||
<MudPaper Class="pa-4" Style="height: 100%;">
|
||||
<MudStack Spacing="3">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5">Lift Module</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="RefreshStatus"
|
||||
Disabled="@(!IsHubReady || isLoading)" />
|
||||
</MudStack>
|
||||
|
||||
@* Lift Status Information *@
|
||||
@if (liftStatus != null)
|
||||
{
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Module Status</MudText>
|
||||
<MudStack Spacing="2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>State:</MudText>
|
||||
<MudChip T="string" Color="@GetStateColor(liftStatus.State)" Size="Size.Small">
|
||||
@liftStatus.State
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Ready:</MudText>
|
||||
<MudChip T="bool" Color="@(liftStatus.IsReady ? Color.Success : Color.Default)" Size="Size.Small">
|
||||
@liftStatus.IsReady
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Current Position:</MudText>
|
||||
<MudText>
|
||||
@($"{liftStatus.CurrentPosition:N0} counts")
|
||||
(@($"{ConvertCountsToMeters(liftStatus.CurrentPosition):F3} m"))
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
|
||||
@* Lift Control Buttons *@
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.h6">Homing & Movement Controls</MudText>
|
||||
|
||||
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="LiftHome"
|
||||
Disabled="@(isLoading || !IsHubReady)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.HomeRepairService" Class="mr-2" />
|
||||
<MudText>Homing</MudText>
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="LiftUp"
|
||||
Disabled="@(isLoading || !IsHubReady || liftStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowUpward" Class="mr-2" />
|
||||
<MudText>Up</MudText>
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
OnClick="LiftDown"
|
||||
Disabled="@(isLoading || !IsHubReady || liftStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Class="mr-2" />
|
||||
<MudText>Down</MudText>
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Warning"
|
||||
OnClick="LiftStop"
|
||||
Disabled="@(!IsHubReady || liftStatus?.IsReady != true || liftStatus?.State != "Moving")">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-2" />
|
||||
<MudText>Stop</MudText>
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
|
||||
<MudNumericField @bind-Value="targetHeightMeters"
|
||||
Label="Target Height (m)"
|
||||
Variant="Variant.Outlined"
|
||||
Min="0"
|
||||
Class="flex-grow-1" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="MoveToPosition"
|
||||
Disabled="@(isLoading || !IsHubReady || liftStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.LocationOn" Class="mr-2" />
|
||||
<MudText>Go</MudText>
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
<MudText Typo="Typo.caption">
|
||||
Scale: 10,000 counts = 0.01 m (1,000,000 counts = 1 m)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public MotionHubClient HubClient { get; set; } = null!;
|
||||
[Parameter] public bool IsHubReady { get; set; }
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
[Inject] private CiA402ServoHubClient ServoHubClient { get; set; } = null!;
|
||||
|
||||
private LiftModuleStatusDto? liftStatus;
|
||||
private bool isLoading = false;
|
||||
private double targetHeightMeters = 0.0;
|
||||
private bool _previousIsHubReady = false;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Detect when IsHubReady changes from false to true
|
||||
if (IsHubReady && !_previousIsHubReady)
|
||||
{
|
||||
await RefreshStatus();
|
||||
}
|
||||
_previousIsHubReady = IsHubReady;
|
||||
|
||||
// Đảm bảo kết nối tới CiA402ServoHub để homing trực tiếp
|
||||
if (!ServoHubClient.IsConnected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ServoHubClient.StartAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nếu connect lỗi, homing sẽ báo lỗi qua Snackbar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RefreshStatus()
|
||||
{
|
||||
if (isLoading || !IsHubReady) return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
liftStatus = await HubClient.GetLiftStatusAsync();
|
||||
if (liftStatus != null)
|
||||
{
|
||||
targetHeightMeters = ConvertCountsToMeters(liftStatus.CurrentPosition);
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error refreshing lift status: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"Ready" => Color.Success,
|
||||
"Moving" => Color.Info,
|
||||
"Error" => Color.Error,
|
||||
"Homing" => Color.Warning,
|
||||
"Initializing" => Color.Warning,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private async Task LiftUp()
|
||||
{
|
||||
if (!IsHubReady || isLoading || liftStatus?.IsReady != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
Snackbar.Add("Lifting up...", Severity.Info);
|
||||
|
||||
await HubClient.LiftUpAsync();
|
||||
|
||||
Snackbar.Add("Lift up command sent successfully", Severity.Success);
|
||||
|
||||
await Task.Delay(500);
|
||||
await RefreshStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error lifting up: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LiftDown()
|
||||
{
|
||||
if (!IsHubReady || isLoading || liftStatus?.IsReady != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
Snackbar.Add("Lifting down...", Severity.Info);
|
||||
|
||||
await HubClient.LiftDownAsync();
|
||||
|
||||
Snackbar.Add("Lift down command sent successfully", Severity.Success);
|
||||
|
||||
await Task.Delay(500);
|
||||
await RefreshStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error lifting down: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LiftStop()
|
||||
{
|
||||
if (!IsHubReady || liftStatus?.IsReady != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await HubClient.LiftStopAsync();
|
||||
Snackbar.Add("Lift stop command sent", Severity.Info);
|
||||
await Task.Delay(300);
|
||||
await RefreshStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error stopping lift: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task MoveToPosition()
|
||||
{
|
||||
if (!IsHubReady || isLoading || liftStatus?.IsReady != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
var targetCounts = ConvertMetersToCounts(targetHeightMeters);
|
||||
Snackbar.Add($"Moving to height {targetHeightMeters:F3} m (~{targetCounts} counts)...", Severity.Info);
|
||||
|
||||
await HubClient.LiftToPositionAsync(targetCounts);
|
||||
|
||||
Snackbar.Add($"Move to height {targetHeightMeters:F3} m command sent successfully", Severity.Success);
|
||||
|
||||
await Task.Delay(500);
|
||||
await RefreshStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error moving to position: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LiftHome()
|
||||
{
|
||||
if (isLoading)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
Snackbar.Add("Starting lift homing (direct device)...", Severity.Info);
|
||||
|
||||
const string liftDeviceId = "lift-motor";
|
||||
|
||||
// Dùng cùng tham số như appsettings (hoặc theo nhu cầu của bạn)
|
||||
const byte homingMethod = 21;
|
||||
const int homingSpeed = 20000;
|
||||
const int homingOffset = 0;
|
||||
|
||||
// Ghi homing params xuống drive giống CiA402ServoCard
|
||||
await ServoHubClient.SetHomingMethodAsync(liftDeviceId, homingMethod);
|
||||
await ServoHubClient.SetHomingSpeedAsync(liftDeviceId, homingSpeed);
|
||||
await ServoHubClient.SetHomingOffsetAsync(liftDeviceId, homingOffset);
|
||||
|
||||
// Start homing trực tiếp trên thiết bị
|
||||
await ServoHubClient.StartHomingAsync(liftDeviceId, homingMethod, homingSpeed);
|
||||
|
||||
Snackbar.Add("Lift homing command sent to lift-motor", Severity.Success);
|
||||
|
||||
await Task.Delay(500);
|
||||
await RefreshStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error homing lift: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static double ConvertCountsToMeters(int counts)
|
||||
{
|
||||
// 10,000 counts = 0.01 m => 1,000,000 counts = 1 m
|
||||
return counts / 1_000_000.0;
|
||||
}
|
||||
|
||||
private static int ConvertMetersToCounts(double meters)
|
||||
{
|
||||
return (int)Math.Round(meters * 1_000_000.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using MudBlazor
|
||||
|
||||
<MudPaper Class="pa-4" Style="height: 100%;">
|
||||
<MudStack Spacing="3">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5">Manual Control</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="RefreshStatus"
|
||||
Disabled="@(isLoading)" />
|
||||
</MudStack>
|
||||
|
||||
@* Enable / Disable PS5 Controller *@
|
||||
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="@(ps5Enabled ? Color.Error : Color.Success)"
|
||||
OnClick="TogglePs5Control"
|
||||
Disabled="@isLoading">
|
||||
@if (ps5Enabled)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.Stop" Class="mr-2" />
|
||||
<MudText>Disable PS5 Controller</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Class="mr-2" />
|
||||
<MudText>Enable PS5 Controller</MudText>
|
||||
}
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@* Status of PS5 Controller *@
|
||||
@if (ps5State != null)
|
||||
{
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Class="mb-3">PS5 Controller Status</MudText>
|
||||
<MudStack Spacing="2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>State:</MudText>
|
||||
<MudChip T="string" Color="@(ps5Enabled ? Color.Success : Color.Default)" Size="Size.Small">
|
||||
@ps5State
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter] public MotionHubClient? HubClient { get; set; }
|
||||
[Parameter] public bool IsHubReady { get; set; }
|
||||
[Inject] private HttpClient Http { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private string? ps5State;
|
||||
private bool ps5Enabled => string.Equals(ps5State, "Active", StringComparison.OrdinalIgnoreCase);
|
||||
private bool isLoading = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshStatus();
|
||||
}
|
||||
|
||||
public async Task RefreshStatus()
|
||||
{
|
||||
if (isLoading) return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
var resp = await Http.GetAsync("/api/motion/ps5/status");
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
var json = await resp.Content.ReadFromJsonAsync<Ps5StatusResponse>();
|
||||
ps5State = json?.State ?? "Unknown";
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to refresh PS controller status: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TogglePs5Control()
|
||||
{
|
||||
if (isLoading) return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
if (ps5Enabled)
|
||||
{
|
||||
var resp = await Http.PostAsync("/api/motion/ps5/disable", null);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
Snackbar.Add("PS controller disabled", Severity.Info);
|
||||
ps5State = "Disabled";
|
||||
}
|
||||
else
|
||||
Snackbar.Add("Failed to disable PS controller", Severity.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
var resp = await Http.PostAsync("/api/motion/ps5/enable", null);
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
Snackbar.Add("PS controller enabled", Severity.Success);
|
||||
ps5State = "Active";
|
||||
}
|
||||
else
|
||||
Snackbar.Add("Failed to enable PS controller", Severity.Error);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
await RefreshStatus();
|
||||
if (ps5State != null) break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Ps5StatusResponse
|
||||
{
|
||||
public string State { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
@using RobotNet10.RobotApp.Client.Shared.Motion
|
||||
@using RobotNet10.Shared.Geometry
|
||||
@using RobotNet10.Shared.Numbers
|
||||
@using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion
|
||||
@using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion
|
||||
@using MudBlazor
|
||||
@implements IDisposable
|
||||
|
||||
<MudPaper Class="pa-4" Style="height: 100%;">
|
||||
<MudStack Spacing="3">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5">Odometry</MudText>
|
||||
</MudStack>
|
||||
|
||||
@* Odometry Information *@
|
||||
@if (Odometry != null)
|
||||
{
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Position</MudText>
|
||||
<MudStack Spacing="2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>X:</MudText>
|
||||
<MudText>@($"{Odometry.PositionX:F3} m")</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Y:</MudText>
|
||||
<MudText>@($"{Odometry.PositionY:F3} m")</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Z:</MudText>
|
||||
<MudText>@($"{Odometry.PositionZ:F3} m")</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Orientation</MudText>
|
||||
<MudStack Spacing="2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Yaw:</MudText>
|
||||
<MudText>@($"{yawDegrees:F2}°")</MudText>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Update Freq:</MudText>
|
||||
<MudChip T="double" Color="@(Odometry.UpdateFrequency > 0 ? Color.Success : Color.Default)" Size="Size.Small">
|
||||
@($"{Odometry.UpdateFrequency:F2} Hz")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">No odometry data available.</MudAlert>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public OdometryDto? Odometry { get; set; }
|
||||
[Parameter] public bool IsHubReady { get; set; }
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private double _smoothedYawDegrees;
|
||||
private bool _hasYaw;
|
||||
|
||||
private double yawDegrees => _smoothedYawDegrees;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Odometry == null)
|
||||
return;
|
||||
|
||||
// Nếu robot gần như đứng yên (ít quay, ít chạy thẳng) thì giữ nguyên yaw
|
||||
var angularZ = Odometry.AngularVelocityZ; // rad/s
|
||||
var linearX = Odometry.LinearVelocityX; // m/s
|
||||
const double angularDeadZone = 0.01; // ~0.57°
|
||||
const double linearDeadZone = 0.005; // 5 mm/s
|
||||
|
||||
var isAlmostStopped =
|
||||
Math.Abs(angularZ) < angularDeadZone &&
|
||||
Math.Abs(linearX) < linearDeadZone;
|
||||
|
||||
// Lần đầu vẫn phải init giá trị
|
||||
if (!_hasYaw)
|
||||
{
|
||||
var q0 = new QuaternionGeometry(Odometry.OrientationX, Odometry.OrientationY, Odometry.OrientationZ, Odometry.OrientationW);
|
||||
_smoothedYawDegrees = q0.ToYawDegrees();
|
||||
_hasYaw = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAlmostStopped)
|
||||
{
|
||||
// Robot đứng yên: không cập nhật yaw để tránh drift chậm
|
||||
return;
|
||||
}
|
||||
|
||||
// Robot đang quay / di chuyển: cho phép yaw thay đổi nhưng có lọc
|
||||
var quaternion = new QuaternionGeometry(Odometry.OrientationX, Odometry.OrientationY, Odometry.OrientationZ, Odometry.OrientationW);
|
||||
var newYaw = quaternion.ToYawDegrees();
|
||||
|
||||
// Normalize delta để tránh nhảy 360° khi wrap-around
|
||||
var delta = newYaw - _smoothedYawDegrees;
|
||||
while (delta > 180.0) delta -= 360.0;
|
||||
while (delta < -180.0) delta += 360.0;
|
||||
|
||||
// Low-pass filter để làm mượt (alpha càng nhỏ càng mượt)
|
||||
const double alpha = 0.3;
|
||||
_smoothedYawDegrees = _smoothedYawDegrees + alpha * delta;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Nothing to dispose currently
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Modules
|
||||
@using MudBlazor
|
||||
|
||||
<MudPaper Class="pa-4" Style="height: 100%;">
|
||||
<MudStack Spacing="3">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.h5">Rotation Module</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
OnClick="RefreshStatus"
|
||||
Disabled="@(!IsHubReady || isLoading)" />
|
||||
</MudStack>
|
||||
|
||||
@* Rotation Status Information *@
|
||||
@if (rotationStatus != null)
|
||||
{
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudText Typo="Typo.h6" Class="mb-3">Module Status</MudText>
|
||||
<MudStack Spacing="2">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>State:</MudText>
|
||||
<MudChip T="string" Color="@GetStateColor(rotationStatus.State)" Size="Size.Small">
|
||||
@rotationStatus.State
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Ready:</MudText>
|
||||
<MudChip T="bool" Color="@(rotationStatus.IsReady ? Color.Success : Color.Default)" Size="Size.Small">
|
||||
@rotationStatus.IsReady
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween">
|
||||
<MudText>Current Angle:</MudText>
|
||||
<MudText>@($"{rotationStatus.CurrentAngle:F2}°")</MudText>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
|
||||
@* Rotation Control Buttons *@
|
||||
<MudStack Spacing="3">
|
||||
<MudText Typo="Typo.h6">Rotation Controls</MudText>
|
||||
|
||||
@* Rotate to Absolute Angle *@
|
||||
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
|
||||
<MudNumericField @bind-Value="targetAngle"
|
||||
Label="Target Angle (°)"
|
||||
Variant="Variant.Outlined"
|
||||
Class="flex-grow-1" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
OnClick="RotateToAngle"
|
||||
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.LocationOn" Class="mr-2" />
|
||||
<MudText>Go</MudText>
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@* Rotate Offset *@
|
||||
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center">
|
||||
<MudNumericField @bind-Value="angleOffset"
|
||||
Label="Offset (°)"
|
||||
Variant="Variant.Outlined"
|
||||
HelperText="+/- for CW/CCW"
|
||||
Class="flex-grow-1" />
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Info"
|
||||
OnClick="RotateOffset"
|
||||
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.RotateRight" Class="mr-2" />
|
||||
<MudText>Offset</MudText>
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@* Quick Rotation Buttons *@
|
||||
<MudStack Row="true" Spacing="3" AlignItems="@AlignItems.Center" Wrap="Wrap.Wrap">
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Secondary"
|
||||
OnClick="() => RotateOffsetQuick(-90)"
|
||||
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.RotateLeft" Class="mr-2" />
|
||||
<MudText>-90°</MudText>
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Secondary"
|
||||
OnClick="() => RotateOffsetQuick(-45)"
|
||||
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.RotateLeft" Class="mr-2" />
|
||||
<MudText>-45°</MudText>
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Secondary"
|
||||
OnClick="() => RotateOffsetQuick(45)"
|
||||
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.RotateRight" Class="mr-2" />
|
||||
<MudText>+45°</MudText>
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Secondary"
|
||||
OnClick="() => RotateOffsetQuick(90)"
|
||||
Disabled="@(isLoading || !IsHubReady || rotationStatus?.IsReady != true)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.RotateRight" Class="mr-2" />
|
||||
<MudText>+90°</MudText>
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public MotionHubClient HubClient { get; set; } = null!;
|
||||
[Parameter] public bool IsHubReady { get; set; }
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private RotationModuleStatusDto? rotationStatus;
|
||||
private bool isLoading = false;
|
||||
private double targetAngle = 0;
|
||||
private double angleOffset = 0;
|
||||
private bool _previousIsHubReady = false;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Detect when IsHubReady changes from false to true
|
||||
if (IsHubReady && !_previousIsHubReady)
|
||||
{
|
||||
await RefreshStatus();
|
||||
}
|
||||
_previousIsHubReady = IsHubReady;
|
||||
}
|
||||
|
||||
public async Task RefreshStatus()
|
||||
{
|
||||
if (isLoading || !IsHubReady) return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
rotationStatus = await HubClient.GetRotationStatusAsync();
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error refreshing rotation status: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"Ready" => Color.Success,
|
||||
"Moving" => Color.Info,
|
||||
"Error" => Color.Error,
|
||||
"Homing" => Color.Warning,
|
||||
"Initializing" => Color.Warning,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private async Task RotateToAngle()
|
||||
{
|
||||
if (!IsHubReady || isLoading || rotationStatus?.IsReady != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
Snackbar.Add($"Rotating to angle {targetAngle}°...", Severity.Info);
|
||||
|
||||
await HubClient.RotateToAngleAsync(targetAngle);
|
||||
|
||||
Snackbar.Add($"Rotate to angle {targetAngle}° command sent successfully", Severity.Success);
|
||||
|
||||
await Task.Delay(500);
|
||||
await RefreshStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error rotating to angle: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RotateOffset()
|
||||
{
|
||||
if (!IsHubReady || isLoading || rotationStatus?.IsReady != true)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
var offsetText = angleOffset >= 0 ? $"+{angleOffset}°" : $"{angleOffset}°";
|
||||
Snackbar.Add($"Rotating offset {offsetText}...", Severity.Info);
|
||||
|
||||
await HubClient.RotateOffsetAsync(angleOffset);
|
||||
|
||||
Snackbar.Add($"Rotate offset {offsetText} command sent successfully", Severity.Success);
|
||||
|
||||
await Task.Delay(500);
|
||||
await RefreshStatus();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error rotating offset: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RotateOffsetQuick(double offset)
|
||||
{
|
||||
angleOffset = offset;
|
||||
await RotateOffset();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@using MudBlazor
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Delete Map</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText>Are you sure you want to delete map "@MapName"? This action cannot be undone.</MudText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Error" Variant="Variant.Filled" OnClick="Submit">Delete</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
[Parameter] public string MapName { get; set; } = string.Empty;
|
||||
|
||||
void Cancel() => MudDialog.Cancel();
|
||||
void Submit() => MudDialog.Close(DialogResult.Ok(true));
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<!-- Robot goal pose: line robot->goal, line goal->mouse (with arrow), circle at goal with radius = distance(goal,mouse).
|
||||
_goalOrientationRad được tính trong SetMousePosition: hướng từ goal đến mouse; 0 khi goal trùng mouse. -->
|
||||
<g visibility="@_visibility" @ref="_groupRef">
|
||||
<defs>
|
||||
<marker id="@_arrowMarkerId" markerWidth="0.15" markerHeight="0.15" refX="0" refY="0.075" orient="auto" markerUnits="userSpaceOnUse">
|
||||
<path d="M 0 0 L 0.15 0.075 L 0 0.15 Z" fill="#FF0000" stroke="none" />
|
||||
</marker>
|
||||
</defs>
|
||||
<!-- Line 1: RobotPosition -> GoalPosition (red, dashed, 0.1); zero length when robot==goal is valid -->
|
||||
<line x1="@_robotX" y1="@_robotY" x2="@_goalX" y2="@_goalY"
|
||||
stroke="green" stroke-width="0.03" stroke-dasharray="0.3,0.2" fill="none" />
|
||||
<!-- Line 2: GoalPosition -> current mouse (blue, dashed, 0.1, arrow at mouse) -->
|
||||
<line x1="@_goalX" y1="@_goalY" x2="@_mouseX" y2="@_mouseY"
|
||||
stroke="#FF0000" stroke-width="0.03" stroke-dasharray="0.3,0.2" fill="none"
|
||||
marker-end="url(#@_arrowMarkerId)" />
|
||||
<!-- Circle: center GoalPosition, radius = distance(Goal, mouse); r=0 when goal==mouse is valid -->
|
||||
<circle cx="@_goalX" cy="@_goalY" r="@_radius"
|
||||
stroke="green" stroke-width="0.03" stroke-dasharray="0.3,0.2" fill="none" />
|
||||
</g>
|
||||
|
||||
@code {
|
||||
private ElementReference _groupRef;
|
||||
private string _arrowMarkerId = "goal-mouse-arrow-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
private double _robotX;
|
||||
private double _robotY;
|
||||
private double _goalX;
|
||||
private double _goalY;
|
||||
private double _goalOrientationRad;
|
||||
private double _mouseX;
|
||||
private double _mouseY;
|
||||
private string _visibility = "hidden";
|
||||
|
||||
/// <summary>Tọa độ X điểm đích (world coordinates).</summary>
|
||||
public double GoalX => _goalX;
|
||||
/// <summary>Tọa độ Y điểm đích (world coordinates).</summary>
|
||||
public double GoalY => _goalY;
|
||||
/// <summary>Hướng điểm đích (yaw, radians) — hướng từ goal đến mouse; 0 khi goal trùng mouse.</summary>
|
||||
public double GoalYaw => _goalOrientationRad;
|
||||
|
||||
/// <summary>Bán kính circle = khoảng cách Goal -> mouse; 0 khi goal trùng mouse (hợp lệ).</summary>
|
||||
private double _radius => Math.Sqrt((_mouseX - _goalX) * (_mouseX - _goalX) + (_mouseY - _goalY) * (_mouseY - _goalY));
|
||||
|
||||
/// <summary>
|
||||
/// Hiển thị nhóm line và circle (robot->goal, goal->mouse, circle).
|
||||
/// </summary>
|
||||
public void Show()
|
||||
{
|
||||
_visibility = "visible";
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ẩn nhóm line và circle.
|
||||
/// </summary>
|
||||
public void Hide()
|
||||
{
|
||||
_visibility = "hidden";
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Đặt tọa độ robot (world coordinates).
|
||||
/// </summary>
|
||||
public void SetRobotPosition(double x, double y)
|
||||
{
|
||||
_robotX = x;
|
||||
_robotY = y;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Đặt tọa độ điểm đích (world coordinates).
|
||||
/// </summary>
|
||||
public void SetGoalPosition(double x, double y)
|
||||
{
|
||||
_goalX = x;
|
||||
_goalY = y;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cập nhật vị trí chuột (world coordinates). Parent gọi từ OnMouseMove để vẽ line goal->mouse và circle.
|
||||
/// Tự tính _goalOrientationRad = hướng từ goal đến mouse (radians); 0 khi goal trùng mouse.
|
||||
/// </summary>
|
||||
public void SetMousePosition(double x, double y)
|
||||
{
|
||||
_mouseX = x;
|
||||
_mouseY = y;
|
||||
var dx = _mouseX - _goalX;
|
||||
var dy = _mouseY - _goalY;
|
||||
_goalOrientationRad = (Math.Abs(dx) < 1e-9 && Math.Abs(dy) < 1e-9) ? 0 : Math.Atan2(dy, dx);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private const double OptimizeMinDistanceMeters = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Nếu khoảng cách từ (GoalX, GoalY) đến (mouseX, mouseY) < 1 m thì gọi Hide().
|
||||
/// Nếu khoảng cách >= 1 m thì đặt lại _mouseX, _mouseY sao cho khoảng cách đúng 1 m (giữ hướng).
|
||||
/// Tọa độ trong SVG là mét (viewBox + scaleY(-1) trong MapLocalization).
|
||||
/// </summary>
|
||||
public void Optimize()
|
||||
{
|
||||
var dx = _mouseX - _goalX;
|
||||
var dy = _mouseY - _goalY;
|
||||
var d = Math.Sqrt(dx * dx + dy * dy);
|
||||
if (d < OptimizeMinDistanceMeters)
|
||||
{
|
||||
Hide();
|
||||
return;
|
||||
}
|
||||
var scale = OptimizeMinDistanceMeters / d;
|
||||
_mouseX = _goalX + dx * scale;
|
||||
_mouseY = _goalY + dy * scale;
|
||||
_goalOrientationRad = (Math.Abs(dx) < 1e-9 && Math.Abs(dy) < 1e-9) ? 0 : Math.Atan2(dy, dx);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
@page "/map/{MapName}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Shared.SLAM
|
||||
@using Microsoft.JSInterop
|
||||
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Map: @MapName</PageTitle>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<div class="map-edit-page">
|
||||
@* Control Bar *@
|
||||
<div class="control-bar">
|
||||
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
|
||||
<MudTooltip Text="Back to Maps">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ArrowBack"
|
||||
Color="Color.Default"
|
||||
Size="Size.Medium" Href="/maps" />
|
||||
</MudTooltip>
|
||||
<MudDivider Vertical="true" FlexItem="true" Class="my-1" />
|
||||
<MudTooltip Text="Fit to View">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FitScreen"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Medium"
|
||||
OnClick="FitViewAsync"
|
||||
Disabled="@(!IsMapLoaded)" />
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</div>
|
||||
|
||||
<div class="content-area">
|
||||
@* Processing Overlay *@
|
||||
<MudOverlay Visible="@MapInfo.IsProcessing" Absolute AutoClose="false" DarkBackground="true" ZIndex="1000">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Large" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Processing map...</MudText>
|
||||
</MudStack>
|
||||
</MudOverlay>
|
||||
|
||||
@* Info Bar *@
|
||||
<div class="info-bar">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Map Information</MudText>
|
||||
<MudStack Spacing="1">
|
||||
<div class="info-item">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Map" Size="Size.Small" Class="mr-1" />
|
||||
<MudText Typo="Typo.body2"><strong>Name:</strong> @MapInfo.Name</MudText>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<MudIcon Icon="@Icons.Material.Filled.CalendarToday" Size="Size.Small" Class="mr-1" />
|
||||
<MudText Typo="Typo.body2"><strong>Created:</strong> @MapInfo.CreatedDate.ToString("yyyy-MM-dd HH:mm")</MudText>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Straighten" Size="Size.Small" Class="mr-1" />
|
||||
<MudText Typo="Typo.body2"><strong>Resolution:</strong> @MapInfo.Resolution.ToString("F3") m/p</MudText>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<MudIcon Icon="@Icons.Material.Filled.AspectRatio" Size="Size.Small" Class="mr-1" />
|
||||
<MudText Typo="Typo.body2"><strong>Size:</strong> @MapInfo.Width.ToString("F1") x @MapInfo.Height.ToString("F1") m</MudText>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Timeline" Size="Size.Small" Class="mr-1" />
|
||||
<MudText Typo="Typo.body2"><strong>Nodes:</strong> @MapInfo.TrajectoryNodeCount</MudText>
|
||||
</div>
|
||||
<div class="info-item info-item-origin">
|
||||
<MudIcon Icon="@Icons.Material.Filled.MyLocation" Size="Size.Small" Class="mr-1" />
|
||||
<MudText Typo="Typo.body2"><strong>Origin:</strong> (@MapInfo.OriginX.ToString("F3"), @MapInfo.OriginY.ToString("F3"))</MudText>
|
||||
<MudSpacer />
|
||||
<MudTooltip Text="Edit Origin">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
OnClick="OpenEditOriginDialog"
|
||||
Disabled="@(!IsMapLoaded)" />
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudDivider Class="my-2" />
|
||||
<MudButton Variant="Variant.Outlined"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="OpenRerenderDialog"
|
||||
Disabled="@(!IsMapLoaded || MapInfo.IsProcessing)"
|
||||
FullWidth="true"
|
||||
Size="Size.Small">
|
||||
Rerender Map
|
||||
</MudButton>
|
||||
<MudOverlay Visible="@IsLoading" Absolute AutoClose="false">
|
||||
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
||||
</MudOverlay>
|
||||
</div>
|
||||
|
||||
@* Map View - structure similar to MapLocalization *@
|
||||
<div class="map-localization-container" @ref="containerRef" tabindex="1">
|
||||
<div @ref="viewMovementRef" class="map-view-movement">
|
||||
@* Map image with Y-flip (like map-canvas in MapLocalization) *@
|
||||
<img @ref="mapImageRef"
|
||||
src="@MapImageUrl"
|
||||
alt="Map"
|
||||
class="map-canvas" />
|
||||
@* SVG overlay for origin marker (like map-editor in MapLocalization) *@
|
||||
<svg @ref="mapContainerRef" class="map-editor" viewBox="0 0 0 0">
|
||||
<defs>
|
||||
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.1" refY="0.1">
|
||||
<line x1="0" y1="0.1" x2="0.5" y2="0.1" stroke="red" stroke-width="0.02" />
|
||||
<path d="M 0.5 0.15 L 0.6 0.1 L 0.5 0.05 Z" fill="red" stroke-width="0" />
|
||||
<line x1="0.1" y1="0" x2="0.1" y2="0.5" stroke="blue" stroke-width="0.02" />
|
||||
<path d="M 0.05 0.5 L 0.1 0.6 L 0.15 0.5 Z" fill="blue" stroke-width="0" />
|
||||
</marker>
|
||||
</defs>
|
||||
@* Robot goal pose component *@
|
||||
<GoalPose @ref="GoalPoseRef" />
|
||||
@* Grid origin marker *@
|
||||
<line class="origin" marker-end="url(#originvector)" />
|
||||
</svg>
|
||||
</div>
|
||||
<MapMousePosition @ref="MapMousePositionRef" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Edit Origin Dialog *@
|
||||
<MudDialog @bind-Visible="_editOriginDialogVisible" Options="_editOriginDialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">
|
||||
<MudIcon Icon="@Icons.Material.Filled.MyLocation" Class="mr-2" />
|
||||
Edit Map Origin
|
||||
</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Set the new origin position from the selected goal pose on the map.
|
||||
</MudText>
|
||||
<MudStack Spacing="2">
|
||||
<MudNumericField @bind-Value="_editOriginX" Label="X (meters)" Variant="Variant.Outlined" />
|
||||
<MudNumericField @bind-Value="_editOriginY" Label="Y (meters)" Variant="Variant.Outlined" />
|
||||
<MudNumericField @bind-Value="_editOriginYaw" Label="@(_editOriginYawUseRadian ? "Yaw (radians)" : "Yaw (degrees)")" Variant="Variant.Outlined"
|
||||
Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.CompareArrows"
|
||||
OnAdornmentClick="ToggleRadianDegree" AdornmentAriaLabel="Toggle radian/degree" />
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseEditOriginDialog">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="ConfirmEditOrigin">Confirm</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@* Rerender Map Dialog *@
|
||||
<MudDialog @bind-Visible="_rerenderDialogVisible" Options="_rerenderDialogOptions">
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Refresh" Class="mr-2" />
|
||||
Rerender Map with Custom Config
|
||||
</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudText Typo="Typo.body2" Class="mb-3">
|
||||
Configure occupancy grid settings and rerender map image files (PNG, JPG, PGM).
|
||||
</MudText>
|
||||
<MudStack Spacing="2">
|
||||
@* Merge Strategy *@
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Merge Strategy</MudText>
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudSelect T="SubmapMergeStrategyDto" @bind-Value="_rerenderConfig.MergeStrategy" Label="Merge Strategy" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value="SubmapMergeStrategyDto.LogOddsSum">Log-Odds Sum (Bayesian)</MudSelectItem>
|
||||
<MudSelectItem Value="SubmapMergeStrategyDto.MaxProbability">Max Probability (Conservative)</MudSelectItem>
|
||||
<MudSelectItem Value="SubmapMergeStrategyDto.PorterDuff">Porter-Duff (Cairo-style)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 300px;">
|
||||
<strong>Submap merge strategy:</strong><br/>
|
||||
• <b>Log-Odds Sum:</b> Sum Bayesian log-odds — clearer walls, less noise<br/>
|
||||
• <b>Max Probability:</b> Take max probability — safer for navigation, thicker walls<br/>
|
||||
• <b>Porter-Duff:</b> Cairo-style blending — matches the original C++ behavior; may blur in overlap areas
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.LogOddsClamp" Label="Log-Odds Clamp (1-20)" Variant="Variant.Outlined" Min="1.0" Max="20.0" Step="0.5" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Log-odds clamp:</strong><br/>
|
||||
• Low (1-5): smoother image, lower contrast<br/>
|
||||
• High (10-20): sharper walls, higher contrast<br/>
|
||||
• Default: 10 — balance between clarity and noise
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudCheckBox @bind-Value="_rerenderConfig.UseLogOddsAverage" Label="Use Log-Odds Average" Dense="true" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Use log-odds average:</strong><br/>
|
||||
• <b>On:</b> Divide log-odds by observation count — more uniform in overlap areas<br/>
|
||||
• <b>Off:</b> Sum directly — areas scanned more often become darker/lighter
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudDivider Class="my-1" />
|
||||
|
||||
@* Threshold Configuration *@
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Threshold Configuration</MudText>
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.FreeSpaceThreshold" Label="Free Space Threshold (0-255)" Variant="Variant.Outlined" Min="0" Max="255" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Free-space threshold:</strong><br/>
|
||||
Pixels with texture value >= this threshold are marked as FREE (white).<br/>
|
||||
• Low (50-80): more areas become free<br/>
|
||||
• High (150-200): only very certain areas become free<br/>
|
||||
• Default: 100
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.OccupiedSpaceThreshold" Label="Occupied Space Threshold (0-255)" Variant="Variant.Outlined" Min="0" Max="255" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Occupied-space threshold:</strong><br/>
|
||||
Pixels with alpha value > this threshold are marked as OCCUPIED (black).<br/>
|
||||
• Low (1-10): thicker walls, more sensitive to obstacles<br/>
|
||||
• High (50-100): only strong walls are drawn<br/>
|
||||
• Default: 1 (most sensitive)
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudDivider Class="my-1" />
|
||||
|
||||
@* Output Mode *@
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Output Mode</MudText>
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudCheckBox @bind-Value="_rerenderConfig.UseBinaryOutput" Label="Binary Output (0/100/-1)" Dense="true" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Binary output mode:</strong><br/>
|
||||
• <b>On:</b> Only 3 values: 0 (white/free), 100 (black/wall), -1 (gray/unknown). Suitable for MCL/Navigation.<br/>
|
||||
• <b>Off:</b> Gradient 0-100 values. Shows detailed occupancy probability.
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudDivider Class="my-1" />
|
||||
|
||||
@* Wall Thinning *@
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Wall Thinning (Post-processing)</MudText>
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudCheckBox @bind-Value="_rerenderConfig.EnableWallThinning" Label="Enable Wall Thinning" Dense="true" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Wall thinning:</strong><br/>
|
||||
Applies morphological erosion to reduce wall thickness.<br/>
|
||||
• <b>On:</b> Thinner walls; the robot can pass narrow corridors more easily<br/>
|
||||
• <b>Off:</b> Keep original wall thickness
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.WallThinningIterations" Label="Thinning Iterations (1-5)" Variant="Variant.Outlined" Min="1" Max="5" Disabled="@(!_rerenderConfig.EnableWallThinning)" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Thinning iterations:</strong><br/>
|
||||
Each iteration erodes ~1 pixel from the wall boundary.<br/>
|
||||
• 1 iteration: slightly thinner (~1 pixel)<br/>
|
||||
• 3-5 iterations: significantly thinner; walls may break/disconnect
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.MinWallThicknessPixels" Label="Min Wall Thickness (pixels, 1-10)" Variant="Variant.Outlined" Min="1" Max="10" Disabled="@(!_rerenderConfig.EnableWallThinning)" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Minimum wall thickness:</strong><br/>
|
||||
Do not erode if the wall is thinner than this value.<br/>
|
||||
• 1 pixel: allows very thin walls (may break)<br/>
|
||||
• 2-3 pixels: safer, preserves wall structure<br/>
|
||||
• With 0.05 m resolution: 2 pixels ≈ 10 cm real wall thickness
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudDivider Class="my-1" />
|
||||
|
||||
@* Ambiguous Cell Handling *@
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Ambiguous Cell Handling</MudText>
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudSelect T="sbyte" @bind-Value="_rerenderConfig.AmbiguousCellValue" Label="Ambiguous Cell Value" Variant="Variant.Outlined" Dense="true">
|
||||
<MudSelectItem Value="@((sbyte)-1)">Unknown (-1)</MudSelectItem>
|
||||
<MudSelectItem Value="@((sbyte)0)">Free (0)</MudSelectItem>
|
||||
<MudSelectItem Value="@((sbyte)100)">Occupied (100)</MudSelectItem>
|
||||
</MudSelect>
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Ambiguous cell value:</strong><br/>
|
||||
Cells with probability in the ambiguous range are assigned this value.<br/>
|
||||
• <b>Unknown (-1):</b> Gray; ignored by MCL — safest<br/>
|
||||
• <b>Free (0):</b> White; robot may pass — riskier<br/>
|
||||
• <b>Occupied (100):</b> Black; robot avoids — conservative
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.AmbiguousRangeLower" Label="Ambiguous Range Lower (0-1)" Variant="Variant.Outlined" Min="0.0" Max="1.0" Step="0.05" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Ambiguous range lower:</strong><br/>
|
||||
Probabilities below this value are considered FREE.<br/>
|
||||
• 0.35 (default): 0–35% is free<br/>
|
||||
• Lower to 0.2: stricter, fewer free areas<br/>
|
||||
• Raise to 0.45: more areas become free
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.AmbiguousRangeUpper" Label="Ambiguous Range Upper (0-1)" Variant="Variant.Outlined" Min="0.0" Max="1.0" Step="0.05" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Ambiguous range upper:</strong><br/>
|
||||
Probabilities above this value are considered OCCUPIED.<br/>
|
||||
• 0.65 (default): 65–100% is wall<br/>
|
||||
• Lower to 0.55: more walls (safer)<br/>
|
||||
• Raise to 0.8: only very certain areas become walls
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudDivider Class="my-1" />
|
||||
|
||||
@* Advanced Options *@
|
||||
<MudText Typo="Typo.subtitle2" Color="Color.Primary">Advanced Options</MudText>
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudCheckBox @bind-Value="_rerenderConfig.EnableMedianFilter" Label="Enable Median Filter" Dense="true" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Median filter:</strong><br/>
|
||||
Reduces salt-and-pepper noise.<br/>
|
||||
• <b>On:</b> Smoother image, removes isolated noisy pixels<br/>
|
||||
• <b>Off:</b> Keeps original details, may contain noise
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Placement="Placement.Right" Arrow="true">
|
||||
<ChildContent>
|
||||
<MudNumericField @bind-Value="_rerenderConfig.MedianFilterKernelSize" Label="Median Filter Kernel Size (3,5,7)" Variant="Variant.Outlined" Min="3" Max="7" Step="2" Disabled="@(!_rerenderConfig.EnableMedianFilter)" />
|
||||
</ChildContent>
|
||||
<TooltipContent>
|
||||
<MudText Typo="Typo.body2" Style="max-width: 280px;">
|
||||
<strong>Filter kernel size:</strong><br/>
|
||||
Neighborhood used to compute the median.<br/>
|
||||
• 3x3: light filtering, preserves details<br/>
|
||||
• 5x5: medium filtering<br/>
|
||||
• 7x7: strong filtering, may blur wall edges
|
||||
</MudText>
|
||||
</TooltipContent>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="CloseRerenderDialog">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="ConfirmRerender">Rerender</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
@@ -0,0 +1,604 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using MudBlazor;
|
||||
using RobotNet10.RobotApp.Client.Clients;
|
||||
using RobotNet10.RobotApp.Client.Shared.SLAM;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
using RobotNet10.Shared.Numbers;
|
||||
using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion;
|
||||
using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Components.SLAM;
|
||||
|
||||
public partial class MapEdit
|
||||
{
|
||||
private const double MinFitScale = 0.5;
|
||||
|
||||
[Parameter]
|
||||
public string MapName { get; set; } = string.Empty;
|
||||
|
||||
[Inject]
|
||||
private SLAMClient SLAMClient { get; set; } = null!;
|
||||
|
||||
private IJSObjectReference _jsModule = null!;
|
||||
private DotNetObjectReference<MapEdit> _dotNetObj = null!;
|
||||
|
||||
// Element references
|
||||
private ElementReference containerRef;
|
||||
private ElementReference viewMovementRef;
|
||||
private ElementReference mapContainerRef;
|
||||
private ElementReference mapImageRef;
|
||||
private MapMousePosition MapMousePositionRef = null!;
|
||||
private GoalPose GoalPoseRef = null!;
|
||||
|
||||
// State
|
||||
private MapInfoDto MapInfo { get; set; } = new();
|
||||
private bool IsLoading { get; set; } = true;
|
||||
private bool IsMapLoaded => MapInfo != null && _imageLoaded;
|
||||
private bool _imageLoaded = false;
|
||||
|
||||
// Map image URL with cache busting
|
||||
private long _imageCacheBuster = DateTime.Now.Ticks;
|
||||
private string MapImageUrl => $"/api/maps/{MapName}/image?v={_imageCacheBuster}";
|
||||
|
||||
// Container rect state
|
||||
private double _containerRectX = 0.0;
|
||||
private double _containerRectY = 0.0;
|
||||
private double _containerRectWidth = 0.0;
|
||||
private double _containerRectHeight = 0.0;
|
||||
private double _containerRectTop = 0.0;
|
||||
private double _containerRectRight = 0.0;
|
||||
private double _containerRectBottom = 0.0;
|
||||
private double _containerRectLeft = 0.0;
|
||||
|
||||
// View state
|
||||
public double CursorX { get; private set; } = 0.0;
|
||||
public double CursorY { get; private set; } = 0.0;
|
||||
private double _clientOriginX = 0.0;
|
||||
private double _clientOriginY = 0.0;
|
||||
private double _scale = 1.0;
|
||||
private double _left = 0.0;
|
||||
private double _top = 0.0;
|
||||
private double _fitScale = 1.0;
|
||||
|
||||
// Map data
|
||||
private double _resolution = 1.0;
|
||||
private double _originX = 0.0;
|
||||
private double _originY = 0.0; // Transformed origin Y (like MapLocalization)
|
||||
private double _mapOriginY = 0.0; // Original origin Y from map (like MapLocalization._mapOriginY)
|
||||
private double _imageWidth = 0.0;
|
||||
private double _imageHeight = 0.0;
|
||||
private int _imagePixelWidth = 0;
|
||||
private int _imagePixelHeight = 0;
|
||||
|
||||
// SVG viewBox origin (like MapLocalization._svgOriginX and _svgOriginY)
|
||||
private double _svgOriginX = 0.0;
|
||||
private double _svgOriginY = 0.0;
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
|
||||
// Load JavaScript module
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "/js/mapLocalization.js");
|
||||
|
||||
// Setup event listeners
|
||||
_dotNetObj = DotNetObjectReference.Create(this);
|
||||
await _jsModule.InvokeVoidAsync("updateContainerRect", _dotNetObj, containerRef, nameof(OnContainerResize));
|
||||
await _jsModule.InvokeVoidAsync("registerResizeObserver", _dotNetObj, containerRef, nameof(OnContainerResize));
|
||||
await _jsModule.InvokeVoidAsync("addMouseWheelEventListener", _dotNetObj, containerRef, nameof(OnMouseWheel));
|
||||
await _jsModule.InvokeVoidAsync("addMouseMoveEventListener", _dotNetObj, containerRef, nameof(OnMouseMove));
|
||||
await _jsModule.InvokeVoidAsync("addMouseDownEventListener", _dotNetObj, containerRef, nameof(OnMouseDown));
|
||||
await _jsModule.InvokeVoidAsync("addMouseUpEventListener", _dotNetObj, containerRef, nameof(OnMouseUp));
|
||||
|
||||
// Start SLAMClient and register event handler
|
||||
await SLAMClient.StartAsync();
|
||||
SLAMClient.IsProcessingChanged += OnIsProcessingChanged;
|
||||
|
||||
// Load map info
|
||||
await LoadMapInfoAsync();
|
||||
await OnImageLoaded();
|
||||
}
|
||||
|
||||
private async Task LoadMapInfoAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
MapInfo = await SLAMClient.GetMapInfoAndSubscribeProcessingAsync(MapName) ?? new();
|
||||
if (!string.IsNullOrEmpty(MapInfo.Name))
|
||||
{
|
||||
_resolution = MapInfo.Resolution;
|
||||
_imageWidth = MapInfo.Width;
|
||||
_imageHeight = MapInfo.Height;
|
||||
_originX = MapInfo.OriginX;
|
||||
_mapOriginY = MapInfo.OriginY; // Original origin Y from map
|
||||
// Transformed origin Y (like MapLocalization: _originY = -imageHeight - mapOriginY)
|
||||
_originY = -_imageHeight - _mapOriginY;
|
||||
|
||||
// Calculate pixel dimensions from world dimensions and resolution
|
||||
if (_resolution > 0)
|
||||
{
|
||||
_imagePixelWidth = (int)Math.Round(_imageWidth / _resolution);
|
||||
_imagePixelHeight = (int)Math.Round(_imageHeight / _resolution);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load map info: {ex.Message}", MudBlazor.Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnIsProcessingChanged(string mapName, bool isProcessing)
|
||||
{
|
||||
if (mapName == MapName)
|
||||
{
|
||||
var wasProcessing = MapInfo.IsProcessing;
|
||||
MapInfo.IsProcessing = isProcessing;
|
||||
|
||||
// When processing completes (true -> false), reload map info and update image
|
||||
if (wasProcessing && !isProcessing)
|
||||
{
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
// Update cache buster to force image reload
|
||||
_imageCacheBuster = DateTime.Now.Ticks;
|
||||
|
||||
// Reload map info to get updated data
|
||||
await LoadMapInfoAsync();
|
||||
|
||||
// Force reload image with reset CSS styles and get natural dimensions
|
||||
await ReloadImageAsync();
|
||||
|
||||
GoalPoseRef.Hide();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadImageAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pixel dimensions already calculated in LoadMapInfoAsync from MapInfo
|
||||
_imageLoaded = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Wait for DOM update and image reload
|
||||
await Task.Delay(100);
|
||||
|
||||
// Configure SVG viewBox with world coordinates first
|
||||
await ConfigureSvgViewBoxAsync();
|
||||
|
||||
// Then fit to view (scales SVG to pixel coords)
|
||||
await FitViewAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapEdit] ReloadImageAsync: Exception - {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnImageLoaded()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Pixel dimensions already calculated in LoadMapInfoAsync from MapInfo
|
||||
_imageLoaded = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Wait for DOM update and image load
|
||||
await Task.Delay(100);
|
||||
|
||||
// Configure SVG viewBox with world coordinates first
|
||||
await ConfigureSvgViewBoxAsync();
|
||||
|
||||
// Then fit to view (scales SVG to pixel coords)
|
||||
await FitViewAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapEdit] OnImageLoaded: Exception - {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region View & Scale
|
||||
|
||||
public async Task FitViewAsync()
|
||||
{
|
||||
if (!IsMapLoaded || _imageWidth <= 0 || _imageHeight <= 0) return;
|
||||
|
||||
// Recalculate fit scale
|
||||
if (_containerRectWidth > 0 && _containerRectHeight > 0)
|
||||
{
|
||||
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
|
||||
if (_fitScale < MinFitScale)
|
||||
_fitScale = MinFitScale;
|
||||
}
|
||||
|
||||
await ScaleFitContentAsync();
|
||||
}
|
||||
|
||||
private void UpdateClientOrigin()
|
||||
{
|
||||
if (!IsMapLoaded) return;
|
||||
|
||||
_clientOriginX = _containerRectLeft + _left - _originX * _scale;
|
||||
_clientOriginY = _containerRectTop + _top - _originY * _scale;
|
||||
}
|
||||
|
||||
private async Task ScaleFitContentAsync()
|
||||
{
|
||||
if (!IsMapLoaded) return;
|
||||
|
||||
_scale = _fitScale;
|
||||
|
||||
var wrapperWidth = _imageWidth * _scale;
|
||||
var wrapperHeight = _imageHeight * _scale;
|
||||
|
||||
var centerLeft = (_containerRectWidth - wrapperWidth) / 2;
|
||||
var centerTop = (_containerRectHeight - wrapperHeight) / 2;
|
||||
|
||||
await SetViewMovement(centerLeft, centerTop);
|
||||
|
||||
// Set SVG rect (pixel coords) and image sizes (like MapLocalization.ScaleFitContentAsync)
|
||||
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, wrapperWidth, wrapperHeight);
|
||||
await _jsModule.InvokeVoidAsync("setCanvasRect", mapImageRef, wrapperWidth, wrapperHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure SVG viewBox with world coordinates (like MapLocalization.DrawOccupancyGridAsync)
|
||||
/// </summary>
|
||||
private async Task ConfigureSvgViewBoxAsync()
|
||||
{
|
||||
if (!IsMapLoaded || _jsModule == null) return;
|
||||
|
||||
// Update SVG viewBox origin
|
||||
_svgOriginX = _originX;
|
||||
_svgOriginY = _mapOriginY;
|
||||
|
||||
// Set SVG config with world coordinates for viewBox
|
||||
await _jsModule.InvokeVoidAsync("setSvgConfig", mapContainerRef, _imageWidth, _imageHeight, _svgOriginX, _svgOriginY);
|
||||
}
|
||||
|
||||
private async Task SetViewMovement(double left, double top)
|
||||
{
|
||||
_top = top;
|
||||
_left = left;
|
||||
|
||||
UpdateClientOrigin();
|
||||
|
||||
if (_jsModule != null && IsMapLoaded)
|
||||
{
|
||||
var width = _imageWidth * _scale;
|
||||
var height = _imageHeight * _scale;
|
||||
await _jsModule.InvokeVoidAsync("setMapMovement", viewMovementRef, _top, _left, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers (JSInvokable)
|
||||
|
||||
[JSInvokable]
|
||||
public void OnContainerResize(double x, double y, double width, double height, double top, double right, double bottom, double left)
|
||||
{
|
||||
_containerRectX = x;
|
||||
_containerRectY = y;
|
||||
_containerRectWidth = width;
|
||||
_containerRectHeight = height;
|
||||
_containerRectTop = top;
|
||||
_containerRectRight = right;
|
||||
_containerRectBottom = bottom;
|
||||
_containerRectLeft = left;
|
||||
|
||||
UpdateClientOrigin();
|
||||
|
||||
// Recalculate fit scale
|
||||
if (_imageWidth > 0 && _imageHeight > 0)
|
||||
{
|
||||
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseWheel(double deltaY, double clientX, double clientY)
|
||||
{
|
||||
if (!IsMapLoaded) return;
|
||||
|
||||
// Calculate scale change
|
||||
double scaleChange;
|
||||
if (deltaY > 0)
|
||||
{
|
||||
if (_scale <= _fitScale / 2) return;
|
||||
scaleChange = _scale > _fitScale ? -(_scale / _fitScale) : -0.1;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_scale >= _fitScale * 100) return;
|
||||
scaleChange = _scale < _fitScale ? 0.5 : (_scale / _fitScale);
|
||||
}
|
||||
|
||||
double oldScale = _scale;
|
||||
_scale += scaleChange;
|
||||
|
||||
// Set SVG rect (pixel coords) and image sizes (like MapLocalization)
|
||||
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, _imageWidth * _scale, _imageHeight * _scale);
|
||||
await _jsModule.InvokeVoidAsync("setCanvasRect", mapImageRef, _imageWidth * _scale, _imageHeight * _scale);
|
||||
|
||||
// Calculate cursor position in world coordinates (exactly like MapLocalization)
|
||||
CursorX = (clientX - _clientOriginX) / oldScale;
|
||||
CursorY = (_clientOriginY - clientY) / oldScale;
|
||||
MapMousePositionRef?.Update(CursorX, CursorY);
|
||||
|
||||
// Calculate mouse position relative to map origin (exactly like MapLocalization)
|
||||
// MapLocalization: mouseX = CursorX - OriginX
|
||||
// MapLocalization: mouseY = CursorY - MapData.OriginY (use original origin Y, not transformed)
|
||||
double mouseX = CursorX - _originX;
|
||||
double mouseY = CursorY - _mapOriginY;
|
||||
|
||||
// Calculate movement adjustment (exactly like MapLocalization)
|
||||
await SetViewMovement(_left - mouseX * scaleChange, _top - (_imageHeight - mouseY) * scaleChange);
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseMove(double clientX, double clientY, long buttons, bool ctrlKey, double movementX, double movementY)
|
||||
{
|
||||
// Calculate cursor position in world coordinates
|
||||
CursorX = (clientX - _clientOriginX) / _scale;
|
||||
CursorY = (_clientOriginY - clientY) / _scale;
|
||||
MapMousePositionRef?.Update(CursorX, CursorY);
|
||||
|
||||
// Left mouse button down: update goal pose to current mouse
|
||||
if (buttons == 1)
|
||||
{
|
||||
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
|
||||
}
|
||||
// Middle mouse button for panning
|
||||
else if (buttons == 4)
|
||||
{
|
||||
await SetViewMovement(_left + movementX, _top + movementY);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseDown(int button, bool altKey, bool ctrlKey, bool shiftKey)
|
||||
{
|
||||
if (button == 0) // Left mouse button: set goal pose
|
||||
{
|
||||
// For MapEdit, robot position is at origin (0, 0) since we don't have live robot pose
|
||||
GoalPoseRef?.SetRobotPosition(0, 0);
|
||||
GoalPoseRef?.SetGoalPosition(CursorX, CursorY);
|
||||
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
|
||||
GoalPoseRef?.Show();
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseUp(int button, bool altKey, bool ctrlKey, bool shiftKey)
|
||||
{
|
||||
if (button == 0) // Left mouse button up: apply Optimize
|
||||
{
|
||||
GoalPoseRef?.Optimize();
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Edit Origin Dialog
|
||||
|
||||
// Dialog state
|
||||
private bool _editOriginDialogVisible = false;
|
||||
private readonly DialogOptions _editOriginDialogOptions = new()
|
||||
{
|
||||
CloseOnEscapeKey = false,
|
||||
CloseButton = true,
|
||||
BackdropClick = false,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
// Dialog form values
|
||||
private double _editOriginX = 0.0;
|
||||
private double _editOriginY = 0.0;
|
||||
private double _editOriginYaw = 0.0;
|
||||
private bool _editOriginYawUseRadian = true;
|
||||
|
||||
private void OpenEditOriginDialog()
|
||||
{
|
||||
// Reset to radians when opening dialog (GoalYaw is in radians)
|
||||
_editOriginYawUseRadian = true;
|
||||
|
||||
if (GoalPoseRef == null)
|
||||
{
|
||||
_editOriginX = MapInfo.OriginX;
|
||||
_editOriginY = MapInfo.OriginY;
|
||||
_editOriginYaw = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Get values from GoalPoseRef
|
||||
_editOriginX = GoalPoseRef.GoalX;
|
||||
_editOriginY = GoalPoseRef.GoalY;
|
||||
_editOriginYaw = GoalPoseRef.GoalYaw;
|
||||
}
|
||||
|
||||
_editOriginDialogVisible = true;
|
||||
}
|
||||
|
||||
private void CloseEditOriginDialog()
|
||||
{
|
||||
_editOriginDialogVisible = false;
|
||||
}
|
||||
|
||||
private void ToggleRadianDegree()
|
||||
{
|
||||
if (_editOriginYawUseRadian)
|
||||
{
|
||||
// Convert from radians to degrees
|
||||
_editOriginYaw = _editOriginYaw * 180.0 / Math.PI;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Convert from degrees to radians
|
||||
_editOriginYaw = _editOriginYaw * Math.PI / 180.0;
|
||||
}
|
||||
_editOriginYawUseRadian = !_editOriginYawUseRadian;
|
||||
}
|
||||
|
||||
private async Task ConfirmEditOrigin()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Convert yaw to radians if needed
|
||||
var yawRadians = _editOriginYawUseRadian
|
||||
? _editOriginYaw
|
||||
: _editOriginYaw * Math.PI / 180.0;
|
||||
|
||||
// Create pose with new origin position and orientation
|
||||
var q = QuaternionNumbers.FromYawRadian(yawRadians);
|
||||
var newOriginPose = new PoseDto
|
||||
{
|
||||
Position = new RobotNet10.Shared.Numbers.Vector3 { X = _editOriginX, Y = _editOriginY, Z = 0 },
|
||||
Orientation = new QuaternionGeometry(q.X, q.Y, q.Z, q.W),
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// Call SLAMClient to transform map origin
|
||||
var success = await SLAMClient.TransformMapOriginAsync(MapName, newOriginPose);
|
||||
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Origin updated to ({_editOriginX:F3}, {_editOriginY:F3}) with yaw {yawRadians:F3} rad", Severity.Success);
|
||||
_editOriginDialogVisible = false;
|
||||
|
||||
// Reload map info to reflect changes
|
||||
await LoadMapInfoAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Failed to update map origin", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to update origin: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Rerender Map Dialog
|
||||
|
||||
// Dialog state
|
||||
private bool _rerenderDialogVisible = false;
|
||||
private readonly DialogOptions _rerenderDialogOptions = new()
|
||||
{
|
||||
CloseOnEscapeKey = false,
|
||||
CloseButton = true,
|
||||
BackdropClick = false,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
// Rerender config
|
||||
private OccupancyGridConfigurationDto _rerenderConfig = new();
|
||||
|
||||
private void OpenRerenderDialog()
|
||||
{
|
||||
// Reset to default values
|
||||
_rerenderConfig = new OccupancyGridConfigurationDto();
|
||||
_rerenderDialogVisible = true;
|
||||
}
|
||||
|
||||
private void CloseRerenderDialog()
|
||||
{
|
||||
_rerenderDialogVisible = false;
|
||||
}
|
||||
|
||||
private async Task ConfirmRerender()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Close dialog
|
||||
_rerenderDialogVisible = false;
|
||||
|
||||
// Call SLAMClient to rerender map
|
||||
var success = await SLAMClient.RerenderMapWithConfigAsync(MapName, _rerenderConfig);
|
||||
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add("Map rerender started. Please wait...", Severity.Info);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add("Failed to start map rerender (map may already be processing)", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to rerender map: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Unsubscribe from event
|
||||
SLAMClient.IsProcessingChanged -= OnIsProcessingChanged;
|
||||
|
||||
// Unsubscribe from map processing group
|
||||
try
|
||||
{
|
||||
if (SLAMClient.IsConnected && !string.IsNullOrEmpty(MapName))
|
||||
{
|
||||
await SLAMClient.UnsubscribeMapProcessingAsync(MapName);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during dispose
|
||||
}
|
||||
|
||||
// Stop SLAMClient
|
||||
try
|
||||
{
|
||||
await SLAMClient.StopAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors during dispose
|
||||
}
|
||||
|
||||
if (_jsModule != null)
|
||||
{
|
||||
await _jsModule.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
.map-edit-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
background-color: #2d2d2d;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #404040;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.info-bar {
|
||||
width: 280px;
|
||||
background-color: #2d2d2d;
|
||||
padding: 16px;
|
||||
border-right: 1px solid #404040;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.info-item-origin {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Reuse same CSS classes as MapLocalization for consistency */
|
||||
.map-localization-container {
|
||||
background-color: #CCCCCC;
|
||||
flex: 1;
|
||||
cursor: grab;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-localization-container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.map-view-movement {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
position: absolute;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: scale(1, 1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.map-editor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: scale(1, -1);
|
||||
transform-origin: center;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.SLAM
|
||||
@using RobotNet10.Shared.Geometry
|
||||
@using RobotNet10.Shared.Localization
|
||||
@using Microsoft.JSInterop
|
||||
@using System.Linq
|
||||
|
||||
@inject SLAMClient CartographerClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="map-localization-container" @ref="containerRef" tabindex="1">
|
||||
<div @ref="viewMovementRef" class="map-view-movement">
|
||||
<!-- Canvas for base occupancy grid (background layer) -->
|
||||
<canvas @ref="mapCanvasBaseRef" class="map-canvas">
|
||||
</canvas>
|
||||
<!-- Canvas for laser scan points (middle layer) -->
|
||||
<canvas @ref="laserScanCanvasRef" class="map-canvas">
|
||||
</canvas>
|
||||
<!-- SVG overlay for robot position, trajectory, etc. (foreground layer) -->
|
||||
<svg @ref="mapContainerRef" class="map-editor" viewBox="0 0 0 0">
|
||||
<defs>
|
||||
<marker id="originvector" markerWidth="2.4" markerHeight="2.4" refX="0.1" refY="0.1">
|
||||
<line x1="0" y1="0.1" x2="0.5" y2="0.1" stroke="red" stroke-width="0.02" />
|
||||
<path d="M 0.5 0.15 L 0.6 0.1 L 0.5 0.05 Z" fill="red" stroke-width="0" />
|
||||
<line x1="0.1" y1="0" x2="0.1" y2="0.5" stroke="blue" stroke-width="0.02" />
|
||||
<path d="M 0.05 0.5 L 0.1 0.6 L 0.15 0.5 Z" fill="blue" stroke-width="0" />
|
||||
</marker>
|
||||
</defs>
|
||||
<!-- Trajectory polyline (ScanMapping) - updated via JsInvoke, no id -->
|
||||
<polyline @ref="trajectoryPolylineRef" points="" fill="none" stroke="#00FF00" stroke-width="0.05" opacity="0.6" />
|
||||
<GoalPose @ref="GoalPoseRef" />
|
||||
<!-- Robot pose component -->
|
||||
<RobotPose @ref="RobotPoseRef" />
|
||||
<!-- Grid origin marker -->
|
||||
<line class="origin" marker-end="url(#originvector)" />
|
||||
@Elements
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<MapMousePosition @ref="MapMousePositionRef" />
|
||||
<RobotPoseInfo @ref="RobotPoseInfoRef" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public RenderFragment? Elements { get; set; }
|
||||
|
||||
private ElementReference containerRef;
|
||||
private ElementReference viewMovementRef;
|
||||
private ElementReference mapContainerRef;
|
||||
private ElementReference mapCanvasBaseRef;
|
||||
private ElementReference laserScanCanvasRef;
|
||||
private ElementReference trajectoryPolylineRef;
|
||||
private MapMousePosition MapMousePositionRef = null!;
|
||||
private GoalPose GoalPoseRef = null!;
|
||||
private RobotPose RobotPoseRef = null!;
|
||||
private RobotPoseInfo RobotPoseInfoRef = null!;
|
||||
|
||||
private bool ShowMap => CurrentGrid != null;
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
using Microsoft.JSInterop;
|
||||
using RobotNet10.RobotApp.Client.Clients;
|
||||
using RobotNet10.RobotApp.Client.Shared.SLAM;
|
||||
using RobotNet10.RobotApp.Shared.Enums;
|
||||
using RobotNet10.Shared.Geometry;
|
||||
using RobotNet10.Shared.Numbers;
|
||||
using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion;
|
||||
using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Components.SLAM;
|
||||
|
||||
public partial class MapLocalization
|
||||
{
|
||||
private const int GridPollIntervalMs = 3000;
|
||||
private const int PoseLaserPollIntervalMs = 500;
|
||||
private const int GridRequestMaxRetries = 10;
|
||||
private const int GridRequestRetryDelayMs = 500;
|
||||
private const double MinFitScale = 0.5;
|
||||
|
||||
private IJSObjectReference _jsModule = null!;
|
||||
private DotNetObjectReference<MapLocalization> _dotNetObj = null!;
|
||||
|
||||
// Container rect state
|
||||
private double _containerRectX = 0.0;
|
||||
private double _containerRectY = 0.0;
|
||||
private double _containerRectWidth = 0.0;
|
||||
private double _containerRectHeight = 0.0;
|
||||
private double _containerRectTop = 0.0;
|
||||
private double _containerRectRight = 0.0;
|
||||
private double _containerRectBottom = 0.0;
|
||||
private double _containerRectLeft = 0.0;
|
||||
|
||||
// View state
|
||||
public double CursorX { get; private set; } = 0.0;
|
||||
public double CursorY { get; private set; } = 0.0;
|
||||
private double _clientOriginX = 0.0;
|
||||
private double _clientOriginY = 0.0;
|
||||
private double _scale = 1.0;
|
||||
private double _left = 0.0;
|
||||
private double _top = 0.0;
|
||||
private double _fitScale = 1.0;
|
||||
|
||||
// Map data
|
||||
private double _resolution = 1.0;
|
||||
private double _originX = 0.0;
|
||||
private double _originY = 0.0; // Transformed origin Y (like MapContainer.OriginY)
|
||||
private double _mapOriginY = 0.0; // Original origin Y from grid (like MapContainer MapData.OriginY)
|
||||
private double _imageWidth = 0.0;
|
||||
private double _imageHeight = 0.0;
|
||||
|
||||
// Grid data
|
||||
public OccupancyGridDto? CurrentGrid { get; private set; }
|
||||
private OccupancyGridDto? _lastDrawnGrid; // Track last drawn grid
|
||||
private DateTime _lastGridUpdateTime = DateTime.MinValue;
|
||||
|
||||
// Trajectory path for polyline (ScanMapping only) - world coordinates for SVG viewBox
|
||||
private List<Vector2> _trajectoryPath = [];
|
||||
|
||||
// SVG viewBox origin (similar to MapContainer.OriginX and OriginY)
|
||||
private double _svgOriginX = 0.0; // SVG viewBox X origin (world coordinates)
|
||||
private double _svgOriginY = 0.0; // SVG viewBox Y origin (world coordinates)
|
||||
|
||||
// State
|
||||
private SLAMState? _currentState;
|
||||
/// <summary>True when grid/pose/laser should be requested and displayed (Localizing or ScanMapping).</summary>
|
||||
private bool IsMapDisplayActive => _currentState == SLAMState.Relocalizing || _currentState == SLAMState.Localizing || _currentState == SLAMState.ScanMapping;
|
||||
/// <summary>Reference grid for view.</summary>
|
||||
private OccupancyGridDto? ReferenceGrid => CurrentGrid;
|
||||
private System.Timers.Timer? _updateTimer;
|
||||
private System.Timers.Timer? _poseLaserTimer; // Timer for pose and laser scan updates (0.5s)
|
||||
private readonly Lock _timerLock = new();
|
||||
private readonly Lock _poseLaserTimerLock = new();
|
||||
private bool _hasRequestedGridForLocalization = false;
|
||||
|
||||
// Robot pose and laser scan data
|
||||
private PoseDto _currentPose = new()
|
||||
{
|
||||
Position = new RobotNet10.Shared.Numbers.Vector3 { X = 0, Y = 0, Z = 0 },
|
||||
Orientation = new QuaternionGeometry { W = 1, X = 0, Y = 0, Z = 0 },
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
private RobotNet10.Shared.Numbers.Vector3[]? _currentLaserScanPoints;
|
||||
|
||||
#region Lifecycle
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
|
||||
// Load JavaScript module (canvas + view/events)
|
||||
_jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "/js/mapLocalization.js");
|
||||
|
||||
// Setup zoom and pan event listeners
|
||||
_dotNetObj = DotNetObjectReference.Create(this);
|
||||
await _jsModule.InvokeVoidAsync("updateContainerRect", _dotNetObj, containerRef, nameof(OnContainerResize));
|
||||
await _jsModule.InvokeVoidAsync("registerResizeObserver", _dotNetObj, containerRef, nameof(OnContainerResize));
|
||||
await _jsModule.InvokeVoidAsync("addMouseWheelEventListener", _dotNetObj, containerRef, nameof(OnMouseWheel));
|
||||
await _jsModule.InvokeVoidAsync("addMouseMoveEventListener", _dotNetObj, containerRef, nameof(OnMouseMove));
|
||||
await _jsModule.InvokeVoidAsync("addMouseDownEventListener", _dotNetObj, containerRef, nameof(OnMouseDown));
|
||||
await _jsModule.InvokeVoidAsync("addMouseUpEventListener", _dotNetObj, containerRef, nameof(OnMouseUp));
|
||||
|
||||
await CartographerClient.StartAsync();
|
||||
|
||||
// Get initial state and trigger OnStateChanged to handle grid loading and timer setup
|
||||
var state = await CartographerClient.GetCurrentStateAsync();
|
||||
OnStateChanged(state);
|
||||
|
||||
// Subscribe to events
|
||||
CartographerClient.StateChanged += OnStateChanged;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region State & Grid
|
||||
|
||||
private void OnStateChanged(SLAMState state)
|
||||
{
|
||||
_currentState = state;
|
||||
UpdateTimerBasedOnState();
|
||||
|
||||
// When entering InitializingLocalizing, Localizing or ScanMapping state, request OccupancyGrid once
|
||||
if ((state == SLAMState.Relocalizing || state == SLAMState.Localizing || state == SLAMState.ScanMapping) && !_hasRequestedGridForLocalization)
|
||||
{
|
||||
_hasRequestedGridForLocalization = true;
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
await RequestOccupancyGridForLocalizationAsync();
|
||||
// Start pose/laser scan timer after requesting grid
|
||||
UpdatePoseLaserTimerBasedOnState();
|
||||
});
|
||||
}
|
||||
else if (state == SLAMState.Relocalizing || state == SLAMState.Localizing || state == SLAMState.ScanMapping)
|
||||
{
|
||||
// Already requested grid, only update timer if grid is available
|
||||
// This handles state transitions like Relocalizing -> Localizing where grid request is still in progress
|
||||
if (CurrentGrid != null)
|
||||
{
|
||||
UpdatePoseLaserTimerBasedOnState();
|
||||
}
|
||||
// If CurrentGrid is null, the timer will be started by the InvokeAsync above when grid is loaded
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdatePoseLaserTimerBasedOnState();
|
||||
}
|
||||
|
||||
// Reset flag when leaving map-display states (InitializingLocalizing, Localizing, ScanMapping)
|
||||
if (state != SLAMState.Relocalizing && state != SLAMState.Localizing && state != SLAMState.ScanMapping)
|
||||
{
|
||||
_hasRequestedGridForLocalization = false;
|
||||
_trajectoryPath.Clear();
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
await ClearLaserScanAsync();
|
||||
await UpdateTrajectoryPolylineAsync(); // Clear polyline via JsInvoke
|
||||
RobotPoseRef.UpdatePose(0, 0, 0);
|
||||
RobotPoseInfoRef.Update(0, 0, 0, 0);
|
||||
});
|
||||
}
|
||||
else if (state == SLAMState.ScanMapping)
|
||||
{
|
||||
// Entering ScanMapping: clear trajectory until timer fetches nodes
|
||||
_trajectoryPath.Clear();
|
||||
_ = InvokeAsync(UpdateTrajectoryPolylineAsync);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Grid & Map
|
||||
|
||||
private async Task LoadMapFromGrid(OccupancyGridDto grid)
|
||||
{
|
||||
CurrentGrid = grid;
|
||||
_resolution = grid.Resolution;
|
||||
_originX = grid.Origin.Position.X;
|
||||
_mapOriginY = grid.Origin.Position.Y; // Original origin Y from grid
|
||||
// Similar to MapContainer: OriginY = -ImageHeight * Resolution - mapData.OriginY
|
||||
_originY = -grid.Height * grid.Resolution - grid.Origin.Position.Y;
|
||||
_imageWidth = grid.Width * grid.Resolution;
|
||||
_imageHeight = grid.Height * grid.Resolution;
|
||||
|
||||
// Update SVG origin from grid origin
|
||||
UpdateSvgOrigin();
|
||||
|
||||
// Draw the grid on canvas
|
||||
await DrawOccupancyGrid();
|
||||
}
|
||||
|
||||
private void UpdateSvgOrigin()
|
||||
{
|
||||
var referenceGrid = ReferenceGrid;
|
||||
if (referenceGrid == null)
|
||||
{
|
||||
_svgOriginX = 0.0;
|
||||
_svgOriginY = 0.0;
|
||||
return;
|
||||
}
|
||||
|
||||
// SVG viewBox origin uses world coordinates from grid origin
|
||||
_svgOriginX = referenceGrid.Origin.Position.X;
|
||||
_svgOriginY = referenceGrid.Origin.Position.Y;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply origin and image size from a grid.
|
||||
/// </summary>
|
||||
private void ApplyOriginFromGrid(OccupancyGridDto grid)
|
||||
{
|
||||
_resolution = grid.Resolution;
|
||||
_originX = grid.Origin.Position.X;
|
||||
_mapOriginY = grid.Origin.Position.Y;
|
||||
_originY = -grid.Height * grid.Resolution - grid.Origin.Position.Y;
|
||||
_imageWidth = grid.Width * grid.Resolution;
|
||||
_imageHeight = grid.Height * grid.Resolution;
|
||||
UpdateSvgOrigin();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply trajectory from grid DTO to _trajectoryPath and update polyline if changed.
|
||||
/// </summary>
|
||||
/// <returns>True if trajectory was updated.</returns>
|
||||
private async Task<bool> TryApplyTrajectoryFromGrid(OccupancyGridDto? grid)
|
||||
{
|
||||
if (grid?.TrajectoryNodes == null || grid.TrajectoryNodes.Length == 0)
|
||||
return false;
|
||||
var newPath = grid.TrajectoryNodes.Select(n => new Vector2(n.Pose.Position.X, n.Pose.Position.Y)).ToList();
|
||||
if (newPath.Count == _trajectoryPath.Count && newPath.SequenceEqual(_trajectoryPath))
|
||||
return false;
|
||||
_trajectoryPath = newPath;
|
||||
await UpdateTrajectoryPolylineAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Drawing
|
||||
|
||||
/// <summary>
|
||||
/// Create a snapshot of grid for _lastDrawnGrid* tracking.
|
||||
/// </summary>
|
||||
private static OccupancyGridDto CreateDrawnGridSnapshot(OccupancyGridDto grid)
|
||||
{
|
||||
return new OccupancyGridDto
|
||||
{
|
||||
Resolution = grid.Resolution,
|
||||
Width = grid.Width,
|
||||
Height = grid.Height,
|
||||
Origin = grid.Origin,
|
||||
Version = grid.Version,
|
||||
KnownCells = grid.KnownCells ?? [],
|
||||
LastBaseUpdated = grid.LastBaseUpdated,
|
||||
LastUpdated = grid.LastUpdated,
|
||||
TrajectoryNodes = grid.TrajectoryNodes
|
||||
};
|
||||
}
|
||||
|
||||
private async Task DrawOccupancyGrid()
|
||||
{
|
||||
var referenceGrid = ReferenceGrid;
|
||||
if (referenceGrid == null) return;
|
||||
|
||||
var grid = CurrentGrid;
|
||||
|
||||
// Check if we need to redraw grid
|
||||
bool needsRedraw = false;
|
||||
if (grid != null && (_lastDrawnGrid == null || _lastDrawnGrid.Version != grid.Version))
|
||||
{
|
||||
needsRedraw = true;
|
||||
}
|
||||
|
||||
if (!needsRedraw)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get container size to calculate scale for auto-fit
|
||||
await Task.Delay(10);
|
||||
var containerSize = await _jsModule.InvokeAsync<double[]>("getElementSize", containerRef);
|
||||
var containerWidth = containerSize[0];
|
||||
var containerHeight = containerSize[1];
|
||||
|
||||
if (containerWidth <= 0 || containerHeight <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Use reference grid for fit scale (base or updating)
|
||||
var refW = referenceGrid.Width;
|
||||
var refH = referenceGrid.Height;
|
||||
if (_imageWidth <= 0 || _imageHeight <= 0)
|
||||
{
|
||||
_imageWidth = refW * referenceGrid.Resolution;
|
||||
_imageHeight = refH * referenceGrid.Resolution;
|
||||
_originX = referenceGrid.Origin.Position.X;
|
||||
_mapOriginY = referenceGrid.Origin.Position.Y;
|
||||
_originY = -referenceGrid.Height * referenceGrid.Resolution - referenceGrid.Origin.Position.Y;
|
||||
}
|
||||
if (_imageWidth > 0 && _imageHeight > 0)
|
||||
{
|
||||
_fitScale = Math.Min(containerWidth / _imageWidth, containerHeight / _imageHeight);
|
||||
}
|
||||
else
|
||||
{
|
||||
var scaleX = containerWidth / refW;
|
||||
var scaleY = containerHeight / refH;
|
||||
_fitScale = Math.Min(scaleX, scaleY);
|
||||
}
|
||||
|
||||
if (_fitScale < MinFitScale)
|
||||
_fitScale = MinFitScale;
|
||||
if (_scale <= 0)
|
||||
_scale = _fitScale;
|
||||
|
||||
// Draw grid
|
||||
if (needsRedraw && grid != null)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("setCanvasSize", mapCanvasBaseRef, grid.Width, grid.Height);
|
||||
if (grid.KnownCells != null && grid.KnownCells.Length > 0)
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("drawOccupancyGrid",
|
||||
mapCanvasBaseRef, grid.Width, grid.Height, grid.KnownCells);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("clearCanvas", mapCanvasBaseRef);
|
||||
}
|
||||
_lastDrawnGrid = CreateDrawnGridSnapshot(grid);
|
||||
}
|
||||
|
||||
var svgWidth = referenceGrid.Width * referenceGrid.Resolution;
|
||||
var svgHeight = referenceGrid.Height * referenceGrid.Resolution;
|
||||
await _jsModule.InvokeVoidAsync("setSvgConfig", mapContainerRef, svgWidth, svgHeight, _svgOriginX, _svgOriginY);
|
||||
|
||||
await ScaleFitContentAsync();
|
||||
|
||||
if (IsMapDisplayActive)
|
||||
{
|
||||
await UpdateRobotPoseSvgAsync();
|
||||
if (_currentLaserScanPoints != null && _currentLaserScanPoints.Length > 0)
|
||||
await DrawLaserScanAsync();
|
||||
if (_currentState == SLAMState.ScanMapping)
|
||||
await UpdateTrajectoryPolylineAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapLocalization] DrawOccupancyGrid: Exception - {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region View & Scale
|
||||
|
||||
/// <summary>
|
||||
/// Public method to fit and center the map view.
|
||||
/// Can be called from external components/pages.
|
||||
/// </summary>
|
||||
public async Task FitViewAsync()
|
||||
{
|
||||
if (ReferenceGrid == null) return;
|
||||
|
||||
// Recalculate fit scale based on current container and image dimensions
|
||||
if (_imageWidth > 0 && _imageHeight > 0 && _containerRectWidth > 0 && _containerRectHeight > 0)
|
||||
{
|
||||
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
|
||||
if (_fitScale < MinFitScale)
|
||||
_fitScale = MinFitScale;
|
||||
}
|
||||
|
||||
await ScaleFitContentAsync();
|
||||
}
|
||||
|
||||
private void UpdateClientOrigin()
|
||||
{
|
||||
if (ReferenceGrid == null) return;
|
||||
|
||||
// Calculate client origin (exactly like MapContainer.SetViewMovement and ViewContainerResize)
|
||||
// MapContainer: ClientOriginX = ViewContainerRectLeft + Left - OriginX * Scale
|
||||
// MapContainer: ClientOriginY = ViewContainerRectTop + Top - OriginY * Scale
|
||||
//
|
||||
// MapLocalization now has the same structure as MapContainer:
|
||||
// - scaleY(-1) is on SVG (class="map-editor"), not on wrapper div
|
||||
// - This matches MapContainer's structure exactly
|
||||
_clientOriginX = _containerRectLeft + _left - _originX * _scale;
|
||||
_clientOriginY = _containerRectTop + _top - _originY * _scale;
|
||||
}
|
||||
|
||||
public async Task ScaleFitContentAsync()
|
||||
{
|
||||
if (ReferenceGrid == null) return;
|
||||
|
||||
_scale = _fitScale;
|
||||
|
||||
var wrapperWidth = _imageWidth * _scale;
|
||||
var wrapperHeight = _imageHeight * _scale;
|
||||
|
||||
var centerLeft = (_containerRectWidth - wrapperWidth) / 2;
|
||||
var centerTop = (_containerRectHeight - wrapperHeight) / 2;
|
||||
|
||||
await SetViewMovement(centerLeft, centerTop);
|
||||
|
||||
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, wrapperWidth, wrapperHeight);
|
||||
await _jsModule.InvokeVoidAsync("setCanvasRect", mapCanvasBaseRef, wrapperWidth, wrapperHeight);
|
||||
await _jsModule.InvokeVoidAsync("setCanvasRect", laserScanCanvasRef, wrapperWidth, wrapperHeight);
|
||||
}
|
||||
|
||||
private async Task SetViewMovement(double left, double top)
|
||||
{
|
||||
_top = top;
|
||||
_left = left;
|
||||
|
||||
// Update client origin (exactly like MapContainer.SetViewMovement)
|
||||
UpdateClientOrigin();
|
||||
|
||||
if (_jsModule != null && ReferenceGrid != null)
|
||||
{
|
||||
var width = _imageWidth * _scale;
|
||||
var height = _imageHeight * _scale;
|
||||
await _jsModule.InvokeVoidAsync("setMapMovement", viewMovementRef, _top, _left, width, height);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Event Handlers (JSInvokable)
|
||||
|
||||
[JSInvokable]
|
||||
public void OnContainerResize(double x, double y, double width, double height, double top, double right, double bottom, double left)
|
||||
{
|
||||
_containerRectX = x;
|
||||
_containerRectY = y;
|
||||
_containerRectWidth = width;
|
||||
_containerRectHeight = height;
|
||||
_containerRectTop = top;
|
||||
_containerRectRight = right;
|
||||
_containerRectBottom = bottom;
|
||||
_containerRectLeft = left;
|
||||
|
||||
// Update client origin (exactly like MapContainer.ViewContainerResize)
|
||||
UpdateClientOrigin();
|
||||
|
||||
// Recalculate fit scale (exactly like MapContainer.ViewContainerResize)
|
||||
// MapContainer: FitScale = Math.Min(ViewContainerRectWidth / ImageWidth, ViewContainerRectHeight / ImageHeight)
|
||||
if (_imageWidth > 0 && _imageHeight > 0)
|
||||
{
|
||||
_fitScale = Math.Min(_containerRectWidth / _imageWidth, _containerRectHeight / _imageHeight);
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseWheel(double deltaY, double clientX, double clientY)
|
||||
{
|
||||
if (ReferenceGrid == null) return;
|
||||
|
||||
// Calculate scale change (exactly like MapContainer.MouseWheelOnMapContainer)
|
||||
double scaleChange;
|
||||
if (deltaY > 0)
|
||||
{
|
||||
if (_scale <= _fitScale / 2) return;
|
||||
scaleChange = _scale > _fitScale ? -(_scale / _fitScale) : -0.1;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_scale >= _fitScale * 100) return;
|
||||
scaleChange = _scale < _fitScale ? 0.5 : (_scale / _fitScale);
|
||||
}
|
||||
|
||||
// Store old scale before updating (exactly like MapContainer)
|
||||
double oldScale = _scale;
|
||||
|
||||
// Update scale (exactly like MapContainer: Scale += scaleChange)
|
||||
_scale += scaleChange;
|
||||
|
||||
// Set SVG rect first (exactly like MapContainer: ImageWidth * Scale, ImageHeight * Scale)
|
||||
await _jsModule.InvokeVoidAsync("setSvgRect", mapContainerRef, _imageWidth * _scale, _imageHeight * _scale);
|
||||
|
||||
await _jsModule.InvokeVoidAsync("setCanvasRect", mapCanvasBaseRef, _imageWidth * _scale, _imageHeight * _scale);
|
||||
await _jsModule.InvokeVoidAsync("setCanvasRect", laserScanCanvasRef, _imageWidth * _scale, _imageHeight * _scale);
|
||||
|
||||
// Calculate cursor position in world coordinates (exactly like MapContainer)
|
||||
// MapContainer: CursorX = (clientX - ClientOriginX) / Scale
|
||||
// MapContainer: CursorY = (ClientOriginY - clientY) / Scale
|
||||
// Update CursorX/CursorY (they are in world coordinates)
|
||||
CursorX = (clientX - _clientOriginX) / oldScale;
|
||||
CursorY = (_clientOriginY - clientY) / oldScale;
|
||||
MapMousePositionRef.Update(CursorX, CursorY);
|
||||
|
||||
// Calculate mouse position relative to map origin (exactly like MapContainer)
|
||||
// MapContainer: mouseX = CursorX - OriginX
|
||||
// MapContainer: mouseY = CursorY - MapData.OriginY (use original origin Y, not transformed)
|
||||
double mouseX = CursorX - _originX;
|
||||
double mouseY = CursorY - _mapOriginY;
|
||||
|
||||
// Calculate movement adjustment (exactly like MapContainer.MouseWheelOnMapContainer)
|
||||
// MapContainer: Left - mouseX * scaleChange, Top - (ImageHeight - mouseY) * scaleChange
|
||||
await SetViewMovement(_left - mouseX * scaleChange, _top - (_imageHeight - mouseY) * scaleChange);
|
||||
|
||||
// Update robot pose after scale change
|
||||
if (IsMapDisplayActive)
|
||||
{
|
||||
await UpdateRobotPoseSvgAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseMove(double clientX, double clientY, long buttons, bool ctrlKey, double movementX, double movementY)
|
||||
{
|
||||
// Calculate cursor position in world coordinates (exactly like MapContainer.MouseMoveOnMapContainer)
|
||||
// MapContainer: CursorX = (clientX - ClientOriginX) / Scale (world coordinates in meters)
|
||||
// MapContainer: CursorY = (ClientOriginY - clientY) / Scale (world coordinates in meters)
|
||||
CursorX = (clientX - _clientOriginX) / _scale;
|
||||
CursorY = (_clientOriginY - clientY) / _scale;
|
||||
MapMousePositionRef.Update(CursorX, CursorY);
|
||||
|
||||
// Update RobotGoalPose mouse position (line goal->mouse and circle)
|
||||
if (buttons == 1) // Right mouse button down: update goal pose to current mouse
|
||||
{
|
||||
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
|
||||
}
|
||||
else if (buttons == 4) // Middle mouse button
|
||||
{
|
||||
await SetViewMovement(_left + movementX, _top + movementY);
|
||||
|
||||
// Update robot pose after pan (pose position in SVG doesn't change, but we update to ensure consistency)
|
||||
if (IsMapDisplayActive)
|
||||
{
|
||||
await UpdateRobotPoseSvgAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseDown(int button, bool altKey, bool ctrlKey, bool shiftKey)
|
||||
{
|
||||
Console.WriteLine($"OnMouseDown: button={button}, altKey={altKey}, ctrlKey={ctrlKey}, shiftKey={shiftKey}");
|
||||
if (button == 0) // Right mouse button: set robot pose and goal pose for RobotGoalPose
|
||||
{
|
||||
var robotX = _currentPose.Position.X;
|
||||
var robotY = _currentPose.Position.Y;
|
||||
GoalPoseRef?.SetRobotPosition(robotX, robotY);
|
||||
GoalPoseRef?.SetGoalPosition(CursorX, CursorY);
|
||||
GoalPoseRef?.SetMousePosition(CursorX, CursorY);
|
||||
GoalPoseRef?.Show();
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
[JSInvokable]
|
||||
public async Task OnMouseUp(int button, bool altKey, bool ctrlKey, bool shiftKey)
|
||||
{
|
||||
if (button == 0) // Right mouse button up: áp dụng Optimize (ẩn nếu goal–mouse < 1 m, ngược lại clamp 1 m)
|
||||
GoalPoseRef?.Optimize();
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Timers
|
||||
|
||||
/// <summary>
|
||||
/// Localizing: grid base requested once only (no timer).
|
||||
/// ScanMapping: timer 3s to poll grid base + grid updating + trajectory nodes.
|
||||
/// </summary>
|
||||
private void UpdateTimerBasedOnState()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_currentState == SLAMState.ScanMapping)
|
||||
{
|
||||
if (_updateTimer == null)
|
||||
{
|
||||
_updateTimer = new System.Timers.Timer(GridPollIntervalMs)
|
||||
{
|
||||
AutoReset = false
|
||||
};
|
||||
_updateTimer.Elapsed += OnTimerElapsed;
|
||||
_updateTimer.Start();
|
||||
}
|
||||
else if (!_updateTimer.Enabled)
|
||||
{
|
||||
_updateTimer.Start();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_updateTimer != null)
|
||||
{
|
||||
_updateTimer.Stop();
|
||||
_updateTimer.Elapsed -= OnTimerElapsed;
|
||||
_updateTimer.Dispose();
|
||||
_updateTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
private void OnTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_currentState != SLAMState.ScanMapping)
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_updateTimer != null && _currentState == SLAMState.ScanMapping)
|
||||
_updateTimer.Start();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
bool hasUpdates = false;
|
||||
|
||||
var grid = await CartographerClient.GetOccupancyGridAsync(_lastGridUpdateTime);
|
||||
if (grid != null)
|
||||
{
|
||||
CurrentGrid = grid;
|
||||
_lastGridUpdateTime = grid.LastBaseUpdated;
|
||||
hasUpdates = true;
|
||||
UpdateSvgOrigin();
|
||||
await TryApplyTrajectoryFromGrid(grid);
|
||||
}
|
||||
|
||||
if (hasUpdates && ReferenceGrid != null)
|
||||
{
|
||||
// BUG FIX: Always update origin when grid changes, not just when dimensions change significantly
|
||||
// Grid origin can change when grid grows even if dimensions stay similar
|
||||
if (CurrentGrid != null)
|
||||
{
|
||||
// Check if origin changed (which indicates grid grow/shift)
|
||||
var originChanged = Math.Abs(_originX - CurrentGrid.Origin.Position.X) > 0.0001 ||
|
||||
Math.Abs(_mapOriginY - CurrentGrid.Origin.Position.Y) > 0.0001;
|
||||
|
||||
// Check if dimensions changed significantly
|
||||
var dimensionsChanged = _imageWidth == 0 || _imageHeight == 0 ||
|
||||
Math.Abs(_imageWidth - CurrentGrid.Width * CurrentGrid.Resolution) > 0.001 ||
|
||||
Math.Abs(_imageHeight - CurrentGrid.Height * CurrentGrid.Resolution) > 0.001;
|
||||
|
||||
if (dimensionsChanged || originChanged)
|
||||
{
|
||||
Console.WriteLine($"[MapLocalization] Grid changed: dims={dimensionsChanged}, origin={originChanged}, " +
|
||||
$"size={CurrentGrid.Width}x{CurrentGrid.Height}, " +
|
||||
$"origin=[{CurrentGrid.Origin.Position.X:F3},{CurrentGrid.Origin.Position.Y:F3}], " +
|
||||
$"prevOrigin=[{_originX:F3},{_mapOriginY:F3}]");
|
||||
await LoadMapFromGrid(CurrentGrid);
|
||||
}
|
||||
else
|
||||
await DrawOccupancyGrid();
|
||||
}
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_updateTimer != null && _currentState == SLAMState.ScanMapping)
|
||||
_updateTimer.Start();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapLocalization] Error in OnTimerElapsed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task RequestOccupancyGridForLocalizationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestTime = DateTime.MinValue;
|
||||
for (int i = 0; i < GridRequestMaxRetries; i++)
|
||||
{
|
||||
if (!IsMapDisplayActive)
|
||||
break;
|
||||
|
||||
var grid = await CartographerClient.GetOccupancyGridAsync(requestTime);
|
||||
if (grid == null)
|
||||
{
|
||||
await Task.Delay(GridRequestRetryDelayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
CurrentGrid = grid;
|
||||
_lastGridUpdateTime = grid.LastBaseUpdated;
|
||||
await LoadMapFromGrid(grid);
|
||||
await TryApplyTrajectoryFromGrid(grid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapLocalization] Error requesting OccupancyGrid for localization: {ex.Message}");
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
private void UpdatePoseLaserTimerBasedOnState()
|
||||
{
|
||||
lock (_poseLaserTimerLock)
|
||||
{
|
||||
if (IsMapDisplayActive)
|
||||
{
|
||||
if (_poseLaserTimer == null)
|
||||
{
|
||||
_poseLaserTimer = new System.Timers.Timer(PoseLaserPollIntervalMs)
|
||||
{
|
||||
AutoReset = false
|
||||
};
|
||||
_poseLaserTimer.Elapsed += OnPoseLaserTimerElapsed;
|
||||
_poseLaserTimer.Start();
|
||||
}
|
||||
else if (!_poseLaserTimer.Enabled)
|
||||
{
|
||||
_poseLaserTimer.Start();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (_poseLaserTimer != null)
|
||||
{
|
||||
_poseLaserTimer.Stop();
|
||||
_poseLaserTimer.Elapsed -= OnPoseLaserTimerElapsed;
|
||||
_poseLaserTimer.Dispose();
|
||||
_poseLaserTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPoseLaserTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
_ = InvokeAsync(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get robot pose
|
||||
var pose = await CartographerClient.GetCurrentPoseAsync();
|
||||
if (pose != null)
|
||||
{
|
||||
_currentPose = pose;
|
||||
await UpdateRobotPoseSvgAsync();
|
||||
}
|
||||
|
||||
// Get laser scan
|
||||
var laserScanPoints = await CartographerClient.GetSamplePointCloudAsync();
|
||||
if (laserScanPoints != null && laserScanPoints.Length > 0)
|
||||
{
|
||||
_currentLaserScanPoints = laserScanPoints;
|
||||
var refGrid = ReferenceGrid;
|
||||
if (refGrid != null && refGrid.Width > 0 && refGrid.Height > 0)
|
||||
await DrawLaserScanAsync();
|
||||
}
|
||||
|
||||
lock (_poseLaserTimerLock)
|
||||
{
|
||||
if (_poseLaserTimer != null && IsMapDisplayActive)
|
||||
{
|
||||
_poseLaserTimer.Start();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapLocalization] Error in OnPoseLaserTimerElapsed: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#region Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Lấy goal pose từ RobotGoalPose (GoalX, GoalY, GoalYaw) dưới dạng PoseDto để gửi SetInitialPose.
|
||||
/// </summary>
|
||||
public PoseDto? GetGoalPoseDto()
|
||||
{
|
||||
if (GoalPoseRef == null)
|
||||
return null;
|
||||
var q = QuaternionNumbers.FromYawRadian(GoalPoseRef.GoalYaw);
|
||||
return new PoseDto
|
||||
{
|
||||
Position = new RobotNet10.Shared.Numbers.Vector3 { X = GoalPoseRef.GoalX, Y = GoalPoseRef.GoalY, Z = 0 },
|
||||
Orientation = new QuaternionGeometry(q.X, q.Y, q.Z, q.W),
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private async Task UpdateRobotPoseSvgAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Hide robot pose if scale is invalid or no grid (base or updating for ScanMapping)
|
||||
var referenceGrid = CurrentGrid;
|
||||
if (_scale <= 0 || referenceGrid == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var robotX = _currentPose.Position.X;
|
||||
var robotY = _currentPose.Position.Y;
|
||||
var yaw = _currentPose.Orientation.ToYawRadian();
|
||||
|
||||
RobotPoseRef.UpdatePose(robotX, robotY, yaw);
|
||||
RobotPoseInfoRef.Update(robotX, robotY, yaw, _currentPose.Score);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapLocalization] UpdateRobotPoseSvgAsync: Exception - {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private (int x, int y) WorldToGrid(double worldX, double worldY)
|
||||
{
|
||||
var grid = ReferenceGrid;
|
||||
if (grid == null)
|
||||
return (0, 0);
|
||||
|
||||
var relativeX = worldX - grid.Origin.Position.X;
|
||||
var relativeY = worldY - grid.Origin.Position.Y;
|
||||
|
||||
var gridX = (int)Math.Floor(relativeX / grid.Resolution);
|
||||
var gridY = (int)Math.Floor(relativeY / grid.Resolution);
|
||||
|
||||
return (gridX, gridY);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update trajectory polyline via JsInvoke (ElementReference, no StateHasChanged).
|
||||
/// Only updates points data; other attributes (fill, stroke, stroke-width, opacity) are fixed in markup.
|
||||
/// </summary>
|
||||
private async Task UpdateTrajectoryPolylineAsync()
|
||||
{
|
||||
var pointsStr = "";
|
||||
if (_trajectoryPath.Count > 1)
|
||||
{
|
||||
var worldPoints = _trajectoryPath.Select(p => $"{p.X},{p.Y}");
|
||||
pointsStr = string.Join(" ", worldPoints);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("setPolylinePointsOnly",
|
||||
trajectoryPolylineRef,
|
||||
pointsStr);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapLocalization] UpdateTrajectoryPolylineAsync: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrawLaserScanAsync()
|
||||
{
|
||||
var referenceGrid = ReferenceGrid;
|
||||
if (_currentLaserScanPoints == null || _currentLaserScanPoints.Length == 0 || referenceGrid == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (referenceGrid.Width <= 0 || referenceGrid.Height <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size
|
||||
await _jsModule.InvokeVoidAsync("setCanvasSize", laserScanCanvasRef, referenceGrid.Width, referenceGrid.Height);
|
||||
|
||||
// laserScanPoints from GetSamplePointCloudAsync() are already in global (map) coordinates.
|
||||
// Only need to convert to grid coordinates via WorldToGrid (uses grid.Origin).
|
||||
// laserScanCanvasRef uses class "map-canvas" with CSS transform: scale(1, -1), so the canvas
|
||||
// is rendered with Y flipped: buffer y=0 (top) appears at bottom, buffer y=height-1 (bottom) at top.
|
||||
// We pass (gridX, gridY) directly: grid Y increases upward. Drawing at (gridX, gridY) puts
|
||||
// world-Y-up at large buffer y; after scale(1,-1) that displays at visual top. Correct.
|
||||
var mapPoints = new List<(double X, double Y)>();
|
||||
|
||||
foreach (var point in _currentLaserScanPoints)
|
||||
{
|
||||
// point.X, point.Y are already world coordinates; WorldToGrid applies Origin transform
|
||||
var (gridX, gridY) = WorldToGrid(point.X, point.Y);
|
||||
|
||||
if (gridX >= 0 && gridX < referenceGrid.Width && gridY >= 0 && gridY < referenceGrid.Height)
|
||||
{
|
||||
// Pass grid coords (Y-up); map-canvas CSS scale(1,-1) handles display flip
|
||||
mapPoints.Add((gridX, gridY));
|
||||
}
|
||||
}
|
||||
|
||||
if (mapPoints.Count > 0)
|
||||
{
|
||||
var pointsArray = mapPoints.Select(p => new double[] { p.X, p.Y }).ToArray();
|
||||
await _jsModule.InvokeVoidAsync("drawPointsOnCanvas", laserScanCanvasRef, pointsArray, "#FF0000", 0.5);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"[MapLocalization] DrawLaserScanAsync: Exception - {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearLaserScanAsync()
|
||||
{
|
||||
await _jsModule.InvokeVoidAsync("clearCanvas", laserScanCanvasRef);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Dispose
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
CartographerClient.StateChanged -= OnStateChanged;
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_updateTimer != null)
|
||||
{
|
||||
_updateTimer.Stop();
|
||||
_updateTimer.Elapsed -= OnTimerElapsed;
|
||||
_updateTimer.Dispose();
|
||||
_updateTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
lock (_poseLaserTimerLock)
|
||||
{
|
||||
if (_poseLaserTimer != null)
|
||||
{
|
||||
_poseLaserTimer.Stop();
|
||||
_poseLaserTimer.Elapsed -= OnPoseLaserTimerElapsed;
|
||||
_poseLaserTimer.Dispose();
|
||||
_poseLaserTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
await _jsModule.DisposeAsync();
|
||||
_dotNetObj?.Dispose();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
.map-localization-container {
|
||||
background-color: #bfbfbf;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: not-allowed;
|
||||
border-top: solid 2px #808080;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-view-movement {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
position: absolute;
|
||||
cursor: default;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: scale(1, -1);
|
||||
transform-origin: center;
|
||||
pointer-events: none;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.map-editor {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform: scale(1, -1);
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.map-mouse-position {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.map-mouse-position span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div style="position: absolute; top: 5px; left: 5px; background-color: white; border-radius: 4px; color: #00cc66; font-size: 15px; font-weight: bold;">
|
||||
<div class="px-1 pt-1">
|
||||
<span class="mdi mdi-cursor-default-outline" /> @X, @Y
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string X = "0.000";
|
||||
private string Y = "0.000";
|
||||
|
||||
public void Update(double x, double y)
|
||||
{
|
||||
X = x.ToString("N3");
|
||||
Y = y.ToString("N3");
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
@using RobotNet10.Shared.Geometry
|
||||
@if (_visible)
|
||||
{
|
||||
<!-- Goal crosshair -->
|
||||
<circle cx="@_gx" cy="@_gy" r="0.08" fill="none" stroke="#FF4500" stroke-width="0.03" />
|
||||
<line x1="@_gxLeft" y1="@_gy" x2="@_gxRight" y2="@_gy" stroke="#FF4500" stroke-width="0.02" />
|
||||
<line x1="@_gx" y1="@_gyBottom" x2="@_gx" y2="@_gyTop" stroke="#FF4500" stroke-width="0.02" />
|
||||
<!-- Reference points -->
|
||||
@foreach (var pt in _referencePoints)
|
||||
{
|
||||
<line x1="@_gx" y1="@_gy" x2="@pt.X" y2="@pt.Y" stroke="#00FFFF" stroke-width="0.01" stroke-dasharray="0.05,0.03" />
|
||||
<circle cx="@pt.X" cy="@pt.Y" r="0.04" fill="#00FFFF" stroke="#008B8B" stroke-width="0.015" />
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool _visible;
|
||||
private double _gx, _gy;
|
||||
private double _gxLeft, _gxRight, _gyBottom, _gyTop;
|
||||
private List<(double X, double Y)> _referencePoints = [];
|
||||
|
||||
public void Update(Pose goal, List<(double X, double Y)> referencePoints)
|
||||
{
|
||||
_gx = goal.Position.X;
|
||||
_gy = goal.Position.Y;
|
||||
_gxLeft = _gx - 0.12;
|
||||
_gxRight = _gx + 0.12;
|
||||
_gyBottom = _gy - 0.12;
|
||||
_gyTop = _gy + 0.12;
|
||||
_referencePoints = referencePoints;
|
||||
_visible = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_visible = false;
|
||||
_referencePoints = [];
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
|
||||
<!-- Robot pose visualization component. SVG trong MapLocalization dùng viewBox (met), scaleY(-1); x, y, r đơn vị mét. -->
|
||||
<g transform="@Transform">
|
||||
<!-- Robot image (width: 1.106m, height: 0.606m) -->
|
||||
<image href="images/AS_AGV SLAM V3-CK 02.png"
|
||||
width="1.106"
|
||||
height="0.606"
|
||||
transform="translate(-0.553, -0.303)" />
|
||||
</g>
|
||||
|
||||
@code {
|
||||
private double _currentX = -1000;
|
||||
private double _currentY = -1000;
|
||||
private double _yaw = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Transform string for SVG group element
|
||||
/// </summary>
|
||||
private string Transform => $"translate({_currentX:F6},{_currentY:F6}) rotate({_yaw * 180.0 / Math.PI:F2})";
|
||||
|
||||
/// <summary>
|
||||
/// Update robot pose với vị trí và góc quay
|
||||
/// </summary>
|
||||
/// <param name="x">X position trong world coordinates</param>
|
||||
/// <param name="y">Y position trong world coordinates</param>
|
||||
/// <param name="yaw">Yaw angle trong radians</param>
|
||||
public void UpdatePose(double x, double y, double yaw)
|
||||
{
|
||||
_currentX = x;
|
||||
_currentY = y;
|
||||
_yaw = yaw;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<div style="position: absolute; top: 5px; right: 5px; background-color: white; border-radius: 4px; color: #00cc66; font-size: 15px; font-weight: bold;">
|
||||
<div class="px-1 pt-1">
|
||||
@X, @Y <span class="mdi mdi-compass-outline" />@Yaw - @Score
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string X = "0.000";
|
||||
private string Y = "0.000";
|
||||
private string Yaw = "0.000";
|
||||
private string Score = "00.00%";
|
||||
|
||||
public void Update(double x, double y, double yaw, double score)
|
||||
{
|
||||
X = x.ToString("N3");
|
||||
Y = y.ToString("N3");
|
||||
Yaw = (yaw * 180.0 / Math.PI).ToString("N3"); // Convert radians to degrees
|
||||
Score = score.ToString("P1", System.Globalization.CultureInfo.InvariantCulture);
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Shared.SLAM
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Select Map for Localization</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
@if (Maps == null || Maps.Length == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary">
|
||||
No maps available. Please create a map first.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="MapInfoDto">
|
||||
@foreach (var map in Maps)
|
||||
{
|
||||
<MudListItem OnClick="@(() => SelectMap(map.Name))">
|
||||
<MudText Typo="Typo.body1">@map.Name</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">
|
||||
Created: @map.CreatedDate.ToString("yyyy-MM-dd HH:mm:ss")
|
||||
</MudText>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
[Parameter] public MapInfoDto[] Maps { get; set; } = Array.Empty<MapInfoDto>();
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private void SelectMap(string mapName)
|
||||
{
|
||||
Dialog.Close(DialogResult.Ok(mapName));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
@using RobotNet10.RobotApp.Shared.DockStation
|
||||
@using RobotNet10.Shared.Enum
|
||||
@using RobotNet10.Shared.Numbers
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">@(IsEditMode ? "Edit" : "Create") Dock Station Config</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="6">
|
||||
<MudTextField @bind-Value="_stationId" Label="Station ID" Variant="Variant.Outlined" Required />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudTextField @bind-Value="_configName" Label="Config Name" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12">
|
||||
<MudTextField @bind-Value="_description" Label="Description" Variant="Variant.Outlined" Lines="2" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mt-4 mb-2">Search Area</MudText>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_x" Label="X (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_y" Label="Y (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_yaw" Label="Yaw (rad)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField @bind-Value="_width" Label="Width (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField @bind-Value="_length" Label="Length (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudItem xs="12" Class="mt-2">
|
||||
<MudSwitch @bind-Value="_isActive" Label="Active" Color="Color.Success" />
|
||||
</MudItem>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mt-4 mb-2">
|
||||
<MudText Typo="Typo.subtitle1">Marker Entries</MudText>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary" OnClick="AddMarkerEntry" />
|
||||
</MudStack>
|
||||
|
||||
@for (int i = 0; i < _entries.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
<MudPaper Class="pa-3 mb-2" Outlined>
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.subtitle2">Marker @(index + 1)</MudText>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||
OnClick="() => RemoveMarkerEntry(index)" />
|
||||
</MudStack>
|
||||
|
||||
<MudGrid Spacing="2" Class="mt-1">
|
||||
<MudItem xs="6">
|
||||
<MudTextField @bind-Value="_entries[index].MarkerId" Label="Marker ID" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudSelect @bind-Value="_entries[index].Type" Label="Type" Variant="Variant.Outlined">
|
||||
@foreach (var type in System.Enum.GetValues<MarkerType>())
|
||||
{
|
||||
<MudSelectItem Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_entries[index].Priority" Label="Priority" Variant="Variant.Outlined" Min="1" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudTextField @bind-Value="_entries[index].DeviceId" Label="Device ID" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudTextField @bind-Value="_entries[index].Code" Label="Code" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mt-2">
|
||||
<MudText Typo="Typo.body2">Reference Points</MudText>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AddCircleOutline" Color="Color.Primary" Size="Size.Small"
|
||||
OnClick="() => AddReferencePoint(index)" />
|
||||
</MudStack>
|
||||
|
||||
@for (int j = 0; j < _entries[index].ReferencePoints.Count; j++)
|
||||
{
|
||||
var ptIndex = j;
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="2" Class="mt-1">
|
||||
<MudNumericField @bind-Value="_entries[index].ReferencePoints[ptIndex].X" Label="X" Variant="Variant.Outlined"
|
||||
Step="0.01" Margin="Margin.Dense" />
|
||||
<MudNumericField @bind-Value="_entries[index].ReferencePoints[ptIndex].Y" Label="Y" Variant="Variant.Outlined"
|
||||
Step="0.01" Margin="Margin.Dense" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.RemoveCircleOutline" Color="Color.Error" Size="Size.Small"
|
||||
OnClick="() => RemoveReferencePoint(index, ptIndex)" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit"
|
||||
Disabled="string.IsNullOrWhiteSpace(_stationId)">
|
||||
@(IsEditMode ? "Update" : "Create")
|
||||
</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
|
||||
[Parameter] public DockStationConfigDto? Config { get; set; }
|
||||
|
||||
private bool IsEditMode => Config is not null;
|
||||
|
||||
private string _stationId = string.Empty;
|
||||
private string _configName = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private double _x;
|
||||
private double _y;
|
||||
private double _yaw;
|
||||
private double _width;
|
||||
private double _length;
|
||||
private bool _isActive = true;
|
||||
private readonly List<MarkerEntryModel> _entries = [];
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
if (Config is not null)
|
||||
{
|
||||
_stationId = Config.StationId;
|
||||
_configName = Config.ConfigName ?? string.Empty;
|
||||
_description = Config.Description ?? string.Empty;
|
||||
_x = Config.X;
|
||||
_y = Config.Y;
|
||||
_yaw = Config.Yaw;
|
||||
_width = Config.Width;
|
||||
_length = Config.Length;
|
||||
_isActive = Config.IsActive;
|
||||
_entries.AddRange(Config.MarkerEntries.Select(e => new MarkerEntryModel
|
||||
{
|
||||
MarkerId = e.MarkerId,
|
||||
Type = e.Type,
|
||||
Priority = e.Priority,
|
||||
DeviceId = e.DeviceId,
|
||||
Code = e.Code,
|
||||
ReferencePoints = e.ReferencePoints.Select(p => new Vector2Model { X = p.X, Y = p.Y }).ToList()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
private void AddMarkerEntry()
|
||||
{
|
||||
_entries.Add(new MarkerEntryModel { Priority = _entries.Count + 1 });
|
||||
}
|
||||
|
||||
private void RemoveMarkerEntry(int index) => _entries.RemoveAt(index);
|
||||
|
||||
private void AddReferencePoint(int entryIndex)
|
||||
{
|
||||
_entries[entryIndex].ReferencePoints.Add(new Vector2Model());
|
||||
}
|
||||
|
||||
private void RemoveReferencePoint(int entryIndex, int ptIndex)
|
||||
{
|
||||
_entries[entryIndex].ReferencePoints.RemoveAt(ptIndex);
|
||||
}
|
||||
|
||||
private List<DockStationMarkerEntryDto> BuildMarkerEntries() =>
|
||||
_entries.Select(e => new DockStationMarkerEntryDto
|
||||
{
|
||||
MarkerId = e.MarkerId ?? string.Empty,
|
||||
Type = e.Type,
|
||||
Priority = e.Priority,
|
||||
DeviceId = e.DeviceId,
|
||||
Code = e.Code,
|
||||
ReferencePoints = e.ReferencePoints.Select(p => new Vector2 { X = p.X, Y = p.Y }).ToList()
|
||||
}).ToList();
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
if (IsEditMode)
|
||||
{
|
||||
var request = new UpdateDockStationConfigRequest
|
||||
{
|
||||
StationId = _stationId,
|
||||
ConfigName = _configName,
|
||||
Description = _description,
|
||||
X = _x,
|
||||
Y = _y,
|
||||
Yaw = _yaw,
|
||||
Width = _width,
|
||||
Length = _length,
|
||||
IsActive = _isActive,
|
||||
MarkerEntries = BuildMarkerEntries()
|
||||
};
|
||||
Dialog.Close(DialogResult.Ok(request));
|
||||
}
|
||||
else
|
||||
{
|
||||
var request = new CreateDockStationConfigRequest
|
||||
{
|
||||
StationId = _stationId,
|
||||
ConfigName = _configName,
|
||||
Description = _description,
|
||||
X = _x,
|
||||
Y = _y,
|
||||
Yaw = _yaw,
|
||||
Width = _width,
|
||||
Length = _length,
|
||||
IsActive = _isActive,
|
||||
MarkerEntries = BuildMarkerEntries()
|
||||
};
|
||||
Dialog.Close(DialogResult.Ok(request));
|
||||
}
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private class MarkerEntryModel
|
||||
{
|
||||
public string? MarkerId { get; set; }
|
||||
public MarkerType Type { get; set; }
|
||||
public int Priority { get; set; } = 1;
|
||||
public string? DeviceId { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public List<Vector2Model> ReferencePoints { get; set; } = [];
|
||||
}
|
||||
|
||||
private class Vector2Model
|
||||
{
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
@using RobotNet10.Shared.Detection
|
||||
@using RobotNet10.Shared.Enum
|
||||
@using RobotNet10.Shared.Numbers
|
||||
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Create Markers Search Request</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_request.X" Label="X (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_request.Y" Label="Y (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_request.Yaw" Label="Yaw (rad)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField @bind-Value="_request.Width" Label="Width (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudNumericField @bind-Value="_request.Length" Label="Length (m)" Variant="Variant.Outlined" Step="0.1" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mt-4 mb-2">
|
||||
<MudText Typo="Typo.subtitle1">Marker Entries</MudText>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary" OnClick="AddMarkerEntry" />
|
||||
</MudStack>
|
||||
|
||||
@for (int i = 0; i < _entries.Count; i++)
|
||||
{
|
||||
var index = i;
|
||||
<MudPaper Class="pa-3 mb-2" Outlined>
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.subtitle2">Marker @(index + 1)</MudText>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error" Size="Size.Small"
|
||||
OnClick="() => RemoveMarkerEntry(index)" />
|
||||
</MudStack>
|
||||
|
||||
<MudGrid Spacing="2" Class="mt-1">
|
||||
<MudItem xs="6">
|
||||
<MudTextField @bind-Value="_entries[index].MarkerId" Label="Marker ID" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="6">
|
||||
<MudSelect @bind-Value="_entries[index].Type" Label="Type" Variant="Variant.Outlined">
|
||||
@foreach (var type in System.Enum.GetValues<MarkerType>())
|
||||
{
|
||||
<MudSelectItem Value="type">@type</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudNumericField @bind-Value="_entries[index].Priority" Label="Priority" Variant="Variant.Outlined" Min="1" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudTextField @bind-Value="_entries[index].DeviceId" Label="Device ID" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="4">
|
||||
<MudTextField @bind-Value="_entries[index].Code" Label="Code" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mt-2">
|
||||
<MudText Typo="Typo.body2">Reference Points</MudText>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.AddCircleOutline" Color="Color.Primary" Size="Size.Small"
|
||||
OnClick="() => AddReferencePoint(index)" />
|
||||
</MudStack>
|
||||
|
||||
@for (int j = 0; j < _entries[index].ReferencePoints.Count; j++)
|
||||
{
|
||||
var ptIndex = j;
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="2" Class="mt-1">
|
||||
<MudNumericField @bind-Value="_entries[index].ReferencePoints[ptIndex].X" Label="X" Variant="Variant.Outlined"
|
||||
Step="0.01" Margin="Margin.Dense" />
|
||||
<MudNumericField @bind-Value="_entries[index].ReferencePoints[ptIndex].Y" Label="Y" Variant="Variant.Outlined"
|
||||
Step="0.01" Margin="Margin.Dense" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.RemoveCircleOutline" Color="Color.Error" Size="Size.Small"
|
||||
OnClick="() => RemoveReferencePoint(index, ptIndex)" />
|
||||
</MudStack>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="Cancel">Cancel</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">Create</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
|
||||
private MarkersSearchRequest _request = new()
|
||||
{
|
||||
X = -0.611,
|
||||
Y = 5.063,
|
||||
Yaw = 180.0,
|
||||
Width = 0.3,
|
||||
Length = 0.3,
|
||||
MarkerSearchRequests = []
|
||||
};
|
||||
|
||||
private readonly List<MarkerEntryModel> _entries = [
|
||||
new MarkerEntryModel(){
|
||||
MarkerId = "trolley-legs",
|
||||
DeviceId = "sick-lidar-001",
|
||||
Priority = 1,
|
||||
Type = MarkerType.ShapeReflective,
|
||||
ReferencePoints = [
|
||||
new Vector2Model(){ X=-1.13, Y = 0.55 },
|
||||
new Vector2Model(){X=1.13, Y=0.55 }
|
||||
]
|
||||
},
|
||||
new MarkerEntryModel(){
|
||||
MarkerId = "trolley-qr",
|
||||
DeviceId = "hik-qr-001",
|
||||
Priority = 2,
|
||||
Type = MarkerType.QRCode,
|
||||
Code = "PHENIKAAX",
|
||||
ReferencePoints = []
|
||||
}
|
||||
];
|
||||
|
||||
private void AddMarkerEntry()
|
||||
{
|
||||
_entries.Add(new MarkerEntryModel { Priority = _entries.Count + 1 });
|
||||
}
|
||||
|
||||
private void RemoveMarkerEntry(int index)
|
||||
{
|
||||
_entries.RemoveAt(index);
|
||||
}
|
||||
|
||||
private void AddReferencePoint(int entryIndex)
|
||||
{
|
||||
_entries[entryIndex].ReferencePoints.Add(new Vector2Model());
|
||||
}
|
||||
|
||||
private void RemoveReferencePoint(int entryIndex, int ptIndex)
|
||||
{
|
||||
_entries[entryIndex].ReferencePoints.RemoveAt(ptIndex);
|
||||
}
|
||||
|
||||
private void Submit()
|
||||
{
|
||||
_request.MarkerSearchRequests = _entries.Select(e => new MarkerEntry
|
||||
{
|
||||
MarkerId = e.MarkerId ?? string.Empty,
|
||||
Type = e.Type,
|
||||
Priority = e.Priority,
|
||||
DeviceId = e.DeviceId ?? string.Empty,
|
||||
Code = e.Code ?? string.Empty,
|
||||
ReferencePoints = e.ReferencePoints.Select(p => new Vector2 { X = p.X, Y = p.Y }).ToArray()
|
||||
}).ToArray();
|
||||
|
||||
Dialog.Close(DialogResult.Ok(_request));
|
||||
}
|
||||
|
||||
private void Cancel() => Dialog.Cancel();
|
||||
|
||||
private class MarkerEntryModel
|
||||
{
|
||||
public string? MarkerId { get; set; }
|
||||
public MarkerType Type { get; set; }
|
||||
public int Priority { get; set; } = 1;
|
||||
public string? DeviceId { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public List<Vector2Model> ReferencePoints { get; set; } = [];
|
||||
}
|
||||
|
||||
private class Vector2Model
|
||||
{
|
||||
public double X { get; set; }
|
||||
public double Y { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<MudDialog>
|
||||
<TitleContent>
|
||||
<MudText Typo="Typo.h6">Create new Robot Configurations</MudText>
|
||||
</TitleContent>
|
||||
<DialogContent>
|
||||
<MudStepper ShowResetButton>
|
||||
<MudStep Title="Select campaign settings">Select campaign settings content</MudStep>
|
||||
<MudStep Title="Create an ad group" SecondaryText="Optional" Skippable="true">Create an ad group content</MudStep>
|
||||
<MudStep Title="Create an ad">Create an ad content</MudStep>
|
||||
</MudStepper>
|
||||
</DialogContent>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] IMudDialogInstance Dialog { get; set; } = null!;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Components.Routing;
|
||||
using RobotNet10.Components;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client;
|
||||
|
||||
public static class Extensions
|
||||
{
|
||||
public static void AddNavigationMenu(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(sp => new OptionLayout()
|
||||
{
|
||||
AppName = "Robot App",
|
||||
NavModels = [
|
||||
// new("mdi-view-dashboard", "/", "Dashboard", NavLinkMatch.All),
|
||||
// new("mdi-file-code", "/programming", "Programming", NavLinkMatch.All),
|
||||
// new("mdi-flag-checkered", "/missions", "Missions", NavLinkMatch.All),
|
||||
// new("mdi-map", "/layout-manager", "Map Editor", NavLinkMatch.All),
|
||||
// new("mdi-robot-industrial", "/vehicle-manager", "Vehicle", NavLinkMatch.All),
|
||||
new("mdi-chip", "/devices", "Devices", NavLinkMatch.All),
|
||||
// Manual Control page
|
||||
new("mdi-gamepad-variant", "/motion/manualcontrol", "Manual Control", NavLinkMatch.All),
|
||||
// new("mdi-axis-arrow", "/motion/odometry", "Odometry (XLOC)", NavLinkMatch.All),
|
||||
// new("mdi-monitor-eye", "/motion/navigation-monitor", "Navigation Monitor", NavLinkMatch.All),
|
||||
new("mdi-developer-board", "/plc/controller", "PLC Controller", NavLinkMatch.All),
|
||||
// new("mdi-crosshairs-gps", "/localization", "Localization", NavLinkMatch.All),
|
||||
// new("mdi-map-plus", "/maps", "Maps", NavLinkMatch.All),
|
||||
new("mdi-chart-box-outline", "/xloc/map", "Visualization", NavLinkMatch.All),
|
||||
// new("mdi-tune", "/navigation/tuning", "Navigation Tuning", NavLinkMatch.All),
|
||||
new("mdi-ev-station", "/dock-station-config", "Dock Station", NavLinkMatch.All),
|
||||
// new("mdi-application-cog-outline", "/config-manager", "Configuration", NavLinkMatch.All),
|
||||
new("mdi-text-box-outline", "/logs", "Logs", NavLinkMatch.All),
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
@page "/auth"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Auth</PageTitle>
|
||||
|
||||
<h1>You are authenticated</h1>
|
||||
|
||||
<AuthorizeView>
|
||||
Hello @context.User.Identity?.Name!
|
||||
</AuthorizeView>
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/config-manager"
|
||||
@using RobotNet10.CustomConfigurationEditor.Components.ConfigManager
|
||||
@rendermode RobotNet10.Components.RenderMode.InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Configuration Manager</PageTitle>
|
||||
|
||||
<RobotNet10.CustomConfigurationEditor.Components.ConfigManager.ConfigManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,247 @@
|
||||
@page "/devices"
|
||||
@implements IAsyncDisposable
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
@using RobotNet10.RobotApp.Client.Services
|
||||
@using RobotNet10.RobotApp.Client.Shared.Devices
|
||||
@using MudBlazor
|
||||
|
||||
@inject DeviceHubClient DeviceHubClient
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Device Diagnostics</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
|
||||
<MudPaper Class="pa-4 mb-4">
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Justify="@Justify.SpaceBetween" Spacing="3">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Device Diagnostics</MudText>
|
||||
<MudStack Row="true" AlignItems="@AlignItems.Center" Spacing="3">
|
||||
<MudTextField @bind-Value="searchText" @bind-Value:after="OnFilterChanged" Placeholder="Search devices..." Class="flex-grow-1"
|
||||
Variant="Variant.Outlined" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Search" Margin="@Margin.Dense" />
|
||||
<MudIconButton Color="Color.Primary" OnClick="RefreshDevices" Disabled="@(!DeviceHubClient.IsConnected || isLoading)" Icon="@Icons.Material.Filled.Refresh" />
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
@* Loading State *@
|
||||
@if (isLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-4" />
|
||||
}
|
||||
|
||||
@* Device Count Summary *@
|
||||
<MudGrid Class="mb-4">
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6">@devices.Count</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Total Devices</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6" Color="Color.Success">@devices.Count(d => d.IsConnected)</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Connected</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6" Color="Color.Error">@devices.Count(d => d.Status == DeviceStatus.Error)</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Errors</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="3">
|
||||
<MudPaper Class="pa-3 text-center">
|
||||
<MudText Typo="Typo.h6" Color="Color.Warning">@devices.Count(d => d.Status == DeviceStatus.Disconnected)</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">Disconnected</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Devices List *@
|
||||
@if (filteredDevices != null && filteredDevices.Any())
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
@foreach (var device in filteredDevices)
|
||||
{
|
||||
<MudItem xs="12" sm="6" md="4" lg="3" xl="2">
|
||||
<DeviceCard Device="@device" />
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
else if (!isLoading && (devices == null || !devices.Any()))
|
||||
{
|
||||
<MudPaper Class="pa-8 text-center">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Devices" Size="Size.Large" Color="Color.Secondary" Class="mb-4" />
|
||||
<MudText Typo="Typo.h6">No devices found</MudText>
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No devices are currently registered in the system.</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else if (!isLoading)
|
||||
{
|
||||
<MudPaper Class="pa-8 text-center">
|
||||
<MudText Typo="Typo.h6">No devices match the filter criteria</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private List<DeviceDto> devices = [];
|
||||
private List<DeviceDto> filteredDevices = [];
|
||||
private bool isLoading = false;
|
||||
private string searchText = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
DeviceHubClient.DeviceUpdated += OnDeviceUpdated;
|
||||
DeviceHubClient.DeviceStatusChanged += OnDeviceStatusChanged;
|
||||
DeviceHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
await DeviceHubClient.StartAsync();
|
||||
await LoadDevices();
|
||||
}
|
||||
|
||||
private async Task LoadDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
if (DeviceHubClient.IsConnected)
|
||||
{
|
||||
devices = (await DeviceHubClient.GetAllDevicesAsync()).ToList();
|
||||
ApplyFilters();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error loading devices: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshDevices()
|
||||
{
|
||||
await LoadDevices();
|
||||
}
|
||||
|
||||
private void OnDeviceUpdated(DeviceUpdateDto update)
|
||||
{
|
||||
if (devices == null) return;
|
||||
|
||||
var device = devices.FirstOrDefault(d => d.DeviceId == update.DeviceId);
|
||||
if (device != null)
|
||||
{
|
||||
if (update.Status.HasValue)
|
||||
device.Status = update.Status.Value;
|
||||
if (update.Properties != null)
|
||||
device.Properties = update.Properties;
|
||||
if (update.LastError != null)
|
||||
device.LastError = update.LastError;
|
||||
if (update.LastUpdateTime.HasValue)
|
||||
device.LastUpdateTime = update.LastUpdateTime.Value;
|
||||
if (update.LastConnectedTime.HasValue)
|
||||
device.LastConnectedTime = update.LastConnectedTime;
|
||||
if (update.LastDisconnectedTime.HasValue)
|
||||
device.LastDisconnectedTime = update.LastDisconnectedTime;
|
||||
if (update.ReconnectAttemptCount.HasValue)
|
||||
device.ReconnectAttemptCount = update.ReconnectAttemptCount.Value;
|
||||
|
||||
device.IsConnected = device.Status == DeviceStatus.Connected;
|
||||
ApplyFilters();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDeviceStatusChanged(string deviceId, DeviceStatus status)
|
||||
{
|
||||
if (devices == null) return;
|
||||
|
||||
var device = devices.FirstOrDefault(d => d.DeviceId == deviceId);
|
||||
if (device != null)
|
||||
{
|
||||
device.Status = status;
|
||||
device.IsConnected = status == DeviceStatus.Connected;
|
||||
ApplyFilters();
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
if (state == HubConnectionState.Connected)
|
||||
{
|
||||
_ = LoadDevices();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFilterChanged()
|
||||
{
|
||||
ApplyFilters();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
if (devices == null)
|
||||
{
|
||||
filteredDevices = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var query = devices.AsEnumerable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchText))
|
||||
{
|
||||
var searchLower = searchText.ToLowerInvariant();
|
||||
query = query.Where(d =>
|
||||
d.DeviceId.ToLowerInvariant().Contains(searchLower) ||
|
||||
d.DeviceName.ToLowerInvariant().Contains(searchLower) ||
|
||||
(d.Description != null && d.Description.ToLowerInvariant().Contains(searchLower))
|
||||
);
|
||||
}
|
||||
|
||||
filteredDevices = query.ToList();
|
||||
}
|
||||
|
||||
private string GetConnectionIcon()
|
||||
{
|
||||
return DeviceHubClient.ConnectionState switch
|
||||
{
|
||||
HubConnectionState.Connected => Icons.Material.Filled.CheckCircle,
|
||||
HubConnectionState.Connecting => Icons.Material.Filled.HourglassEmpty,
|
||||
HubConnectionState.Reconnecting => Icons.Material.Filled.Sync,
|
||||
_ => Icons.Material.Filled.Error
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetConnectionColor()
|
||||
{
|
||||
return DeviceHubClient.ConnectionState switch
|
||||
{
|
||||
HubConnectionState.Connected => Color.Success,
|
||||
HubConnectionState.Connecting => Color.Warning,
|
||||
HubConnectionState.Reconnecting => Color.Warning,
|
||||
_ => Color.Error
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
DeviceHubClient.DeviceUpdated -= OnDeviceUpdated;
|
||||
DeviceHubClient.DeviceStatusChanged -= OnDeviceStatusChanged;
|
||||
DeviceHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/devices/battery/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Battery - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<BatteryCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@page "/devices/cameraqr/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Camera QR - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<CameraQrCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
@page "/devices/cia402servo/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>CiA402Servo Device - @DeviceId</PageTitle>
|
||||
|
||||
<RobotNet10.Components.DivContainer OverflowY="overflow-y-auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<CiA402ServoCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
</RobotNet10.Components.DivContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/devices/imu/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Inertial Measurement Unit - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<InertialMeasurementUnitCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@page "/devices/lidar/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>Lidar - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<LidarCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
@page "/devices/modbustcp/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>ModbusTCP Device - @DeviceId</PageTitle>
|
||||
|
||||
<RobotNet10.Components.DivContainer OverflowY="overflow-y-auto">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<ModbusTcpCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
</RobotNet10.Components.DivContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
@page "/devices/rfhandle/{DeviceId}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Components.Devices
|
||||
|
||||
<PageTitle>RF Handle - @DeviceId</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-4">
|
||||
<RfHandleCard DeviceId="@DeviceId" />
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string DeviceId { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
@page "/dock-station-config"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using RobotNet10.RobotApp.Client.Services
|
||||
@using RobotNet10.RobotApp.Client.Dialogs
|
||||
@using RobotNet10.RobotApp.Shared.DockStation
|
||||
@inject DockStationConfigState State
|
||||
@inject IDialogService DialogService
|
||||
@inject ISnackbar Snackbar
|
||||
@implements IDisposable
|
||||
|
||||
<PageTitle>Dock Station Config</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<MudText Typo="Typo.h5" Class="mb-4">Dock Station Configuration</MudText>
|
||||
|
||||
@if (!string.IsNullOrEmpty(State.ErrorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mb-3">
|
||||
@State.ErrorMessage
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
<MudGrid>
|
||||
@* Left Panel - Config List *@
|
||||
<MudItem xs="12" md="5" lg="4">
|
||||
<MudPaper Class="pa-3" Elevation="2">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||
<MudText Typo="Typo.h6">Stations</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add"
|
||||
OnClick="OpenCreateDialog" Disabled="State.IsSaving">
|
||||
New
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@if (State.IsLoading && State.Configs.Count == 0)
|
||||
{
|
||||
<MudProgressLinear Indeterminate />
|
||||
}
|
||||
else if (State.Configs.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Tertiary" Align="Align.Center" Class="pa-4">
|
||||
No dock station configs found.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudList T="DockStationConfigSummaryDto" Dense SelectedValueChanged="OnConfigSelected">
|
||||
@foreach (var config in State.Configs)
|
||||
{
|
||||
<MudListItem Value="config"
|
||||
Icon="@(config.IsActive ? Icons.Material.Filled.EvStation : Icons.Material.Outlined.EvStation)"
|
||||
IconColor="@(config.IsActive ? Color.Success : Color.Default)">
|
||||
<MudStack Spacing="0">
|
||||
<MudText Typo="Typo.body1"><b>@config.StationId</b></MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary">
|
||||
@(string.IsNullOrEmpty(config.ConfigName) ? "No name" : config.ConfigName)
|
||||
· @config.MarkerEntryCount marker(s)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudListItem>
|
||||
}
|
||||
</MudList>
|
||||
}
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
|
||||
@* Right Panel - Detail View *@
|
||||
<MudItem xs="12" md="7" lg="8">
|
||||
@if (State.SelectedConfig is not null)
|
||||
{
|
||||
var cfg = State.SelectedConfig;
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Class="mb-3">
|
||||
<MudText Typo="Typo.h6">@cfg.StationId</MudText>
|
||||
<MudChip T="string" Size="Size.Small"
|
||||
Color="@(cfg.IsActive ? Color.Success : Color.Default)">
|
||||
@(cfg.IsActive ? "Active" : "Inactive")
|
||||
</MudChip>
|
||||
<MudSpacer />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Color="Color.Primary"
|
||||
OnClick="OpenEditDialog" Disabled="State.IsSaving" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
|
||||
OnClick="OpenDeleteDialog" Disabled="State.IsSaving" />
|
||||
</MudStack>
|
||||
|
||||
@if (!string.IsNullOrEmpty(cfg.ConfigName))
|
||||
{
|
||||
<MudText Typo="Typo.subtitle1" Class="mb-1">@cfg.ConfigName</MudText>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(cfg.Description))
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Tertiary" Class="mb-3">@cfg.Description</MudText>
|
||||
}
|
||||
|
||||
<MudDivider Class="my-3" />
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Search Area</MudText>
|
||||
<MudGrid Spacing="2">
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">X: <b>@cfg.X.ToString("F3")</b> m</MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Y: <b>@cfg.Y.ToString("F3")</b> m</MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Yaw: <b>@cfg.Yaw.ToString("F4")</b> rad</MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Width: <b>@cfg.Width.ToString("F3")</b> m</MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Length: <b>@cfg.Length.ToString("F3")</b> m</MudText></MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudDivider Class="my-3" />
|
||||
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-2">Marker Entries (@cfg.MarkerEntries.Count)</MudText>
|
||||
<MudPaper Style="max-height: calc(100vh - 460px); overflow-y: auto; padding-right: 4px;" Elevation="0">
|
||||
@foreach (var entry in cfg.MarkerEntries.OrderBy(e => e.Priority))
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-2" Outlined>
|
||||
<MudGrid Spacing="1">
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Marker: <b>@entry.MarkerId</b></MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Type: <b>@entry.Type</b></MudText></MudItem>
|
||||
<MudItem xs="4"><MudText Typo="Typo.body2">Priority: <b>@entry.Priority</b></MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Device: <b>@(entry.DeviceId ?? "-")</b></MudText></MudItem>
|
||||
<MudItem xs="6"><MudText Typo="Typo.body2">Code: <b>@(entry.Code ?? "-")</b></MudText></MudItem>
|
||||
</MudGrid>
|
||||
@if (entry.ReferencePoints.Count > 0)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Class="mt-1">
|
||||
Reference Points: @string.Join(", ", entry.ReferencePoints.Select(p => $"({p.X:F3}, {p.Y:F3})"))
|
||||
</MudText>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudDivider Class="my-3" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Tertiary">
|
||||
Created: @cfg.CreatedAt.ToString("yyyy-MM-dd HH:mm") | Updated: @cfg.UpdatedAt.ToString("yyyy-MM-dd HH:mm")
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudPaper Class="pa-8 d-flex align-center justify-center" Elevation="0" Style="min-height:300px">
|
||||
<MudText Typo="Typo.body1" Color="Color.Tertiary">Select a dock station config to view details</MudText>
|
||||
</MudPaper>
|
||||
}
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
State.OnStateChanged += StateHasChanged;
|
||||
await State.LoadConfigsAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
State.OnStateChanged -= StateHasChanged;
|
||||
}
|
||||
|
||||
private async Task OnConfigSelected(DockStationConfigSummaryDto? config)
|
||||
{
|
||||
if (config is not null)
|
||||
await State.SelectConfigAsync(config.Id);
|
||||
}
|
||||
|
||||
private async Task OpenCreateDialog()
|
||||
{
|
||||
var dialog = await DialogService.ShowAsync<DockStationConfigDialog>(
|
||||
"Create Dock Station Config",
|
||||
new DialogParameters { ["Config"] = null },
|
||||
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
|
||||
var result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled && result.Data is CreateDockStationConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.CreateConfigAsync(request);
|
||||
Snackbar.Add("Dock station config created.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenEditDialog()
|
||||
{
|
||||
if (State.SelectedConfig is null) return;
|
||||
|
||||
var dialog = await DialogService.ShowAsync<DockStationConfigDialog>(
|
||||
"Edit Dock Station Config",
|
||||
new DialogParameters { ["Config"] = State.SelectedConfig },
|
||||
new DialogOptions { MaxWidth = MaxWidth.Medium, FullWidth = true });
|
||||
|
||||
var result = await dialog.Result;
|
||||
if (result is not null && !result.Canceled && result.Data is UpdateDockStationConfigRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.UpdateConfigAsync(State.SelectedConfig.Id, request);
|
||||
Snackbar.Add("Dock station config updated.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenDeleteDialog()
|
||||
{
|
||||
if (State.SelectedConfig is null) return;
|
||||
|
||||
var confirm = await DialogService.ShowMessageBoxAsync(
|
||||
"Confirm Delete",
|
||||
$"Are you sure you want to delete dock station config '{State.SelectedConfig.StationId}'?",
|
||||
yesText: "Delete", cancelText: "Cancel");
|
||||
|
||||
if (confirm == true)
|
||||
{
|
||||
try
|
||||
{
|
||||
await State.DeleteConfigAsync(State.SelectedConfig.Id);
|
||||
Snackbar.Add("Dock station config deleted.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
@page "/layout-editor/{LevelId:guid}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Layout Editor</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.LayoutEditor.LayoutEditorComponent LevelId="@LevelId" />
|
||||
|
||||
<MudThemeProvider IsDarkMode/>
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid LevelId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/layout-manager"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Layout Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.LayoutManager.LayoutManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,556 @@
|
||||
@page "/localization"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Components.SLAM
|
||||
@using RobotNet10.RobotApp.Client.Dialogs
|
||||
@using RobotNet10.RobotApp.Client.Shared.SLAM
|
||||
@using RobotNet10.RobotApp.Shared.Enums
|
||||
@using RobotNet10.Shared.Detection
|
||||
@using RobotNet10.Shared.Geometry
|
||||
|
||||
<PageTitle>Localization</PageTitle>
|
||||
|
||||
<div class="h-100 w-100 d-flex flex-column overflow-hidden">
|
||||
<!-- Control Bar -->
|
||||
<div class="position-relative d-flex flex-row align-items-center gap-3 flex-wrap px-1" style="background-color: var(--mud-palette-background);">
|
||||
<!-- Map Name -->
|
||||
<div class="d-flex flex-row align-items-center gap-2" style="min-width: 300px;">
|
||||
<MudTextField @bind-Value="MapName"
|
||||
Label="Map Name" ShrinkLabel
|
||||
Variant="Variant.Outlined"
|
||||
Margin="Margin.Dense"
|
||||
Disabled="@(CurrentState != SLAMState.Ready)"
|
||||
Style="max-width: 300px;" />
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="d-flex flex-row align-items-center gap-2">
|
||||
<MudButtonGroup Variant="Variant.Filled" Color="Color.Primary">
|
||||
@if (IsLocalizationActive)
|
||||
{
|
||||
@* Fit View Button - always visible when map is displayed *@
|
||||
<MudTooltip Text="Fit View">
|
||||
<MudIconButton Color="Color.Default" Variant="Variant.Outlined"
|
||||
Icon="@Icons.Material.Filled.FitScreen"
|
||||
OnClick="FitViewAsync" />
|
||||
</MudTooltip>
|
||||
|
||||
@* Localizing or InitializingLocalizing: Show Stop, Initial Pose, Marker Detect *@
|
||||
<MudTooltip Text="Stop">
|
||||
<MudIconButton Color="Color.Error" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.Stop"
|
||||
OnClick="StopLocalizationAsync" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Initialize Pose">
|
||||
<MudIconButton Color="Color.Secondary" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.MyLocation"
|
||||
OnClick="SetInitialPoseAsync" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@(_isDetecting ? "Stop Detect" : "Marker Detect")">
|
||||
<MudIconButton Color="@(_isDetecting? Color.Error: Color.Tertiary)" Variant="Variant.Filled"
|
||||
Icon="@(_isDetecting ? Icons.Material.Filled.StopCircle : Icons.Material.Filled.Search)"
|
||||
OnClick="ToggleMarkerDetectAsync"
|
||||
Disabled="@(CurrentState == SLAMState.Relocalizing)" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else if (IsScanMappingActive)
|
||||
{
|
||||
@* Fit View Button - always visible when map is displayed *@
|
||||
<MudTooltip Text="Fit View">
|
||||
<MudIconButton Color="Color.Default" Variant="Variant.Outlined"
|
||||
Icon="@Icons.Material.Filled.FitScreen"
|
||||
OnClick="FitViewAsync" />
|
||||
</MudTooltip>
|
||||
|
||||
@* ScanMapping or SavingMap: Show Save Map *@
|
||||
<MudTooltip Text="@(CurrentState == SLAMState.SavingMap ? "Saving..." : "Save Map")">
|
||||
<MudIconButton Color="Color.Success" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.Save"
|
||||
OnClick="SaveMapAsync"
|
||||
Disabled="@(CurrentState == SLAMState.SavingMap)" />
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Ready or other states: Show Localize and Scan Mapping *@
|
||||
<MudTooltip Text="Localize">
|
||||
<MudIconButton Color="Color.Primary" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.LocationOn"
|
||||
OnClick="StartLocalizationAsync"
|
||||
Disabled="@(CurrentState != SLAMState.Ready)" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Scan Mapping">
|
||||
<MudIconButton Color="Color.Primary" Variant="Variant.Filled"
|
||||
Icon="@Icons.Material.Filled.Map"
|
||||
OnClick="StartScanMappingAsync"
|
||||
Disabled="@(CurrentState != SLAMState.Ready || string.IsNullOrWhiteSpace(MapName))" />
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudButtonGroup>
|
||||
</div>
|
||||
|
||||
@* Map Save Progress Indicator *@
|
||||
@if (CurrentState == SLAMState.SavingMap && _saveProgress >= 0)
|
||||
{
|
||||
<div class="d-flex flex-row align-items-center gap-2" style="min-width: 300px;">
|
||||
<MudProgressLinear Color="Color.Primary"
|
||||
Value="@_saveProgress"
|
||||
Class="flex-grow-1"
|
||||
Striped="true"
|
||||
Size="Size.Medium" />
|
||||
<span class="small fw-medium">@_saveProgress%</span>
|
||||
<span class="small">@(_workItemsCompleted)/@(_workItemsAdded)</span>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-grow-1"></div>
|
||||
<!-- State Indicator -->
|
||||
<div class="d-flex flex-row align-items-center gap-2">
|
||||
<span class="status-chip d-inline-flex align-items-center px-3 py-1 rounded-pill small fw-medium" style="background-color: @GetStateChipBackgroundColor(CurrentState); color: @GetStateChipTextColor(CurrentState);">
|
||||
@GetStateText(CurrentState ?? SLAMState.Idle)
|
||||
</span>
|
||||
</div>
|
||||
<MudOverlay Visible="@(!CartographerClient.IsConnected)" AutoClose="false" DarkBackground Absolute />
|
||||
</div>
|
||||
|
||||
<!-- Map Localization - Fill remaining space -->
|
||||
<div class="flex-grow-1 w-100" style="min-height: 0;">
|
||||
<MapLocalization @ref="MapLocalizationRef">
|
||||
<Elements>
|
||||
<MarkerDetectOverlay @ref="MarkerDetectOverlayRef" />
|
||||
</Elements>
|
||||
</MapLocalization>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private SLAMState? CurrentState { get; set; }
|
||||
private bool IsLocalizationActive => CurrentState == SLAMState.Localizing || CurrentState == SLAMState.Relocalizing;
|
||||
private bool IsScanMappingActive => CurrentState == SLAMState.ScanMapping || CurrentState == SLAMState.SavingMap;
|
||||
private MapLocalization? MapLocalizationRef { get; set; }
|
||||
private MarkerDetectOverlay? MarkerDetectOverlayRef { get; set; }
|
||||
|
||||
[Inject]
|
||||
private SLAMClient CartographerClient { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private IDialogService DialogService { get; set; } = null!;
|
||||
|
||||
[Inject]
|
||||
private MarkerDetectorHubClient MarkerDetectorClient { get; set; } = null!;
|
||||
|
||||
// Marker detection state
|
||||
private Guid? _detectSessionId;
|
||||
private MarkersSearchRequest? _detectRequest;
|
||||
private System.Timers.Timer? _detectTimer;
|
||||
private bool _isDetecting;
|
||||
private string MapName = "";
|
||||
|
||||
// Map save progress state
|
||||
private int _saveProgress = -1;
|
||||
private int _workItemsAdded;
|
||||
private int _workItemsCompleted;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await CartographerClient.StartAsync();
|
||||
|
||||
// Subscribe to events
|
||||
CartographerClient.StateChanged += OnStateChanged;
|
||||
CartographerClient.MapSaveProgressChanged += OnMapSaveProgressChanged;
|
||||
|
||||
// Get initial state
|
||||
CurrentState = await CartographerClient.GetCurrentStateAsync();
|
||||
|
||||
// If currently in localization or scan mapping state, get the current map name
|
||||
if (CurrentState == SLAMState.Relocalizing ||
|
||||
CurrentState == SLAMState.Localizing ||
|
||||
CurrentState == SLAMState.ScanMapping)
|
||||
{
|
||||
var currentMap = await CartographerClient.GetCurrentMapAsync();
|
||||
if (!string.IsNullOrEmpty(currentMap))
|
||||
{
|
||||
MapName = currentMap;
|
||||
}
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStateChanged(SLAMState state)
|
||||
{
|
||||
CurrentState = state;
|
||||
|
||||
// Reset progress when not saving
|
||||
if (state != SLAMState.SavingMap)
|
||||
{
|
||||
_saveProgress = -1;
|
||||
_workItemsAdded = 0;
|
||||
_workItemsCompleted = 0;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnMapSaveProgressChanged(int workItemsAdded, int workItemsCompleted, int percentComplete)
|
||||
{
|
||||
_workItemsAdded = workItemsAdded;
|
||||
_workItemsCompleted = workItemsCompleted;
|
||||
_saveProgress = percentComplete;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task StartLocalizationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CartographerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to server", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get list of available maps
|
||||
var maps = await CartographerClient.ListMapsAsync();
|
||||
if (maps == null || maps.Length == 0)
|
||||
{
|
||||
Snackbar.Add("No maps available. Please create a map first.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show dialog to select map
|
||||
var parameters = new DialogParameters<SelectMapDialog>
|
||||
{
|
||||
{ x => x.Maps, maps }
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<SelectMapDialog>("Select Map for Localization", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled && result.Data is string mapName)
|
||||
{
|
||||
// Start localization with selected map
|
||||
var success = await CartographerClient.StartLocalizationAsync(mapName);
|
||||
if (success)
|
||||
{
|
||||
MapName = mapName;
|
||||
Snackbar.Add($"Started localization with map: {mapName}", Severity.Success);
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to start localization with map: {mapName}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to start localization: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopLocalizationAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await CartographerClient.StopLocalizationAsync();
|
||||
MapName = "";
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to stop localization: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartScanMappingAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!CartographerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to server", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(MapName))
|
||||
{
|
||||
Snackbar.Add("Please enter a map name.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
var success = await CartographerClient.StartScanMappingAsync(MapName);
|
||||
if (success)
|
||||
Snackbar.Add("Scan mapping started", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("Failed to start scan mapping", Severity.Error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to start scan mapping: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveMapAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_saveProgress = 0;
|
||||
_workItemsAdded = 0;
|
||||
_workItemsCompleted = 0;
|
||||
StateHasChanged();
|
||||
|
||||
var mapPath = await CartographerClient.SaveMapAsync();
|
||||
if (!string.IsNullOrEmpty(mapPath))
|
||||
Snackbar.Add($"Map saved successfully: {mapPath}", Severity.Success);
|
||||
else
|
||||
Snackbar.Add("Map save initiated. Monitor progress above.", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to save map: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetInitialPoseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var pose = MapLocalizationRef?.GetGoalPoseDto();
|
||||
if (pose == null)
|
||||
{
|
||||
Snackbar.Add("No goal pose set. Right-click on map to set robot goal, then click Initial Pose.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
await CartographerClient.SetInitialPoseAsync(pose);
|
||||
Snackbar.Add("Initial pose set from goal.", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to set initial pose: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FitViewAsync()
|
||||
{
|
||||
if (MapLocalizationRef != null)
|
||||
{
|
||||
await MapLocalizationRef.FitViewAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleMarkerDetectAsync()
|
||||
{
|
||||
if (_isDetecting)
|
||||
{
|
||||
await StopMarkerDetectAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
await OpenMarkersSearchRequestDialogAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OpenMarkersSearchRequestDialogAsync()
|
||||
{
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Medium,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<MarkersSearchRequestDialog>("Create Markers Search Request", options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result is not { Canceled: false, Data: MarkersSearchRequest request })
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
// Stop previous detection if running
|
||||
if (_isDetecting)
|
||||
await StopMarkerDetectAsync();
|
||||
|
||||
// Connect and create session
|
||||
await MarkerDetectorClient.StartAsync();
|
||||
var createResult = await MarkerDetectorClient.CreateSessionAsync(request);
|
||||
if (!createResult.IsSuccess)
|
||||
{
|
||||
Snackbar.Add($"Failed to create session: {createResult.Message}", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
_detectSessionId = createResult.Data;
|
||||
_detectRequest = request;
|
||||
_isDetecting = true;
|
||||
|
||||
// Start polling for goal
|
||||
_detectTimer = new System.Timers.Timer(1000);
|
||||
_detectTimer.Elapsed += OnDetectTimerElapsed;
|
||||
_detectTimer.Start();
|
||||
|
||||
Snackbar.Add($"Marker detection started (session: {_detectSessionId})", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to start marker detection: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopMarkerDetectAsync()
|
||||
{
|
||||
if (_detectTimer != null)
|
||||
{
|
||||
_detectTimer.Stop();
|
||||
_detectTimer.Elapsed -= OnDetectTimerElapsed;
|
||||
_detectTimer.Dispose();
|
||||
_detectTimer = null;
|
||||
}
|
||||
|
||||
_isDetecting = false;
|
||||
_detectSessionId = null;
|
||||
_detectRequest = null;
|
||||
|
||||
MarkerDetectOverlayRef?.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
await MarkerDetectorClient.StopAsync();
|
||||
}
|
||||
catch { }
|
||||
|
||||
Snackbar.Add("Marker detection stopped.", Severity.Info);
|
||||
}
|
||||
|
||||
private async void OnDetectTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
if (!_detectSessionId.HasValue)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await MarkerDetectorClient.GetGoalAsync(_detectSessionId.Value);
|
||||
Console.WriteLine($"goal = [{result.Data.Position.X}, {result.Data.Position.Y}, {result.Data.Position.Z}] [{result.Data.Orientation.X}, {result.Data.Orientation.Y}, {result.Data.Orientation.Z}, {result.Data.Orientation.W}]");
|
||||
|
||||
if (result.IsSuccess && result.Data is Pose goal && (goal.Orientation.W != 0 || goal.Orientation.Z != 0))
|
||||
{
|
||||
var points = ComputeReferencePointsWorld(goal);
|
||||
await InvokeAsync(() => MarkerDetectOverlayRef?.Update(goal, points));
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"reject MarkerDetectorClient.GetGoalAsync");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private List<(double X, double Y)> ComputeReferencePointsWorld(Pose goal)
|
||||
{
|
||||
var points = new List<(double X, double Y)>();
|
||||
if (_detectRequest is not MarkersSearchRequest req)
|
||||
return points;
|
||||
|
||||
var yaw = goal.Orientation.ToYawRadian();
|
||||
var cos = Math.Cos(yaw);
|
||||
var sin = Math.Sin(yaw);
|
||||
|
||||
foreach (var entry in req.MarkerSearchRequests)
|
||||
{
|
||||
foreach (var pt in entry.ReferencePoints)
|
||||
{
|
||||
var wx = goal.Position.X + pt.X * cos - pt.Y * sin;
|
||||
var wy = goal.Position.Y + pt.X * sin + pt.Y * cos;
|
||||
points.Add((wx, wy));
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
private string GetStateChipBackgroundColor(SLAMState? state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SLAMState.Ready => "#4caf50",
|
||||
SLAMState.Relocalizing => "#2196f3",
|
||||
SLAMState.Localizing => "#2196f3",
|
||||
SLAMState.ScanMapping => "#9c27b0",
|
||||
SLAMState.SavingMap => "#ff9800",
|
||||
SLAMState.Error => "#f44336",
|
||||
_ => "#9e9e9e"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetStateChipTextColor(SLAMState? state)
|
||||
{
|
||||
return "white";
|
||||
}
|
||||
|
||||
private string GetStateText(SLAMState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
SLAMState.Idle => "Idle",
|
||||
SLAMState.Initializing => "Initializing",
|
||||
SLAMState.Ready => "Ready",
|
||||
SLAMState.Relocalizing => "Relocalizing",
|
||||
SLAMState.Localizing => "Localizing",
|
||||
SLAMState.ScanMapping => "Scan Mapping",
|
||||
SLAMState.SavingMap => "Saving Map",
|
||||
SLAMState.Error => "Error",
|
||||
_ => "Unknown"
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// Unsubscribe from events
|
||||
CartographerClient.StateChanged -= OnStateChanged;
|
||||
CartographerClient.MapSaveProgressChanged -= OnMapSaveProgressChanged;
|
||||
|
||||
// Cleanup marker detection timer
|
||||
if (_detectTimer != null)
|
||||
{
|
||||
_detectTimer.Stop();
|
||||
_detectTimer.Elapsed -= OnDetectTimerElapsed;
|
||||
_detectTimer.Dispose();
|
||||
_detectTimer = null;
|
||||
}
|
||||
|
||||
// Stop marker detector client
|
||||
if (_isDetecting)
|
||||
{
|
||||
try
|
||||
{
|
||||
await MarkerDetectorClient.StopAsync();
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
_isDetecting = false;
|
||||
_detectSessionId = null;
|
||||
_detectRequest = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
@page "/logs"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
|
||||
@using System.Text.Json.Serialization
|
||||
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject HttpClient Http
|
||||
@inject IConfiguration Configuration
|
||||
@inject ISnackbar Snackbar
|
||||
|
||||
<PageTitle>Logs</PageTitle>
|
||||
|
||||
<div class="w-100 h-100 d-flex flex-column">
|
||||
<div class="d-flex flex-row align-items-center justify-content-between" style="border-bottom: 1px solid silver">
|
||||
<MudTextField Class="mt-1 ms-2" T="string" Value="FilterLog" Adornment="Adornment.End" ValueChanged="OnSearch" AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
IconSize="Size.Medium" Variant="Variant.Outlined" Margin="Margin.Dense" AdornmentColor="Color.Secondary" Label="Search"></MudTextField>
|
||||
<MudSpacer />
|
||||
<div class="m-1 d-flex flex-row">
|
||||
<MudDatePicker Class="mx-4" Label="Date" Date="DateLog" DateChanged="OnDateChanged" MaxDate="DateTime.Today" Variant="Variant.Outlined" Color="Color.Primary"
|
||||
ShowToolbar="false" Margin="Margin.Dense" AdornmentColor="Color.Primary" />
|
||||
|
||||
<MudTooltip Text="Export">
|
||||
<MudFab Class="mt-2" Color="Color.Info" StartIcon="@Icons.Material.Filled.ImportExport" Size="Size.Small" OnClick="ExportLogs" />
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="Refresh">
|
||||
<MudFab Class="mx-4 mt-2" StartIcon="@Icons.Material.Filled.Refresh" Color="Color.Primary" Size="Size.Small" OnClick="LoadLogs" />
|
||||
</MudTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow-1 mt-2 ms-2 position-relative" style="background-color: rgba(0, 0, 0, 0);">
|
||||
<MudOverlay Visible="IsLoading" DarkBackground="true" Absolute="true">
|
||||
<MudProgressCircular Color="Color.Info" Indeterminate="true" />
|
||||
</MudOverlay>
|
||||
<div class="h-100 w-100 position-relative">
|
||||
<div class="log-container" @ref="LogContainerRef">
|
||||
@if (ShowRawLog)
|
||||
{
|
||||
<div class="d-flex justify-content-center my-3">
|
||||
<div><MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ShowRawLog = false)">Normal</MudButton></div>
|
||||
</div>
|
||||
<big style="font-size: 14px;">
|
||||
@foreach (var log in ShowLogs)
|
||||
{
|
||||
@log <br />
|
||||
}
|
||||
</big>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (SearchLogs.Count < ShowLogs.Count)
|
||||
{
|
||||
<div class="d-flex justify-content-center my-3">
|
||||
<div><MudButton Variant="Variant.Outlined" Size="Size.Small" OnClick="@(() => ShowRawLog = true)">Raw log</MudButton></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (var log in SearchLogs)
|
||||
{
|
||||
<div class="log">
|
||||
<span class="log-head @log.BackgroundClass">
|
||||
@log.Time <span class="log-level">@log.Level</span>
|
||||
</span>
|
||||
<span>@log.Message</span>
|
||||
@if (log.HasException)
|
||||
{
|
||||
<br />
|
||||
<pre class="log-exception">
|
||||
@log.Exception
|
||||
</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.ScrollToBottom = (element) => {
|
||||
if (element) {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
private DateTime DateLog = DateTime.Today;
|
||||
private bool IsLoading;
|
||||
private readonly List<string> ShowLogs = new();
|
||||
private readonly List<LoggerModel> SearchLogs = new();
|
||||
private ElementReference LogContainerRef { get; set; }
|
||||
private bool ShowRawLog { get; set; }
|
||||
private string? FilterLog { get; set; }
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
if (!firstRender) return;
|
||||
|
||||
await LoadLogs();
|
||||
}
|
||||
|
||||
private async Task LoadLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
ShowLogs.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
var logs = await Http.GetFromJsonAsync<IEnumerable<string>>($"api/LogsManager?date={DateLog}");
|
||||
ShowLogs.AddRange(logs ?? []);
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
|
||||
await ReloadLogs();
|
||||
}
|
||||
catch (AccessTokenNotAvailableException ex)
|
||||
{
|
||||
ex.Redirect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReloadLogs()
|
||||
{
|
||||
IsLoading = true;
|
||||
SearchLogs.Clear();
|
||||
StateHasChanged();
|
||||
|
||||
foreach (var line in ShowLogs.Where(log => string.IsNullOrEmpty(FilterLog) || log.Contains(FilterLog)).TakeLast(2000))
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = System.Text.Json.JsonSerializer.Deserialize<LoggerModel>(line);
|
||||
if (log is not null) SearchLogs.Add(log);
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
await JSRuntime.InvokeVoidAsync("ScrollToBottom", LogContainerRef);
|
||||
}
|
||||
|
||||
private async Task OnSearch(string text)
|
||||
{
|
||||
FilterLog = text;
|
||||
await ReloadLogs();
|
||||
}
|
||||
|
||||
private async Task OnDateChanged(DateTime? date)
|
||||
{
|
||||
if (date is not null && date.HasValue)
|
||||
{
|
||||
DateLog = date.Value;
|
||||
await LoadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileContent = await Http.GetFromJsonAsync<IEnumerable<string>>($"api/LogsManager?date={DateLog}");
|
||||
var formattedContent = string.Join("\n", fileContent ?? []);
|
||||
var fileName = $"LogsManager_{DateLog.ToShortDateString()}.txt";
|
||||
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, formattedContent, "text/plain");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Lỗi khi tải file: {ex.Message}", Severity.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
public class LoggerModel
|
||||
{
|
||||
[JsonPropertyName("time")]
|
||||
public string? Time { get; set; }
|
||||
|
||||
[JsonPropertyName("level")]
|
||||
public string? Level { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[JsonPropertyName("exception")]
|
||||
public string? Exception { get; set; }
|
||||
|
||||
public string ColorClass => Level switch
|
||||
{
|
||||
"WARN" => "text-warning",
|
||||
"INFO" => "text-info",
|
||||
"DEBUG" => "text-success",
|
||||
"ERROR" => "text-danger",
|
||||
"FATAL" => "text-secondary",
|
||||
_ => "text-muted",
|
||||
};
|
||||
|
||||
public string BackgroundClass => Level switch
|
||||
{
|
||||
"WARN" => "bg-warning text-dark",
|
||||
"INFO" => "bg-info text-dark",
|
||||
"DEBUG" => "bg-success text-white",
|
||||
"ERROR" => "bg-danger text-white",
|
||||
"FATAL" => "bg-secondary text-white",
|
||||
_ => "bg-dark text-white",
|
||||
};
|
||||
|
||||
public bool HasException => !string.IsNullOrEmpty(Exception);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
.log-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.log {
|
||||
word-wrap: break-word;
|
||||
line-height: 18px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-logger {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-level {
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
}
|
||||
|
||||
.log-exception {
|
||||
line-height: 16px;
|
||||
margin-left: 30px;
|
||||
color: crimson;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
@page "/maps"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client.Shared.SLAM
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Components.SLAM
|
||||
@implements IAsyncDisposable
|
||||
@inject SLAMClient CartographerClient
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
|
||||
<PageTitle>Map Management</PageTitle>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
<MudCard Elevation="2">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h5">Map Management</MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Filled.Refresh"
|
||||
OnClick="RefreshMapsAsync"
|
||||
Disabled="@(!CartographerClient.IsConnected || IsLoading)">
|
||||
Refresh
|
||||
</MudButton>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@if (IsLoading)
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" />
|
||||
}
|
||||
else if (Maps.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body1" Color="Color.Secondary">
|
||||
No maps found. Create a map by starting scan mapping.
|
||||
</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudTable Items="@Maps" Hover="true" Striped="true">
|
||||
<HeaderContent>
|
||||
<MudTh>Name</MudTh>
|
||||
<MudTh>Created</MudTh>
|
||||
<MudTh>Resolution</MudTh>
|
||||
<MudTh>Size</MudTh>
|
||||
<MudTh>Trajectory Nodes</MudTh>
|
||||
<MudTh></MudTh>
|
||||
</HeaderContent>
|
||||
<RowTemplate>
|
||||
<MudTd DataLabel="Name">
|
||||
<MudText Typo="Typo.body1">@context.Name</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Created">
|
||||
<MudText Typo="Typo.body2">@context.CreatedDate.ToString("yyyy-MM-dd HH:mm")</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Resolution">
|
||||
<MudText Typo="Typo.body2">@context.Resolution.ToString("F3") m/p</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Size">
|
||||
<MudText Typo="Typo.body2">@context.Width.ToString("F1") x @context.Height.ToString("F1") m</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Trajectory Nodes">
|
||||
<MudText Typo="Typo.body2">@context.TrajectoryNodeCount</MudText>
|
||||
</MudTd>
|
||||
<MudTd DataLabel="Actions">
|
||||
<MudTooltip Text="View map details">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.OpenInNew"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Small"
|
||||
Href="@($"/map/{context.Name}")" />
|
||||
</MudTooltip>
|
||||
</MudTd>
|
||||
</RowTemplate>
|
||||
</MudTable>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
private List<MapInfoDto> Maps { get; set; } = new();
|
||||
private bool IsLoading { get; set; } = false;
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
await CartographerClient.StartAsync();
|
||||
await RefreshMapsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshMapsAsync()
|
||||
{
|
||||
if (!CartographerClient.IsConnected)
|
||||
{
|
||||
Snackbar.Add("Not connected to server", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IsLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
Maps = (await CartographerClient.ListMapsAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load maps: {ex.Message}", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMapAsync(string mapName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await CartographerClient.StartLocalizationAsync(mapName);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Started localization with map: {mapName}", Severity.Success);
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to start localization with map: {mapName}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to load map: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteMapAsync(string mapName)
|
||||
{
|
||||
var parameters = new DialogParameters
|
||||
{
|
||||
["MapName"] = mapName
|
||||
};
|
||||
|
||||
var options = new DialogOptions
|
||||
{
|
||||
CloseOnEscapeKey = true,
|
||||
MaxWidth = MaxWidth.Small,
|
||||
FullWidth = true
|
||||
};
|
||||
|
||||
var dialog = await DialogService.ShowAsync<DeleteMapDialog>("Delete Map", parameters, options);
|
||||
var result = await dialog.Result;
|
||||
|
||||
if (result != null && !result.Canceled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var success = await CartographerClient.DeleteMapAsync(mapName);
|
||||
if (success)
|
||||
{
|
||||
Snackbar.Add($"Map deleted: {mapName}", Severity.Success);
|
||||
await RefreshMapsAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Failed to delete map: {mapName}", Severity.Error);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to delete map: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatFileSize(long bytes)
|
||||
{
|
||||
string[] sizes = { "B", "KB", "MB", "GB" };
|
||||
double len = bytes;
|
||||
int order = 0;
|
||||
while (len >= 1024 && order < sizes.Length - 1)
|
||||
{
|
||||
order++;
|
||||
len = len / 1024;
|
||||
}
|
||||
return $"{len:0.##} {sizes[order]}";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
// CartographerClient is scoped, will be disposed by DI
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
@page "/missions"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Mission Manager</PageTitle>
|
||||
|
||||
<RobotNet10.ScriptEditor.InstanceMissionManager />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,133 @@
|
||||
@page "/motion/manualcontrol"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Components.Motion
|
||||
@using RobotNet10.RobotApp.Client.Shared.Motion
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>Manual Control</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="mt-4">
|
||||
@* Disconnected Overlay *@
|
||||
<MudOverlay Visible="@(!isHubReady)" DarkBackground="true" ZIndex="9999" AutoClose="false">
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
|
||||
<MudText Typo="Typo.h5" Color="Color.Primary">MotionHub Disconnected</MudText>
|
||||
<MudText Typo="Typo.body1">Waiting for connection...</MudText>
|
||||
</MudStack>
|
||||
</MudOverlay>
|
||||
|
||||
<MudStack Spacing="4">
|
||||
@* 4 Cards Grid *@
|
||||
<MudGrid>
|
||||
@* Card 1: Manual Control *@
|
||||
<MudItem xs="12" md="6">
|
||||
<ManualControlCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 2: Odometry (from OdometryService → XLOC) *@
|
||||
<MudItem xs="12" md="6">
|
||||
<OdometryCard Odometry="currentOdometry" IsHubReady="odometryHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 3: Lift Module *@
|
||||
<MudItem xs="12" md="6">
|
||||
<LiftModuleCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
|
||||
@* Card 4: Rotation Module *@
|
||||
<MudItem xs="12" md="6">
|
||||
<RotationModuleCard HubClient="motionHubClient" IsHubReady="isHubReady" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private MotionHubClient MotionHubClientInjected { get; set; } = null!;
|
||||
[Inject] private OdometryHubClient OdometryHubClientInjected { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private MotionHubClient motionHubClient = null!;
|
||||
private bool isHubReady = false;
|
||||
|
||||
private OdometryHubClient odometryHubClient = null!;
|
||||
private bool odometryHubReady = false;
|
||||
private OdometryDto? currentOdometry;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
motionHubClient = MotionHubClientInjected;
|
||||
odometryHubClient = OdometryHubClientInjected;
|
||||
|
||||
// Subscribe to connection state changes
|
||||
motionHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
odometryHubClient.ConnectionStateChanged += OnOdometryConnectionStateChanged;
|
||||
odometryHubClient.OdometryReceived += OnOdometryReceived;
|
||||
|
||||
try
|
||||
{
|
||||
await motionHubClient.StartAsync();
|
||||
isHubReady = motionHubClient.IsConnected;
|
||||
|
||||
// Wait a bit for connection to establish
|
||||
await Task.Delay(500);
|
||||
isHubReady = motionHubClient.IsConnected;
|
||||
|
||||
await odometryHubClient.StartAsync();
|
||||
odometryHubReady = odometryHubClient.IsConnected;
|
||||
|
||||
if (odometryHubReady)
|
||||
{
|
||||
currentOdometry = await odometryHubClient.GetCurrentOdometryAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error initializing: {ex.Message}", Severity.Error);
|
||||
isHubReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
isHubReady = state == HubConnectionState.Connected;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnOdometryConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
odometryHubReady = state == HubConnectionState.Connected;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnOdometryReceived(OdometryDto dto)
|
||||
{
|
||||
currentOdometry = dto;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (motionHubClient != null)
|
||||
{
|
||||
motionHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
}
|
||||
|
||||
if (odometryHubClient != null)
|
||||
{
|
||||
odometryHubClient.ConnectionStateChanged -= OnOdometryConnectionStateChanged;
|
||||
odometryHubClient.OdometryReceived -= OnOdometryReceived;
|
||||
}
|
||||
|
||||
await ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,987 @@
|
||||
@page "/motion/navigation-monitor"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Shared.NavigationMonitor
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using MudBlazor
|
||||
@using ApexCharts
|
||||
@using Color = MudBlazor.Color
|
||||
@using Size = MudBlazor.Size
|
||||
|
||||
<PageTitle>Navigation Monitor</PageTitle>
|
||||
<div style="height: 100%; width:100%; display: flex; flex-direction: column; overflow-x: hidden; overflow: auto" class="p-2">
|
||||
@* Connection status *@
|
||||
@if (!hubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Dense="true" Class="mb-2">
|
||||
Disconnected from Navigation Monitor hub. Reconnecting...
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* Controls *@
|
||||
<MudPaper Class="pa-3 mb-2" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="4">
|
||||
<MudSwitch Value="telemetryEnabled" Color="Color.Primary"
|
||||
Label="Enable Telemetry" Disabled="@(!hubClient.IsConnected)"
|
||||
ValueChanged="@((bool v) => OnTelemetryToggle(v))" T="bool" />
|
||||
<MudSwitch Value="safetyStopEnabled" Color="Color.Error"
|
||||
Label="Enable Safety Stop" Disabled="@(!hubClient.IsConnected)"
|
||||
ValueChanged="@((bool v) => OnSafetyStopToggle(v))" T="bool" />
|
||||
<MudSpacer />
|
||||
@if (telemetryEnabled)
|
||||
{
|
||||
<MudChip T="string" Color="Color.Success" Size="Size.Small">@($"{updateFrequencyHz:F0} Hz")</MudChip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small">Disabled</MudChip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@* Safety Stop Latch Banner *@
|
||||
@if (safetyStopLatched)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Dense="false" Class="mb-2" NoIcon="false">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center">
|
||||
<MudText Typo="Typo.body1">
|
||||
<strong>SAFETY STOP ACTIVE</strong> — @safetyStopReason
|
||||
</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Warning"
|
||||
OnClick="OnReleaseSafetyStop"
|
||||
Disabled="@(!hubClient.IsConnected)">
|
||||
Release Safety Stop
|
||||
</MudButton>
|
||||
</MudStack>
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* DockTo Telemetry - special panel when docking *@
|
||||
@if (telemetry.DockTo is not null)
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3" Elevation="2" Style="border-left: 4px solid var(--mud-palette-info);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.GpsFixed" Color="Color.Info" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Info">Docking Telemetry</MudText>
|
||||
<MudSpacer />
|
||||
<MudChip T="string" Color="@GetDockPhaseColor()" Size="Size.Small">@telemetry.DockTo.Phase</MudChip>
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Outlined">@telemetry.DockTo.Direction</MudChip>
|
||||
</MudStack>
|
||||
@{
|
||||
var dock = telemetry.DockTo!;
|
||||
var svgBounds = GetDockSvgBounds(dock, telemetry.X, telemetry.Y);
|
||||
var vb = svgBounds;
|
||||
}
|
||||
<MudGrid>
|
||||
@* SVG Visualization - left side *@
|
||||
<MudItem xs="12" md="7">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Docking Path</MudText>
|
||||
<div style="background: #1e1e2e; border-radius: 4px; padding: 8px; height: 100%;">
|
||||
<svg viewBox="@FormatViewBox(vb)"
|
||||
width="100%" height="280" preserveAspectRatio="xMidYMid meet"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@* Grid reference lines *@
|
||||
<line x1="@vb.MinX.ToString("F3")" y1="0" x2="@((vb.MinX + vb.Width).ToString("F3"))" y2="0"
|
||||
stroke="#444" stroke-width="@((vb.Width * 0.003).ToString("F4"))" stroke-dasharray="@((vb.Width * 0.01).ToString("F4"))" />
|
||||
<line x1="0" y1="@vb.MinY.ToString("F3")" x2="0" y2="@((vb.MinY + vb.Height).ToString("F3"))"
|
||||
stroke="#444" stroke-width="@((vb.Width * 0.003).ToString("F4"))" stroke-dasharray="@((vb.Width * 0.01).ToString("F4"))" />
|
||||
|
||||
@* Waypoints path *@
|
||||
@if (dock.Waypoints.Count >= 2)
|
||||
{
|
||||
var pathPoints = string.Join(" ", dock.Waypoints.Select(w => $"{w.X.ToString("F3")},{FlipY(w.Y).ToString("F3")}"));
|
||||
<polyline points="@pathPoints"
|
||||
fill="none" stroke="#5b9bd5" stroke-width="@((vb.Width * 0.008).ToString("F4"))"
|
||||
stroke-dasharray="@((vb.Width * 0.015).ToString("F4"))" stroke-linecap="round" opacity="0.8" />
|
||||
}
|
||||
|
||||
@* Distance to goal line *@
|
||||
<line x1="@telemetry.X.ToString("F3")" y1="@FlipY(telemetry.Y).ToString("F3")"
|
||||
x2="@dock.GoalX.ToString("F3")" y2="@FlipY(dock.GoalY).ToString("F3")"
|
||||
stroke="#888" stroke-width="@((vb.Width * 0.003).ToString("F4"))"
|
||||
stroke-dasharray="@((vb.Width * 0.008).ToString("F4"))" opacity="0.5" />
|
||||
|
||||
@* Start Node *@
|
||||
<circle cx="@dock.StartX.ToString("F3")" cy="@FlipY(dock.StartY).ToString("F3")"
|
||||
r="@((vb.Width * 0.025).ToString("F4"))" fill="#4caf50" stroke="#fff" stroke-width="@((vb.Width * 0.005).ToString("F4"))" />
|
||||
<text x="@dock.StartX.ToString("F3")" y="@((FlipY(dock.StartY) - vb.Width * 0.04).ToString("F3"))"
|
||||
text-anchor="middle" fill="#4caf50" font-size="@((vb.Width * 0.04).ToString("F4"))" font-weight="bold">Start</text>
|
||||
|
||||
@* Goal Node with direction arrow *@
|
||||
@{
|
||||
var goalSvgY = FlipY(dock.GoalY);
|
||||
var arrowLen = vb.Width * 0.06;
|
||||
var goalArrowX = dock.GoalX + arrowLen * Math.Cos(dock.GoalTheta);
|
||||
var goalArrowY = goalSvgY - arrowLen * Math.Sin(dock.GoalTheta);
|
||||
}
|
||||
<circle cx="@dock.GoalX.ToString("F3")" cy="@goalSvgY.ToString("F3")"
|
||||
r="@((vb.Width * 0.025).ToString("F4"))" fill="#f44336" stroke="#fff" stroke-width="@((vb.Width * 0.005).ToString("F4"))" />
|
||||
<line x1="@dock.GoalX.ToString("F3")" y1="@goalSvgY.ToString("F3")"
|
||||
x2="@goalArrowX.ToString("F3")" y2="@goalArrowY.ToString("F3")"
|
||||
stroke="#f44336" stroke-width="@((vb.Width * 0.008).ToString("F4"))" marker-end="url(#arrowGoal)" />
|
||||
<text x="@dock.GoalX.ToString("F3")" y="@((goalSvgY - vb.Width * 0.04).ToString("F3"))"
|
||||
text-anchor="middle" fill="#f44336" font-size="@((vb.Width * 0.04).ToString("F4"))" font-weight="bold">Goal</text>
|
||||
|
||||
@* Robot current position (triangle pointing in Theta direction) *@
|
||||
@{
|
||||
var rSvgY = FlipY(telemetry.Y);
|
||||
var rSize = vb.Width * 0.03;
|
||||
var rTheta = telemetry.Theta;
|
||||
// Triangle vertices: tip forward, two rear corners
|
||||
var tipX = telemetry.X + rSize * 1.5 * Math.Cos(rTheta);
|
||||
var tipY = rSvgY - rSize * 1.5 * Math.Sin(rTheta);
|
||||
var leftX = telemetry.X + rSize * Math.Cos(rTheta + 2.5);
|
||||
var leftY = rSvgY - rSize * Math.Sin(rTheta + 2.5);
|
||||
var rightX = telemetry.X + rSize * Math.Cos(rTheta - 2.5);
|
||||
var rightY = rSvgY - rSize * Math.Sin(rTheta - 2.5);
|
||||
}
|
||||
<polygon points="@($"{tipX.ToString("F3")},{tipY.ToString("F3")} {leftX.ToString("F3")},{leftY.ToString("F3")} {rightX.ToString("F3")},{rightY.ToString("F3")}")"
|
||||
fill="#2196f3" stroke="#fff" stroke-width="@((vb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@telemetry.X.ToString("F3")" y="@((rSvgY - vb.Width * 0.045).ToString("F3"))"
|
||||
text-anchor="middle" fill="#2196f3" font-size="@((vb.Width * 0.035).ToString("F4"))">Robot</text>
|
||||
|
||||
@* Arrow marker definition *@
|
||||
<defs>
|
||||
<marker id="arrowGoal" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#f44336" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</MudItem>
|
||||
|
||||
@* Docking Info - right side *@
|
||||
<MudItem xs="12" md="5">
|
||||
<MudSimpleTable Dense="true" Hover="true" Class="mb-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Phase</td>
|
||||
<td class="text-right">
|
||||
<MudChip T="string" Color="@GetDockPhaseColor()" Size="Size.Small">@telemetry.DockTo.Phase</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Direction</td><td class="text-right"><strong>@telemetry.DockTo.Direction</strong></td></tr>
|
||||
<tr>
|
||||
<td>Fine Positioning Retries</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.DockTo.RetryCount > 0 ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.DockTo.RetryCount / @telemetry.DockTo.MaxRetries</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Waypoints</td><td class="text-right"><strong>@telemetry.DockTo.TotalWaypoints</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>Goal X</td><td class="text-right"><strong>@telemetry.DockTo.GoalX.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Goal Y</td><td class="text-right"><strong>@telemetry.DockTo.GoalY.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Goal Theta</td><td class="text-right"><strong>@((telemetry.DockTo.GoalTheta * 180 / Math.PI).ToString("F2"))°</strong></td></tr>
|
||||
<tr>
|
||||
<td>Distance to Goal</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.DistanceToGoal < 0.1 ? Color.Success : Color.Info)">
|
||||
<strong>@telemetry.DistanceToGoal.ToString("F3") m</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@* Telemetry Data Cards *@
|
||||
<MudGrid>
|
||||
@* Position *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Position</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>X</td><td class="text-right"><strong>@telemetry.X.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Y</td><td class="text-right"><strong>@telemetry.Y.ToString("F3") m</strong></td></tr>
|
||||
<tr><td>Theta</td><td class="text-right"><strong>@((telemetry.Theta * 180 / Math.PI).ToString("F2"))°</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Velocity *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Velocity</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr><td>Linear</td><td class="text-right"><strong>@telemetry.LinearVelocity.ToString("F3") m/s</strong></td></tr>
|
||||
<tr><td>Angular</td><td class="text-right"><strong>@telemetry.AngularVelocity.ToString("F3") rad/s</strong></td></tr>
|
||||
<tr><td>Confidence</td><td class="text-right"><strong>@telemetry.ModelConfidence.ToString("F2")</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Navigation State *@
|
||||
<MudItem xs="12" md="4">
|
||||
<MudCard Elevation="1" Style="height:180px">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Navigation</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>State</td>
|
||||
<td class="text-right">
|
||||
<MudChip T="string" Color="@GetNavStateColor()" Size="Size.Small">@telemetry.NavigationState</MudChip>
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td>Driving</td><td class="text-right"><strong>@(telemetry.Driving ? "Yes" : "No")</strong></td></tr>
|
||||
<tr><td>To Goal</td><td class="text-right"><strong>@telemetry.DistanceToGoal.ToString("F3") m</strong></td></tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Tracking (CTE) *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="1">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Tracking</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Cross-Track Error</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(telemetry.CrossTrackError > safetyConfig.MaxCrossTrackError ? Color.Error : Color.Default)">
|
||||
<strong>@telemetry.CrossTrackError.ToString("F3") m</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Heading Error</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.HeadingError) * 180 / Math.PI > safetyConfig.MaxHeadingError ? Color.Error : Color.Default)">
|
||||
<strong>@((telemetry.HeadingError * 180 / Math.PI).ToString("F2"))°</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Acceleration *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="1">
|
||||
<MudCardHeader Class="pb-0">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.h6">Acceleration</MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Linear</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.LinearAcceleration) > safetyConfig.MaxLinearAcceleration ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.LinearAcceleration.ToString("F2") m/s²</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Angular</td>
|
||||
<td class="text-right">
|
||||
<MudText Color="@(Math.Abs(telemetry.AngularAcceleration) > safetyConfig.MaxAngularVelocity ? Color.Warning : Color.Default)">
|
||||
<strong>@telemetry.AngularAcceleration.ToString("F2") m/s²</strong>
|
||||
</MudText>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Telemetry Charts *@
|
||||
<MudText Typo="Typo.h6" Class="mt-2 mb-2">Telemetry Charts</MudText>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Cross-Track Error</MudText>
|
||||
<ApexChart @ref="_cteChart" TItem="TelemetryPoint" Options="_cteOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="CTE (m)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.Cte)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Velocity</MudText>
|
||||
<ApexChart @ref="_velocityChart" TItem="TelemetryPoint" Options="_velocityOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Linear (m/s)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.LinearVel)"
|
||||
OrderBy="p => p.X" />
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Angular (rad/s)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.AngularVel)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
<MudItem xs="12" md="4">
|
||||
<MudText Typo="Typo.subtitle2" Class="mb-1">Heading Error</MudText>
|
||||
<ApexChart @ref="_headingChart" TItem="TelemetryPoint" Options="_headingOptions" Height="220">
|
||||
<ApexPointSeries TItem="TelemetryPoint" Items="chartDataPoints"
|
||||
SeriesType="SeriesType.Line" Name="Heading (deg)"
|
||||
XValue="@(p => (decimal)p.TimeSec)"
|
||||
YValue="@(p => (decimal)p.HeadingErrorDeg)"
|
||||
OrderBy="p => p.X" />
|
||||
</ApexChart>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
@* Navigation Path Visualization *@
|
||||
@if (_cachedWaypoints is { Count: >= 2 })
|
||||
{
|
||||
<MudPaper Class="pa-3 mb-3 mt-2" Elevation="2" Style="border-left: 4px solid var(--mud-palette-primary);">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.h6" Color="Color.Primary">Navigation Path</MudText>
|
||||
<MudSpacer />
|
||||
<MudChip T="string" Color="Color.Info" Size="Size.Small">@_cachedWaypoints.Count waypoints</MudChip>
|
||||
<MudChip T="string" Color="Color.Default" Size="Size.Small" Variant="Variant.Outlined">@telemetry.NavigationState</MudChip>
|
||||
</MudStack>
|
||||
@{
|
||||
var wp = _cachedWaypoints;
|
||||
var pvb = GetPathSvgBounds(wp, _robotTrail);
|
||||
var pStroke = (pvb.Width * 0.005).ToString("F4");
|
||||
var pThin = (pvb.Width * 0.003).ToString("F4");
|
||||
var pDash = (pvb.Width * 0.01).ToString("F4");
|
||||
}
|
||||
<div style="background: #1e1e2e; border-radius: 4px; padding: 8px;">
|
||||
<svg viewBox="@FormatViewBox(pvb)"
|
||||
width="100%" height="350" preserveAspectRatio="xMidYMid meet"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@* Grid reference lines *@
|
||||
<line x1="@pvb.MinX.ToString("F3")" y1="0" x2="@((pvb.MinX + pvb.Width).ToString("F3"))" y2="0"
|
||||
stroke="#444" stroke-width="@pThin" stroke-dasharray="@pDash" />
|
||||
<line x1="0" y1="@pvb.MinY.ToString("F3")" x2="0" y2="@((pvb.MinY + pvb.Height).ToString("F3"))"
|
||||
stroke="#444" stroke-width="@pThin" stroke-dasharray="@pDash" />
|
||||
|
||||
@* Waypoints path line *@
|
||||
@{
|
||||
var pathLine = string.Join(" ", wp.Select(w => $"{w.X.ToString("F3")},{FlipY(w.Y).ToString("F3")}"));
|
||||
}
|
||||
<polyline points="@pathLine"
|
||||
fill="none" stroke="#5b9bd5" stroke-width="@pStroke"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.7" />
|
||||
|
||||
@* Waypoint dots *@
|
||||
@for (int wi = 0; wi < wp.Count; wi++)
|
||||
{
|
||||
var wpt = wp[wi];
|
||||
var wColor = wpt.Direction == "BACKWARD" ? "#ff9800" : "#5b9bd5";
|
||||
var wRadius = (pvb.Width * 0.008).ToString("F4");
|
||||
<circle cx="@wpt.X.ToString("F3")" cy="@FlipY(wpt.Y).ToString("F3")"
|
||||
r="@wRadius" fill="@wColor" opacity="0.8" />
|
||||
}
|
||||
|
||||
@* Start waypoint *@
|
||||
@{
|
||||
var wpStart = wp[0];
|
||||
var startR = (pvb.Width * 0.02).ToString("F4");
|
||||
}
|
||||
<circle cx="@wpStart.X.ToString("F3")" cy="@FlipY(wpStart.Y).ToString("F3")"
|
||||
r="@startR" fill="#4caf50" stroke="#fff"
|
||||
stroke-width="@((pvb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@wpStart.X.ToString("F3")"
|
||||
y="@((FlipY(wpStart.Y) - pvb.Width * 0.035).ToString("F3"))"
|
||||
text-anchor="middle" fill="#4caf50"
|
||||
font-size="@((pvb.Width * 0.035).ToString("F4"))" font-weight="bold">Start</text>
|
||||
|
||||
@* Goal waypoint *@
|
||||
@{
|
||||
var wpGoal = wp[^1];
|
||||
var goalR = (pvb.Width * 0.02).ToString("F4");
|
||||
}
|
||||
<circle cx="@wpGoal.X.ToString("F3")" cy="@FlipY(wpGoal.Y).ToString("F3")"
|
||||
r="@goalR" fill="#f44336" stroke="#fff"
|
||||
stroke-width="@((pvb.Width * 0.004).ToString("F4"))" />
|
||||
<text x="@wpGoal.X.ToString("F3")"
|
||||
y="@((FlipY(wpGoal.Y) - pvb.Width * 0.035).ToString("F3"))"
|
||||
text-anchor="middle" fill="#f44336"
|
||||
font-size="@((pvb.Width * 0.035).ToString("F4"))" font-weight="bold">Goal</text>
|
||||
|
||||
@* Robot trail (breadcrumb) *@
|
||||
@if (_robotTrail.Count >= 2)
|
||||
{
|
||||
var trailLine = string.Join(" ", _robotTrail.Select(t => $"{t.X.ToString("F3")},{FlipY(t.Y).ToString("F3")}"));
|
||||
<polyline points="@trailLine"
|
||||
fill="none" stroke="#66bb6a" stroke-width="@pThin"
|
||||
stroke-linecap="round" stroke-linejoin="round" opacity="0.6"
|
||||
stroke-dasharray="@((pvb.Width * 0.008).ToString("F4"))" />
|
||||
}
|
||||
|
||||
@* Legend *@
|
||||
@{
|
||||
var legendX = pvb.MinX + pvb.Width * 0.02;
|
||||
var legendY = pvb.MinY + pvb.Height * 0.06;
|
||||
var legendFs = (pvb.Width * 0.025).ToString("F4");
|
||||
var legendR = (pvb.Width * 0.008).ToString("F4");
|
||||
var legendStep = pvb.Height * 0.05;
|
||||
}
|
||||
<circle cx="@legendX.ToString("F3")" cy="@legendY.ToString("F3")" r="@legendR" fill="#5b9bd5" />
|
||||
<text x="@((legendX + pvb.Width * 0.02).ToString("F3"))" y="@((legendY + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Forward</text>
|
||||
<circle cx="@legendX.ToString("F3")" cy="@((legendY + legendStep).ToString("F3"))" r="@legendR" fill="#ff9800" />
|
||||
<text x="@((legendX + pvb.Width * 0.02).ToString("F3"))" y="@((legendY + legendStep + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Backward</text>
|
||||
<line x1="@legendX.ToString("F3")" y1="@((legendY + 2 * legendStep).ToString("F3"))"
|
||||
x2="@((legendX + pvb.Width * 0.03).ToString("F3"))" y2="@((legendY + 2 * legendStep).ToString("F3"))"
|
||||
stroke="#66bb6a" stroke-width="@pThin" stroke-dasharray="@((pvb.Width * 0.008).ToString("F4"))" />
|
||||
<text x="@((legendX + pvb.Width * 0.04).ToString("F3"))" y="@((legendY + 2 * legendStep + pvb.Height * 0.012).ToString("F3"))"
|
||||
fill="#aaa" font-size="@legendFs">Robot Trail</text>
|
||||
</svg>
|
||||
</div>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@* Safety Configuration *@
|
||||
<MudExpansionPanels Class="mt-3">
|
||||
<MudExpansionPanel>
|
||||
<TitleContent>
|
||||
<div class="d-flex flex-row">
|
||||
<MudText>Safety Configuration</MudText>
|
||||
<MudButton Class="ms-4" Variant="Variant.Text" Color="Color.Primary" OnClick="ApplySafetyConfig"
|
||||
Disabled="@(!hubClient.IsConnected)" Size="Size.Small">Apply</MudButton>
|
||||
</div>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
<MudGrid>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxLinearVelocity" Label="Max Linear Velocity (m/s)"
|
||||
Step="0.1" Min="0.1" Max="5.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxAngularVelocity" Label="Max Angular Velocity (rad/s)"
|
||||
Step="0.5" Min="0.5" Max="15.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxLinearAcceleration" Label="Max Linear Accel (m/s2)"
|
||||
Step="0.5" Min="0.5" Max="10.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxCrossTrackError" Label="Max CTE (m)"
|
||||
Step="0.05" Min="0.05" Max="2.0" Format="F2" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="6" md="4">
|
||||
<MudNumericField @bind-Value="safetyConfig.MaxHeadingError" Label="Max Heading Error (deg)"
|
||||
Step="5.0" Min="5.0" Max="180.0" Format="F1" Variant="Variant.Outlined" />
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
</MudExpansionPanels>
|
||||
|
||||
@* Safety Violations *@
|
||||
<MudPaper Class="pa-3 mt-3" Elevation="1">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Class="mb-2">
|
||||
<MudText Typo="Typo.h6">Safety Violations</MudText>
|
||||
<MudSpacer />
|
||||
<MudButton Variant="Variant.Text" Size="Size.Small" OnClick="ClearViolations">Clear</MudButton>
|
||||
</MudStack>
|
||||
|
||||
@if (violations.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Color="Color.Secondary">No violations recorded.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="max-height: 300px; overflow-y: auto;">
|
||||
@foreach (var v in violations)
|
||||
{
|
||||
<MudAlert Severity="@(v.Severity == SafetyViolationSeverity.Critical ? Severity.Error : Severity.Warning)"
|
||||
Dense="true" Class="mb-1" NoIcon="false">
|
||||
<MudText Typo="Typo.caption">
|
||||
@DateTimeOffset.FromUnixTimeMilliseconds(v.TimestampMs).ToLocalTime().ToString("HH:mm:ss.fff")
|
||||
— @v.Message
|
||||
</MudText>
|
||||
</MudAlert>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
</div>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private NavigationMonitorHubClient hubClient { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
|
||||
private NavigationTelemetryDto telemetry = new();
|
||||
private NavigationSafetyConfigDto safetyConfig = new();
|
||||
private List<NavigationSafetyViolationDto> violations = new();
|
||||
private bool telemetryEnabled;
|
||||
private bool safetyStopEnabled;
|
||||
private bool safetyStopLatched;
|
||||
private string safetyStopReason = "";
|
||||
private double updateFrequencyHz = 10;
|
||||
private const int MaxViolations = 100;
|
||||
|
||||
// UI render throttle: render at 2Hz
|
||||
private const int UiRefreshIntervalMs = 500;
|
||||
private System.Threading.Timer? _uiRefreshTimer;
|
||||
private volatile bool _telemetryDirty;
|
||||
|
||||
// Chart data & ApexCharts
|
||||
private List<TelemetryPoint> chartDataPoints = new();
|
||||
private const int MaxChartPoints = 120;
|
||||
private long chartStartTimeMs;
|
||||
private volatile bool _chartDirty;
|
||||
|
||||
// Path visualization: cached waypoints persist after navigation ends
|
||||
private List<WaypointDto> _cachedWaypoints = new();
|
||||
private List<(double X, double Y)> _robotTrail = new();
|
||||
private const int MaxTrailPoints = 120;
|
||||
|
||||
private ApexChart<TelemetryPoint>? _cteChart;
|
||||
private ApexChart<TelemetryPoint>? _velocityChart;
|
||||
private ApexChart<TelemetryPoint>? _headingChart;
|
||||
|
||||
private ApexChartOptions<TelemetryPoint> _cteOptions = new();
|
||||
private ApexChartOptions<TelemetryPoint> _velocityOptions = new();
|
||||
private ApexChartOptions<TelemetryPoint> _headingOptions = new();
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
ConfigureChartOptions();
|
||||
|
||||
hubClient.TelemetryReceived += OnTelemetryReceived;
|
||||
hubClient.SafetyViolationReceived += OnSafetyViolationReceived;
|
||||
hubClient.MonitorStateChanged += OnMonitorStateChanged;
|
||||
hubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
_uiRefreshTimer = new System.Threading.Timer(OnUiRefreshTick, null, UiRefreshIntervalMs, UiRefreshIntervalMs);
|
||||
|
||||
try
|
||||
{
|
||||
await hubClient.StartAsync();
|
||||
var state = await hubClient.GetStateAsync();
|
||||
ApplyMonitorState(state);
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Failed to connect: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnTelemetryReceived(NavigationTelemetryDto dto)
|
||||
{
|
||||
telemetry = dto;
|
||||
|
||||
// Cache waypoints: update when new path arrives, persist after navigation ends
|
||||
if (dto.Waypoints is { Count: >= 2 })
|
||||
{
|
||||
// New waypoints = new navigation task → reset trail
|
||||
if (!WaypointsMatch(_cachedWaypoints, dto.Waypoints))
|
||||
{
|
||||
_cachedWaypoints = dto.Waypoints;
|
||||
_robotTrail.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Track robot trail (keep trail after navigation ends for visualization)
|
||||
if (dto.Driving)
|
||||
{
|
||||
_robotTrail.Add((dto.X, dto.Y));
|
||||
if (_robotTrail.Count > MaxTrailPoints)
|
||||
_robotTrail.RemoveAt(0);
|
||||
}
|
||||
|
||||
// Add chart data point every telemetry tick (now 2Hz from server)
|
||||
if (chartStartTimeMs == 0) chartStartTimeMs = dto.TimestampMs;
|
||||
chartDataPoints.Add(new TelemetryPoint
|
||||
{
|
||||
TimeSec = (dto.TimestampMs - chartStartTimeMs) / 1000.0,
|
||||
Cte = dto.CrossTrackError,
|
||||
LinearVel = dto.LinearVelocity,
|
||||
AngularVel = dto.AngularVelocity,
|
||||
HeadingErrorDeg = dto.HeadingError * 180 / Math.PI
|
||||
});
|
||||
if (chartDataPoints.Count > MaxChartPoints)
|
||||
chartDataPoints.RemoveRange(0, chartDataPoints.Count - MaxChartPoints);
|
||||
|
||||
_telemetryDirty = true;
|
||||
_chartDirty = true;
|
||||
}
|
||||
|
||||
private void OnUiRefreshTick(object? state)
|
||||
{
|
||||
if (!_telemetryDirty) return;
|
||||
_telemetryDirty = false;
|
||||
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
if (_chartDirty)
|
||||
{
|
||||
_chartDirty = false;
|
||||
try
|
||||
{
|
||||
if (_cteChart is not null) await _cteChart.UpdateSeriesAsync(true);
|
||||
if (_velocityChart is not null) await _velocityChart.UpdateSeriesAsync(true);
|
||||
if (_headingChart is not null) await _headingChart.UpdateSeriesAsync(true);
|
||||
}
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
|
||||
private void OnSafetyViolationReceived(NavigationSafetyViolationDto dto)
|
||||
{
|
||||
violations.Insert(0, dto);
|
||||
if (violations.Count > MaxViolations)
|
||||
violations.RemoveRange(MaxViolations, violations.Count - MaxViolations);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnMonitorStateChanged(NavigationMonitorStateDto state)
|
||||
{
|
||||
ApplyMonitorState(state);
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void ApplyMonitorState(NavigationMonitorStateDto state)
|
||||
{
|
||||
telemetryEnabled = state.TelemetryEnabled;
|
||||
safetyStopEnabled = state.SafetyStopEnabled;
|
||||
safetyStopLatched = state.SafetyStopLatched;
|
||||
safetyStopReason = state.SafetyStopReason;
|
||||
updateFrequencyHz = state.UpdateFrequencyHz;
|
||||
safetyConfig = state.SafetyConfig;
|
||||
}
|
||||
|
||||
private async Task OnTelemetryToggle(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
telemetryEnabled = enabled;
|
||||
if (enabled)
|
||||
{
|
||||
// Reset chart history when re-enabling
|
||||
chartDataPoints.Clear();
|
||||
chartStartTimeMs = 0;
|
||||
_cachedWaypoints.Clear();
|
||||
_robotTrail.Clear();
|
||||
}
|
||||
await hubClient.SetTelemetryEnabledAsync(enabled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSafetyStopToggle(bool enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
safetyStopEnabled = enabled;
|
||||
await hubClient.SetSafetyStopEnabledAsync(enabled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnReleaseSafetyStop()
|
||||
{
|
||||
try
|
||||
{
|
||||
await hubClient.ReleaseSafetyStopAsync();
|
||||
Snackbar.Add("Safety stop released", Severity.Info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ApplySafetyConfig()
|
||||
{
|
||||
try
|
||||
{
|
||||
await hubClient.UpdateSafetyConfigAsync(safetyConfig);
|
||||
Snackbar.Add("Safety config updated", Severity.Success);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error: {ex.Message}", Severity.Error);
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearViolations()
|
||||
{
|
||||
violations.Clear();
|
||||
}
|
||||
|
||||
private MudBlazor.Color GetNavStateColor() => telemetry.NavigationState switch
|
||||
{
|
||||
"Moving" or "Rotating" => MudBlazor.Color.Info,
|
||||
"Docking" or "FinePositioning" => MudBlazor.Color.Tertiary,
|
||||
"Completed" => MudBlazor.Color.Success,
|
||||
"Error" or "Canceled" or "SafetyStop" => MudBlazor.Color.Error,
|
||||
"Paused" => MudBlazor.Color.Warning,
|
||||
_ => MudBlazor.Color.Default
|
||||
};
|
||||
|
||||
private MudBlazor.Color GetDockPhaseColor() => telemetry.DockTo?.Phase switch
|
||||
{
|
||||
"Approaching" => MudBlazor.Color.Info,
|
||||
"Aligning" => MudBlazor.Color.Warning,
|
||||
"Advancing" => MudBlazor.Color.Success,
|
||||
_ => MudBlazor.Color.Default
|
||||
};
|
||||
|
||||
// SVG helpers for docking visualization
|
||||
private record struct SvgBounds(double MinX, double MinY, double Width, double Height);
|
||||
|
||||
private SvgBounds GetDockSvgBounds(DockToTelemetryDto dock, double robotX, double robotY)
|
||||
{
|
||||
var xs = new List<double> { dock.StartX, dock.GoalX, robotX };
|
||||
var ys = new List<double> { dock.StartY, dock.GoalY, robotY };
|
||||
foreach (var w in dock.Waypoints) { xs.Add(w.X); ys.Add(w.Y); }
|
||||
|
||||
double minX = xs.Min(), maxX = xs.Max();
|
||||
double minY = ys.Min(), maxY = ys.Max();
|
||||
|
||||
// Ensure minimum size and add padding
|
||||
double rangeX = maxX - minX;
|
||||
double rangeY = maxY - minY;
|
||||
if (rangeX < 0.5) { minX -= 0.25; rangeX = 0.5; maxX = minX + rangeX; }
|
||||
if (rangeY < 0.5) { minY -= 0.25; rangeY = 0.5; maxY = minY + rangeY; }
|
||||
|
||||
double pad = Math.Max(rangeX, rangeY) * 0.15;
|
||||
// Flip Y: SVG Y-down, world Y-up. We flip individual Y coords with FlipY(),
|
||||
// so viewBox uses flipped min/max
|
||||
double svgMinX = minX - pad;
|
||||
double svgMinY = -(maxY + pad); // flipped
|
||||
double svgW = rangeX + 2 * pad;
|
||||
double svgH = rangeY + 2 * pad;
|
||||
|
||||
return new SvgBounds(svgMinX, svgMinY, svgW, svgH);
|
||||
}
|
||||
|
||||
private static string FormatViewBox(SvgBounds vb)
|
||||
=> $"{vb.MinX.ToString("F3")} {vb.MinY.ToString("F3")} {vb.Width.ToString("F3")} {vb.Height.ToString("F3")}";
|
||||
|
||||
private static double FlipY(double worldY) => -worldY;
|
||||
|
||||
// SVG helpers for navigation path visualization
|
||||
private static SvgBounds GetPathSvgBounds(List<WaypointDto> waypoints, List<(double X, double Y)> trail)
|
||||
{
|
||||
var xs = waypoints.Select(w => w.X).ToList();
|
||||
var ys = waypoints.Select(w => w.Y).ToList();
|
||||
foreach (var t in trail) { xs.Add(t.X); ys.Add(t.Y); }
|
||||
|
||||
double minX = xs.Min(), maxX = xs.Max();
|
||||
double minY = ys.Min(), maxY = ys.Max();
|
||||
|
||||
double rangeX = maxX - minX;
|
||||
double rangeY = maxY - minY;
|
||||
if (rangeX < 0.5) { minX -= 0.25; rangeX = 0.5; maxX = minX + rangeX; }
|
||||
if (rangeY < 0.5) { minY -= 0.25; rangeY = 0.5; maxY = minY + rangeY; }
|
||||
|
||||
double pad = Math.Max(rangeX, rangeY) * 0.15;
|
||||
double svgMinX = minX - pad;
|
||||
double svgMinY = -(maxY + pad);
|
||||
double svgW = rangeX + 2 * pad;
|
||||
double svgH = rangeY + 2 * pad;
|
||||
|
||||
return new SvgBounds(svgMinX, svgMinY, svgW, svgH);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Quick check if waypoints are the same path (compare first/last + count)
|
||||
/// </summary>
|
||||
private static bool WaypointsMatch(List<WaypointDto> a, List<WaypointDto> b)
|
||||
{
|
||||
if (a.Count != b.Count || a.Count == 0) return false;
|
||||
return Math.Abs(a[0].X - b[0].X) < 0.001
|
||||
&& Math.Abs(a[0].Y - b[0].Y) < 0.001
|
||||
&& Math.Abs(a[^1].X - b[^1].X) < 0.001
|
||||
&& Math.Abs(a[^1].Y - b[^1].Y) < 0.001;
|
||||
}
|
||||
|
||||
// ApexCharts configuration (following TelemetryChartPanel pattern)
|
||||
private void ConfigureChartOptions()
|
||||
{
|
||||
var axisColor = "#009933";
|
||||
|
||||
var baseGrid = () => new Grid
|
||||
{
|
||||
BorderColor = "#00993330",
|
||||
StrokeDashArray = 4,
|
||||
Xaxis = new GridXAxis { Lines = new Lines { Show = false } },
|
||||
Yaxis = new GridYAxis { Lines = new Lines { Show = true } }
|
||||
};
|
||||
|
||||
var baseChart = () => new Chart
|
||||
{
|
||||
ForeColor = axisColor,
|
||||
Animations = new Animations { Enabled = false },
|
||||
Toolbar = new Toolbar { Show = false },
|
||||
Zoom = new Zoom { Enabled = false }
|
||||
};
|
||||
|
||||
var baseXAxis = () => new XAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Time (s)", Style = new AxisTitleStyle { Color = axisColor } },
|
||||
Labels = new XAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return parseFloat(val).toFixed(0); }",
|
||||
Style = new AxisLabelStyle { Colors = axisColor }
|
||||
}
|
||||
};
|
||||
|
||||
var baseTooltip = () => new Tooltip
|
||||
{
|
||||
Shared = true,
|
||||
X = new TooltipX { Format = "0.1f" },
|
||||
Y = new TooltipY { Formatter = @"function(val) { return val.toFixed(4); }" }
|
||||
};
|
||||
|
||||
var yAxisStyle = new AxisTitleStyle { Color = axisColor };
|
||||
var yLabelStyle = new AxisLabelStyle { Colors = axisColor };
|
||||
|
||||
// CTE chart — blue
|
||||
_cteOptions.Chart = baseChart();
|
||||
_cteOptions.Grid = baseGrid();
|
||||
_cteOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_cteOptions.Xaxis = baseXAxis();
|
||||
_cteOptions.Colors = new List<string> { "#0D47A1" };
|
||||
_cteOptions.Tooltip = baseTooltip();
|
||||
_cteOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "CTE (m)", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3,
|
||||
Min = 0
|
||||
}
|
||||
};
|
||||
|
||||
// Velocity chart — green (linear) + purple (angular)
|
||||
_velocityOptions.Chart = baseChart();
|
||||
_velocityOptions.Grid = baseGrid();
|
||||
_velocityOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_velocityOptions.Xaxis = baseXAxis();
|
||||
_velocityOptions.Colors = new List<string> { "#1B5E20", "#7B1FA2" };
|
||||
_velocityOptions.Tooltip = baseTooltip();
|
||||
_velocityOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Velocity", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(3); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 3
|
||||
}
|
||||
};
|
||||
|
||||
// Heading error chart — orange
|
||||
_headingOptions.Chart = baseChart();
|
||||
_headingOptions.Grid = baseGrid();
|
||||
_headingOptions.Stroke = new Stroke { Curve = Curve.Smooth, Width = 3 };
|
||||
_headingOptions.Xaxis = baseXAxis();
|
||||
_headingOptions.Colors = new List<string> { "#E65100" };
|
||||
_headingOptions.Tooltip = baseTooltip();
|
||||
_headingOptions.Yaxis = new List<YAxis>
|
||||
{
|
||||
new YAxis
|
||||
{
|
||||
Title = new AxisTitle { Text = "Heading (deg)", Style = yAxisStyle },
|
||||
Labels = new YAxisLabels
|
||||
{
|
||||
Formatter = @"function(val) { return val.toFixed(2); }",
|
||||
Style = yLabelStyle
|
||||
},
|
||||
DecimalsInFloat = 2
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private class TelemetryPoint
|
||||
{
|
||||
public double TimeSec { get; set; }
|
||||
public double Cte { get; set; }
|
||||
public double LinearVel { get; set; }
|
||||
public double AngularVel { get; set; }
|
||||
public double HeadingErrorDeg { get; set; }
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_uiRefreshTimer is not null)
|
||||
await _uiRefreshTimer.DisposeAsync();
|
||||
hubClient.TelemetryReceived -= OnTelemetryReceived;
|
||||
hubClient.SafetyViolationReceived -= OnSafetyViolationReceived;
|
||||
hubClient.MonitorStateChanged -= OnMonitorStateChanged;
|
||||
hubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
await hubClient.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
@page "/motion/odometry"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Motion
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>Odometry (XLOC)</PageTitle>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-3 mb-4">
|
||||
@* Header *@
|
||||
<MudPaper Class="pa-4 mb-3 odom-page-header" Elevation="0">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Wrap="Wrap.Wrap">
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Route" Color="Color.Primary" Style="font-size: 28px;" />
|
||||
<div>
|
||||
<MudText Typo="Typo.h6" Style="line-height: 1.2;">Odometry → XLOC</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary">OdometryService → XlocIntegrationService · ~10 Hz</MudText>
|
||||
</div>
|
||||
</MudStack>
|
||||
<MudChip T="string"
|
||||
Color="@(OdometryHubClient.IsConnected ? Color.Success : Color.Default)"
|
||||
Variant="Variant.Filled"
|
||||
Size="Size.Small"
|
||||
Icon="@(OdometryHubClient.IsConnected ? Icons.Material.Filled.Link : Icons.Material.Filled.LinkOff)"
|
||||
Style="font-weight: 500;">
|
||||
@(OdometryHubClient.IsConnected ? "Connected" : "Disconnected")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
|
||||
@if (!OdometryHubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Warning" Class="mb-4" Dense="true" Icon="@Icons.Material.Filled.Sync">
|
||||
Đang kết nối tới hub odometry...
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@if (lastOdom != null)
|
||||
{
|
||||
<MudGrid Spacing="3">
|
||||
@* Meta: frame_id, child_frame_id, stamp *@
|
||||
<MudItem xs="12">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-meta">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Tag" Size="Size.Small" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.subtitle2">Header</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2" Wrap="Wrap.Wrap">
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary">@lastOdom.FrameId</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary">@lastOdom.ChildFrameId</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary">@lastOdom.Timestamp.ToString("HH:mm:ss.fff")</MudChip>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Pose: Position + Orientation *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-position">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Place" Size="Size.Small" Color="Color.Primary" />
|
||||
<MudText Typo="Typo.subtitle2">Position</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(m)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="X" Value="@Format(lastOdom.PositionX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Y" Value="@Format(lastOdom.PositionY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Z" Value="@Format(lastOdom.PositionZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-orientation">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Explore" Size="Size.Small" Color="Color.Secondary" />
|
||||
<MudText Typo="Typo.subtitle2">Orientation</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(yaw °)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2" Wrap="Wrap.Wrap" AlignItems="AlignItems.End">
|
||||
<MudTextField T="string" Label="Yaw" Value="@(FormatDegrees(YawDegrees))" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 100px;" />
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="pb-2">Q: (@Format(lastOdom.OrientationX), @Format(lastOdom.OrientationY), @Format(lastOdom.OrientationZ), @Format(lastOdom.OrientationW))</MudText>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Twist: Linear + Angular *@
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-linear">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.Speed" Size="Size.Small" Color="Color.Info" />
|
||||
<MudText Typo="Typo.subtitle2">Linear velocity</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(m/s)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="Vx" Value="@Format(lastOdom.LinearVelocityX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Vy" Value="@Format(lastOdom.LinearVelocityY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="Vz" Value="@Format(lastOdom.LinearVelocityZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12" md="6">
|
||||
<MudCard Elevation="0" Class="odom-card odom-card-angular">
|
||||
<MudCardHeader>
|
||||
<CardHeaderContent>
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Outlined.RotateRight" Size="Size.Small" Color="Color.Warning" />
|
||||
<MudText Typo="Typo.subtitle2">Angular velocity</MudText>
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="ml-1">(rad/s)</MudText>
|
||||
</MudStack>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="pt-0">
|
||||
<MudStack Row="true" Spacing="2">
|
||||
<MudTextField T="string" Label="ωx" Value="@Format(lastOdom.AngularVelocityX)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="ωy" Value="@Format(lastOdom.AngularVelocityY)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
<MudTextField T="string" Label="ωz" Value="@Format(lastOdom.AngularVelocityZ)" ReadOnly="true" Variant="Variant.Outlined" Margin="Margin.Dense" Style="max-width: 120px;" />
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
<MudItem xs="12">
|
||||
<MudPaper Class="pa-2" Elevation="0" Style="border-radius: 8px; background: var(--mud-palette-background-grey);">
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Align="Align.Center">
|
||||
pose_position · pose_orientation · twist_linear · twist_angular → ToXlocOdometry()
|
||||
</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
else if (OdometryHubClient.IsConnected)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.Schedule">
|
||||
Chưa nhận dữ liệu. Đang chờ broadcast từ server.
|
||||
</MudAlert>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
[Inject]
|
||||
private OdometryHubClient OdometryHubClient { get; set; } = null!;
|
||||
|
||||
private OdometryDto? lastOdom;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
OdometryHubClient.OdometryReceived += OnOdometryReceived;
|
||||
OdometryHubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
await OdometryHubClient.StartAsync();
|
||||
if (OdometryHubClient.IsConnected)
|
||||
{
|
||||
lastOdom = await OdometryHubClient.GetCurrentOdometryAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOdometryReceived(OdometryDto dto)
|
||||
{
|
||||
lastOdom = dto;
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState _)
|
||||
{
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private double YawDegrees
|
||||
{
|
||||
get
|
||||
{
|
||||
if (lastOdom == null) return 0;
|
||||
var qw = lastOdom.OrientationW;
|
||||
var qz = lastOdom.OrientationZ;
|
||||
var qx = lastOdom.OrientationX;
|
||||
var qy = lastOdom.OrientationY;
|
||||
var yaw = Math.Atan2(2.0 * (qw * qz + qx * qy), 1.0 - 2.0 * (qy * qy + qz * qz));
|
||||
return yaw * 180.0 / Math.PI;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Format(double value) => value.ToString("F4");
|
||||
private static string FormatDegrees(double value) => value.ToString("F2") + " °";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
OdometryHubClient.OdometryReceived -= OnOdometryReceived;
|
||||
OdometryHubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
await OdometryHubClient.DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* Odometry page – header và card có màu */
|
||||
.odom-page-header {
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--mud-palette-primary);
|
||||
background-color: var(--mud-palette-surface);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Card chung: bo góc, viền trái 4px màu */
|
||||
.odom-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mud-palette-lines-default);
|
||||
border-left-width: 4px;
|
||||
}
|
||||
|
||||
.odom-card-meta {
|
||||
border-left-color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.odom-card-position {
|
||||
border-left-color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.odom-card-orientation {
|
||||
border-left-color: var(--mud-palette-secondary);
|
||||
}
|
||||
|
||||
.odom-card-linear {
|
||||
border-left-color: var(--mud-palette-info);
|
||||
}
|
||||
|
||||
.odom-card-angular {
|
||||
border-left-color: var(--mud-palette-warning);
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
@page "/plc/controller"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
@implements IAsyncDisposable
|
||||
|
||||
@using RobotNet10.RobotApp.Client.Clients
|
||||
@using RobotNet10.RobotApp.Client.Shared.Plc
|
||||
@using Microsoft.AspNetCore.SignalR.Client
|
||||
@using Microsoft.Extensions.DependencyInjection
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>PLC Controller</PageTitle>
|
||||
|
||||
<div style="height: 100%; display: flex; flex-direction: column; overflow: hidden;">
|
||||
@* Sticky Header Section *@
|
||||
<div style="flex-shrink: 0; background: var(--mud-palette-background); z-index: 10;">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="py-3">
|
||||
<MudStack Spacing="2">
|
||||
<MudText Typo="Typo.h5">PLC Controller Status</MudText>
|
||||
|
||||
@* Connection Status *@
|
||||
<MudAlert Severity="@(hubClient?.IsConnected == true ? Severity.Success : Severity.Warning)" Dense>
|
||||
@if (hubClient?.IsConnected == true)
|
||||
{
|
||||
<MudText>Connected to PlcControllerHub</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText>Disconnected from PlcControllerHub</MudText>
|
||||
}
|
||||
</MudAlert>
|
||||
|
||||
@* Control Section *@
|
||||
<MudPaper Class="pa-3">
|
||||
<MudStack Row="true" Spacing="2" AlignItems="@AlignItems.Center">
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Success"
|
||||
OnClick="EnableUpdate"
|
||||
Disabled="@(isUpdateEnabled || !isHubReady)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.PlayArrow" Size="Size.Small" Class="mr-1" />
|
||||
Enable Update
|
||||
</MudButton>
|
||||
|
||||
<MudButton Variant="Variant.Filled"
|
||||
Color="Color.Error"
|
||||
OnClick="DisableUpdate"
|
||||
Disabled="@(!isUpdateEnabled)">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Stop" Size="Size.Small" Class="mr-1" />
|
||||
Disable Update
|
||||
</MudButton>
|
||||
|
||||
<MudChip T="string" Color="@(isUpdateEnabled? Color.Success: Color.Default)" Size="Size.Small">
|
||||
@(isUpdateEnabled ? "Updating (2Hz)" : "Stopped")
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudPaper>
|
||||
</MudStack>
|
||||
</MudContainer>
|
||||
</div>
|
||||
|
||||
@* Scrollable Content Area *@
|
||||
<div style="flex: 1; overflow-y: auto; overflow-x: hidden;">
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="py-3">
|
||||
@if (status != null)
|
||||
{
|
||||
<MudGrid Spacing="2">
|
||||
@* System Status Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>System Status</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudChip T="string" Color="@(status.IsReady? Color.Success: Color.Error)" Size="Size.Small">
|
||||
@(status.IsReady ? "Ready" : "Not Ready")
|
||||
</MudChip>
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Peripheral Mode:</MudText>
|
||||
<MudChip T="string" Color="@GetModeColor(status.PeripheralMode)" Size="Size.Small">
|
||||
@status.PeripheralMode
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Safety Speed:</MudText>
|
||||
<MudChip T="string" Color="@GetSpeedColor(status.SafetySpeed)" Size="Size.Small">
|
||||
@status.SafetySpeed
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Stop State:</MudText>
|
||||
<MudChip T="string" Color="@GetStopStateColor(status.StopState)" Size="Size.Small">
|
||||
@status.StopState
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Lift State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Lift State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Lifted Up", status.LiftedUp, false))
|
||||
@StatusIndicator(("Lifted Down", status.LiftedDown, false))
|
||||
@StatusIndicator(("Lift Home", status.LiftHome, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* (Lift Module control removed; now use LiftModuleCard in Motion/ManualControl page) *@
|
||||
|
||||
@* Motor State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Motor State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Left Motor", status.LeftMotorReady, false))
|
||||
@StatusIndicator(("Right Motor", status.RightMotorReady, false))
|
||||
@StatusIndicator(("Lift Motor", status.LiftMotorReady, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Safety Sensors Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Safety Sensors</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Emergency", status.Emergency, true))
|
||||
@StatusIndicator(("Bumper", status.Bumper, true))
|
||||
@StatusIndicator(("Lidar Front", status.LidarFrontProtectField, true))
|
||||
@StatusIndicator(("Lidar Back", status.LidarBackProtectField, true))
|
||||
@StatusIndicator(("Lidar Tim", status.LidarFrontTimProtectField, true))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
|
||||
@* Button State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Button State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Start Button", status.ButtonStart, false))
|
||||
@StatusIndicator(("Stop Button", status.ButtonStop, true))
|
||||
@StatusIndicator(("Reset Button", status.ButtonReset, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Other State Card *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Other State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Has Load", status.HasLoad, false))
|
||||
@StatusIndicator(("Charger Enabled", status.EnabledCharger, false))
|
||||
@StatusIndicator(("Charging", status.Charging, false))
|
||||
@StatusIndicator(("Muted Base", status.MutedBase, false))
|
||||
@StatusIndicator(("Muted Load", status.MutedLoad, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Command State Card - Các lệnh đã ghi xuống PLC *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Command State</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">System State:</MudText>
|
||||
<MudChip T="string" Color="@GetSystemStateColor(status.CurrentSystemState)" Size="Size.Small">
|
||||
@status.CurrentSystemState
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">Operation State:</MudText>
|
||||
<MudChip T="string" Color="@GetOperationStateColor(status.CurrentOperationState)" Size="Size.Small">
|
||||
@status.CurrentOperationState
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">RF Mode:</MudText>
|
||||
<MudChip T="string" Color="@GetRFModeColor(status.CurrentRFMode)" Size="Size.Small">
|
||||
@status.CurrentRFMode
|
||||
</MudChip>
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Command Values Card - Các giá trị điều khiển đã ghi *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Command Values</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Horizontal Load", status.SetHorizontalLoadValue, false))
|
||||
@StatusIndicator(("Muted Base (Set)", status.SetMutedBaseValue, false))
|
||||
@StatusIndicator(("Muted Load (Set)", status.SetMutedLoadValue, false))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
|
||||
@* Command Values Card - Các giá trị điều khiển đã ghi *@
|
||||
<MudItem xs="12" md="6" lg="4">
|
||||
<MudCard Style="height: 100%;">
|
||||
<MudCardHeader Class="py-2">
|
||||
<CardHeaderContent>
|
||||
<MudText Typo="Typo.subtitle1"><strong>Command Values</strong></MudText>
|
||||
</CardHeaderContent>
|
||||
<CardHeaderActions>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Settings" Color="Color.Primary" Size="Size.Small" />
|
||||
</CardHeaderActions>
|
||||
</MudCardHeader>
|
||||
<MudCardContent Class="py-2">
|
||||
<MudStack Spacing="1">
|
||||
@StatusIndicator(("Charger Enable (Set)", status.SetEnableChargerValue, false))
|
||||
@StatusIndicator(("Has Load (Set)", status.SetHasLoadValue, false))
|
||||
@StatusIndicator(("RF E-Stop (Set)", status.SetRFEStopValue, true))
|
||||
@StatusIndicator(("Batter Low (Set)", status.SetBatteryLowValue, true))
|
||||
</MudStack>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
}
|
||||
</MudContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Inject] private IServiceProvider ServiceProvider { get; set; } = null!;
|
||||
[Inject] private ISnackbar Snackbar { get; set; } = null!;
|
||||
[Inject] private HttpClient Http { get; set; } = null!;
|
||||
|
||||
private PlcControllerHubClient? hubClient;
|
||||
private PlcControllerStatusDto? status;
|
||||
private bool isLoading = false;
|
||||
private bool isHubReady = false;
|
||||
private bool isUpdateEnabled = false;
|
||||
private System.Threading.Timer? updateTimer;
|
||||
private bool _disposed = false;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
hubClient = ServiceProvider.GetService<PlcControllerHubClient>();
|
||||
if (hubClient is null)
|
||||
{
|
||||
isHubReady = false;
|
||||
return; // PLC hub client not registered (disconnected)
|
||||
}
|
||||
|
||||
// Subscribe to connection state changes
|
||||
hubClient.ConnectionStateChanged += OnConnectionStateChanged;
|
||||
|
||||
try
|
||||
{
|
||||
await hubClient.StartAsync();
|
||||
isHubReady = hubClient.IsConnected;
|
||||
|
||||
// Wait a bit for connection to establish
|
||||
await Task.Delay(500);
|
||||
isHubReady = hubClient.IsConnected;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error initializing: {ex.Message}", Severity.Error);
|
||||
isHubReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConnectionStateChanged(HubConnectionState state)
|
||||
{
|
||||
isHubReady = state == HubConnectionState.Connected;
|
||||
|
||||
// Stop update if disconnected
|
||||
if (!isHubReady && isUpdateEnabled)
|
||||
{
|
||||
DisableUpdate();
|
||||
}
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private void EnableUpdate()
|
||||
{
|
||||
if (_disposed || isUpdateEnabled || !isHubReady)
|
||||
return;
|
||||
|
||||
isUpdateEnabled = true;
|
||||
|
||||
// Start timer at 2Hz (500ms)
|
||||
updateTimer = new System.Threading.Timer(
|
||||
async _ => await RefreshStatus(),
|
||||
null,
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromMilliseconds(500));
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void DisableUpdate()
|
||||
{
|
||||
if (!isUpdateEnabled)
|
||||
return;
|
||||
|
||||
isUpdateEnabled = false;
|
||||
|
||||
// Stop and dispose timer
|
||||
updateTimer?.Change(Timeout.Infinite, Timeout.Infinite);
|
||||
updateTimer?.Dispose();
|
||||
updateTimer = null;
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RefreshStatus()
|
||||
{
|
||||
if (_disposed || isLoading || hubClient is null || !isHubReady)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
isLoading = true;
|
||||
status = await hubClient.GetStatusAsync();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Snackbar.Add($"Error refreshing status: {ex.Message}", Severity.Error);
|
||||
isHubReady = false;
|
||||
DisableUpdate();
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private Color GetModeColor(string mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
"AUTOMATIC" => Color.Success,
|
||||
"MANUAL" => Color.Warning,
|
||||
"SERVICE" => Color.Info,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetSpeedColor(string speed)
|
||||
{
|
||||
return speed switch
|
||||
{
|
||||
"Very_Slow" => Color.Error,
|
||||
"Slow" => Color.Warning,
|
||||
"Normal" => Color.Default,
|
||||
"Medium" => Color.Info,
|
||||
"Optimal" => Color.Success,
|
||||
"Fast" => Color.Primary,
|
||||
"Very_Fast" => Color.Secondary,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetStopStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"None" => Color.Success,
|
||||
"EMC" => Color.Error,
|
||||
"Bumper" => Color.Error,
|
||||
"FrontProtective" => Color.Warning,
|
||||
"BackProtective" => Color.Warning,
|
||||
"TimProtective" => Color.Warning,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetSystemStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"INIT" => Color.Info,
|
||||
"IDLE" => Color.Default,
|
||||
"PAUSED" => Color.Warning,
|
||||
"PROCCESSING" => Color.Primary,
|
||||
"DOCKING" => Color.Secondary,
|
||||
"CHARGING" => Color.Tertiary,
|
||||
"MAINTENANCE" => Color.Warning,
|
||||
"MANUAL" => Color.Info,
|
||||
"OVERRIDE" => Color.Warning,
|
||||
"ERROR" => Color.Error,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetOperationStateColor(string state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
"Move" => Color.Primary,
|
||||
"Lifting" => Color.Info,
|
||||
"LiftRotating" => Color.Secondary,
|
||||
"None" => Color.Default,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
private Color GetRFModeColor(string mode)
|
||||
{
|
||||
return mode switch
|
||||
{
|
||||
"Default" => Color.Success,
|
||||
"Maintenance" => Color.Warning,
|
||||
"Override" => Color.Error,
|
||||
"None" => Color.Default,
|
||||
_ => Color.Default
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Stop update timer
|
||||
DisableUpdate();
|
||||
|
||||
// Unsubscribe from events
|
||||
if (hubClient != null)
|
||||
{
|
||||
hubClient.ConnectionStateChanged -= OnConnectionStateChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@* Status Indicator Component *@
|
||||
@code {
|
||||
private RenderFragment<(string Label, bool Value, bool DangerWhenTrue)> StatusIndicator => context =>
|
||||
@<MudStack Row="true" Justify="Justify.SpaceBetween" Class="mb-1">
|
||||
<MudText Typo="Typo.body2">@context.Label:</MudText>
|
||||
<MudChip T="bool"
|
||||
Color="@(context.Value ? (context.DangerWhenTrue ? Color.Error : Color.Success) : Color.Default)"
|
||||
Size="Size.Small">
|
||||
@(context.Value ? "ON" : "OFF")
|
||||
</MudChip>
|
||||
</MudStack>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/programming"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Script Editor</PageTitle>
|
||||
|
||||
<RobotNet10.ScriptEditor.ScriptEditor />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
@page "/station-manager/{LevelId:guid}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Station Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.StationManager.StationManagerComponent LayoutLevelId="LevelId" />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter]
|
||||
public Guid LevelId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
@page "/navigation/tuning"
|
||||
@using RobotNet10.NavigationTuneUI.Components
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Navigation Tuning</PageTitle>
|
||||
|
||||
<AuthorizeView Roles="Distributor">
|
||||
<TuningDashboard />
|
||||
</AuthorizeView>
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
@@ -0,0 +1,18 @@
|
||||
@page "/vehicletypes/edit/{Id:guid}"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Vehicle Editor</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.VehicleTypeManager.VehicleTypeEditComponent Id="@Id" />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public Guid Id { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
@page "/vehicle-manager"
|
||||
@rendermode InteractiveWebAssemblyNoPrerender
|
||||
|
||||
<PageTitle>Vehicle Manager</PageTitle>
|
||||
|
||||
<RobotNet10.MapEditor.Components.VehicleTypeManager.VehicleTypeManagerComponent />
|
||||
|
||||
<MudThemeProvider IsDarkMode />
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@attribute [Authorize]
|
||||
@@ -0,0 +1,77 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
using MudBlazor;
|
||||
using MudBlazor.Services;
|
||||
using RobotNet10.CustomConfigurationEditor.Services.API;
|
||||
using RobotNet10.CustomConfigurationEditor.Services.State;
|
||||
using RobotNet10.MapEditor.Services.API;
|
||||
using RobotNet10.MapEditor.Services.State;
|
||||
using RobotNet10.RobotApp.Client;
|
||||
using RobotNet10.RobotApp.Client.Clients;
|
||||
using RobotNet10.RobotApp.Client.Services;
|
||||
using RobotNet10.ScriptEditor;
|
||||
using RobotNet10.NavigationTuneUI.Clients;
|
||||
using RobotNet10.NavigationTuneUI.Services;
|
||||
using System.Globalization;
|
||||
|
||||
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Logging.AddFilter("System.Net.Http.HttpClient", LogLevel.Warning);
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthenticationStateDeserialization();
|
||||
|
||||
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress), });
|
||||
builder.Services.AddMudServices(config =>
|
||||
{
|
||||
config.SnackbarConfiguration.VisibleStateDuration = 2000;
|
||||
config.SnackbarConfiguration.HideTransitionDuration = 500;
|
||||
config.SnackbarConfiguration.ShowTransitionDuration = 500;
|
||||
config.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomLeft;
|
||||
});
|
||||
builder.Services.AddNavigationMenu();
|
||||
builder.Services.AddScriptEditor<ScriptEngineResource>();
|
||||
|
||||
// MapEditor Services
|
||||
builder.Services.AddScoped<MapManagerApiService>();
|
||||
builder.Services.AddScoped<LayoutManagerState>();
|
||||
builder.Services.AddScoped<LayoutEditorState>();
|
||||
builder.Services.AddScoped<VehicleTypeManagerState>();
|
||||
builder.Services.AddScoped<VehicleTypeEditState>();
|
||||
builder.Services.AddScoped<StationManagerState>();
|
||||
|
||||
// Device Hub Clients
|
||||
builder.Services.AddScoped<DeviceHubClient>();
|
||||
builder.Services.AddScoped<OdometryHubClient>();
|
||||
builder.Services.AddScoped<BatteryHubClient>();
|
||||
builder.Services.AddScoped<InertialMeasurementUnitHubClient>();
|
||||
builder.Services.AddScoped<LidarHubClient>();
|
||||
builder.Services.AddScoped<ModbusTcpHubClient>();
|
||||
builder.Services.AddScoped<RfHandleHubClient>();
|
||||
builder.Services.AddScoped<CameraQrHubClient>();
|
||||
builder.Services.AddScoped<CiA402ServoHubClient>();
|
||||
builder.Services.AddScoped<MotionHubClient>();
|
||||
builder.Services.AddScoped<SLAMClient>();
|
||||
builder.Services.AddScoped<PlcControllerHubClient>();
|
||||
builder.Services.AddScoped<MarkerDetectorHubClient>();
|
||||
builder.Services.AddScoped<NavigationMonitorHubClient>();
|
||||
|
||||
// Navigation Tuning Services
|
||||
builder.Services.AddScoped<TuningHubClient>();
|
||||
builder.Services.AddScoped<TuningApiService>();
|
||||
|
||||
// Config Manager Services
|
||||
builder.Services.AddScoped<ConfigApiService>();
|
||||
builder.Services.AddScoped<ConfigManagerState>(sp =>
|
||||
{
|
||||
var configApi = sp.GetRequiredService<ConfigApiService>();
|
||||
var authStateProvider = sp.GetService<AuthenticationStateProvider>();
|
||||
return new ConfigManagerState(configApi, authStateProvider, "Distributor");
|
||||
});
|
||||
|
||||
// DockStation Config Services
|
||||
builder.Services.AddScoped<DockStationApiService>();
|
||||
builder.Services.AddScoped<DockStationConfigState>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
@@ -0,0 +1,8 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo($"Account/Login?returnUrl={Uri.EscapeDataString(NavigationManager.Uri)}", forceLoad: true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
<UseRidGraph>false</UseRidGraph>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Commons\RobotNet10.Script\RobotNet10.Script.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.Components\RobotNet10.Components.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.CustomConfigurationEditor\RobotNet10.CustomConfigurationEditor.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.ScriptEditor\RobotNet10.ScriptEditor.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.MapEditor\RobotNet10.MapEditor.csproj" />
|
||||
<ProjectReference Include="..\..\Components\RobotNet10.NavigationTuneUI\RobotNet10.NavigationTuneUI.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.ScriptEngine.Shared\RobotNet10.ScriptEngine.Shared.csproj" />
|
||||
<ProjectReference Include="..\..\Shared\RobotNet10.Shared\RobotNet10.Shared.csproj" />
|
||||
<ProjectReference Include="..\RobotNet10.RobotApp.Script.Shared\RobotNet10.RobotApp.Script.Shared.csproj" />
|
||||
<ProjectReference Include="..\RobotNet10.RobotApp.Script\RobotNet10.RobotApp.Script.csproj" />
|
||||
<ProjectReference Include="..\RobotNet10.RobotApp.Shared\RobotNet10.RobotApp.Shared.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR client để kết nối với DeviceHub
|
||||
/// </summary>
|
||||
public class DeviceHubClient : IAsyncDisposable
|
||||
{
|
||||
private readonly HubConnection _hubConnection;
|
||||
private bool _disposed;
|
||||
|
||||
public event Action<DeviceUpdateDto>? DeviceUpdated;
|
||||
public event Action<string, DeviceStatus>? DeviceStatusChanged;
|
||||
public event Action<HubConnectionState>? ConnectionStateChanged;
|
||||
|
||||
public bool IsConnected => _hubConnection.State == HubConnectionState.Connected;
|
||||
public HubConnectionState ConnectionState => _hubConnection.State;
|
||||
|
||||
public DeviceHubClient(NavigationManager navigationManager)
|
||||
{
|
||||
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/devices");
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(hubUrl)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.On<DeviceUpdateDto>("DeviceUpdated", update => DeviceUpdated?.Invoke(update));
|
||||
_hubConnection.On<string, DeviceStatus>("DeviceStatusChanged", (deviceId, status) =>
|
||||
DeviceStatusChanged?.Invoke(deviceId, status));
|
||||
|
||||
_hubConnection.Reconnecting += error =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Reconnected += connectionId =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
_hubConnection.Closed += error =>
|
||||
{
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
}
|
||||
|
||||
public async Task StartAsync()
|
||||
{
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
if (_hubConnection.State != HubConnectionState.Disconnected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
ConnectionStateChanged?.Invoke(_hubConnection.State);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DeviceDto[]> GetAllDevicesAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceDto[]>("GetAllDevices");
|
||||
}
|
||||
|
||||
public async Task<DeviceDto?> GetDeviceAsync(string deviceId)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceDto?>("GetDevice", deviceId);
|
||||
}
|
||||
|
||||
public async Task<DeviceDto[]> GetDevicesByTypeAsync(DeviceType deviceType)
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<DeviceDto[]>("GetDevicesByType", deviceType);
|
||||
}
|
||||
|
||||
public async Task<int> GetDeviceCountAsync()
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<int>("GetDeviceCount");
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
await StopAsync();
|
||||
await _hubConnection.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Net.Http.Json;
|
||||
using RobotNet10.RobotApp.Shared.DockStation;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Services;
|
||||
|
||||
public class DockStationApiService(HttpClient httpClient)
|
||||
{
|
||||
private const string BaseUrl = "api/dock-station-config";
|
||||
|
||||
public async Task<List<DockStationConfigSummaryDto>> GetAllAsync()
|
||||
=> await httpClient.GetFromJsonAsync<List<DockStationConfigSummaryDto>>(BaseUrl) ?? [];
|
||||
|
||||
public async Task<DockStationConfigDto?> GetByIdAsync(Guid id)
|
||||
=> await httpClient.GetFromJsonAsync<DockStationConfigDto>($"{BaseUrl}/{id}");
|
||||
|
||||
public async Task<DockStationConfigDto> CreateAsync(CreateDockStationConfigRequest request)
|
||||
{
|
||||
var response = await httpClient.PostAsJsonAsync(BaseUrl, request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<DockStationConfigDto>()
|
||||
?? throw new InvalidOperationException("Failed to deserialize created dock station config.");
|
||||
}
|
||||
|
||||
public async Task<DockStationConfigDto> UpdateAsync(Guid id, UpdateDockStationConfigRequest request)
|
||||
{
|
||||
var response = await httpClient.PutAsJsonAsync($"{BaseUrl}/{id}", request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await response.Content.ReadFromJsonAsync<DockStationConfigDto>()
|
||||
?? throw new InvalidOperationException("Failed to deserialize updated dock station config.");
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
var response = await httpClient.DeleteAsync($"{BaseUrl}/{id}");
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using RobotNet10.RobotApp.Shared.DockStation;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Services;
|
||||
|
||||
public class DockStationConfigState(DockStationApiService apiService)
|
||||
{
|
||||
public List<DockStationConfigSummaryDto> Configs { get; private set; } = [];
|
||||
public DockStationConfigDto? SelectedConfig { get; private set; }
|
||||
public bool IsLoading { get; private set; }
|
||||
public bool IsSaving { get; private set; }
|
||||
public string? ErrorMessage { get; private set; }
|
||||
|
||||
public event Action? OnStateChanged;
|
||||
|
||||
public async Task LoadConfigsAsync()
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
try
|
||||
{
|
||||
Configs = await apiService.GetAllAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
Configs = [];
|
||||
}
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public async Task SelectConfigAsync(Guid id)
|
||||
{
|
||||
IsLoading = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
try
|
||||
{
|
||||
SelectedConfig = await apiService.GetByIdAsync(id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
SelectedConfig = null;
|
||||
}
|
||||
IsLoading = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public void ClearSelection()
|
||||
{
|
||||
SelectedConfig = null;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
|
||||
public async Task<DockStationConfigDto> CreateConfigAsync(CreateDockStationConfigRequest request)
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
try
|
||||
{
|
||||
var result = await apiService.CreateAsync(request);
|
||||
await LoadConfigsAsync();
|
||||
SelectedConfig = result;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpdateConfigAsync(Guid id, UpdateDockStationConfigRequest request)
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
try
|
||||
{
|
||||
SelectedConfig = await apiService.UpdateAsync(id, request);
|
||||
await LoadConfigsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteConfigAsync(Guid id)
|
||||
{
|
||||
IsSaving = true;
|
||||
ErrorMessage = null;
|
||||
NotifyStateChanged();
|
||||
try
|
||||
{
|
||||
await apiService.DeleteAsync(id);
|
||||
if (SelectedConfig?.Id == id) ClearSelection();
|
||||
await LoadConfigsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = ex.Message;
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSaving = false;
|
||||
NotifyStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private void NotifyStateChanged() => OnStateChanged?.Invoke();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using RobotNet10.RobotApp.Script.Shared;
|
||||
using RobotNet10.ScriptEngine.Shared;
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Services;
|
||||
|
||||
public class ScriptEngineResource : IScriptEngineResource
|
||||
{
|
||||
public Type AppGlobalType => RobotAppScriptEngineResource.GlobalType;
|
||||
|
||||
public ImmutableArray<string> UsingNamespaces => RobotAppScriptEngineResource.UsingNamespaces;
|
||||
|
||||
public ImmutableArray<string> Modules => RobotAppScriptEngineResource.Modules;
|
||||
|
||||
public ImmutableArray<string> DocModules => RobotAppScriptEngineResource.DocModules;
|
||||
|
||||
public IDictionary<string, object?> GetMissionGlobals(Guid id, CancellationToken cancellationToken)
|
||||
=> new Dictionary<string, object?>();
|
||||
|
||||
public IDictionary<string, object?> GetTaskGlobals()
|
||||
=> new Dictionary<string, object?>();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using RobotNet10.Shared.Geometry;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho Camera QR data
|
||||
/// </summary>
|
||||
public class CameraQrDataDto
|
||||
{
|
||||
public bool IsConnected { get; set; }
|
||||
public Dictionary<string, PoseStamped> Codes { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho CiA402Servo data
|
||||
/// </summary>
|
||||
public struct CiA402ServoDataDto
|
||||
{
|
||||
public ushort Statusword { get; set; }
|
||||
public string DriveState { get; set; }
|
||||
public string OperationMode { get; set; }
|
||||
public int Position { get; set; }
|
||||
public int Velocity { get; set; }
|
||||
public short Torque { get; set; }
|
||||
public ushort ErrorCode { get; set; }
|
||||
public bool IsConnected { get; set; }
|
||||
/// <summary>Profile Speed (0x6081) - đọc từ drive khi GetServoData</summary>
|
||||
public uint ProfileSpeed { get; set; }
|
||||
/// <summary>Profile Acceleration (0x6083) - đọc từ drive khi GetServoData</summary>
|
||||
public uint ProfileAcceleration { get; set; }
|
||||
/// <summary>Profile Deceleration (0x6084) - đọc từ drive khi GetServoData</summary>
|
||||
public uint ProfileDeceleration { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho thông tin device để truyền qua SignalR
|
||||
/// </summary>
|
||||
public class DeviceDto
|
||||
{
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
public string DeviceName { get; set; } = string.Empty;
|
||||
public DeviceType DeviceType { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DeviceStatus Status { get; set; }
|
||||
public bool IsConnected { get; set; }
|
||||
public DateTime LastUpdateTime { get; set; }
|
||||
public DateTime? LastConnectedTime { get; set; }
|
||||
public DateTime? LastDisconnectedTime { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public int ReconnectAttemptCount { get; set; }
|
||||
public bool AutoReconnectEnabled { get; set; }
|
||||
public int ReconnectDelayMs { get; set; }
|
||||
public int MaxReconnectAttempts { get; set; }
|
||||
public List<PropertyDescription> PropertyDescriptions { get; set; } = new();
|
||||
public Dictionary<string, string> Properties { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho Device info (shared với server)
|
||||
/// </summary>
|
||||
public class DeviceInfoDto
|
||||
{
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
public string DeviceName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// Trạng thái của thiết bị (State Machine)
|
||||
/// </summary>
|
||||
public enum DeviceStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Chưa khởi tạo
|
||||
/// </summary>
|
||||
Uninitialized = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Đang khởi tạo
|
||||
/// </summary>
|
||||
Initializing,
|
||||
|
||||
/// <summary>
|
||||
/// Đang kết nối
|
||||
/// </summary>
|
||||
Connecting,
|
||||
|
||||
/// <summary>
|
||||
/// Đã kết nối và sẵn sàng
|
||||
/// </summary>
|
||||
Connected,
|
||||
|
||||
/// <summary>
|
||||
/// Đang ngắt kết nối
|
||||
/// </summary>
|
||||
Disconnecting,
|
||||
|
||||
/// <summary>
|
||||
/// Đã ngắt kết nối
|
||||
/// </summary>
|
||||
Disconnected,
|
||||
|
||||
/// <summary>
|
||||
/// Đang kết nối lại (auto-reconnect)
|
||||
/// </summary>
|
||||
Reconnecting,
|
||||
|
||||
/// <summary>
|
||||
/// Lỗi - cần xử lý
|
||||
/// </summary>
|
||||
Error,
|
||||
|
||||
/// <summary>
|
||||
/// Đã bị dispose
|
||||
/// </summary>
|
||||
Disposed
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// Loại thiết bị trong hệ thống AMR
|
||||
/// </summary>
|
||||
public enum DeviceType
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Servo motor theo chuẩn CiA402
|
||||
/// </summary>
|
||||
CiA402Servo = 0,
|
||||
|
||||
/// <summary>
|
||||
/// LiDAR - cảm biến quét laser
|
||||
/// </summary>
|
||||
Lidar,
|
||||
|
||||
/// <summary>
|
||||
/// IMU - Inertial Measurement Unit
|
||||
/// </summary>
|
||||
Imu,
|
||||
|
||||
/// <summary>
|
||||
/// Pin/Battery
|
||||
/// </summary>
|
||||
Battery,
|
||||
|
||||
/// <summary>
|
||||
/// ModbusTCP client/server
|
||||
/// </summary>
|
||||
ModbusTcp,
|
||||
|
||||
/// <summary>
|
||||
/// Tay điều khiển RF (RF Handle)
|
||||
/// </summary>
|
||||
RfHandle,
|
||||
|
||||
/// <summary>
|
||||
/// Camera phát hiện QR code
|
||||
/// </summary>
|
||||
CameraQr,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho device update events qua SignalR
|
||||
/// </summary>
|
||||
public class DeviceUpdateDto
|
||||
{
|
||||
public string DeviceId { get; set; } = string.Empty;
|
||||
public DeviceStatus? Status { get; set; }
|
||||
public Dictionary<string, string>? Properties { get; set; }
|
||||
public string? LastError { get; set; }
|
||||
public DateTime? LastUpdateTime { get; set; }
|
||||
public DateTime? LastConnectedTime { get; set; }
|
||||
public DateTime? LastDisconnectedTime { get; set; }
|
||||
public int? ReconnectAttemptCount { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho Modbus value (register)
|
||||
/// </summary>
|
||||
public struct ModbusValue
|
||||
{
|
||||
public ushort Address { get; set; }
|
||||
public ushort Index { get; set; }
|
||||
public ushort Value { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho Modbus bool value (coil/discrete input)
|
||||
/// </summary>
|
||||
public struct ModbusBoolValue
|
||||
{
|
||||
public ushort Address { get; set; }
|
||||
public ushort Index { get; set; }
|
||||
public bool Value { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho Modbus range data
|
||||
/// </summary>
|
||||
public struct ModbusRangeData
|
||||
{
|
||||
public ushort StartAddress { get; set; }
|
||||
public ushort Quantity { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string[] ChildrenNames { get; set; }
|
||||
public ModbusValue[] Values { get; set; }
|
||||
public ModbusBoolValue[] BoolValues { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho ModbusTCP data
|
||||
/// </summary>
|
||||
public struct ModbusTcpData
|
||||
{
|
||||
public string IpAddress { get; set; }
|
||||
public int Port { get; set; }
|
||||
public byte SlaveId { get; set; }
|
||||
public bool IsConnected { get; set; }
|
||||
public ModbusRangeData[] HoldingRegisters { get; set; }
|
||||
public ModbusRangeData[] InputRegisters { get; set; }
|
||||
public ModbusRangeData[] Coils { get; set; }
|
||||
public ModbusRangeData[] DiscreteInputs { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
/// <summary>
|
||||
/// Mô tả một property của thiết bị (dùng để hiển thị trên web UI)
|
||||
/// </summary>
|
||||
public class PropertyDescription
|
||||
{
|
||||
/// <summary>
|
||||
/// Tên key của property (phải khớp với key trong Properties dictionary)
|
||||
/// </summary>
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Tên hiển thị trên UI
|
||||
/// </summary>
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Mô tả chi tiết về property
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Loại dữ liệu (ví dụ: "string", "number", "boolean", "date", "url", etc.)
|
||||
/// </summary>
|
||||
public string DataType { get; set; } = "string";
|
||||
|
||||
/// <summary>
|
||||
/// Đơn vị đo (ví dụ: "V", "A", "Hz", "°C", "m/s", etc.) - null nếu không có
|
||||
/// </summary>
|
||||
public string? Unit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Giá trị mặc định
|
||||
/// </summary>
|
||||
public string? DefaultValue { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Có thể chỉnh sửa trên UI hay không
|
||||
/// </summary>
|
||||
public bool IsReadOnly { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Thứ tự hiển thị trên UI (số nhỏ hơn hiển thị trước)
|
||||
/// </summary>
|
||||
public int DisplayOrder { get; set; } = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Nhóm/category của property (để nhóm các properties lại với nhau trên UI)
|
||||
/// </summary>
|
||||
public string? Category { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Format string để hiển thị giá trị (ví dụ: "{0:F2}", "{0:yyyy-MM-dd}", etc.)
|
||||
/// </summary>
|
||||
public string? Format { get; set; }
|
||||
|
||||
public PropertyDescription()
|
||||
{
|
||||
}
|
||||
|
||||
public PropertyDescription(string key, string displayName, string? description = null)
|
||||
{
|
||||
Key = key ?? throw new ArgumentNullException(nameof(key));
|
||||
DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName));
|
||||
Description = description;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Devices;
|
||||
|
||||
|
||||
// ======================================================================
|
||||
// DTO LITE – khớp 100% IRfHandle Lite + RfHandleHub Lite
|
||||
// ======================================================================
|
||||
public class RfHandleDataDto
|
||||
{
|
||||
public string DeviceId { get; set; } = "";
|
||||
|
||||
public int Heartbeat { get; set; }
|
||||
public bool RemoteReady { get; set; }
|
||||
public bool EStop { get; set; }
|
||||
|
||||
public bool LiftUp { get; set; }
|
||||
public bool LiftDown { get; set; }
|
||||
public bool RotateLeft { get; set; }
|
||||
public bool RotateRight { get; set; }
|
||||
|
||||
public bool ModeSelect { get; set; }
|
||||
public bool Enable { get; set; }
|
||||
|
||||
public int Speed { get; set; }
|
||||
public double Linear { get; set; }
|
||||
public double Angular { get; set; }
|
||||
public string Mode { get; set; } = "Unknown";
|
||||
|
||||
public DateTime LastUpdateTime { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho trạng thái LiftModule (dùng cho SignalR)
|
||||
/// </summary>
|
||||
public class LiftModuleStatusDto
|
||||
{
|
||||
public string State { get; set; } = string.Empty;
|
||||
public bool IsReady { get; set; }
|
||||
public int CurrentPosition { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Modules;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho trạng thái RotationModule (dùng cho SignalR)
|
||||
/// </summary>
|
||||
public class RotationModuleStatusDto
|
||||
{
|
||||
public string State { get; set; } = string.Empty;
|
||||
public bool IsReady { get; set; }
|
||||
public double CurrentAngle { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho trạng thái ManualControlService
|
||||
/// </summary>
|
||||
public class ManualControlStatusDto
|
||||
{
|
||||
public string State { get; set; } = string.Empty;
|
||||
public bool IsEnabled { get; set; }
|
||||
public double CurrentLinearVelocity { get; set; }
|
||||
public double CurrentAngularVelocity { get; set; }
|
||||
public RfHandleStatusDto? RfHandleStatus { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho trạng thái RF Handle
|
||||
/// </summary>
|
||||
public class RfHandleStatusDto
|
||||
{
|
||||
public int Heartbeat { get; set; }
|
||||
public bool Ready { get; set; }
|
||||
public bool Locked { get; set; }
|
||||
public bool EStop { get; set; }
|
||||
public bool Enable { get; set; }
|
||||
public int Speed { get; set; }
|
||||
public double Linear { get; set; }
|
||||
public double Angular { get; set; }
|
||||
public string Mode { get; set; } = string.Empty;
|
||||
public DateTime LastUpdateTime { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Motion;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho thông tin Odometry từ IOdometryEstimator
|
||||
/// SignalR serialization-friendly version using simple properties
|
||||
/// instead of System.Numerics types which don't serialize properly with System.Text.Json
|
||||
/// </summary>
|
||||
public class OdometryDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Timestamp của pose
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Frame ID (header.frame_id, parent frame, typically "odom")
|
||||
/// </summary>
|
||||
public string FrameId { get; set; } = "odom";
|
||||
|
||||
/// <summary>
|
||||
/// Child frame ID (typically "base_link" or "base_footprint") - cùng giá trị gửi lên XLOC
|
||||
/// </summary>
|
||||
public string ChildFrameId { get; set; } = "base_link";
|
||||
|
||||
/// <summary>
|
||||
/// Position X (meters)
|
||||
/// </summary>
|
||||
public double PositionX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Position Y (meters)
|
||||
/// </summary>
|
||||
public double PositionY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Position Z (meters)
|
||||
/// </summary>
|
||||
public double PositionZ { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Orientation Quaternion X
|
||||
/// </summary>
|
||||
public double OrientationX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Orientation Quaternion Y
|
||||
/// </summary>
|
||||
public double OrientationY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Orientation Quaternion Z
|
||||
/// </summary>
|
||||
public double OrientationZ { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Orientation Quaternion W
|
||||
/// </summary>
|
||||
public double OrientationW { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tần số update odometry hiện tại (Hz)
|
||||
/// </summary>
|
||||
public double UpdateFrequency { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vận tốc tuyến tính X (m/s) - hướng tiến
|
||||
/// </summary>
|
||||
public double LinearVelocityX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vận tốc tuyến tính Y (m/s)
|
||||
/// </summary>
|
||||
public double LinearVelocityY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vận tốc tuyến tính Z (m/s)
|
||||
/// </summary>
|
||||
public double LinearVelocityZ { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vận tốc góc X (rad/s)
|
||||
/// </summary>
|
||||
public double AngularVelocityX { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vận tốc góc Y (rad/s)
|
||||
/// </summary>
|
||||
public double AngularVelocityY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Vận tốc góc Z (rad/s) - quay quanh trục dọc
|
||||
/// </summary>
|
||||
public double AngularVelocityZ { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using RobotNet.VDA5050.Type;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Shared.Plc;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho trạng thái PlcController (dùng cho SignalR)
|
||||
/// </summary>
|
||||
public class PlcControllerStatusDto
|
||||
{
|
||||
public bool IsReady { get; set; }
|
||||
|
||||
// Operating mode
|
||||
public string PeripheralMode { get; set; } = string.Empty;
|
||||
public string SafetySpeed { get; set; } = string.Empty;
|
||||
|
||||
// Safety sensors
|
||||
public bool Emergency { get; set; }
|
||||
public bool Bumper { get; set; }
|
||||
public bool LidarFrontProtectField { get; set; }
|
||||
public bool LidarBackProtectField { get; set; }
|
||||
public bool LidarFrontTimProtectField { get; set; }
|
||||
|
||||
// Lift state
|
||||
public bool LiftedUp { get; set; }
|
||||
public bool LiftedDown { get; set; }
|
||||
public bool LiftHome { get; set; }
|
||||
|
||||
// Motor state
|
||||
public bool LeftMotorReady { get; set; }
|
||||
public bool RightMotorReady { get; set; }
|
||||
public bool LiftMotorReady { get; set; }
|
||||
|
||||
// Button state
|
||||
public bool ButtonStart { get; set; }
|
||||
public bool ButtonStop { get; set; }
|
||||
public bool ButtonReset { get; set; }
|
||||
|
||||
// Other state
|
||||
public bool HasLoad { get; set; }
|
||||
public bool EnabledCharger { get; set; }
|
||||
public bool Charging { get; set; }
|
||||
public bool MutedBase { get; set; }
|
||||
public bool MutedLoad { get; set; }
|
||||
|
||||
// Current stop state (for display)
|
||||
public string StopState { get; set; } = "None";
|
||||
|
||||
// Write state - các giá trị đã được ghi xuống PLC
|
||||
public string CurrentSystemState { get; set; } = "INIT";
|
||||
public string CurrentOperationState { get; set; } = "None";
|
||||
public string CurrentRFMode { get; set; } = "None";
|
||||
public bool SetHorizontalLoadValue { get; set; }
|
||||
public bool SetMutedBaseValue { get; set; }
|
||||
public bool SetMutedLoadValue { get; set; }
|
||||
public bool SetEnableChargerValue { get; set; }
|
||||
public bool SetHasLoadValue { get; set; }
|
||||
public bool SetRFEStopValue { get; set; }
|
||||
public bool SetBatteryLowValue { get; set; }
|
||||
public bool SetLightOnValue { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using RobotNet10.Shared.Geometry;
|
||||
using RobotNet10.Shared.Numbers;
|
||||
using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion;
|
||||
|
||||
namespace RobotNet10.RobotApp.Client.Shared.SLAM;
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho Pose với covariance (3x3 matrix flattened to 9 values)
|
||||
/// </summary>
|
||||
public class PoseDto
|
||||
{
|
||||
public RobotNet10.Shared.Numbers.Vector3 Position { get; set; } = new();
|
||||
public QuaternionGeometry Orientation { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Covariance matrix (3x3) flattened to 9 values: [xx, xy, xθ, yx, yy, yθ, θx, θy, θθ]
|
||||
/// null if covariance is not available
|
||||
/// </summary>
|
||||
public double[]? Covariance { get; set; }
|
||||
|
||||
public double Score { get; set; }
|
||||
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho OccupancyGrid (optimized sparse format for SignalR transmission)
|
||||
/// Chỉ chứa các cell đã biết (known cells) và occupied cells để giảm bandwidth
|
||||
/// </summary>
|
||||
public class OccupancyGridDto
|
||||
{
|
||||
public double Resolution { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public Pose Origin { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Sparse format: chỉ chứa các cell đã biết (value >= 0)
|
||||
/// Format: byte[] với mỗi cell = 5 bytes: [index_byte0, index_byte1, index_byte2, index_byte3, value]
|
||||
/// Index: 4 bytes little-endian (32-bit unsigned integer, max 4,294,967,295)
|
||||
/// Value: 1 byte (0-100 = occupancy probability, 0 = free, 100 = occupied)
|
||||
/// Index được tính: y * Width + x (row-major order)
|
||||
/// </summary>
|
||||
public byte[] KnownCells { get; set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Version number để detect changes (increment mỗi khi grid thay đổi)
|
||||
/// </summary>
|
||||
public long Version { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Thời gian cuối cùng update grid base (từ CartographerService.LastUpdatedBase).
|
||||
/// </summary>
|
||||
public DateTime LastBaseUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Thời gian cuối cùng update OccupancyGridUpdating (từ CartographerService.LastUpdatedUpdating).
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Trajectory nodes (từ pose graph) kèm theo grid; dùng để vẽ trajectory polyline.
|
||||
/// Có khi lấy grid base hoặc grid updating.
|
||||
/// </summary>
|
||||
public TrajectoryNodeDto[]? TrajectoryNodes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho TrajectoryNode
|
||||
/// </summary>
|
||||
public class TrajectoryNodeDto
|
||||
{
|
||||
public int NodeId { get; set; }
|
||||
public Pose Pose { get; set; } = new();
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho MapInfo
|
||||
/// </summary>
|
||||
public class MapInfoDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public DateTime CreatedDate { get; set; }
|
||||
public double Resolution { get; set; }
|
||||
/// <summary>
|
||||
/// Width of map in meters
|
||||
/// </summary>
|
||||
public double Width { get; set; }
|
||||
/// <summary>
|
||||
/// Height of map in meters
|
||||
/// </summary>
|
||||
public double Height { get; set; }
|
||||
public int TrajectoryNodeCount { get; set; }
|
||||
/// <summary>
|
||||
/// Origin X coordinate in meters
|
||||
/// </summary>
|
||||
public double OriginX { get; set; }
|
||||
/// <summary>
|
||||
/// Origin Y coordinate in meters
|
||||
/// </summary>
|
||||
public double OriginY { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates if the map is currently being processed on the server
|
||||
/// </summary>
|
||||
public bool IsProcessing { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho error information
|
||||
/// </summary>
|
||||
public class ErrorDto
|
||||
{
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for merging multiple submaps into a single occupancy grid.
|
||||
/// </summary>
|
||||
public enum SubmapMergeStrategyDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Porter-Duff Source-Over compositing (Cairo-style).
|
||||
/// </summary>
|
||||
PorterDuff = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Sum log-odds from all submaps (Bayesian approach).
|
||||
/// </summary>
|
||||
LogOddsSum = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Take maximum probability (most pessimistic/conservative).
|
||||
/// </summary>
|
||||
MaxProbability = 2
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DTO cho OccupancyGridConfiguration - dùng để customize cách render occupancy grid
|
||||
/// </summary>
|
||||
public class OccupancyGridConfigurationDto
|
||||
{
|
||||
#region Merge Strategy
|
||||
|
||||
/// <summary>
|
||||
/// Strategy for merging overlapping cells from multiple submaps.
|
||||
/// Default: LogOddsSum (clearer free/occupied distinction)
|
||||
/// </summary>
|
||||
public SubmapMergeStrategyDto MergeStrategy { get; set; } = SubmapMergeStrategyDto.LogOddsSum;
|
||||
|
||||
/// <summary>
|
||||
/// Clamp log-odds to prevent extreme values from dominating.
|
||||
/// Range: [1, 20], Default: 10
|
||||
/// </summary>
|
||||
public double LogOddsClamp { get; set; } = 10.0;
|
||||
|
||||
/// <summary>
|
||||
/// When true, use average log-odds instead of sum.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool UseLogOddsAverage { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threshold Configuration
|
||||
|
||||
/// <summary>
|
||||
/// Threshold for classifying a cell as FREE.
|
||||
/// Higher value = stricter (fewer free cells).
|
||||
/// Range: [0, 255], Default: 100
|
||||
/// </summary>
|
||||
public int FreeSpaceThreshold { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Threshold for classifying a cell as OCCUPIED.
|
||||
/// Higher value = stricter (fewer occupied cells, thinner walls).
|
||||
/// Range: [0, 255], Default: 0
|
||||
/// </summary>
|
||||
public int OccupiedSpaceThreshold { get; set; } = 0;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Output Mode
|
||||
|
||||
/// <summary>
|
||||
/// When true, output only binary values (0=free, 100=occupied, -1=unknown).
|
||||
/// When false, output gradient values (0-100) based on probability.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool UseBinaryOutput { get; set; } = true;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Wall Thinning (Post-processing)
|
||||
|
||||
/// <summary>
|
||||
/// Enable morphological erosion to thin walls in the occupancy grid.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool EnableWallThinning { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Number of erosion iterations for wall thinning.
|
||||
/// Range: [1, 5], Default: 1
|
||||
/// </summary>
|
||||
public int WallThinningIterations { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum wall thickness to preserve (in pixels) during wall thinning.
|
||||
/// Range: [1, 10], Default: 1
|
||||
/// </summary>
|
||||
public int MinWallThicknessPixels { get; set; } = 1;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Ambiguous Cell Handling
|
||||
|
||||
/// <summary>
|
||||
/// How to handle ambiguous cells (probability ~0.5).
|
||||
/// Values: -1 = Unknown, 0 = Free, 100 = Occupied
|
||||
/// Default: -1
|
||||
/// </summary>
|
||||
public sbyte AmbiguousCellValue { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Lower bound of the ambiguous range (probability).
|
||||
/// Default: 0.35
|
||||
/// </summary>
|
||||
public double AmbiguousRangeLower { get; set; } = 0.35;
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound of the ambiguous range (probability).
|
||||
/// Default: 0.65
|
||||
/// </summary>
|
||||
public double AmbiguousRangeUpper { get; set; } = 0.65;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Advanced Options
|
||||
|
||||
/// <summary>
|
||||
/// Apply median filter to reduce noise.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool EnableMedianFilter { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Kernel size for median filter (must be odd number).
|
||||
/// Range: [3, 7], Default: 3
|
||||
/// </summary>
|
||||
public int MedianFilterKernelSize { get; set; } = 3;
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static RobotNet10.Components.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using MudBlazor
|
||||
@using RobotNet10.RobotApp.Client
|
||||
@using RobotNet10.Shared.Geometry
|
||||
@using RobotNet10.Shared.Numbers
|
||||
@using QuaternionGeometry = RobotNet10.Shared.Geometry.Quaternion
|
||||
@using QuaternionNumbers = RobotNet10.Shared.Numbers.Quaternion
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"MapManagerApi": {
|
||||
"BaseUrl": "https://0.0.0.0:5001"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* MapLocalization: canvas drawing + view/events (gộp từ mapCanvas.js và mapLocalization.js).
|
||||
* Dùng ElementReference từ Blazor, không dùng element ID.
|
||||
*/
|
||||
|
||||
// ==================== Canvas: occupancy grid ====================
|
||||
|
||||
function drawCell(pixels, canvasWidth, canvasHeight, x, y, r, g, b, a) {
|
||||
if (x >= 0 && x < canvasWidth && y >= 0 && y < canvasHeight) {
|
||||
const pixelIndex = (y * canvasWidth + x) * 4;
|
||||
pixels[pixelIndex] = r;
|
||||
pixels[pixelIndex + 1] = g;
|
||||
pixels[pixelIndex + 2] = b;
|
||||
pixels[pixelIndex + 3] = a;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {Uint8Array|number[]|ArrayBuffer} knownCells - sparse: 5 bytes per cell [index_byte0..3, value]
|
||||
*/
|
||||
export function drawOccupancyGrid(canvas, width, height, knownCells) {
|
||||
if (!canvas || !knownCells || knownCells.length === 0) return;
|
||||
if (typeof canvas.getContext !== 'function') return;
|
||||
|
||||
let cellsArray;
|
||||
if (knownCells instanceof ArrayBuffer) {
|
||||
cellsArray = new Uint8Array(knownCells);
|
||||
} else if (knownCells instanceof Uint8Array) {
|
||||
cellsArray = knownCells;
|
||||
} else if (Array.isArray(knownCells)) {
|
||||
cellsArray = new Uint8Array(knownCells);
|
||||
} else if (typeof knownCells === 'string') {
|
||||
try {
|
||||
const binaryString = atob(knownCells);
|
||||
cellsArray = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) cellsArray[i] = binaryString.charCodeAt(i);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvasWidth = width;
|
||||
const canvasHeight = height;
|
||||
const sizeChanged = canvas.width !== canvasWidth || canvas.height !== canvasHeight;
|
||||
const previousKnownCells = canvas._previousKnownCells || new Set();
|
||||
const previousWidth = canvas._previousWidth || width;
|
||||
const previousHeight = canvas._previousHeight || height;
|
||||
|
||||
if (sizeChanged || previousWidth !== width || previousHeight !== height) {
|
||||
canvas.width = canvasWidth;
|
||||
canvas.height = canvasHeight;
|
||||
previousKnownCells.clear();
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
if (sizeChanged || previousWidth !== width || previousHeight !== height) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
const unknownA = 0;
|
||||
|
||||
const currentKnownCellsSet = new Set();
|
||||
for (let i = 0; i < cellsArray.length; i += 5) {
|
||||
const cellIndex = (cellsArray[i] | (cellsArray[i + 1] << 8) | (cellsArray[i + 2] << 16) | (cellsArray[i + 3] << 24)) >>> 0;
|
||||
currentKnownCellsSet.add(cellIndex);
|
||||
}
|
||||
|
||||
if (!sizeChanged && previousWidth === width && previousHeight === height) {
|
||||
previousKnownCells.forEach(cellIndex => {
|
||||
if (!currentKnownCellsSet.has(cellIndex)) {
|
||||
const gridX = cellIndex % width;
|
||||
const gridY = Math.floor(cellIndex / width);
|
||||
const canvasX = gridX;
|
||||
const canvasY = gridY; // No Y-flip: ROS convention (gridY=0 at bottom) → CSS scale(1,-1) handles display flip
|
||||
if (canvasX >= 0 && canvasX < width && canvasY >= 0 && canvasY < height) {
|
||||
drawCell(pixels, canvas.width, canvas.height, canvasX, canvasY, 0, 0, 0, unknownA);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < cellsArray.length; i += 5) {
|
||||
if (i + 4 >= cellsArray.length) break;
|
||||
const cellIndex = (cellsArray[i] | (cellsArray[i + 1] << 8) | (cellsArray[i + 2] << 16) | (cellsArray[i + 3] << 24)) >>> 0;
|
||||
const cellValue = cellsArray[i + 4];
|
||||
if (cellValue < 0 || cellValue > 100) continue;
|
||||
|
||||
const gridX = cellIndex % width;
|
||||
const gridY = Math.floor(cellIndex / width);
|
||||
const canvasX = gridX;
|
||||
const canvasY = gridY; // No Y-flip: ROS convention (gridY=0 at bottom) → CSS scale(1,-1) handles display flip
|
||||
if (canvasX < 0 || canvasX >= width || canvasY < 0 || canvasY >= height) continue;
|
||||
|
||||
let intensity;
|
||||
if (cellValue === 0) {
|
||||
intensity = 254;
|
||||
} else if (cellValue >= 1 && cellValue <= 100) {
|
||||
const v = 254.0 - (cellValue * 254.0 / 100.0);
|
||||
intensity = Math.max(0, Math.min(254, Math.round(v)));
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
drawCell(pixels, canvas.width, canvas.height, canvasX, canvasY, intensity, intensity, intensity, 255);
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
canvas._previousKnownCells = currentKnownCellsSet;
|
||||
canvas._previousWidth = width;
|
||||
canvas._previousHeight = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
*/
|
||||
export function clearCanvas(canvas) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
canvas._previousKnownCells = new Set();
|
||||
canvas._previousWidth = 0;
|
||||
canvas._previousHeight = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
*/
|
||||
export function setCanvasSize(canvas, width, height) {
|
||||
if (!canvas) return;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @returns {number[]} [width, height]
|
||||
*/
|
||||
export function getElementSize(element) {
|
||||
if (!element) return [800, 600];
|
||||
try {
|
||||
if (typeof element.getBoundingClientRect === 'function') {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return [rect.width || 0, rect.height || 0];
|
||||
}
|
||||
} catch (e) {}
|
||||
return [800, 600];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number[][]} points - [[x,y], ...]
|
||||
* @param {string} color
|
||||
* @param {number} radius
|
||||
*/
|
||||
export function drawPointsOnCanvas(canvas, points, color, radius) {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
/*ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = color;
|
||||
for (const point of points) {
|
||||
if (point.length >= 2) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(point[0], point[1], radius, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
}*/
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (const point of points) {
|
||||
if (point[0] < 0 || point[0] >= canvas.width || point[1] < 0 || point[1] >= canvas.height) continue;
|
||||
const index = (Math.floor(point[1]) * canvas.width + Math.floor(point[0])) * 4;
|
||||
pixels[index] = 255;
|
||||
pixels[index + 1] = 0;
|
||||
pixels[index + 2] = 0;
|
||||
pixels[index + 3] = 255;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
|
||||
// ==================== View / SVG / Rect ====================
|
||||
|
||||
/**
|
||||
* @param {SVGElement} svg
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
* @param {number} originX
|
||||
* @param {number} originY
|
||||
*/
|
||||
export function setSvgConfig(svg, width, height, originX, originY) {
|
||||
if (!svg) return;
|
||||
svg.setAttribute('width', width.toString());
|
||||
svg.setAttribute('height', height.toString());
|
||||
svg.setAttribute('viewBox', `${originX} ${originY} ${width} ${height}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SVGElement} svg
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
*/
|
||||
export function setSvgRect(svg, width, height) {
|
||||
if (!svg) return;
|
||||
svg.setAttribute('width', width.toString());
|
||||
svg.setAttribute('height', height.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLCanvasElement} canvas
|
||||
* @param {number} width
|
||||
* @param {number} height
|
||||
*/
|
||||
export function setCanvasRect(canvas, width, height) {
|
||||
if (!canvas) return;
|
||||
canvas.style.width = width + 'px';
|
||||
canvas.style.height = height + 'px';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} top
|
||||
* @param {number} left
|
||||
* @param {number} [width]
|
||||
* @param {number} [height]
|
||||
*/
|
||||
export function setMapMovement(element, top, left, width = null, height = null) {
|
||||
if (!element) return;
|
||||
element.style.top = top + 'px';
|
||||
element.style.left = left + 'px';
|
||||
if (width !== null && height !== null) {
|
||||
element.style.width = width + 'px';
|
||||
element.style.height = height + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SVGPolylineElement} polylineElement
|
||||
* @param {string} points - "x1,y1 x2,y2 ..."
|
||||
*/
|
||||
export function setPolylinePointsOnly(polylineElement, points) {
|
||||
if (!polylineElement) return;
|
||||
polylineElement.setAttribute('points', points || '');
|
||||
}
|
||||
|
||||
// ==================== Events (container, wheel, mouse) ====================
|
||||
|
||||
/**
|
||||
* @param {any} dotnetRef
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} funcName
|
||||
*/
|
||||
export async function updateContainerRect(dotnetRef, element, funcName) {
|
||||
if (!element || !dotnetRef) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
await dotnetRef.invokeMethodAsync(funcName, rect.x, rect.y, rect.width, rect.height, rect.top, rect.right, rect.bottom, rect.left);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} dotnetRef
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} funcName
|
||||
*/
|
||||
export function registerResizeObserver(dotnetRef, element, funcName) {
|
||||
if (!element || !dotnetRef) return;
|
||||
const resizeObserver = new ResizeObserver(async () => {
|
||||
await updateContainerRect(dotnetRef, element, funcName);
|
||||
});
|
||||
resizeObserver.observe(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} dotnetRef
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} funcName
|
||||
*/
|
||||
export function addMouseWheelEventListener(dotnetRef, element, funcName) {
|
||||
if (!element || !dotnetRef) return;
|
||||
element.addEventListener('wheel', async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
await dotnetRef.invokeMethodAsync(funcName, ev.deltaY, ev.clientX, ev.clientY);
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} dotnetRef
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} funcName
|
||||
*/
|
||||
export function addMouseMoveEventListener(dotnetRef, element, funcName) {
|
||||
if (!element || !dotnetRef) return;
|
||||
element.addEventListener('mousemove', async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
await dotnetRef.invokeMethodAsync(funcName, ev.clientX, ev.clientY, ev.buttons, ev.ctrlKey, ev.movementX, ev.movementY);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} dotnetRef
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} funcName
|
||||
*/
|
||||
export function addMouseDownEventListener(dotnetRef, element, funcName) {
|
||||
if (!element || !dotnetRef) return;
|
||||
element.addEventListener('mousedown', async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
await dotnetRef.invokeMethodAsync(funcName, ev.button, ev.altKey, ev.ctrlKey, ev.shiftKey);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} dotnetRef
|
||||
* @param {HTMLElement} element
|
||||
* @param {string} funcName
|
||||
*/
|
||||
export function addMouseUpEventListener(dotnetRef, element, funcName) {
|
||||
if (!element || !dotnetRef) return;
|
||||
element.addEventListener('mouseup', async (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
await dotnetRef.invokeMethodAsync(funcName, ev.button, ev.altKey, ev.ctrlKey, ev.shiftKey);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user