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;
///
/// SignalR Hub cho Cartographer/Localization - cung cấp real-time mapping và localization data
///
[Authorize]
public class SLAMHub(ISLAMService _slamService, ILogger _logger) : Hub
{
///
/// Called when a client connects to the hub
///
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
// Clients will poll for occupancy grid using GetOccupancyGridBase() and GetOccupancyGridUpdating() methods
}
///
/// Called when a client disconnects from the hub
///
public override async Task OnDisconnectedAsync(Exception? exception)
{
await base.OnDisconnectedAsync(exception);
}
///
/// Lấy trạng thái hiện tại của CartographerService
///
public Task GetCurrentState()
{
var state = _slamService.State;
return Task.FromResult(MapState(state));
}
///
/// Lấy tên map hiện tại đang được sử dụng
///
public Task GetCurrentMap()
{
return Task.FromResult(_slamService.CurrentMap);
}
///
/// Bắt đầu localization với map đã lưu
///
public async Task 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;
}
}
///
/// Dừng localization
///
public Task StopLocalization()
{
_slamService.StopLocalization();
return Task.CompletedTask;
}
///
/// Bắt đầu scan & mapping
///
public async Task 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;
}
}
///
/// 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.
///
public async Task 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;
}
}
///
/// Liệt kê tất cả maps có sẵn
///
public MapInfoDto[] ListMaps()
{
try
{
var maps = _slamService.ListMaps();
return [.. maps.Select(MapMapInfoToDto)];
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to list maps");
return [];
}
}
///
/// 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ý
///
/// Tên map
/// MapInfoDto với trạng thái IsProcessing
public async Task 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;
}
}
///
/// Hủy đăng ký client khỏi group nhận cập nhật trạng thái xử lý
///
/// Tên map
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);
}
}
///
/// Xóa map
///
public async Task DeleteMap(string mapName)
{
try
{
return await _slamService.DeleteMapAsync(mapName);
}
catch (Exception ex)
{
_logger.LogError(ex, "CartographerHub: Failed to delete map");
return false;
}
}
///
/// Transform map origin to a new pose
///
/// Name of the map to transform
/// New origin pose (position and orientation)
/// True if transform was successful
public async Task 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;
}
}
///
/// 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.
///
/// Name of the map to rerender
/// Custom occupancy grid configuration
/// True if rerender was started successfully
public async Task 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;
}
}
///
/// Set initial pose cho localization
///
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
});
}
}
///
/// Lấy occupancy grid mới hơn thời gian chỉ định
///
/// Thời gian để so sánh
/// OccupancyGridDto nếu có update mới hơn, null nếu không có
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);
}
///
/// 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
///
/// PoseDto nếu có pose, null nếu không có
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);
}
///
/// 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.
///
/// Danh sách Point32 trong global (map) frame, empty list nếu không có
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();
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,
};
}
}