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; /// /// Viewport state for SVG canvas /// 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}"; } /// /// Robot data for monitoring /// 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; } } /// /// State management for RobotMonitor page /// public class RobotMonitorState( MapManagerApiService mapApiService, RobotApiService robotApiService, RobotModelApiService robotModelApiService, RobotStateHubClient? hubClient = null) { // ===== DATA ===== public List Layouts { get; private set; } = []; public List AvailableVersions { get; private set; } = []; public List AvailableLevels { get; private set; } = []; public List 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 Nodes { get; private set; } = []; public List Edges { get; private set; } = []; public byte[]? BackgroundImage { get; private set; } // ===== ROBOTS ===== public Dictionary 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 _robotModelImageCache = []; private readonly Dictionary _robotModelCache = []; // modelId -> RobotModelDto private readonly Dictionary _robotModelIdCache = []; // robotId -> modelId // ===== TIMEOUT CONFIGURATION ===== private const int RobotTimeoutSeconds = 10; // Remove robots after 10 seconds of no updates /// /// Initialize monitor state /// 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(); } /// /// Load all layouts /// 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(); } /// /// Handle layout selection change /// 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(); } /// /// Handle version selection change /// 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(); } /// /// Handle level selection change /// 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(); } /// /// Handle robot selection change (from dropdown) /// public void OnRobotSelected(string? robotId) { SelectRobot(robotId); } /// /// Load layout data for selected level /// 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(); } /// /// Load robots from API /// 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 = []; } } /// /// Initialize SignalR connection /// 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; } } /// /// Handle monitor deactivated event (when connection is evicted due to max connections) /// private void HandleMonitorDeactivated() { IsMonitorDeactivated = true; ErrorMessage = "Monitor connection deactivated: Maximum 5 connections per level reached. Another connection has taken your place."; NotifyStateChanged(); } /// /// Handle SignalR connection error /// private void HandleConnectionError(string error) { ErrorMessage = $"SignalR connection error: {error}"; NotifyStateChanged(); } /// /// Initialize viewport to fit the image bounds /// 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 // ========================================== /// /// Handle StateMsg update from SignalR /// 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(); } /// /// Load robot model image and info /// 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 // ========================================== /// /// Select a robot /// public void SelectRobot(string? robotId) { SelectedRobotId = robotId; NotifyStateChanged(); } // ========================================== // VIEWPORT OPERATIONS // ========================================== /// /// Pan the viewport by delta (in SVG units) /// public void Pan(double deltaX, double deltaY) { Viewport.ViewBoxX += deltaX; Viewport.ViewBoxY += deltaY; NotifyStateChanged(); // Use immediate for smooth panning } /// /// Zoom the viewport around a point (in SVG/world coordinates) /// 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 } /// /// Zoom in/out centered at the viewport center /// 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(); } /// /// Fit viewport to image bounds /// public void FitToScreen() { InitializeViewport(); NotifyStateChanged(); } /// /// Focus viewport on selected robot (with optional zoom) /// 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 // ========================================== /// /// Get physical dimensions of the layout /// 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 ); } /// /// Transform world coordinates (layout) to SVG coordinates /// 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)); } /// /// Transform SVG coordinates to world coordinates (layout) /// 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 // ========================================== /// /// Toggle follow robot mode /// public void ToggleFollowRobot() { FollowRobot = !FollowRobot; NotifyStateChanged(); } /// /// Toggle robot info panel visibility /// public void ToggleRobotInfoPanel() { RobotInfoPanelExpanded = !RobotInfoPanelExpanded; NotifyStateChanged(); } // ========================================== // UTILITIES // ========================================== /// /// Remove robots that haven't been updated for a while /// private void RemoveInactiveRobots() { var now = DateTime.UtcNow; var timeout = TimeSpan.FromSeconds(RobotTimeoutSeconds); var robotsToRemove = new List(); 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; } } } /// /// Cleanup resources (unsubscribe from SignalR, etc.) /// 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 } } } /// /// Get detailed error message from exception /// 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(); } /// /// Notify state changed immediately (for critical updates) /// public void NotifyStateChanged() { OnStateChanged?.Invoke(); } }