Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -0,0 +1,181 @@
using System.Net.Http.Json;
using RobotNet10.FleetManager.Shared.DTOs.Robot;
using RobotNet10.FleetManager.Shared.DTOs.Responses;
namespace RobotNet10.FleetManager.Client.Services;
/// <summary>
/// API service for robot operations.
/// Provides methods to interact with the robot API endpoints.
/// </summary>
public class RobotApiService
{
private readonly HttpClient _httpClient;
private readonly ILogger<RobotApiService>? _logger;
public RobotApiService(HttpClient httpClient, ILogger<RobotApiService>? logger = null)
{
_httpClient = httpClient;
_logger = logger;
}
/// <summary>
/// Get all robots with optional filters
/// </summary>
public async Task<List<RobotDto>> GetAllAsync(Guid? modelId = null, Guid? mapId = null)
{
try
{
var queryParams = new List<string>();
if (modelId.HasValue)
queryParams.Add($"modelId={modelId.Value}");
if (mapId.HasValue)
queryParams.Add($"mapId={mapId.Value}");
var queryString = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : "";
var response = await _httpClient.GetAsync($"/api/robots{queryString}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<RobotDto>>() ?? new List<RobotDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting all robots");
throw;
}
}
/// <summary>
/// Get robot by ID
/// </summary>
public async Task<RobotDto?> GetByIdAsync(Guid id)
{
try
{
var response = await _httpClient.GetAsync($"/api/robots/{id}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RobotDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting robot {Id}", id);
throw;
}
}
/// <summary>
/// Get robot by RobotId (string identifier)
/// </summary>
public async Task<RobotDto?> GetByRobotIdAsync(string robotId)
{
try
{
var response = await _httpClient.GetAsync($"/api/robots/robotId/{Uri.EscapeDataString(robotId)}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RobotDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting robot with RobotId {RobotId}", robotId);
throw;
}
}
/// <summary>
/// Search robots
/// </summary>
public async Task<List<RobotDto>> SearchAsync(string query)
{
try
{
var response = await _httpClient.GetAsync($"/api/robots/search?query={Uri.EscapeDataString(query)}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<RobotDto>>() ?? new List<RobotDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error searching robots with query {Query}", query);
throw;
}
}
/// <summary>
/// Get all robots by model ID
/// </summary>
public async Task<List<RobotDto>> GetByModelIdAsync(Guid modelId)
{
try
{
var response = await _httpClient.GetAsync($"/api/robots/model/{modelId}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<RobotDto>>() ?? new List<RobotDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting robots by model {ModelId}", modelId);
throw;
}
}
/// <summary>
/// Create a new robot
/// </summary>
public async Task<RobotDto> CreateAsync(CreateRobotRequest request)
{
try
{
var response = await _httpClient.PostAsJsonAsync("/api/robots", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RobotDto>()
?? throw new InvalidOperationException("Failed to deserialize created robot");
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error creating robot");
throw;
}
}
/// <summary>
/// Update an existing robot
/// </summary>
public async Task<RobotDto> UpdateAsync(Guid id, UpdateRobotRequest request)
{
try
{
var response = await _httpClient.PutAsJsonAsync($"/api/robots/{id}", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RobotDto>()
?? throw new InvalidOperationException("Failed to deserialize updated robot");
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error updating robot {Id}", id);
throw;
}
}
/// <summary>
/// Delete a robot
/// </summary>
public async Task DeleteAsync(Guid id)
{
try
{
var response = await _httpClient.DeleteAsync($"/api/robots/{id}");
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error deleting robot {Id}", id);
throw;
}
}
}

View File

@@ -0,0 +1,253 @@
using System.Net.Http.Json;
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
using RobotNet10.FleetManager.Shared.DTOs.Responses;
namespace RobotNet10.FleetManager.Client.Services;
/// <summary>
/// API service for robot model operations.
/// Provides methods to interact with the robot model API endpoints.
/// </summary>
public class RobotModelApiService
{
private readonly HttpClient _httpClient;
private readonly ILogger<RobotModelApiService>? _logger;
public RobotModelApiService(HttpClient httpClient, ILogger<RobotModelApiService>? logger = null)
{
_httpClient = httpClient;
_logger = logger;
}
/// <summary>
/// Get all robot models
/// </summary>
public async Task<List<RobotModelDto>> GetAllAsync()
{
try
{
var response = await _httpClient.GetAsync("/api/robot-models");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<RobotModelDto>>() ?? new List<RobotModelDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting all robot models");
throw;
}
}
/// <summary>
/// Get robot model by ID
/// </summary>
public async Task<RobotModelDto?> GetByIdAsync(Guid id)
{
try
{
var response = await _httpClient.GetAsync($"/api/robot-models/{id}");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RobotModelDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting robot model {Id}", id);
throw;
}
}
/// <summary>
/// Search robot models
/// </summary>
public async Task<List<RobotModelDto>> SearchAsync(string query)
{
try
{
var response = await _httpClient.GetAsync($"/api/robot-models/search?query={Uri.EscapeDataString(query)}");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<List<RobotModelDto>>() ?? new List<RobotModelDto>();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error searching robot models with query {Query}", query);
throw;
}
}
/// <summary>
/// Get usage information for a robot model
/// </summary>
public async Task<RobotModelUsageInfoDto> GetUsageInfoAsync(Guid id)
{
try
{
var response = await _httpClient.GetAsync($"/api/robot-models/{id}/usage");
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RobotModelUsageInfoDto>()
?? throw new InvalidOperationException("Failed to deserialize usage info");
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting usage info for robot model {Id}", id);
throw;
}
}
/// <summary>
/// Create a new robot model
/// </summary>
public async Task<RobotModelDto> CreateAsync(CreateRobotModelRequest request)
{
try
{
var response = await _httpClient.PostAsJsonAsync("/api/robot-models", request);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger?.LogError("Error creating robot model. Status: {StatusCode}, Response: {Error}",
response.StatusCode, errorContent);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
throw new InvalidOperationException($"Validation error: {errorContent}");
}
response.EnsureSuccessStatusCode();
}
return await response.Content.ReadFromJsonAsync<RobotModelDto>()
?? throw new InvalidOperationException("Failed to deserialize created robot model");
}
catch (HttpRequestException ex)
{
_logger?.LogError(ex, "HTTP error creating robot model");
throw;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error creating robot model");
throw;
}
}
/// <summary>
/// Update an existing robot model
/// </summary>
public async Task<RobotModelDto> UpdateAsync(Guid id, UpdateRobotModelRequest request)
{
try
{
var response = await _httpClient.PutAsJsonAsync($"/api/robot-models/{id}", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<RobotModelDto>()
?? throw new InvalidOperationException("Failed to deserialize updated robot model");
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error updating robot model {Id}", id);
throw;
}
}
/// <summary>
/// Delete a robot model
/// </summary>
public async Task DeleteAsync(Guid id)
{
try
{
var response = await _httpClient.DeleteAsync($"/api/robot-models/{id}");
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error deleting robot model {Id}", id);
throw;
}
}
/// <summary>
/// Get robot model image
/// </summary>
public async Task<string?> GetImageAsync(Guid id)
{
try
{
var response = await _httpClient.GetAsync($"/api/robot-models/{id}/image");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
response.EnsureSuccessStatusCode();
var imageBytes = await response.Content.ReadAsByteArrayAsync();
return Convert.ToBase64String(imageBytes);
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error getting image for robot model {Id}", id);
throw;
}
}
/// <summary>
/// Upload robot model image
/// </summary>
public async Task UploadImageAsync(Guid id, Stream imageStream, string fileName)
{
try
{
using var content = new MultipartFormDataContent();
using var streamContent = new StreamContent(imageStream);
streamContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("image/png");
content.Add(streamContent, "file", fileName);
var response = await _httpClient.PostAsync($"/api/robot-models/{id}/image", content);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger?.LogError("Error uploading image. Status: {StatusCode}, Response: {Error}",
response.StatusCode, errorContent);
if (response.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
throw new InvalidOperationException($"Image upload validation error: {errorContent}");
}
response.EnsureSuccessStatusCode();
}
}
catch (HttpRequestException ex)
{
_logger?.LogError(ex, "HTTP error uploading image for robot model {Id}", id);
throw;
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error uploading image for robot model {Id}", id);
throw;
}
}
/// <summary>
/// Delete robot model image
/// </summary>
public async Task DeleteImageAsync(Guid id)
{
try
{
var response = await _httpClient.DeleteAsync($"/api/robot-models/{id}/image");
response.EnsureSuccessStatusCode();
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error deleting image for robot model {Id}", id);
throw;
}
}
}

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();
}
}

View File

@@ -0,0 +1,227 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
using RobotNet.VDA5050.State;
using RobotNet10.FleetManager.Shared.DTOs.Robot;
namespace RobotNet10.FleetManager.Client.Services;
/// <summary>
/// SignalR Hub Client for robot state and visualization updates.
/// Manages connection to the SignalR hub and provides methods to subscribe/unsubscribe to robot updates.
/// </summary>
/// <remarks>
/// This client automatically handles reconnection and provides events for state and visualization updates.
/// Dispose the client when done to properly clean up the connection.
/// </remarks>
public class RobotStateHubClient : IAsyncDisposable
{
private readonly HubConnection _hubConnection;
private readonly ILogger<RobotStateHubClient>? _logger;
public event Action<RobotMonitorBoardcastData>? OnMonitorBoardcastUpdate;
public event Action<StateMsg>? OnStateUpdate;
public event Action<string>? OnConnectionError;
public event Action? OnMonitorDeactivated;
public RobotStateHubClient(NavigationManager navigationManager, ILogger<RobotStateHubClient>? logger = null)
{
_logger = logger;
var hubUrl = navigationManager.ToAbsoluteUri("/hubs/robot-state");
_hubConnection = new HubConnectionBuilder()
.WithUrl(hubUrl)
.WithAutomaticReconnect()
.Build();
// Register event handlers
_hubConnection.On<StateMsg>("OnStateUpdate", (state) =>
{
OnStateUpdate?.Invoke(state);
});
_hubConnection.On<RobotMonitorBoardcastData>("OnMonitorUpdate", (state) =>
{
OnMonitorBoardcastUpdate?.Invoke(state);
});
_hubConnection.On("OnMonitorDeactivated", () =>
{
OnMonitorDeactivated?.Invoke();
});
_hubConnection.Closed += async (error) =>
{
if (error != null)
{
_logger?.LogError(error, "SignalR connection closed with error");
OnConnectionError?.Invoke(error.Message);
}
else
{
_logger?.LogInformation("SignalR connection closed");
}
await Task.CompletedTask;
};
_hubConnection.Reconnecting += async (error) =>
{
_logger?.LogWarning(error, "SignalR connection reconnecting");
await Task.CompletedTask;
};
_hubConnection.Reconnected += async (connectionId) =>
{
_logger?.LogInformation("SignalR connection reconnected with ID {ConnectionId}", connectionId);
await Task.CompletedTask;
};
}
/// <summary>
/// Connect to the SignalR hub
/// </summary>
public async Task ConnectAsync()
{
try
{
if (_hubConnection.State == HubConnectionState.Disconnected)
{
await _hubConnection.StartAsync();
_logger?.LogInformation("SignalR hub connected");
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error connecting to SignalR hub");
OnConnectionError?.Invoke(ex.Message);
throw;
}
}
/// <summary>
/// Disconnect from the SignalR hub
/// </summary>
public async Task DisconnectAsync()
{
try
{
if (_hubConnection.State != HubConnectionState.Disconnected)
{
await _hubConnection.StopAsync();
_logger?.LogInformation("SignalR hub disconnected");
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error disconnecting from SignalR hub");
throw;
}
}
/// <summary>
/// Subscribe to receive updates for a specific robot
/// </summary>
/// <param name="robotId">Robot identifier (serialNumber)</param>
public async Task SubscribeToRobotAsync(string robotId)
{
try
{
if (_hubConnection.State == HubConnectionState.Connected)
{
await _hubConnection.InvokeAsync("SubscribeToRobot", robotId);
_logger?.LogInformation("Subscribed to robot {RobotId}", robotId);
}
else
{
_logger?.LogWarning("Cannot subscribe to robot {RobotId}: Hub not connected", robotId);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error subscribing to robot {RobotId}", robotId);
throw;
}
}
/// <summary>
/// Unsubscribe from updates for a specific robot
/// </summary>
/// <param name="robotId">Robot identifier (serialNumber)</param>
public async Task UnsubscribeFromRobotAsync(string robotId)
{
try
{
if (_hubConnection.State == HubConnectionState.Connected)
{
await _hubConnection.InvokeAsync("UnsubscribeFromRobot", robotId);
_logger?.LogInformation("Unsubscribed from robot {RobotId}", robotId);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error unsubscribing from robot {RobotId}", robotId);
throw;
}
}
/// <summary>
/// Subscribe to receive monitor updates for a specific levelId
/// Each connection can only subscribe to one levelId at a time.
/// Maximum 5 connections per levelId (FIFO).
/// </summary>
/// <param name="levelId">Level identifier (LayoutLevel.Id)</param>
public async Task SubscribeToLevelForMonitorAsync(Guid levelId)
{
try
{
if (_hubConnection.State == HubConnectionState.Connected)
{
await _hubConnection.InvokeAsync("SubscribeToLevelForMonitor", levelId);
_logger?.LogInformation("Subscribed to level {LevelId} for monitor", levelId);
}
else
{
_logger?.LogWarning("Cannot subscribe to level {LevelId}: Hub not connected", levelId);
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error subscribing to level {LevelId} for monitor", levelId);
throw;
}
}
/// <summary>
/// Unsubscribe from monitor updates for the current level
/// </summary>
public async Task UnsubscribeFromLevelForMonitorAsync()
{
try
{
if (_hubConnection.State == HubConnectionState.Connected)
{
await _hubConnection.InvokeAsync("UnsubscribeFromLevelForMonitor");
_logger?.LogInformation("Unsubscribed from level for monitor");
}
}
catch (Exception ex)
{
_logger?.LogError(ex, "Error unsubscribing from level for monitor");
throw;
}
}
/// <summary>
/// Get the current connection state
/// </summary>
public HubConnectionState ConnectionState => _hubConnection.State;
public async ValueTask DisposeAsync()
{
if (_hubConnection != null)
{
await DisconnectAsync();
await _hubConnection.DisposeAsync();
}
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,23 @@
using RobotNet10.ScriptEngine.Shared;
using System.Collections.Immutable;
using RobotNet10.FleetManager.Script.Shared;
namespace RobotNet10.FleetManager.Client.Services;
public class ScriptEngineResource : IScriptEngineResource
{
public Type AppGlobalType => FleetManagerScriptEngineResource.GlobalType;
public ImmutableArray<string> UsingNamespaces => FleetManagerScriptEngineResource.UsingNamespaces;
public ImmutableArray<string> Modules => FleetManagerScriptEngineResource.Modules;
public ImmutableArray<string> DocModules => FleetManagerScriptEngineResource.DocModules;
public IDictionary<string, object?> GetMissionGlobals(Guid id, CancellationToken cancellationToken)
=> new Dictionary<string, object?>();
public IDictionary<string, object?> GetTaskGlobals()
=> new Dictionary<string, object?>();
}