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,874 @@
using RobotNet10.FleetManager.Client;
using RobotNet10.FleetManager.Shared.DTOs.Robot;
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
using RobotNet10.MapEditor.Services.API;
using RobotNet10.MapEditor.Shared.DTOs.Edge;
using RobotNet10.MapEditor.Shared.DTOs.Layout;
using RobotNet10.MapEditor.Shared.DTOs.Node;
namespace RobotNet10.FleetManager.Client.Services;
/// <summary>
/// Viewport state for SVG canvas
/// </summary>
public class ViewportState
{
public double ViewBoxX { get; set; }
public double ViewBoxY { get; set; }
public double ViewBoxWidth { get; set; }
public double ViewBoxHeight { get; set; }
public double ZoomLevel { get; set; } = 1.0;
public string ToViewBoxString() =>
$"{ViewBoxX:F2} {ViewBoxY:F2} {ViewBoxWidth:F2} {ViewBoxHeight:F2}";
}
/// <summary>
/// Robot data for monitoring
/// </summary>
public class RobotMonitorData
{
public string RobotId { get; set; } = string.Empty;
public Guid? ModelId { get; set; }
public RobotModelDto? Model { get; set; } // Robot model info (Length, Width, etc.)
public string? ModelImageBase64 { get; set; }
public DateTime LastUpdateTime { get; set; }
public RobotMonitorBoardcastData? Data { get; set; }
}
/// <summary>
/// State management for RobotMonitor page
/// </summary>
public class RobotMonitorState(
MapManagerApiService mapApiService,
RobotApiService robotApiService,
RobotModelApiService robotModelApiService,
RobotStateHubClient? hubClient = null)
{
// ===== DATA =====
public List<LayoutDto> Layouts { get; private set; } = [];
public List<LayoutVersionDto> AvailableVersions { get; private set; } = [];
public List<LayoutLevelDto> AvailableLevels { get; private set; } = [];
public List<RobotDto> AvailableRobots { get; private set; } = [];
public Guid? SelectedLayoutId { get; set; }
public Guid? SelectedVersionId { get; set; }
public Guid? SelectedLevelId { get; set; }
public LayoutLevelDto? Level { get; private set; }
public List<NodeDto> Nodes { get; private set; } = [];
public List<EdgeDto> Edges { get; private set; } = [];
public byte[]? BackgroundImage { get; private set; }
// ===== ROBOTS =====
public Dictionary<string, RobotMonitorData> Robots { get; private set; } = [];
public string? SelectedRobotId { get; set; }
// ===== DISPLAY OPTIONS =====
public bool ShowGrid { get; set; } = false;
public bool ShowBackgroundImage { get; set; } = true;
public bool ShowPath { get; set; } = false;
public bool ShowName { get; set; } = true;
public bool FollowRobot { get; set; } = false;
public bool RobotInfoPanelExpanded { get; set; } = false;
// ===== VIEWPORT =====
public ViewportState Viewport { get; } = new();
// ===== UI STATE =====
public bool IsLoading { get; private set; }
public string? ErrorMessage { get; set; }
public bool IsMonitorDeactivated { get; private set; } = false;
// ===== EVENTS =====
public event Action? OnDataChanged;
public event Action? OnStateChanged;
// ===== DEPENDENCIES =====
private readonly MapManagerApiService _mapApiService = mapApiService;
private readonly RobotApiService _robotApiService = robotApiService;
private readonly RobotModelApiService _robotModelApiService = robotModelApiService;
private readonly RobotStateHubClient? _hubClient = hubClient;
// ===== ROBOT MODEL CACHE =====
private readonly Dictionary<Guid, string> _robotModelImageCache = [];
private readonly Dictionary<Guid, RobotModelDto> _robotModelCache = []; // modelId -> RobotModelDto
private readonly Dictionary<string, Guid> _robotModelIdCache = []; // robotId -> modelId
// ===== TIMEOUT CONFIGURATION =====
private const int RobotTimeoutSeconds = 10; // Remove robots after 10 seconds of no updates
/// <summary>
/// Initialize monitor state
/// </summary>
public async Task InitializeAsync()
{
IsLoading = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
// Load layouts
await LoadLayoutsAsync();
// Load robots and their models
await LoadRobotsAsync();
// Connect SignalR if available
if (_hubClient != null)
{
await InitializeSignalRAsync();
// Register OnMonitorDeactivated event handler
_hubClient.OnMonitorDeactivated += HandleMonitorDeactivated;
}
}
catch (Exception ex)
{
ErrorMessage = $"Failed to initialize: {ex.Message}";
// Log error for debugging
System.Diagnostics.Debug.WriteLine($"RobotMonitor initialization error: {ex}");
}
IsLoading = false;
NotifyStateChanged();
}
/// <summary>
/// Load all layouts
/// </summary>
public async Task LoadLayoutsAsync()
{
try
{
var allLayouts = await _mapApiService.SearchLayoutsAsync();
// Only get active layouts
Layouts = [.. allLayouts.Where(l => l.IsActive)];
ErrorMessage = null; // Clear any previous errors
// Auto-select first layout, last version, last level
if (Layouts.Count > 0)
{
var firstLayout = Layouts[0];
SelectedLayoutId = firstLayout.Id;
// Load versions for first layout
AvailableVersions = await _mapApiService.GetVersionsAsync(firstLayout.Id);
if (AvailableVersions.Count > 0)
{
var lastVersion = AvailableVersions.Last();
SelectedVersionId = lastVersion.Id;
// Load levels for last version
AvailableLevels = await _mapApiService.GetLevelsAsync(lastVersion.Id);
if (AvailableLevels.Count > 0)
{
var lastLevel = AvailableLevels.Last();
SelectedLevelId = lastLevel.Id;
// Load layout data for last level
await LoadLayoutDataAsync();
}
}
}
}
catch (Exception ex)
{
// Build detailed error message
var errorDetails = new System.Text.StringBuilder();
errorDetails.AppendLine($"Failed to load layouts: {ex.Message}");
// Add inner exception details if available
if (ex.InnerException != null)
{
errorDetails.AppendLine($"Inner exception: {ex.InnerException.Message}");
}
// Add stack trace for debugging (first few lines only)
if (ex.StackTrace != null)
{
var stackLines = ex.StackTrace.Split('\n').Take(3);
errorDetails.AppendLine($"Stack trace: {string.Join(" ", stackLines)}");
}
ErrorMessage = errorDetails.ToString().Trim();
Layouts = [];
}
NotifyStateChanged();
}
/// <summary>
/// Handle layout selection change
/// </summary>
public async Task OnLayoutSelectedAsync(Guid? layoutId)
{
SelectedLayoutId = layoutId;
SelectedVersionId = null;
SelectedLevelId = null;
AvailableVersions = [];
AvailableLevels = [];
if (layoutId.HasValue)
{
// Load versions for selected layout
try
{
AvailableVersions = await _mapApiService.GetVersionsAsync(layoutId.Value);
}
catch (Exception ex)
{
var errorDetails = GetDetailedErrorMessage(ex, "load versions");
ErrorMessage = errorDetails;
AvailableVersions = [];
}
}
// Clear layout data
await LoadLayoutDataAsync();
NotifyStateChanged();
}
/// <summary>
/// Handle version selection change
/// </summary>
public async Task OnVersionSelectedAsync(Guid? versionId)
{
SelectedVersionId = versionId;
SelectedLevelId = null;
AvailableLevels = [];
if (versionId.HasValue)
{
// Load levels for selected version
try
{
AvailableLevels = await _mapApiService.GetLevelsAsync(versionId.Value);
AvailableLevels = [.. AvailableLevels.OrderBy(l => l.LevelOrder)];
}
catch (Exception ex)
{
var errorDetails = GetDetailedErrorMessage(ex, "load levels");
ErrorMessage = errorDetails;
AvailableLevels = [];
}
}
// Clear layout data
await LoadLayoutDataAsync();
NotifyStateChanged();
}
/// <summary>
/// Handle level selection change
/// </summary>
public async Task OnLevelSelectedAsync(Guid? levelId)
{
// Unsubscribe from old level if exists
if (SelectedLevelId.HasValue && _hubClient != null)
{
try
{
await _hubClient.UnsubscribeFromLevelForMonitorAsync();
}
catch
{
// Ignore unsubscribe errors
}
}
SelectedLevelId = levelId;
await LoadLayoutDataAsync();
// Subscribe to new level
if (SelectedLevelId.HasValue && _hubClient != null)
{
try
{
await _hubClient.SubscribeToLevelForMonitorAsync(SelectedLevelId.Value);
IsMonitorDeactivated = false; // Reset deactivated state when subscribing
}
catch
{
// Ignore subscription errors
}
}
NotifyStateChanged();
}
/// <summary>
/// Handle robot selection change (from dropdown)
/// </summary>
public void OnRobotSelected(string? robotId)
{
SelectRobot(robotId);
}
/// <summary>
/// Load layout data for selected level
/// </summary>
public async Task LoadLayoutDataAsync()
{
if (!SelectedLevelId.HasValue)
{
Level = null;
Nodes = [];
Edges = [];
BackgroundImage = null;
NotifyStateChanged();
return;
}
IsLoading = true;
ErrorMessage = null;
NotifyStateChanged();
try
{
// Load level info
Level = await _mapApiService.GetLevelAsync(SelectedLevelId.Value);
// Load layout data (nodes, edges)
var layoutData = await _mapApiService.GetLayoutDataAsync(SelectedLevelId.Value);
Nodes = layoutData.Nodes?.ToList() ?? [];
Edges = layoutData.Edges?.ToList() ?? [];
// Load background image
try
{
BackgroundImage = await _mapApiService.GetLayoutImageAsync(SelectedLevelId.Value);
}
catch
{
BackgroundImage = null;
}
// Initialize viewport
InitializeViewport();
}
catch (Exception ex)
{
var errorDetails = GetDetailedErrorMessage(ex, "load layout data");
ErrorMessage = errorDetails;
}
IsLoading = false;
NotifyStateChanged();
}
/// <summary>
/// Load robots from API
/// </summary>
private async Task LoadRobotsAsync()
{
try
{
AvailableRobots = await _robotApiService.GetAllAsync();
// Cache robot model IDs
foreach (var robot in AvailableRobots)
{
if (robot.ModelId != Guid.Empty)
{
_robotModelIdCache[robot.RobotId] = robot.ModelId;
}
}
}
catch (Exception ex)
{
var errorDetails = GetDetailedErrorMessage(ex, "load robots");
ErrorMessage = errorDetails;
AvailableRobots = [];
}
}
/// <summary>
/// Initialize SignalR connection
/// </summary>
private async Task InitializeSignalRAsync()
{
if (_hubClient == null) return;
try
{
// Register event handlers
_hubClient.OnMonitorBoardcastUpdate += HandleStateUpdate;
//_hubClient.OnVisualizationUpdate += HandleVisualizationUpdate;
_hubClient.OnConnectionError += HandleConnectionError;
// Connect
await _hubClient.ConnectAsync();
// Subscribe to all robots
// Subscribe to level will be done in OnLevelSelectedAsync when level is selected
if (SelectedLevelId.HasValue && _hubClient != null)
{
try
{
await _hubClient.SubscribeToLevelForMonitorAsync(SelectedLevelId.Value);
IsMonitorDeactivated = false; // Reset deactivated state when subscribing
}
catch
{
// Ignore subscription errors
}
}
}
catch (Exception ex)
{
var errorDetails = GetDetailedErrorMessage(ex, "connect SignalR");
ErrorMessage = errorDetails;
}
}
/// <summary>
/// Handle monitor deactivated event (when connection is evicted due to max connections)
/// </summary>
private void HandleMonitorDeactivated()
{
IsMonitorDeactivated = true;
ErrorMessage = "Monitor connection deactivated: Maximum 5 connections per level reached. Another connection has taken your place.";
NotifyStateChanged();
}
/// <summary>
/// Handle SignalR connection error
/// </summary>
private void HandleConnectionError(string error)
{
ErrorMessage = $"SignalR connection error: {error}";
NotifyStateChanged();
}
/// <summary>
/// Initialize viewport to fit the image bounds
/// </summary>
private void InitializeViewport()
{
if (Level?.EditorSettings != null)
{
var settings = Level.EditorSettings;
// Calculate physical dimensions
double physicalWidth = (settings.ImageWidth ?? 1000) * settings.Resolution;
double physicalHeight = (settings.ImageHeight ?? 500) * settings.Resolution;
// Set viewport to fit image
Viewport.ViewBoxX = 0;
Viewport.ViewBoxY = 0;
Viewport.ViewBoxWidth = physicalWidth;
Viewport.ViewBoxHeight = physicalHeight;
Viewport.ZoomLevel = 1.0;
}
else
{
// Default viewport
Viewport.ViewBoxX = 0;
Viewport.ViewBoxY = 0;
Viewport.ViewBoxWidth = 50;
Viewport.ViewBoxHeight = 30;
Viewport.ZoomLevel = 1.0;
}
}
// ==========================================
// SIGNALR HANDLERS
// ==========================================
/// <summary>
/// Handle StateMsg update from SignalR
/// </summary>
private void HandleStateUpdate(RobotMonitorBoardcastData state)
{
var robotId = state.RobotId;
// Get or create robot data
if (!Robots.TryGetValue(robotId, out var robotData))
{
robotData = new RobotMonitorData
{
RobotId = robotId,
ModelId = _robotModelIdCache.TryGetValue(robotId, out var modelId) ? modelId : null
};
Robots[robotId] = robotData;
// Load robot model image asynchronously
_ = LoadRobotModelImageAsync(robotData);
}
// Update position and state
robotData.Data = state;
robotData.LastUpdateTime = DateTime.UtcNow;
// If FollowRobot and this is selected robot, update viewport
if (FollowRobot && SelectedRobotId == robotId)
{
FocusOnRobot(robotId);
}
// Remove inactive robots periodically (check every update)
RemoveInactiveRobots();
OnDataChanged?.Invoke();
}
/// <summary>
/// Load robot model image and info
/// </summary>
private async Task LoadRobotModelImageAsync(RobotMonitorData robotData)
{
if (!robotData.ModelId.HasValue) return;
try
{
// Load model info if not cached
if (!_robotModelCache.TryGetValue(robotData.ModelId.Value, out var model))
{
model = await _robotModelApiService.GetByIdAsync(robotData.ModelId.Value);
if (model != null)
{
_robotModelCache[robotData.ModelId.Value] = model;
}
}
if (model != null)
{
robotData.Model = model;
}
// Check image cache first
if (_robotModelImageCache.TryGetValue(robotData.ModelId.Value, out var cachedImage))
{
robotData.ModelImageBase64 = cachedImage;
NotifyStateChanged();
return;
}
// Load image from API
var imageBase64 = await _robotModelApiService.GetImageAsync(robotData.ModelId.Value);
if (imageBase64 != null)
{
_robotModelImageCache[robotData.ModelId.Value] = imageBase64;
robotData.ModelImageBase64 = imageBase64;
NotifyStateChanged();
}
}
catch
{
// Ignore errors loading images
}
}
// ==========================================
// ROBOT SELECTION
// ==========================================
/// <summary>
/// Select a robot
/// </summary>
public void SelectRobot(string? robotId)
{
SelectedRobotId = robotId;
NotifyStateChanged();
}
// ==========================================
// VIEWPORT OPERATIONS
// ==========================================
/// <summary>
/// Pan the viewport by delta (in SVG units)
/// </summary>
public void Pan(double deltaX, double deltaY)
{
Viewport.ViewBoxX += deltaX;
Viewport.ViewBoxY += deltaY;
NotifyStateChanged(); // Use immediate for smooth panning
}
/// <summary>
/// Zoom the viewport around a point (in SVG/world coordinates)
/// </summary>
public void Zoom(double factor, double svgCenterX, double svgCenterY)
{
// Limit zoom level
var newZoom = Viewport.ZoomLevel * factor;
if (newZoom < 0.1 || newZoom > 10) return;
// Calculate the ratio of the cursor position within the current viewBox
var ratioX = (svgCenterX - Viewport.ViewBoxX) / Viewport.ViewBoxWidth;
var ratioY = (svgCenterY - Viewport.ViewBoxY) / Viewport.ViewBoxHeight;
// New dimensions after zoom
var newWidth = Viewport.ViewBoxWidth / factor;
var newHeight = Viewport.ViewBoxHeight / factor;
// Adjust ViewBox position so the cursor point stays at the same world position
Viewport.ViewBoxX = svgCenterX - ratioX * newWidth;
Viewport.ViewBoxY = svgCenterY - ratioY * newHeight;
Viewport.ViewBoxWidth = newWidth;
Viewport.ViewBoxHeight = newHeight;
Viewport.ZoomLevel = newZoom;
NotifyStateChanged(); // Use immediate for smooth zooming
}
/// <summary>
/// Zoom in/out centered at the viewport center
/// </summary>
public void ZoomAtCenter(double factor)
{
// Limit zoom level
var newZoom = Viewport.ZoomLevel * factor;
if (newZoom < 0.1 || newZoom > 10) return;
// Calculate center of current viewport
var centerX = Viewport.ViewBoxX + Viewport.ViewBoxWidth / 2;
var centerY = Viewport.ViewBoxY + Viewport.ViewBoxHeight / 2;
// New dimensions after zoom
var newWidth = Viewport.ViewBoxWidth / factor;
var newHeight = Viewport.ViewBoxHeight / factor;
// Adjust viewBox to keep the same center point
Viewport.ViewBoxX = centerX - newWidth / 2;
Viewport.ViewBoxY = centerY - newHeight / 2;
Viewport.ViewBoxWidth = newWidth;
Viewport.ViewBoxHeight = newHeight;
Viewport.ZoomLevel = newZoom;
NotifyStateChanged();
}
/// <summary>
/// Fit viewport to image bounds
/// </summary>
public void FitToScreen()
{
InitializeViewport();
NotifyStateChanged();
}
/// <summary>
/// Focus viewport on selected robot (with optional zoom)
/// </summary>
public void FocusOnRobot(string robotId, bool zoomToFit = false)
{
if (!Robots.TryGetValue(robotId, out var robot) || robot is null || robot.Data is null)
return;
// Center viewport on robot
var (X, Y) = WorldToSvg(robot.Data.AgvPosition.X, robot.Data.AgvPosition.Y);
if (zoomToFit && robot.Model != null)
{
// Zoom to fit robot with some padding
var padding = 2.0; // meters padding around robot
var robotSize = Math.Max(robot.Model.Length, robot.Model.Width);
var viewSize = robotSize + padding * 2;
Viewport.ViewBoxWidth = viewSize;
Viewport.ViewBoxHeight = viewSize;
Viewport.ZoomLevel = GetPhysicalDimensions().Width / viewSize;
}
Viewport.ViewBoxX = X - Viewport.ViewBoxWidth / 2;
Viewport.ViewBoxY = Y - Viewport.ViewBoxHeight / 2;
NotifyStateChanged();
}
// ==========================================
// COORDINATE TRANSFORM
// ==========================================
/// <summary>
/// Get physical dimensions of the layout
/// </summary>
public (double Width, double Height) GetPhysicalDimensions()
{
if (Level?.EditorSettings == null)
return (50, 30);
var settings = Level.EditorSettings;
return (
(settings.ImageWidth ?? 1000) * settings.Resolution,
(settings.ImageHeight ?? 500) * settings.Resolution
);
}
/// <summary>
/// Transform world coordinates (layout) to SVG coordinates
/// </summary>
public (double X, double Y) WorldToSvg(double worldX, double worldY)
{
var (_, physicalHeight) = GetPhysicalDimensions();
var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0;
var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0;
return (worldX - originX, physicalHeight - (worldY - originY));
}
/// <summary>
/// Transform SVG coordinates to world coordinates (layout)
/// </summary>
public (double X, double Y) SvgToWorld(double svgX, double svgY)
{
var (_, physicalHeight) = GetPhysicalDimensions();
var originX = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginX : 0;
var originY = Level != null && Level.EditorSettings != null ? Level.EditorSettings.OriginY : 0;
return (svgX + originX, physicalHeight - svgY + originY);
}
// ==========================================
// DISPLAY OPTIONS
// ==========================================
/// <summary>
/// Toggle follow robot mode
/// </summary>
public void ToggleFollowRobot()
{
FollowRobot = !FollowRobot;
NotifyStateChanged();
}
/// <summary>
/// Toggle robot info panel visibility
/// </summary>
public void ToggleRobotInfoPanel()
{
RobotInfoPanelExpanded = !RobotInfoPanelExpanded;
NotifyStateChanged();
}
// ==========================================
// UTILITIES
// ==========================================
/// <summary>
/// Remove robots that haven't been updated for a while
/// </summary>
private void RemoveInactiveRobots()
{
var now = DateTime.UtcNow;
var timeout = TimeSpan.FromSeconds(RobotTimeoutSeconds);
var robotsToRemove = new List<string>();
foreach (var kvp in Robots)
{
if (now - kvp.Value.LastUpdateTime > timeout)
{
robotsToRemove.Add(kvp.Key);
}
}
foreach (var robotId in robotsToRemove)
{
Robots.Remove(robotId);
// Clear selection if removed robot was selected
if (SelectedRobotId == robotId)
{
SelectedRobotId = null;
}
}
}
/// <summary>
/// Cleanup resources (unsubscribe from SignalR, etc.)
/// </summary>
public async Task CleanupAsync()
{
if (_hubClient != null)
{
// Unregister event handlers
_hubClient.OnMonitorBoardcastUpdate -= HandleStateUpdate;
//_hubClient.OnVisualizationUpdate -= HandleVisualizationUpdate;
_hubClient.OnConnectionError -= HandleConnectionError;
_hubClient.OnMonitorDeactivated -= HandleMonitorDeactivated;
// Unsubscribe from level before disconnecting
try
{
await _hubClient.UnsubscribeFromLevelForMonitorAsync();
}
catch
{
// Ignore unsubscribe errors
}
// Disconnect
try
{
await _hubClient.DisconnectAsync();
}
catch
{
// Ignore disconnect errors
}
}
}
/// <summary>
/// Get detailed error message from exception
/// </summary>
private static string GetDetailedErrorMessage(Exception ex, string operation)
{
var errorDetails = new System.Text.StringBuilder();
errorDetails.AppendLine($"Failed to {operation}: {ex.Message}");
// Add inner exception details if available
if (ex.InnerException != null)
{
errorDetails.AppendLine($"Inner exception: {ex.InnerException.Message}");
}
// For HttpRequestException, try to extract more details
if (ex is System.Net.Http.HttpRequestException httpEx)
{
var message = httpEx.Message;
// Extract status code if present
if (message.Contains("404"))
{
errorDetails.AppendLine("Status: 404 Not Found - The requested resource was not found on the server.");
}
else if (message.Contains("401"))
{
errorDetails.AppendLine("Status: 401 Unauthorized - Authentication required.");
}
else if (message.Contains("403"))
{
errorDetails.AppendLine("Status: 403 Forbidden - Access denied.");
}
else if (message.Contains("500"))
{
errorDetails.AppendLine("Status: 500 Internal Server Error - Server encountered an error.");
}
// Add URL if present in message
if (message.Contains("URL:"))
{
var urlStart = message.IndexOf("URL:");
if (urlStart >= 0)
{
var urlPart = message[urlStart..];
errorDetails.AppendLine(urlPart);
}
}
}
return errorDetails.ToString().Trim();
}
/// <summary>
/// Notify state changed immediately (for critical updates)
/// </summary>
public void NotifyStateChanged()
{
OnStateChanged?.Invoke();
}
}