Initial commit

This commit is contained in:
2026-07-03 16:31:37 +07:00
commit 899c7c637d
1939 changed files with 641750 additions and 0 deletions

View File

@@ -0,0 +1,92 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared.Sensor;
using System.Linq;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho Battery device - cung cấp real-time battery data
/// </summary>
[Authorize]
public class BatteryHub(IDeviceProvider deviceProvider) : Hub
{
/// <summary>
/// Lấy thông tin device (DeviceName) theo device ID
/// </summary>
public async Task<DeviceInfoDto?> GetDeviceInfo(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IBattery)
{
return null;
}
return new DeviceInfoDto
{
DeviceId = device.DeviceId,
DeviceName = device.DeviceName
};
}
/// <summary>
/// Lấy thông tin battery theo device ID
/// </summary>
public async Task<BatteryState?> GetBatteryData(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IBattery battery)
{
return null;
}
var data = await battery.ReadBatteryStateAsync();
return SanitizeBatteryState(data);
}
/// <summary>
/// Sanitize BatteryState to ensure all double values are valid for JSON serialization
/// Replaces NaN and Infinity with 0.0
/// </summary>
private static BatteryState SanitizeBatteryState(BatteryState state)
{
var sanitized = state;
// Sanitize single double values
sanitized.Voltage = SanitizeFloat(state.Voltage);
sanitized.Current = SanitizeFloat(state.Current);
sanitized.Charge = SanitizeFloat(state.Charge);
sanitized.Capacity = SanitizeFloat(state.Capacity);
sanitized.DesignCapacity = SanitizeFloat(state.DesignCapacity);
sanitized.Percentage = SanitizeFloat(state.Percentage);
// Sanitize double arrays
if (state.CellVoltage != null && state.CellVoltage.Length > 0)
{
sanitized.CellVoltage = state.CellVoltage.Select(SanitizeFloat).ToArray();
}
if (state.CellTemperature != null && state.CellTemperature.Length > 0)
{
sanitized.CellTemperature = state.CellTemperature.Select(SanitizeFloat).ToArray();
}
return sanitized;
}
/// <summary>
/// Sanitize a double value: replace NaN and Infinity with 0.0
/// </summary>
private static double SanitizeFloat(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
{
return 0.0;
}
return value;
}
}

View File

@@ -0,0 +1,58 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared.Geometry;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho Camera QR device - cung cấp real-time QR detection data
/// </summary>
[Authorize]
public class CameraQrHub(IDeviceProvider deviceProvider) : Hub
{
/// <summary>
/// Lấy thông tin device (DeviceName) theo device ID
/// </summary>
public async Task<DeviceInfoDto?> GetDeviceInfo(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICameraQr)
{
return null;
}
return new DeviceInfoDto
{
DeviceId = device.DeviceId,
DeviceName = device.DeviceName
};
}
/// <summary>
/// Lấy dữ liệu Camera QR theo device ID
/// </summary>
public async Task<CameraQrDataDto?> GetCameraQrData(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICameraQr cameraQr)
{
return null;
}
return MapToDto(cameraQr);
}
/// <summary>
/// Map ICameraQr sang CameraQrDataDto
/// </summary>
private static CameraQrDataDto MapToDto(ICameraQr cameraQr)
{
return new CameraQrDataDto
{
IsConnected = cameraQr.IsConnected,
Codes = cameraQr.Codes,
};
}
}

View File

@@ -0,0 +1,556 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.CANOpen.CiA402.Enums;
using RobotNet10.CANOpen.CiA402.Models;
using RobotNet10.CANOpen.Exceptions;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho CiA402Servo device - cung cấp real-time servo data
/// </summary>
[Authorize]
public class CiA402ServoHub(IDeviceProvider deviceProvider) : Hub
{
/// <summary>
/// Lấy thông tin device (DeviceName) theo device ID
/// </summary>
public async Task<DeviceInfoDto?> GetDeviceInfo(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo)
{
return null;
}
return new DeviceInfoDto
{
DeviceId = device.DeviceId,
DeviceName = device.DeviceName
};
}
/// <summary>
/// Lấy dữ liệu servo theo device ID
/// </summary>
public async Task<CiA402ServoDataDto?> GetServoData(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
{
return null;
}
return await MapToDtoAsync(servo);
}
/// <summary>
/// Map ICiA402Servo sang CiA402ServoDataDto. Các lệnh đọc SDO (GetOperationMode, GetLatestErrorCode, GetProfile*) có thể timeout
/// khi bus bận hoặc drive không phản hồi — bắt exception và trả về dữ liệu từ cache + giá trị mặc định.
/// </summary>
private static async Task<CiA402ServoDataDto> MapToDtoAsync(ICiA402Servo servo)
{
var statusword = servo.CachedStatusword;
var driveState = statusword.GetState();
var position = servo.CachedPosition;
var velocity = servo.CachedVelocity;
var torque = servo.CachedTorque;
OperationMode operationMode = OperationMode.ProfilePosition;
try
{
operationMode = await servo.GetOperationModeAsync();
}
catch (CanOpenTimeoutException)
{
// SDO timeout (vd. 0x6061) — dùng mặc định, tránh fail hub
}
catch (Exception)
{
// Lỗi khác khi đọc mode — giữ mặc định
}
ushort errorCode = 0;
try
{
errorCode = await servo.GetLatestErrorCodeAsync();
}
catch (CanOpenTimeoutException)
{
// SDO timeout — giữ 0
}
catch (Exception)
{
// Bỏ qua
}
uint profileSpeed = 0, profileAcceleration = 0, profileDeceleration = 0;
try
{
profileSpeed = await servo.GetProfileSpeedAsync();
profileAcceleration = await servo.GetProfileAccelerationAsync();
profileDeceleration = await servo.GetProfileDecelerationAsync();
}
catch (CanOpenTimeoutException)
{
// Timeout — giữ 0
}
catch (Exception)
{
// Nếu drive chưa sẵn sàng đọc, giữ 0
}
return new CiA402ServoDataDto
{
Statusword = statusword.Value,
DriveState = driveState.ToString(),
OperationMode = operationMode.ToString(),
Position = position,
Velocity = velocity,
Torque = torque,
ErrorCode = errorCode,
IsConnected = servo is not DeviceBase deviceBase || deviceBase.IsConnected,
ProfileSpeed = profileSpeed,
ProfileAcceleration = profileAcceleration,
ProfileDeceleration = profileDeceleration
};
}
// State Machine Control Methods
public async Task EnableOperationAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.EnableOperationAsync();
}
public async Task DisableOperationAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.DisableOperationAsync();
}
public async Task QuickStopAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.QuickStopAsync();
}
public async Task FaultResetAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.FaultResetAsync();
}
public async Task ShutdownAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.ShutdownAsync();
}
public async Task SwitchOnAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SwitchOnAsync();
}
public async Task EnableAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.EnableAsync();
}
public async Task DisableAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.DisableAsync();
}
// Operation Mode
public async Task SetOperationModeAsync(string deviceId, string modeString)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
if (!Enum.TryParse<OperationMode>(modeString, ignoreCase: true, out var mode))
throw new ArgumentException($"Invalid operation mode: {modeString}");
await servo.SetOperationModeAsync(mode);
}
// Position Control
public async Task SetTargetPositionAsync(string deviceId, int position)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetTargetPositionAsync(position);
}
public async Task MoveToPositionAsync(string deviceId, int position, uint velocity = 1000, uint acceleration = 5000, uint deceleration = 5000)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.MoveToPositionAsync(position, velocity, acceleration, deceleration);
}
// Velocity Control
public async Task SetTargetVelocityAsync(string deviceId, int velocity)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetTargetVelocityAsync(velocity);
}
public async Task TargetVelocityAsync(string deviceId, int targetVelocity, uint acceleration = 5000, uint deceleration = 5000)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.TargetVelocityAsync(targetVelocity, acceleration, deceleration);
}
public async Task ProfileVelocityAsync(string deviceId, int targetVelocity, uint acceleration = 5000, uint deceleration = 5000)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.ProfileVelocityAsync(targetVelocity, acceleration, deceleration);
}
// Torque Control
public async Task SetTargetTorqueAsync(string deviceId, short torque)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetTargetTorqueAsync(torque);
}
public async Task RunTorqueAsync(string deviceId, short torque)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.RunTorqueAsync(torque);
}
// Profile Settings
public async Task SetProfileAccelerationAsync(string deviceId, uint acceleration)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetProfileAccelerationAsync(acceleration);
}
public async Task SetProfileDecelerationAsync(string deviceId, uint deceleration)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetProfileDecelerationAsync(deceleration);
}
public async Task SetProfileVelocityAsync(string deviceId, uint velocity)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetProfileVelocityAsync(velocity);
}
public async Task SetProfileSpeedAsync(string deviceId, uint velocity)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetProfileSpeedAsync(velocity);
}
/// <summary>
/// Ghi cùng lúc cả 3 profile (speed, acceleration, deceleration) xuống drive qua SDO.
/// </summary>
public async Task SetProfileSettingsAsync(string deviceId, uint profileSpeed, uint profileAcceleration, uint profileDeceleration)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetProfileSpeedAsync(profileSpeed);
await servo.SetProfileAccelerationAsync(profileAcceleration);
await servo.SetProfileDecelerationAsync(profileDeceleration);
}
// Homing
public async Task SetHomingMethodAsync(string deviceId, byte method)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetHomingMethodAsync(method);
}
public async Task SetHomingSpeedAsync(string deviceId, int speed)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetHomingSpeedAsync(speed);
}
public async Task SetHomingOffsetAsync(string deviceId, int offset)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.SetHomingOffsetAsync(offset);
}
/// <summary>
/// Đọc lại homing method từ drive để kiểm tra đã ghi xuống chưa.
/// </summary>
public async Task<byte> GetHomingMethodAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetHomingMethodAsync();
}
/// <summary>
/// Đọc lại homing speed từ drive để kiểm tra đã ghi xuống chưa.
/// </summary>
public async Task<int> GetHomingSpeedAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetHomingSpeedAsync();
}
/// <summary>
/// Đọc lại homing offset từ drive để kiểm tra đã ghi xuống chưa.
/// </summary>
public async Task<int> GetHomingOffsetAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetHomingOffsetAsync();
}
public async Task StartHomingAsync(string deviceId, byte method, int speed)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.StartHomingAsync(method, speed);
}
// Position Control - Additional Methods
public async Task StartPositionMoveAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.StartPositionMoveAsync();
}
public async Task WaitUntilAtTargetAsync(string deviceId, int tolerance = 100, bool useStatusword = true, int checkIntervalMs = 10)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
await servo.WaitUntilAtTargetAsync(tolerance, useStatusword, checkIntervalMs);
}
// Error and Status Information
public async Task<bool> IsInFaultStateAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.IsInFaultStateAsync();
}
public async Task<byte> GetErrorRegisterAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetErrorRegisterAsync();
}
public async Task<ushort[]> GetErrorHistoryAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetErrorHistoryAsync();
}
public async Task<ushort> GetLatestErrorCodeAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetLatestErrorCodeAsync();
}
public async Task<bool> TryFaultResetAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.TryFaultResetAsync();
}
public async Task<bool> IsEnabledAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.IsEnabledAsync();
}
public async Task<bool> IsReadyAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.IsReadyAsync();
}
// Statusword & Controlword
public async Task<ushort> GetStatuswordAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
var statusword = await servo.GetStatuswordAsync();
return statusword.Value;
}
public async Task SetControlwordAsync(string deviceId, ushort controlwordValue)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
var controlword = new Controlword(controlwordValue);
await servo.SetControlwordAsync(controlword);
}
public async Task<string> GetStateAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
var state = await servo.GetStateAsync();
return state.ToString();
}
public async Task<string> GetOperationModeAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
var mode = await servo.GetOperationModeAsync();
return mode.ToString();
}
// Position, Velocity, Torque
public async Task<int> GetActualPositionAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetActualPositionAsync();
}
public async Task<int> GetActualVelocityAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetActualVelocityAsync();
}
public async Task<short> GetActualTorqueAsync(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return await servo.GetActualTorqueAsync();
}
// Target Position Checking
public Task<bool> IsAtTarget(string deviceId, int tolerance = 100, bool useStatusword = true)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ICiA402Servo servo)
throw new InvalidOperationException($"Device {deviceId} is not a CiA402Servo");
return Task.FromResult(servo.IsAtTarget(tolerance, useStatusword));
}
}

View File

@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho DeviceProvider - cung cấp real-time device information
/// </summary>
[Authorize]
public class DeviceHub(IDeviceProvider deviceProvider) : Hub
{
/// <summary>
/// Lấy tất cả devices
/// </summary>
public Task<DeviceDto[]> GetAllDevices()
{
var devices = deviceProvider.GetAllDevices();
return Task.FromResult(devices.Select(MapToDto).ToArray());
}
/// <summary>
/// Lấy device theo ID
/// </summary>
public Task<DeviceDto?> GetDevice(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
return Task.FromResult(device != null ? MapToDto(device) : null);
}
/// <summary>
/// Lấy devices theo type
/// </summary>
public Task<DeviceDto[]> GetDevicesByType(DeviceType deviceType)
{
var serverType = deviceType;
var devices = deviceProvider.GetDevicesByType(serverType);
return Task.FromResult(devices.Select(MapToDto).ToArray());
}
/// <summary>
/// Lấy số lượng devices
/// </summary>
public Task<int> GetDeviceCount()
{
return Task.FromResult(deviceProvider.GetDeviceCount());
}
/// <summary>
/// Map DeviceBase sang DeviceDto
/// </summary>
private static DeviceDto MapToDto(DeviceBase device)
{
return new DeviceDto
{
DeviceId = device.DeviceId,
DeviceName = device.DeviceName,
DeviceType = device.Type,
Description = device.Description,
Status = device.Status,
IsConnected = device.IsConnected,
LastUpdateTime = device.LastUpdateStateTime,
LastConnectedTime = device.LastConnectedTime,
LastDisconnectedTime = device.LastDisconnectedTime,
LastError = device.LastError?.Message,
ReconnectAttemptCount = device.ReconnectAttemptCount,
AutoReconnectEnabled = device.AutoReconnectEnabled,
ReconnectDelayMs = device.ReconnectDelayMs,
MaxReconnectAttempts = device.MaxReconnectAttempts,
PropertyDescriptions = device.PropertyDescriptions.Select(MapPropertyDescription).ToList(),
Properties = device.Properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value)
};
}
/// <summary>
/// Map PropertyDescription sang PropertyDescriptionDto
/// </summary>
private static PropertyDescription MapPropertyDescription(PropertyDescription prop)
{
return new PropertyDescription
{
Key = prop.Key,
DisplayName = prop.DisplayName,
Description = prop.Description,
DataType = prop.DataType,
Unit = prop.Unit,
DefaultValue = prop.DefaultValue,
IsReadOnly = prop.IsReadOnly,
DisplayOrder = prop.DisplayOrder,
Category = prop.Category,
Format = prop.Format
};
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// HubContext để broadcast device updates từ DeviceProvider
/// </summary>
public class DeviceHubContext(IHubContext<DeviceHub> hubContext)
{
/// <summary>
/// Broadcast device update đến tất cả clients
/// </summary>
public async Task BroadcastDeviceUpdate(DeviceBase device)
{
var update = new DeviceUpdateDto
{
DeviceId = device.DeviceId,
Status = device.Status,
Properties = device.Properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
LastError = device.LastError?.Message,
LastUpdateTime = device.LastUpdateStateTime,
LastConnectedTime = device.LastConnectedTime,
LastDisconnectedTime = device.LastDisconnectedTime,
ReconnectAttemptCount = device.ReconnectAttemptCount
};
await hubContext.Clients.All.SendAsync("DeviceUpdated", update);
}
/// <summary>
/// Broadcast device status change
/// </summary>
public async Task BroadcastDeviceStatusChanged(string deviceId, DeviceStatus status)
{
await hubContext.Clients.All.SendAsync("DeviceStatusChanged", deviceId, status);
}
}

View File

@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.Shared.Geometry;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared.Sensor;
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho IMU device - cung cấp real-time IMU data
/// </summary>
[Authorize]
public class InertialMeasurementUnitHub(IDeviceProvider deviceProvider) : Hub
{
/// <summary>
/// Lấy thông tin device (DeviceName) theo device ID
/// </summary>
public async Task<DeviceInfoDto?> GetDeviceInfo(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IInertialMeasurementUnit)
{
return null;
}
return new DeviceInfoDto
{
DeviceId = device.DeviceId,
DeviceName = device.DeviceName
};
}
/// <summary>
/// Lấy thông tin IMU theo device ID
/// </summary>
public async Task<Imu?> GetImuData(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IInertialMeasurementUnit imu)
{
return null;
}
return await imu.ReadAllDataAsync();
}
/// <summary>
/// Đọc tất cả dữ liệu IMU
/// </summary>
public async Task<Imu?> ReadAllData(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IInertialMeasurementUnit imu)
{
return null;
}
return await imu.ReadAllDataAsync();
}
/// <summary>
/// Lấy dictionary properties hiện tại của device (để hiển thị trên UI)
/// </summary>
public Task<Dictionary<string, string>?> GetDeviceProperties(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IInertialMeasurementUnit)
{
return Task.FromResult<Dictionary<string, string>?>(null);
}
var props = device.Properties.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
return Task.FromResult<Dictionary<string, string>?>(props);
}
/// <summary>
/// Lấy danh sách mô tả properties của device (để hiển thị trên UI)
/// </summary>
public Task<List<PropertyDescription>?> GetDevicePropertyDescriptions(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IInertialMeasurementUnit)
{
return Task.FromResult<List<PropertyDescription>?>(null);
}
var list = device.PropertyDescriptions
.Select(p => new PropertyDescription
{
Key = p.Key,
DisplayName = p.DisplayName,
Description = p.Description,
DataType = p.DataType,
Unit = p.Unit,
DefaultValue = p.DefaultValue,
IsReadOnly = p.IsReadOnly,
DisplayOrder = p.DisplayOrder,
Category = p.Category,
Format = p.Format
})
.ToList();
return Task.FromResult<List<PropertyDescription>?>(list);
}
}

View File

@@ -0,0 +1,145 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho Lidar device - cung cấp real-time lidar scan data
/// </summary>
[Authorize]
public class LidarHub(IDeviceProvider deviceProvider) : Hub
{
/// <summary>
/// Độ phân giải mới: 1 độ (1 tia/độ) tính bằng radian
/// </summary>
private const double TARGET_ANGLE_INCREMENT_RAD = (Math.PI / 180.0); // 1 độ = π/180 radian
/// <summary>
/// Lấy thông tin device (DeviceName) theo device ID
/// </summary>
public async Task<DeviceInfoDto?> GetDeviceInfo(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ILidar)
{
return null;
}
return new DeviceInfoDto
{
DeviceId = device.DeviceId,
DeviceName = device.DeviceName
};
}
/// <summary>
/// Lấy thông tin lidar scan data theo device ID
/// Trả về LaserScan với độ phân giải mới 1 độ (1 tia/độ)
/// </summary>
public async Task<LaserScan?> GetLidarData(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not ILidar lidar || lidar.CurrentMeasurementData == null)
{
return null;
}
var originalScan = lidar.CurrentMeasurementData.Value;
// Tạo LaserScan mới với độ phân giải 1°
var newScan = new LaserScan
{
Header = originalScan.Header,
AngleMin = originalScan.AngleMin,
AngleMax = originalScan.AngleMax,
AngleIncrement = TARGET_ANGLE_INCREMENT_RAD, // 1° spacing (π/180 rad)
TimeIncrement = originalScan.TimeIncrement,
ScanTime = originalScan.ScanTime,
RangeMin = originalScan.RangeMin,
RangeMax = originalScan.RangeMax
};
// Tính số điểm mới với độ phân giải 1 độ
var angleSpan = originalScan.AngleMax - originalScan.AngleMin;
var newPointCount = (int)Math.Round(angleSpan / TARGET_ANGLE_INCREMENT_RAD) + 1;
newScan.Ranges = new double[newPointCount];
newScan.Intensities = new double[newPointCount];
// Tính toán lại Ranges và Intensities với độ phân giải mới
for (int i = 0; i < newPointCount; i++)
{
// Tính góc bắt đầu và kết thúc cho bin hiện tại
var targetAngleStart = originalScan.AngleMin + i * TARGET_ANGLE_INCREMENT_RAD;
var targetAngleEnd = Math.Min(
originalScan.AngleMin + (i + 1) * TARGET_ANGLE_INCREMENT_RAD,
originalScan.AngleMax
);
// Tìm các index trong khoảng [targetAngleStart, targetAngleEnd]
var indexStart = (targetAngleStart - originalScan.AngleMin) / originalScan.AngleIncrement;
var indexEnd = (targetAngleEnd - originalScan.AngleMin) / originalScan.AngleIncrement;
var index0 = (int)Math.Floor(indexStart);
var index1 = (int)Math.Ceiling(indexEnd);
// Clamp indices to valid range
index0 = Math.Max(0, Math.Min(index0, originalScan.Ranges.Length - 1));
index1 = Math.Max(0, Math.Min(index1, originalScan.Ranges.Length - 1));
// Tính trung bình các điểm có intensity > 0 trong khoảng [index0, index1]
double sumRange = 0;
double sumIntensity = 0;
int validPointCount = 0;
for (int idx = index0; idx <= index1; idx++)
{
// Kiểm tra intensity để xác định điểm hợp lệ
bool hasValidIntensity = originalScan.Intensities != null &&
idx < originalScan.Intensities.Length &&
originalScan.Intensities[idx] > 0;
if (hasValidIntensity)
{
var range = originalScan.Ranges[idx];
// Chỉ lấy các điểm không phải NaN/Infinity
if (!double.IsNaN(range) && !double.IsInfinity(range))
{
sumRange += range;
sumIntensity += originalScan.Intensities![idx];
validPointCount++;
}
}
}
// Tính giá trị trung bình hoặc set -1 nếu không có điểm hợp lệ
if (validPointCount > 0)
{
newScan.Ranges[i] = sumRange / validPointCount;
newScan.Intensities[i] = sumIntensity / validPointCount;
}
else
{
newScan.Ranges[i] = -1.0; // No valid data
newScan.Intensities[i] = 0.0;
}
}
// Final safety pass: ensure no NaN/Infinity values remain
// This should ideally never catch anything if the logic above is correct
for (int i = 0; i < newScan.Ranges.Length; i++)
{
if (double.IsNaN(newScan.Ranges[i]) || double.IsInfinity(newScan.Ranges[i]))
{
newScan.Ranges[i] = -1.0;
}
}
return newScan;
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Detection;
using RobotNet10.Shared;
using RobotNet10.Shared.Detection;
using RobotNet10.Shared.Geometry;
namespace RobotNet10.RobotApp.Hubs;
[Authorize]
public class MarkerDetectorHub(IMarkerDetector markerDetector, ILogger<MarkerDetectorHub> logger) : Hub
{
private const string SessionKey = "DetectSessionId";
public async Task<MessageResult<Guid>> CreateSession(MarkersSearchRequest request)
{
try
{
// If this client already has a session, dispose it first
if (Context.Items.TryGetValue(SessionKey, out var existing) && existing is Guid existingSessionId)
{
var existingSession = markerDetector.GetSession(existingSessionId);
existingSession?.Dispose();
Context.Items.Remove(SessionKey);
logger.LogInformation("Disposed existing session {SessionId} for client {ConnectionId}", existingSessionId, Context.ConnectionId);
}
request.Yaw = Math.PI * request.Yaw / 180.0;
var session = await markerDetector.CreateSessionAsync(request);
Context.Items[SessionKey] = session.SessionId;
logger.LogInformation("Created session {SessionId} for client {ConnectionId}", session.SessionId, Context.ConnectionId);
return new MessageResult<Guid>(true, session.SessionId);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to create detect session for client {ConnectionId}", Context.ConnectionId);
return new MessageResult<Guid>(false, Message: ex.Message);
}
}
public MessageResult<Pose> GetGoal(Guid sessionId)
{
var session = markerDetector.GetSession(sessionId);
if (session == null)
return new(false, Message: $"Session '{sessionId}' not found.");
if (session.Goal.HasValue)
{
return new(true, session.Goal.Value.Pose);
}
else
{
return new(false);
}
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (Context.Items.TryGetValue(SessionKey, out var existing) && existing is Guid sessionId)
{
var session = markerDetector.GetSession(sessionId);
session?.Dispose();
logger.LogInformation("Disposed session {SessionId} on disconnect of client {ConnectionId}", sessionId, Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
}

View File

@@ -0,0 +1,241 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho ModbusTCP device - cung cấp real-time Modbus data
/// </summary>
[Authorize]
public class ModbusTcpHub(IDeviceProvider deviceProvider) : Hub
{
/// <summary>
/// Lấy thông tin device (DeviceName) theo device ID
/// </summary>
public async Task<DeviceInfoDto?> GetDeviceInfo(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IModbusTcpDevice)
{
return null;
}
return new DeviceInfoDto
{
DeviceId = device.DeviceId,
DeviceName = device.DeviceName
};
}
/// <summary>
/// Lấy tất cả dữ liệu Modbus theo device ID
/// </summary>
public async Task<ModbusTcpData?> GetModbusData(string deviceId)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IModbusTcpDevice modbusDevice)
{
return null;
}
return MapToDto(modbusDevice);
}
/// <summary>
/// Ghi một coil theo device ID
/// </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> WriteCoil(string deviceId, ushort address, bool value)
{
var device = deviceProvider.GetDevice(deviceId);
if (device is not IModbusTcpDevice modbusDevice)
{
return false;
}
try
{
await modbusDevice.WriteCoilAsync(address, value);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Map IModbusTcpDevice sang ModbusTcpDataDto
/// </summary>
private static ModbusTcpData MapToDto(IModbusTcpDevice modbusDevice)
{
var dto = new ModbusTcpData
{
IpAddress = modbusDevice.IpAddress,
Port = modbusDevice.Port,
SlaveId = modbusDevice.SlaveId,
IsConnected = modbusDevice.IsConnected
};
var holdingRegisters = new List<ModbusRangeData>();
// Lấy Holding Registers
foreach (var range in modbusDevice.HoldingRegisterRanges)
{
try
{
var values = modbusDevice.ReadHoldingRegisters(range.StartAddress, range.Quantity);
var rangeData = new ModbusRangeData
{
StartAddress = range.StartAddress,
Quantity = range.Quantity,
Name = range.Name,
ChildrenNames = range.ChildrenNames == null ? [] : [.. range.ChildrenNames]
};
var modbusValues = new List<ModbusValue>();
for (ushort i = 0; i < values.Length; i++)
{
var address = (ushort)(range.StartAddress + i);
var childName = (range.ChildrenNames != null && i < range.ChildrenNames.Length) ? range.ChildrenNames[i] : "";
modbusValues.Add(new ModbusValue
{
Address = address,
Index = i,
Value = values[i],
Name = childName
});
}
rangeData.Values = [.. modbusValues];
holdingRegisters.Add(rangeData);
}
catch
{
// Skip range nếu không đọc được
}
}
dto.HoldingRegisters = [.. holdingRegisters];
var inputRegisters = new List<ModbusRangeData>();
// Lấy Input Registers
foreach (var range in modbusDevice.InputRegisterRanges)
{
try
{
var values = modbusDevice.ReadInputRegisters(range.StartAddress, range.Quantity);
var rangeData = new ModbusRangeData
{
StartAddress = range.StartAddress,
Quantity = range.Quantity,
Name = range.Name,
ChildrenNames = range.ChildrenNames == null ? [] : [.. range.ChildrenNames]
};
var modbusValues = new List<ModbusValue>();
for (ushort i = 0; i < values.Length; i++)
{
var address = (ushort)(range.StartAddress + i);
var childName = (range.ChildrenNames != null && i < range.ChildrenNames.Length) ? range.ChildrenNames[i] : "";
modbusValues.Add(new ModbusValue
{
Address = address,
Index = i,
Value = values[i],
Name = childName
});
}
rangeData.Values = [.. modbusValues];
inputRegisters.Add(rangeData);
}
catch
{
// Skip range nếu không đọc được
}
}
dto.InputRegisters = [.. inputRegisters];
// Lấy Coils
var coils = new List<ModbusRangeData>();
foreach (var range in modbusDevice.CoilRanges)
{
try
{
var values = modbusDevice.ReadCoils(range.StartAddress, range.Quantity);
var rangeData = new ModbusRangeData
{
StartAddress = range.StartAddress,
Quantity = range.Quantity,
Name = range.Name,
ChildrenNames = range.ChildrenNames ?? []
};
var modbusValues = new List<ModbusBoolValue>();
for (ushort i = 0; i < values.Length; i++)
{
var address = (ushort)(range.StartAddress + i);
var childName = (range.ChildrenNames != null && i < range.ChildrenNames.Length) ? range.ChildrenNames[i] : "";
modbusValues.Add(new ModbusBoolValue
{
Address = address,
Index = i,
Value = values[i],
Name = childName
});
}
rangeData.BoolValues = [.. modbusValues];
coils.Add(rangeData);
}
catch
{
// Skip range nếu không đọc được
}
}
dto.Coils = [.. coils];
// Lấy Discrete Inputs
var inputs = new List<ModbusRangeData>();
foreach (var range in modbusDevice.DiscreteInputRanges)
{
try
{
var values = modbusDevice.ReadDiscreteInputs(range.StartAddress, range.Quantity);
var rangeData = new ModbusRangeData
{
StartAddress = range.StartAddress,
Quantity = range.Quantity,
Name = range.Name,
ChildrenNames = range.ChildrenNames ?? []
};
var modbusValues = new List<ModbusBoolValue>();
for (ushort i = 0; i < values.Length; i++)
{
var address = (ushort)(range.StartAddress + i);
var childName = (range.ChildrenNames != null && i < range.ChildrenNames.Length) ? range.ChildrenNames[i] : "";
modbusValues.Add(new ModbusBoolValue
{
Address = address,
Index = i,
Value = values[i],
Name = childName
});
}
rangeData.BoolValues = [.. modbusValues];
inputs.Add(rangeData);
}
catch
{
// Skip range nếu không đọc được
}
}
dto.DiscreteInputs = [.. inputs];
return dto;
}
}

View File

@@ -0,0 +1,230 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Motion;
using RobotNet10.RobotApp.Client.Shared.Modules;
using RobotNet10.RobotApp.Motion;
using RobotNet10.RobotApp.Modules;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub thống nhất cho tất cả các chức năng Motion:
/// - ManualControl (điều khiển thủ công qua RF Handle)
/// - Odometry (ước lượng vị trí robot)
/// - LiftModule (điều khiển nâng hạ)
/// - RotationModule (điều khiển xoay)
/// </summary>
[AllowAnonymous]
public class MotionHub(
ManualControlService manualControlService,
IOdometryEstimator odometryEstimator,
ILiftModule liftModule,
IRotationModule rotationModule) : Hub
{
#region Manual Control
/// <summary>
/// Lấy trạng thái hiện tại của ManualControlService
/// </summary>
public Task<ManualControlStatusDto> GetStatus()
{
var status = manualControlService.CurrentRfHandleStatus;
var twist = manualControlService.CurrentTwist;
return Task.FromResult(new ManualControlStatusDto
{
State = manualControlService.State.ToString(),
IsEnabled = manualControlService.State == ManualControlState.Maintenance ||
manualControlService.State == ManualControlState.Override,
CurrentLinearVelocity = twist.Linear.X,
CurrentAngularVelocity = twist.Angular.Z,
RfHandleStatus = status != null ? new RfHandleStatusDto
{
Heartbeat = status.Heartbeat,
Ready = status.Ready,
Locked = status.Locked,
EStop = status.EStop,
Enable = status.Enable,
Speed = status.Speed,
Linear = status.Linear,
Angular = status.Angular,
Mode = status.Mode,
LastUpdateTime = status.LastUpdateTime
} : null
});
}
/// <summary>
/// Start manual control update loop
/// </summary>
public Task Enable()
{
manualControlService.Start();
return Task.CompletedTask;
}
/// <summary>
/// Stop manual control update loop
/// </summary>
public Task Disable()
{
manualControlService.Stop();
return Task.CompletedTask;
}
#endregion
#region Odometry
/// <summary>
/// Lấy pose hiện tại của robot (odometry)
/// </summary>
public OdometryDto GetCurrentPose()
{
var pose = odometryEstimator.CurrentPose;
var frequency = odometryEstimator.UpdateFrequency;
return pose.ToOdometryDto(frequency);
}
/// <summary>
/// Reset odometry về vị trí ban đầu
/// </summary>
public void ResetOdometry() => odometryEstimator.ResetOdometry();
/// <summary>
/// Reset odometry về một pose cụ thể
/// </summary>
public void ResetOdometryPose(OdometryDto dto)
{
if (dto != null)
{
odometryEstimator.ResetOdometry(dto.ToPose());
}
}
#endregion
#region Lift Module
/// <summary>
/// Lấy trạng thái hiện tại của LiftModule
/// </summary>
public async Task<LiftModuleStatusDto> GetLiftStatus()
{
var position = await liftModule.GetCurrentPositionAsync();
return new LiftModuleStatusDto
{
State = liftModule.State.ToString(),
IsReady = liftModule.IsReady,
CurrentPosition = position
};
}
/// <summary>
/// Lấy vị trí hiện tại của Lift
/// </summary>
public async Task<int> GetLiftCurrentPosition()
{
return await liftModule.GetCurrentPositionAsync();
}
/// <summary>
/// Nâng lên
/// </summary>
public async Task LiftUp()
{
await liftModule.LiftUpAsync();
}
/// <summary>
/// Hạ xuống
/// </summary>
public async Task LiftDown()
{
await liftModule.LiftDownAsync();
}
/// <summary>
/// Dừng nâng/hạ (khi đang di chuyển)
/// </summary>
public async Task LiftStop()
{
await liftModule.LiftStopAsync();
}
/// <summary>
/// Di chuyển đến vị trí cụ thể
/// </summary>
public async Task LiftToPosition(int position)
{
await liftModule.LiftToPositionAsync(position);
}
/// <summary>
/// Kiểm tra Lift module đã homed chưa
/// </summary>
public async Task<bool> IsLiftHomed()
{
return await liftModule.IsHomedAsync();
}
/// <summary>
/// Thực hiện homing thủ công cho LiftModule
/// </summary>
public async Task LiftHome()
{
await liftModule.HomeAsync();
}
#endregion
#region Rotation Module
/// <summary>
/// Lấy trạng thái hiện tại của RotationModule
/// </summary>
public async Task<RotationModuleStatusDto> GetRotationStatus()
{
var angle = await rotationModule.GetCurrentAngleAsync();
return new RotationModuleStatusDto
{
State = rotationModule.State.ToString(),
IsReady = rotationModule.IsReady,
CurrentAngle = angle
};
}
/// <summary>
/// Lấy góc hiện tại
/// </summary>
public async Task<double> GetRotationCurrentAngle()
{
return await rotationModule.GetCurrentAngleAsync();
}
/// <summary>
/// Xoay đến góc cụ thể (absolute)
/// </summary>
public async Task RotateToAngle(double angleDegrees)
{
await rotationModule.RotateToAngleAsync(angleDegrees);
}
/// <summary>
/// Xoay một góc offset (relative)
/// </summary>
public async Task RotateOffset(double angleOffsetDegrees)
{
await rotationModule.RotateOffsetAsync(angleOffsetDegrees);
}
/// <summary>
/// Kiểm tra Rotation module đã homed chưa
/// </summary>
public async Task<bool> IsRotationHomed()
{
return await rotationModule.IsHomedAsync();
}
#endregion
}

View File

@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Services.NavigationMonitor;
using RobotNet10.RobotApp.Shared.NavigationMonitor;
namespace RobotNet10.RobotApp.Hubs;
[Authorize]
public class NavigationMonitorHub(NavigationMonitorService monitorService) : Hub
{
public async Task Subscribe()
{
await Groups.AddToGroupAsync(Context.ConnectionId, "monitor");
}
public async Task Unsubscribe()
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, "monitor");
}
public void SetTelemetryEnabled(bool enabled)
{
monitorService.SetTelemetryEnabled(enabled);
}
public void SetSafetyStopEnabled(bool enabled)
{
monitorService.SetSafetyStopEnabled(enabled);
}
public void UpdateSafetyConfig(NavigationSafetyConfigDto config)
{
monitorService.UpdateSafetyConfig(config);
}
public void ReleaseSafetyStop()
{
monitorService.ReleaseSafetyStop();
}
public NavigationMonitorStateDto GetState()
{
return monitorService.GetState();
}
}

View File

@@ -0,0 +1,42 @@
using RobotNet10.RobotApp.Client.Shared.Motion;
using RobotNet10.Shared.Geometry;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// Extension methods for OdometryDto conversions (server-side only)
/// </summary>
public static class OdometryDtoExtensions
{
/// <summary>
/// Convert from Pose to OdometryDto (compatibility path when only raw pose is available).
/// </summary>
public static OdometryDto ToOdometryDto(this Pose pose, double updateFrequency, string frameId = "odom")
{
return new OdometryDto
{
Timestamp = DateTime.UtcNow,
FrameId = frameId,
PositionX = pose.Position.X,
PositionY = pose.Position.Y,
PositionZ = pose.Position.Z,
OrientationX = pose.Orientation.X,
OrientationY = pose.Orientation.Y,
OrientationZ = pose.Orientation.Z,
OrientationW = pose.Orientation.W,
UpdateFrequency = updateFrequency
};
}
/// <summary>
/// Convert OdometryDto to Pose (for Reset operations)
/// </summary>
public static Pose ToPose(this OdometryDto dto)
{
return new Pose
{
Position = new Point(dto.PositionX, dto.PositionY, dto.PositionZ),
Orientation = new Quaternion(dto.OrientationX, dto.OrientationY, dto.OrientationZ, dto.OrientationW)
};
}
}

View File

@@ -0,0 +1,53 @@
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Motion;
using RobotNet10.RobotApp.Motion;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho Odometry - hiển thị pose và velocity từ OdometryService (wheel encoder + IMU).
/// Cùng nguồn OdometryService.CurrentOdometry được XlocIntegrationService dùng để DispatchOdometry sang XLOC
/// (khi EnableRawOdometry bật) và NavigationIntegrationService dùng để dispatch sang navigation.
/// </summary>
public class OdometryHub(OdometryService odometryService) : Hub
{
/// <summary>
/// Lấy odometry hiện tại (pose + velocity) để hiển thị trên UI
/// </summary>
public Task<OdometryDto?> GetCurrentOdometry()
{
var odom = odometryService.CurrentOdometry;
var dto = MapToDto(odom);
return Task.FromResult<OdometryDto?>(dto);
}
private static OdometryDto MapToDto(Odometry odom)
{
var p = odom.Pose.Pose.Position;
var q = odom.Pose.Pose.Orientation;
var linear = odom.Twist.Twist.Linear;
var angular = odom.Twist.Twist.Angular;
return new OdometryDto
{
Timestamp = odom.Header.Stamp,
FrameId = odom.Header.FrameId ?? "odom",
ChildFrameId = odom.ChildFrameId ?? "base_link",
PositionX = p.X,
PositionY = p.Y,
PositionZ = p.Z,
OrientationX = q.X,
OrientationY = q.Y,
OrientationZ = q.Z,
OrientationW = q.W,
LinearVelocityX = linear.X,
LinearVelocityY = linear.Y,
LinearVelocityZ = linear.Z,
AngularVelocityX = angular.X,
AngularVelocityY = angular.Y,
AngularVelocityZ = angular.Z,
UpdateFrequency = 0 // Caller can compute if needed
};
}
}

View File

@@ -0,0 +1,60 @@
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Motion;
using RobotNet10.RobotApp.Motion;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// HubContext để broadcast odometry đến tất cả clients (realtime hiển thị odom)
/// </summary>
public class OdometryHubContext
{
private readonly IHubContext<OdometryHub> _hubContext;
private readonly OdometryService _odometryService;
public OdometryHubContext(IHubContext<OdometryHub> hubContext, OdometryService odometryService)
{
_hubContext = hubContext;
_odometryService = odometryService;
}
/// <summary>
/// Broadcast odometry hiện tại đến tất cả clients
/// </summary>
public async Task BroadcastOdometryAsync()
{
var odom = _odometryService.CurrentOdometry;
var dto = MapToDto(odom);
await _hubContext.Clients.All.SendAsync("ReceiveOdometry", dto);
}
private static OdometryDto MapToDto(Odometry odom)
{
var p = odom.Pose.Pose.Position;
var q = odom.Pose.Pose.Orientation;
var linear = odom.Twist.Twist.Linear;
var angular = odom.Twist.Twist.Angular;
return new OdometryDto
{
Timestamp = odom.Header.Stamp,
FrameId = odom.Header.FrameId ?? "odom",
ChildFrameId = odom.ChildFrameId ?? "base_link",
PositionX = p.X,
PositionY = p.Y,
PositionZ = p.Z,
OrientationX = q.X,
OrientationY = q.Y,
OrientationZ = q.Z,
OrientationW = q.W,
LinearVelocityX = linear.X,
LinearVelocityY = linear.Y,
LinearVelocityZ = linear.Z,
AngularVelocityX = angular.X,
AngularVelocityY = angular.Y,
AngularVelocityZ = angular.Z,
UpdateFrequency = 0
};
}
}

View File

@@ -0,0 +1,84 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Plc;
using RobotNet10.RobotApp.Interfaces;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho PlcController
/// Cung cấp real-time updates về trạng thái PLC (read-only)
/// </summary>
[Authorize]
public class PlcControllerHub(IPlcController plcController) : Hub
{
/// <summary>
/// Lấy trạng thái hiện tại của PlcController
/// </summary>
public Task<PlcControllerStatusDto> GetStatus()
{
return Task.FromResult(new PlcControllerStatusDto
{
IsReady = plcController.IsReady,
// Operating mode
PeripheralMode = plcController.PeripheralMode.ToString(),
SafetySpeed = plcController.SafetySpeed.ToString(),
// Safety sensors
Emergency = plcController.Emergency,
Bumper = plcController.Bumper,
LidarFrontProtectField = plcController.LidarFrontProtectField,
LidarBackProtectField = plcController.LidarBackProtectField,
LidarFrontTimProtectField = plcController.LidarFrontTimProtectField,
// Lift state
LiftedUp = plcController.LiftedUp,
LiftedDown = plcController.LiftedDown,
LiftHome = plcController.LiftHome,
// Motor state
LeftMotorReady = plcController.LeftMotorReady,
RightMotorReady = plcController.RightMotorReady,
LiftMotorReady = plcController.LiftMotorReady,
// Button state
ButtonStart = plcController.ButtonStart,
ButtonStop = plcController.ButtonStop,
ButtonReset = plcController.ButtonReset,
// Other state
HasLoad = plcController.HasLoad,
EnabledCharger = plcController.EnabledCharger,
Charging = plcController.Charging,
MutedBase = plcController.MutedBase,
MutedLoad = plcController.MutedLoad,
// Determine current stop state
StopState = GetStopState(),
// Write state - các giá trị đã được ghi xuống PLC
CurrentSystemState = plcController.CurrentSystemState.ToString(),
CurrentOperationState = plcController.CurrentOperationState.ToString(),
CurrentRFMode = plcController.CurrentRFMode.ToString(),
SetHorizontalLoadValue = plcController.SetHorizontalLoadValue,
SetMutedBaseValue = plcController.SetMutedBaseValue,
SetMutedLoadValue = plcController.SetMutedLoadValue,
SetEnableChargerValue = plcController.SetEnableChargerValue,
SetHasLoadValue = plcController.SetHasLoadValue,
SetRFEStopValue = plcController.SetRFEStopValue,
SetBatteryLowValue = plcController.SetBatteryLowValue,
SetLightOnValue = plcController.SetLightOnValue
});
}
private string GetStopState()
{
if (plcController.Emergency) return "EMC";
if (plcController.Bumper) return "Bumper";
if (!plcController.LidarFrontProtectField) return "FrontProtective";
if (!plcController.LidarBackProtectField) return "BackProtective";
if (plcController.LidarFrontTimProtectField) return "TimProtective";
return "None";
}
}

View File

@@ -0,0 +1,61 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
namespace RobotNet10.RobotApp.Hubs;
[Authorize]
public class RfHandleHub(IDeviceProvider _deviceProvider) : Hub
{
// Snapshot / RPC calls
public Task<DeviceInfoDto?> GetDeviceInfo(string deviceId)
{
var device = _deviceProvider.GetDevice(deviceId);
if (device is not IRfHandle)
return Task.FromResult<DeviceInfoDto?>(null);
return Task.FromResult<DeviceInfoDto?>(new DeviceInfoDto
{
DeviceId = deviceId,
DeviceName = device.DeviceName
});
}
public Task<RfHandleDataDto?> GetRfHandleData(string deviceId)
{
var device = _deviceProvider.GetDevice(deviceId);
if (device is not IRfHandle rf)
return Task.FromResult<RfHandleDataDto?>(null);
var dto = MapToDto(rf, deviceId);
return Task.FromResult<RfHandleDataDto?>(dto);
}
// ====== Helper & DTO mapper ======
private static RfHandleDataDto MapToDto(IRfHandle rf, string deviceId)
{
return new RfHandleDataDto
{
DeviceId = deviceId,
Heartbeat = rf.Heartbeat,
RemoteReady = rf.RemoteReady,
EStop = rf.EStop,
LiftUp = rf.LiftUp,
LiftDown = rf.LiftDown,
RotateLeft = rf.RotateLeft,
RotateRight = rf.RotateRight,
ModeSelect = rf.ModeSelect,
Enable = rf.Enable,
Speed = rf.Speed,
Linear = rf.Linear,
Angular = rf.Angular,
Mode = rf.Mode.ToString(),
LastUpdateTime = rf.LastUpdateTime
};
}
}

View File

@@ -0,0 +1,476 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using RobotNet10.RobotApp.Client.Shared.SLAM;
using RobotNet10.RobotApp.Shared;
using RobotNet10.RobotApp.Shared.Enums;
using RobotNet10.RobotApp.SLAM;
using RobotNet10.RobotApp.SLAM.Cartographer.Geometry;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Localization;
using RobotNet10.Shared.Numbers;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub cho Cartographer/Localization - cung cấp real-time mapping và localization data
/// </summary>
[Authorize]
public class SLAMHub(ISLAMService _slamService, ILogger<SLAMHub> _logger) : Hub
{
/// <summary>
/// Called when a client connects to the hub
/// </summary>
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
// Clients will poll for occupancy grid using GetOccupancyGridBase() and GetOccupancyGridUpdating() methods
}
/// <summary>
/// Called when a client disconnects from the hub
/// </summary>
public override async Task OnDisconnectedAsync(Exception? exception)
{
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Lấy trạng thái hiện tại của CartographerService
/// </summary>
public Task<SLAMState> GetCurrentState()
{
var state = _slamService.State;
return Task.FromResult(MapState(state));
}
/// <summary>
/// Lấy tên map hiện tại đang được sử dụng
/// </summary>
public Task<string?> GetCurrentMap()
{
return Task.FromResult(_slamService.CurrentMap);
}
/// <summary>
/// Bắt đầu localization với map đã lưu
/// </summary>
public async Task<bool> StartLocalization(string mapName, PoseDto? initialPoseDto = null)
{
try
{
Pose? initialPose = initialPoseDto != null ? MapPoseFromDto(initialPoseDto) : null;
_slamService.StartLocalization(mapName, initialPose);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to start localization");
await Clients.Caller.SendAsync("OnError", new ErrorDto
{
Message = ex.Message,
Timestamp = DateTime.UtcNow
});
return false;
}
}
/// <summary>
/// Dừng localization
/// </summary>
public Task StopLocalization()
{
_slamService.StopLocalization();
return Task.CompletedTask;
}
/// <summary>
/// Bắt đầu scan & mapping
/// </summary>
public async Task<bool> StartScanMapping(string mapName)
{
try
{
_slamService.StartScanMapping(mapName);
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to start scan mapping");
await Clients.Caller.SendAsync("OnError", new ErrorDto
{
Message = ex.Message,
Timestamp = DateTime.UtcNow
});
return false;
}
}
/// <summary>
/// Lưu map hiện tại.
/// Note: This is now fire-and-forget. The actual save happens asynchronously.
/// Monitor state changes to know when save completes.
/// </summary>
public async Task<string?> SaveMap()
{
try
{
_slamService.SaveScanMap();
// Return null since we no longer wait for completion
// Clients should monitor state changes instead
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to save map");
await Clients.Caller.SendAsync("OnError", new ErrorDto
{
Message = ex.Message,
Timestamp = DateTime.UtcNow
});
return null;
}
}
/// <summary>
/// Liệt kê tất cả maps có sẵn
/// </summary>
public MapInfoDto[] ListMaps()
{
try
{
var maps = _slamService.ListMaps();
return [.. maps.Select(MapMapInfoToDto)];
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to list maps");
return [];
}
}
/// <summary>
/// Lấy thông tin map và đăng ký client vào group để 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?> GetMapInfoAndSubscribeProcessing(string mapName)
{
try
{
// Subscribe client to group with mapName
await Groups.AddToGroupAsync(Context.ConnectionId, mapName);
// Get map info
var mapInfo = _slamService.GetMapInfo(mapName);
if (mapInfo == null)
{
_logger.LogWarning("SLAMHub: Map not found: {MapName}", mapName);
return null;
}
// Get processing status
var isProcessing = _slamService.GetMapProcessingStatus(mapName);
// Map to DTO with IsProcessing
var dto = MapMapInfoToDto(mapInfo);
dto.IsProcessing = isProcessing;
return dto;
}
catch (Exception ex)
{
_logger.LogError(ex, "SLAMHub: Failed to get map info and subscribe: {MapName}", mapName);
return null;
}
}
/// <summary>
/// Hủy đăng ký client khỏi group nhận cập nhật trạng thái xử lý
/// </summary>
/// <param name="mapName">Tên map</param>
public async Task UnsubscribeMapProcessing(string mapName)
{
try
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, mapName);
}
catch (Exception ex)
{
_logger.LogError(ex, "SLAMHub: Failed to unsubscribe from map processing: {MapName}", mapName);
}
}
/// <summary>
/// Xóa map
/// </summary>
public async Task<bool> DeleteMap(string mapName)
{
try
{
return await _slamService.DeleteMapAsync(mapName);
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to delete map");
return false;
}
}
/// <summary>
/// Transform map origin to a new pose
/// </summary>
/// <param name="mapName">Name of the map to transform</param>
/// <param name="newOriginDto">New origin pose (position and orientation)</param>
/// <returns>True if transform was successful</returns>
public async Task<bool> TransformMapOrigin(string mapName, PoseDto newOriginDto)
{
try
{
var newOrigin = MapPoseFromDto(newOriginDto);
return await _slamService.TransformMapOriginAsync(mapName, newOrigin);
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to transform map origin");
await Clients.Caller.SendAsync("OnError", new ErrorDto
{
Message = ex.Message,
Timestamp = DateTime.UtcNow
});
return false;
}
}
/// <summary>
/// Rerender map image files (PNG, JPG, PGM) with custom OccupancyGridConfiguration.
/// This is an async operation - returns true if processing started successfully.
/// Clients subscribed to the map group will receive OnMapProcessingChanged notifications.
/// </summary>
/// <param name="mapName">Name of the map to rerender</param>
/// <param name="configDto">Custom occupancy grid configuration</param>
/// <returns>True if rerender was started successfully</returns>
public async Task<bool> RerenderMapWithConfig(string mapName, OccupancyGridConfigurationDto configDto)
{
try
{
return await _slamService.RerenderMapWithConfigAsync(mapName, configDto);
}
catch (Exception ex)
{
_logger.LogError(ex, "SLAMHub: Failed to rerender map with config");
await Clients.Caller.SendAsync("OnError", new ErrorDto
{
Message = ex.Message,
Timestamp = DateTime.UtcNow
});
return false;
}
}
/// <summary>
/// Set initial pose cho localization
/// </summary>
public async Task SetInitialPose(PoseDto poseDto)
{
try
{
var pose = MapPoseFromDto(poseDto);
_slamService.SetInitialPose(pose);
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to set initial pose");
await Clients.Caller.SendAsync("OnError", new ErrorDto
{
Message = ex.Message,
Timestamp = DateTime.UtcNow
});
}
}
/// <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 OccupancyGridDto? GetOccupancyGrid(DateTime since)
{
var lastUpdated = _slamService.LastUpdatedOccupancyGrid;
var grid = _slamService.GetOccupancyGrid(since);
var trajectoryNodes = _slamService.GetTrajectoryNodes();
if (grid == null)
{
_logger.LogDebug(
"SLAMHub.GetOccupancyGrid: No grid available (since={Since}, lastUpdated={LastUpdated}, state={State})",
since, lastUpdated, _slamService.State);
return null;
}
_logger.LogDebug(
"SLAMHub.GetOccupancyGrid: Returning grid {W}x{H}, lastUpdated={LastUpdated}",
grid.Width, grid.Height, lastUpdated);
return MapOccupancyGridToDto(grid, lastUpdated, lastUpdated, versionTicks: lastUpdated.Ticks, trajectoryNodes);
}
/// <summary>
/// Lấy pose hiện tại của robot
/// Trả về pose từ ScanMappingService nếu đang ScanMapping, hoặc từ CartographerService nếu đang Localizing
/// </summary>
/// <returns>PoseDto nếu có pose, null nếu không có</returns>
public PoseDto GetCurrentPose()
{
var state = _slamService.State;
// Khi đang ScanMapping, lấy pose từ ScanMappingService
if (state == SLAMState.ScanMapping)
{
return MapPoseToDto(_slamService.CurrentPose, 0);
}
// Khi đang Localizing, lấy pose từ CartographerService
else if (state == SLAMState.Localizing)
{
return MapPoseToDto(_slamService.CurrentPose, _slamService.LocalizationScore ?? 0, _slamService.PoseCovariance);
}
return MapPoseToDto(new Pose(), 0);
}
/// <summary>
/// Lấy sample point cloud từ tất cả lidar devices (đã transform sang pose-graph global frame).
/// Pipeline: LocalTrajectoryBuilder2D → trajectory-with-global-orientation, GlobalTrajectoryBuilder2D → GetLocalToGlobalTransform → global.
/// </summary>
/// <returns>Danh sách Point32 trong global (map) frame, empty list nếu không có</returns>
public Vector3[] GetSamplePointCloud()
{
var points = _slamService.GetAggregatedSamplePointCloud();
return points?.Count > 0 ? [.. points] : [];
}
// Mapping methods (convert server types to DTOs)
private static SLAMState MapState(SLAMState state)
{
return state switch
{
SLAMState.Idle => SLAMState.Idle,
SLAMState.Initializing => SLAMState.Initializing,
SLAMState.Ready => SLAMState.Ready,
SLAMState.Relocalizing => SLAMState.Relocalizing,
SLAMState.Localizing => SLAMState.Localizing,
SLAMState.ScanMapping => SLAMState.ScanMapping,
SLAMState.SavingMap => SLAMState.SavingMap,
SLAMState.Error => SLAMState.Error,
_ => SLAMState.Idle
};
}
private static Pose MapPoseFromDto(PoseDto dto)
{
return new Pose
{
Position = dto.Position,
Orientation = dto.Orientation
};
}
private static PoseDto MapPoseToDto(Pose pose, double score, Matrix3x3? covariance = null)
{
var dto = new PoseDto
{
Position = pose.Position,
Orientation = pose.Orientation,
Score = score,
Timestamp = DateTime.UtcNow
};
// Convert covariance matrix to flattened array if available
if (covariance.HasValue)
{
var cov = covariance.Value;
// Matrix3x3 uses indexer [row, col] where:
// row 0 = x, row 1 = y, row 2 = theta
// col 0 = x, col 1 = y, col 2 = theta
dto.Covariance =
[
cov[0, 0], cov[0, 1], cov[0, 2], // XX, XY, XT
cov[1, 0], cov[1, 1], cov[1, 2], // YX, YY, YT
cov[2, 0], cov[2, 1], cov[2, 2] // TX, TY, TT
];
}
return dto;
}
private OccupancyGridDto MapOccupancyGridToDto(OccupancyGrid grid, DateTime lastBaseUpdated, DateTime lastUpdated, long versionTicks, List<(int NodeId, Pose Pose)>? trajectoryNodes = null)
{
if (grid == null)
{
_logger.LogWarning("CartographerHub: MapOccupancyGridToDto called with null grid");
throw new ArgumentNullException(nameof(grid));
}
// Convert to sparse format: only include known cells (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)
// Value: 1 byte (0-100)
var knownCells = new List<byte>();
int knownCellCount = 0;
for (int i = 0; i < grid.Data.Length; i++)
{
var value = grid.Data[i];
if (value >= 0) // Only include known cells (skip unknown = -1)
{
knownCellCount++;
// Encode index as 4 bytes (little-endian)
knownCells.Add((byte)(i & 0xFF)); // Byte 0: LSB
knownCells.Add((byte)((i >> 8) & 0xFF)); // Byte 1
knownCells.Add((byte)((i >> 16) & 0xFF)); // Byte 2
knownCells.Add((byte)((i >> 24) & 0xFF)); // Byte 3: MSB
// Encode value as 1 byte (0-100)
knownCells.Add((byte)value);
}
}
TrajectoryNodeDto[]? trajectoryDtos = null;
if (trajectoryNodes != null && trajectoryNodes.Count > 0)
{
trajectoryDtos = [.. trajectoryNodes.Select(node => new TrajectoryNodeDto
{
NodeId = node.NodeId,
Pose = node.Pose,
Timestamp = DateTime.UtcNow
})];
}
return new OccupancyGridDto
{
Resolution = grid.Resolution,
Width = grid.Width,
Height = grid.Height,
Origin = grid.Origin,
KnownCells = [.. knownCells],
Version = versionTicks,
LastBaseUpdated = lastBaseUpdated,
LastUpdated = lastUpdated,
TrajectoryNodes = trajectoryDtos
};
}
private static MapInfoDto MapMapInfoToDto(MapInfo mapInfo)
{
return new MapInfoDto
{
Name = mapInfo.Name,
CreatedDate = mapInfo.CreatedDate,
Resolution = mapInfo.Resolution,
Width = mapInfo.Size.Width,
Height = mapInfo.Size.Height,
TrajectoryNodeCount = mapInfo.TrajectoryNodeCount,
OriginX = mapInfo.Origin.Position.X,
OriginY = mapInfo.Origin.Position.Y,
};
}
}

View File

@@ -0,0 +1,13 @@
using Microsoft.AspNetCore.SignalR;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub for broadcasting XLOC real-time data
/// Clients can subscribe to pose and diagnostics updates
/// </summary>
public class XlocHub : Hub
{
// Hub methods for client-to-server communication can be added here if needed
// Currently this hub is used only for server-to-client broadcasting
}

View File

@@ -0,0 +1,273 @@
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
namespace RobotNet10.RobotApp.Hubs;
/// <summary>
/// SignalR Hub for streaming XLOC pose data in realtime
/// AND controlling SLAM operations manually (start/stop localization/mapping)
/// </summary>
public class XlocPoseHub : Hub
{
private readonly ILogger<XlocPoseHub> _logger;
private readonly Xloc.XlocIntegrationService? _xlocService;
public XlocPoseHub(ILogger<XlocPoseHub> logger, Xloc.XlocIntegrationService? xlocService = null)
{
_logger = logger;
_xlocService = xlocService;
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation("Client connected to XlocPoseHub: {ConnectionId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client disconnected from XlocPoseHub: {ConnectionId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
/// <summary>
/// Client requests current pose (on-demand)
/// </summary>
public async Task RequestCurrentPose()
{
_logger.LogDebug("Client {ConnectionId} requested current pose", Context.ConnectionId);
await Task.CompletedTask;
}
#region Manual SLAM Control Methods
/// <summary>
/// Activate a map for localization
/// </summary>
public async Task<bool> ActivateMap(string mapFilePath)
{
if (_xlocService == null)
{
_logger.LogWarning("XlocIntegrationService not available");
return false;
}
_logger.LogInformation("Client {ConnectionId} activating map: {MapPath}", Context.ConnectionId, mapFilePath);
return _xlocService.ActivateMap(mapFilePath);
}
/// <summary>
/// Start localization mode
/// </summary>
public async Task<bool> StartLocalization()
{
if (_xlocService == null)
{
_logger.LogWarning("XlocIntegrationService not available");
return false;
}
_logger.LogInformation("Client {ConnectionId} starting localization", Context.ConnectionId);
return _xlocService.StartLocalization();
}
/// <summary>
/// Stop localization mode
/// </summary>
public async Task<bool> StopLocalization()
{
if (_xlocService == null)
{
_logger.LogWarning("XlocIntegrationService not available");
return false;
}
_logger.LogInformation("Client {ConnectionId} stopping localization", Context.ConnectionId);
return _xlocService.StopLocalization();
}
/// <summary>
/// Start mapping mode
/// </summary>
public async Task<bool> StartMapping()
{
if (_xlocService == null)
{
_logger.LogWarning("XlocIntegrationService not available");
return false;
}
_logger.LogInformation("Client {ConnectionId} starting mapping", Context.ConnectionId);
return _xlocService.StartMapping();
}
/// <summary>
/// Stop mapping and save to file
/// </summary>
public async Task<bool> StopMapping(string saveMapFilePath)
{
if (_xlocService == null)
{
_logger.LogWarning("XlocIntegrationService not available");
return false;
}
_logger.LogInformation("Client {ConnectionId} stopping mapping, saving to: {MapPath}",
Context.ConnectionId, saveMapFilePath);
return _xlocService.StopMapping(saveMapFilePath);
}
/// <summary>
/// Get current 2D pose (x, y, yaw)
/// </summary>
public async Task<object?> GetCurrentPose2D()
{
if (_xlocService == null)
return null;
var pose = _xlocService.GetCurrentPose2D();
if (!pose.HasValue)
return null;
var (x, y, yaw) = pose.Value;
return new { x, y, yaw, yawDegrees = yaw * 180.0 / Math.PI };
}
/// <summary>
/// Get sampled laser scan data (1 degree intervals) for web visualization
/// Returns array of {angle, range} objects
/// </summary>
public async Task<object?> GetSampledLaserScan()
{
if (_xlocService == null)
return null;
var laserData = _xlocService.GetSampledLaserScan();
if (laserData == null || laserData.Count == 0)
return null;
// Convert to JSON-friendly format
var points = laserData.Select(p => new { angle = p.angle, range = p.range }).ToArray();
return new {
pointCount = points.Length,
points = points,
timestamp = DateTime.UtcNow
};
}
/// <summary>
/// Get XLOC diagnostics data
/// </summary>
public async Task<object?> GetDiagnostics()
{
if (_xlocService == null)
return null;
var diagnostics = _xlocService.GetDiagnostics();
if (diagnostics == null)
return null;
return new
{
header = new
{
seq = diagnostics.HeaderSeq,
stamp = new
{
sec = diagnostics.HeaderStampSec,
nsec = diagnostics.HeaderStampNsec
},
frameId = diagnostics.HeaderFrameId
},
xlocState = diagnostics.XlocState,
stateString = diagnostics.StateString,
currentActiveMap = diagnostics.CurrentActiveMap,
reliability = diagnostics.Reliability,
matchingScore = diagnostics.MatchingScore
};
}
#endregion
}
/// <summary>
/// Pose data for SignalR clients
/// </summary>
public class XlocPoseData
{
/// <summary>
/// X position in meters
/// </summary>
public double X { get; set; }
/// <summary>
/// Y position in meters
/// </summary>
public double Y { get; set; }
/// <summary>
/// Z position in meters
/// </summary>
public double Z { get; set; }
/// <summary>
/// Yaw angle in radians
/// </summary>
public double Yaw { get; set; }
/// <summary>
/// Yaw angle in degrees
/// </summary>
public double YawDegrees => Yaw * 180.0 / Math.PI;
/// <summary>
/// Quaternion X
/// </summary>
public double QuaternionX { get; set; }
/// <summary>
/// Quaternion Y
/// </summary>
public double QuaternionY { get; set; }
/// <summary>
/// Quaternion Z
/// </summary>
public double QuaternionZ { get; set; }
/// <summary>
/// Quaternion W
/// </summary>
public double QuaternionW { get; set; }
/// <summary>
/// Timestamp of pose
/// </summary>
public DateTime Timestamp { get; set; }
/// <summary>
/// Is localization active
/// </summary>
public bool IsActive { get; set; }
/// <summary>
/// Xloc state (0=MAPPING, 1=LOCALIZATION, 2=PROCESSING, 3=READY, 4=ERROR)
/// </summary>
public byte XlocState { get; set; }
/// <summary>
/// Current active map name
/// </summary>
public string MapName { get; set; } = string.Empty;
/// <summary>
/// Localization reliability (0.0 to 1.0)
/// </summary>
public double Reliability { get; set; }
/// <summary>
/// SLAM matching score
/// </summary>
public double MatchingScore { get; set; }
}