diff --git a/RobotApp.Client/MainLayout.razor b/RobotApp.Client/MainLayout.razor index b066067..fe69899 100644 --- a/RobotApp.Client/MainLayout.razor +++ b/RobotApp.Client/MainLayout.razor @@ -73,7 +73,7 @@ // new(){Icon = "mdi-map-legend", Path="/maps-manager", Label = "Mapping", Match = NavLinkMatch.All}, new(){Icon = "mdi-monitor", Path="/robot-monitor", Label = "Robot Monitor", Match = NavLinkMatch.All}, new(){Icon = "mdi-application-cog", Path="/robot-config", Label = "Config", Match = NavLinkMatch.All}, - new(){Icon = "mdi-application-cog", Path="/robot-state", Label = "state", Match = NavLinkMatch.All}, + new(){Icon = "mdi-state-machine", Path="/robot-state", Label = "state", Match = NavLinkMatch.All}, ]; private bool collapseNavMenu = true; diff --git a/RobotApp.Client/Pages/Home.razor b/RobotApp.Client/Pages/Home.razor index 2d0d52f..47da492 100644 --- a/RobotApp.Client/Pages/Home.razor +++ b/RobotApp.Client/Pages/Home.razor @@ -1,6 +1,7 @@ @page "/" @using System.Text.Json @using System.Text.Json.Serialization +@using RobotApp.Client.Services @using RobotApp.VDA5050.InstantAction @using RobotApp.VDA5050.Order @using RobotApp.VDA5050.Type @@ -41,7 +42,7 @@ @foreach (var node in Order.Nodes) { - + @@ -226,51 +227,107 @@
+ @foreach (var edge in Order.Edges) { - - - - - - @foreach (var node in Order.Nodes) - { - - @node.NodeId - - } - - + - - - @foreach (var node in Order.Nodes) - { - - @node.NodeId - - } - - + + + + + + + + + + + + @foreach (var node in Order.Nodes) + { + + @node.NodeId + + } + + + + + + + @foreach (var node in Order.Nodes) + { + + @node.NodeId + + } + + + + + + + + + + @if (edge.Radius > 0) + { + + + Góc I + Góc II + Góc III + Góc IV + + + } + @if (edge.Radius > 0 && !edge.Expanded) + { + + + Apply Curve (tạo node) + + + } + + + + Remove Edge + + + + + - - Remove Edge - - } +
+ @@ -369,6 +426,25 @@ bool IsEditNodeOpen; Node? EditingNode; OrderMessage Order = new(); + void ApplyCurve(UiEdge edge) + { + if (edge.Radius <= 0 || edge.Expanded) + return; + + var startNode = Order.Nodes.First(n => n.NodeId == edge.StartNodeId); + + // 1️⃣ Sinh node C (điểm kết thúc cung tròn) + var endNode = OrderMessage.CreateCurveNode(startNode, edge); + Order.Nodes.Add(endNode); + + // 2️⃣ Edge trở thành A → C (đường cong) + edge.EndNodeId = endNode.NodeId; + edge.Expanded = true; + + // 3️⃣ Resequence node (đúng chuẩn VDA5050) + for (int i = 0; i < Order.Nodes.Count; i++) + Order.Nodes[i].SequenceId = i; + } async Task SendOrderToServer() { @@ -413,7 +489,7 @@ NodeId = $"NODE_{Order.Nodes.Count + 1}", SequenceId = Order.Nodes.Count, Released = true, - NodePosition = new NodePosition() + NodePosition = new NodePosition { MapId = "MAP_01" } }); } @@ -469,7 +545,7 @@ { if (Order.Nodes.Count < 2) return; - Order.Edges.Add(new VDA5050.Order.Edge + Order.Edges.Add(new RobotApp.Client.Services.UiEdge { EdgeId = $"EDGE_{Order.Edges.Count + 1}", StartNodeId = Order.Nodes[^2].NodeId, @@ -507,7 +583,7 @@ } - void RemoveEdge(VDA5050.Order.Edge edge) + void RemoveEdge(RobotApp.Client.Services.UiEdge edge) { Order.Edges.Remove(edge); } @@ -522,113 +598,6 @@ }); - public class UiActionParameter : ActionParameter - { - [JsonIgnore] - public string ValueString - { - get => Value?.ToString() ?? ""; - set => Value = value; - } - } - - public class OrderMessage - { - public int HeaderId { get; set; } - public string Timestamp { get; set; } = ""; - public string Version { get; set; } = "v1"; - public string Manufacturer { get; set; } = "PNKX"; - public string SerialNumber { get; set; } = "AMR-01"; - public string OrderId { get; set; } = Guid.NewGuid().ToString(); - public int OrderUpdateId { get; set; } - public string? ZoneSetId { get; set; } - public List Nodes { get; set; } = new(); - public List Edges { get; set; } = new(); - - public object ToSchemaObject() - { - int seq = 0; - - return new - { - headerId = HeaderId++, - - timestamp = string.IsNullOrWhiteSpace(Timestamp) - ? DateTime.UtcNow.ToString("O") - : Timestamp, - - version = Version, - manufacturer = Manufacturer, - serialNumber = SerialNumber, - orderId = OrderId, - orderUpdateId = OrderUpdateId, - - zoneSetId = string.IsNullOrWhiteSpace(ZoneSetId) - ? null - : ZoneSetId, - - // ================= NODES ================= - nodes = Nodes - .Select(n => new - { - nodeId = n.NodeId, - sequenceId = seq++, - released = n.Released, - - nodePosition = new - { - x = n.NodePosition.X, - y = n.NodePosition.Y, - theta = n.NodePosition.Theta, - - allowedDeviationXY = n.NodePosition.AllowedDeviationXY, - allowedDeviationTheta = n.NodePosition.AllowedDeviationTheta, - - mapId = string.IsNullOrWhiteSpace(n.NodePosition.MapId) - ? "MAP_01" - : n.NodePosition.MapId - }, - - actions = n.Actions - .Select(a => new - { - actionId = a.ActionId, - actionType = a.ActionType, - blockingType = a.BlockingType, - - actionParameters = a.ActionParameters != null - ? a.ActionParameters - .Select(p => new - { - key = p.Key, - value = p.Value - }) - .ToArray() - : Array.Empty() - }) - .ToArray() - }) - .ToArray(), // ✅ QUAN TRỌNG - - // ================= EDGES ================= - edges = Edges - .Select(e => new - { - edgeId = e.EdgeId, - sequenceId = seq++, - released = true, - - startNodeId = e.StartNodeId, - endNodeId = e.EndNodeId, - - actions = Array.Empty() - }) - .ToArray() // ✅ QUAN TRỌNG - }; - } - - } - private async Task OpenEditNodeDialog(Node node) { var parameters = new DialogParameters @@ -649,4 +618,4 @@ StateHasChanged(); } -} \ No newline at end of file +} diff --git a/RobotApp.Client/Pages/State.razor b/RobotApp.Client/Pages/State.razor index ec708c9..da16797 100644 --- a/RobotApp.Client/Pages/State.razor +++ b/RobotApp.Client/Pages/State.razor @@ -1,8 +1,9 @@ @page "/robot-state" +@using RobotApp.Client.Services @using RobotApp.VDA5050.State -@rendermode InteractiveWebAssemblyNoPrerender - -@inject StateMsg RobotState +@inject RobotStateClient RobotStateClient +@implements IDisposable +@rendermode InteractiveWebAssembly @@ -11,34 +12,46 @@
🤖 VDA 5050 Robot Dashboard - - @RobotState.Version • - @RobotState.Manufacturer - @RobotState.SerialNumber - + @if (CurrentState != null) + { + + @CurrentState.Version • + @CurrentState.Manufacturer • + @CurrentState.SerialNumber + + } + else + { + + Connecting to robot... + + }
- @* @if (RobotState.Current != null) + + @if (CurrentState != null) { - @(RobotState.Current.IsOnline ? "ONLINE" : "OFFLINE") + Color="@(IsConnected ? Color.Success : Color.Error)" + Variant="Variant.Filled"> + @(IsConnected ? "ONLINE" : "OFFLINE") - } *@ + }
- @if (RobotState == null) + @if (CurrentState == null) { - - Waiting for robot state (VDA5050)... + + Waiting for robot state data... + Connecting to SignalR hub and subscribing to robot updates. } else { - var msg = RobotState; + var msg = CurrentState; - + @@ -56,9 +69,7 @@ OrderUpdateId - - @msg.OrderUpdateId - + @msg.OrderUpdateId @@ -104,7 +115,7 @@ - DeviationRange: @msg.AgvPosition.DeviationRange + Deviation: @msg.AgvPosition.DeviationRange @@ -141,7 +152,7 @@ @msg.BatteryState.BatteryVoltage.ToString("F1") V - Health + Health (SOH) @msg.BatteryState.BatteryHealth % @@ -164,16 +175,14 @@ Last Node: @msg.LastNodeId Seq: @msg.LastNodeSequenceId - Distance: @msg.DistanceSinceLastNode:F1 m + Distance since last node: @msg.DistanceSinceLastNode:F1 m - @{ var nodeReleased = msg.NodeStates?.Count(n => n.Released) ?? 0; var nodeTotal = msg.NodeStates?.Length ?? 0; var edgeReleased = msg.EdgeStates?.Count(e => e.Released) ?? 0; var edgeTotal = msg.EdgeStates?.Length ?? 0; } -
Nodes: @nodeReleased / @nodeTotal Edges: @edgeReleased / @edgeTotal @@ -190,175 +199,89 @@ - 🚨 Errors & Information - - @{ - var rows = new List(); - - if (msg.Errors != null) - { - foreach (var err in msg.Errors) - { - rows.Add(new MessageRow( - err.ErrorType ?? "-", - err.ErrorLevel ?? "-", - err.ErrorDescription ?? "", - true - )); - } - } - - if (msg.Information != null) - { - foreach (var info in msg.Information) - { - rows.Add(new MessageRow( - info.InfoType ?? "-", - info.InfoLevel ?? "-", - info.InfoDescription ?? "", - false - )); - } - } - - var sortedMessages = rows - .OrderBy(r => r.IsError ? 0 : 1) // Errors trước - .ThenBy(r => r.Type) - .ToList(); - } - - - + Height="200px" + FixedHeader="true"> - Type Level Description - - + @context.Type - - - - @context.Level - - - - - + + + @context.Level + + + + @context.Description - - + No errors or information messages - - - + - ⚙️ Actions - - - - - - - - Action Action ID Status - - - - @context.ActionType - + @context.ActionType + @context.ActionId + + + @context.ActionStatus + - - - - @context.ActionId - - - - - - @context.ActionStatus - - - - + - + No active actions - - @@ -377,21 +300,75 @@ - } @code { + private StateMsg? CurrentState; + private bool IsConnected => RobotStateClient.LatestStates.ContainsKey(RobotSerial); - private async Task OnStateChanged() - => await InvokeAsync(StateHasChanged); + // Thay bằng serial number thật của robot bạn muốn theo dõi + private readonly string RobotSerial = "T800-002"; + private List MessageRows = new(); - private record MessageRow( - string Type, - string Level, - string Description, - bool IsError - ); + protected override async Task OnInitializedAsync() + { + // Subscribe sự kiện nhận state + RobotStateClient.OnStateReceived += OnRobotStateReceived; + + // Bắt đầu kết nối SignalR (nếu chưa) + if (RobotStateClient.LatestStates.Count == 0) + { + await RobotStateClient.StartAsync(); + } + + // Đăng ký theo dõi robot cụ thể + await RobotStateClient.SubscribeRobotAsync(RobotSerial); + + // Lấy state hiện tại nếu đã có + CurrentState = RobotStateClient.GetLatestState(RobotSerial); + UpdateMessageRows(); + } + + private void OnRobotStateReceived(string serialNumber, StateMsg state) + { + if (serialNumber != RobotSerial) return; + + InvokeAsync(() => + { + CurrentState = state; + UpdateMessageRows(); + StateHasChanged(); + }); + } + + private void UpdateMessageRows() + { + MessageRows.Clear(); + + if (CurrentState?.Errors != null) + { + foreach (var err in CurrentState.Errors) + { + MessageRows.Add(new MessageRow(err.ErrorType ?? "-", err.ErrorLevel ?? "ERROR", err.ErrorDescription ?? "", true)); + } + } + + if (CurrentState?.Information != null) + { + foreach (var info in CurrentState.Information) + { + MessageRows.Add(new MessageRow(info.InfoType ?? "-", info.InfoLevel ?? "INFO", info.InfoDescription ?? "", false)); + } + } + } + + public void Dispose() + { + RobotStateClient.OnStateReceived -= OnRobotStateReceived; + } + + private record MessageRow(string Type, string Level, string Description, bool IsError); } \ No newline at end of file diff --git a/RobotApp.Client/Program.cs b/RobotApp.Client/Program.cs index 3dcc49a..a9a910c 100644 --- a/RobotApp.Client/Program.cs +++ b/RobotApp.Client/Program.cs @@ -1,5 +1,6 @@ using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using MudBlazor.Services; +using RobotApp.Client.Services; using System.Globalization; CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); @@ -11,6 +12,8 @@ builder.Services.AddAuthenticationStateDeserialization(); builder.Services.AddScoped(_ => new HttpClient() { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); builder.Services.AddScoped(); +builder.Services.AddScoped(); + builder.Services.AddMudServices(config => { config.SnackbarConfiguration.VisibleStateDuration = 2000; diff --git a/RobotApp.Client/Services/RobotStateClient.cs b/RobotApp.Client/Services/RobotStateClient.cs new file mode 100644 index 0000000..ecc7005 --- /dev/null +++ b/RobotApp.Client/Services/RobotStateClient.cs @@ -0,0 +1,159 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.SignalR.Client; +using RobotApp.Common.Shares; +using RobotApp.VDA5050.State; +using System.Collections.Concurrent; +using System.Text.Json; + +namespace RobotApp.Client.Services; + +public class RobotStateClient : IAsyncDisposable +{ + private readonly NavigationManager _nav; + private HubConnection? _connection; + private bool _started; + private readonly object _lock = new(); + public ConcurrentDictionary LatestStates { get; } = new(); + + public event Action? OnStateReceived; + public event Action? OnStateReceivedAny; + + public RobotStateClient(NavigationManager nav) + { + _nav = nav; + } + + public async Task StartAsync(string hubPath = "/hubs/robot") + { + lock (_lock) + { + if (_started) + return; + + _started = true; + } + + if (string.IsNullOrWhiteSpace(hubPath)) + throw new ArgumentException("Hub path is empty", nameof(hubPath)); + + var hubUri = _nav.ToAbsoluteUri(hubPath); + Console.WriteLine($"[SIGNALR] Connecting to {hubUri}"); + + _connection = new HubConnectionBuilder() + .WithUrl(hubUri) + .WithAutomaticReconnect(new[] + { + TimeSpan.Zero, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(10) + }) + .Build(); + + _connection.Closed += async error => + { + Console.WriteLine($"[SIGNALR] Connection closed: {error?.Message}"); + _started = false; + await Task.Delay(3000); + + if (_connection != null) + { + try + { + await StartAsync(hubPath); + } + catch { } + } + }; + + _connection.Reconnecting += error => + { + Console.WriteLine($"[SIGNALR] Reconnecting... {error?.Message}"); + return Task.CompletedTask; + }; + + _connection.Reconnected += connectionId => + { + Console.WriteLine($"[SIGNALR] Reconnected: {connectionId}"); + return Task.CompletedTask; + }; + + _connection.On("ReceiveState", stateJson => + { + try + { + var state = JsonSerializer.Deserialize( + stateJson, + JsonOptionExtends.Read + ); + + if (state?.SerialNumber == null) + return; + + LatestStates[state.SerialNumber] = state; + + OnStateReceived?.Invoke(state.SerialNumber, state); + OnStateReceivedAny?.Invoke(state); + + Console.WriteLine( + $"[CLIENT] {state.SerialNumber} | " + + $"X={state.AgvPosition?.X:F2}, " + + $"Y={state.AgvPosition?.Y:F2}, " + + $"Battery={state.BatteryState?.BatteryCharge:F1}%" + ); + } + catch (Exception ex) + { + Console.WriteLine($"[CLIENT] Deserialize error: {ex.Message}"); + } + }); + + try + { + await _connection.StartAsync(); + Console.WriteLine("[SIGNALR] Connected successfully!"); + } + catch (Exception ex) + { + _started = false; + Console.WriteLine($"❌ [SIGNALR] Connection failed: {ex.Message}"); + } + } + + public async Task SubscribeRobotAsync(string serialNumber) + { + if (_connection?.State != HubConnectionState.Connected) + throw new InvalidOperationException("SignalR is not connected"); + + await _connection.InvokeAsync("JoinRobot", serialNumber); + Console.WriteLine($"[SIGNALR] Subscribed to {serialNumber}"); + } + + public async Task UnsubscribeRobotAsync(string serialNumber) + { + if (_connection?.State == HubConnectionState.Connected) + { + await _connection.InvokeAsync("LeaveRobot", serialNumber); + } + + LatestStates.TryRemove(serialNumber, out _); + Console.WriteLine($"[SIGNALR] Unsubscribed from {serialNumber}"); + } + + public StateMsg? GetLatestState(string serialNumber) + { + LatestStates.TryGetValue(serialNumber, out var state); + return state; + } + + public async ValueTask DisposeAsync() + { + _started = false; + + if (_connection != null) + { + await _connection.DisposeAsync(); + _connection = null; + Console.WriteLine("[SIGNALR] Disposed"); + } + } +} diff --git a/RobotApp.Client/Services/UiEdge.cs b/RobotApp.Client/Services/UiEdge.cs new file mode 100644 index 0000000..ce083ca --- /dev/null +++ b/RobotApp.Client/Services/UiEdge.cs @@ -0,0 +1,257 @@ +using RobotApp.VDA5050.InstantAction; +using RobotApp.VDA5050.Order; +using System.Text.Json.Serialization; + +namespace RobotApp.Client.Services; + +// ====================================================== +// EDGE UI +// ====================================================== +public class UiEdge : VDA5050.Order.Edge +{ + public double Radius { get; set; } = 0.0; + public Quadrant Quadrant { get; set; } = Quadrant.I; + + // Đánh dấu đã expand chưa + public bool Expanded { get; set; } = false; +} + +public enum Quadrant +{ + I, + II, + III, + IV +} + +// ====================================================== +// GEOMETRY MODELS +// ====================================================== +public record Point(double X, double Y); + +public record QuarterResult( + Point EndPoint, + object Trajectory +); + +// ====================================================== +// GEOMETRY HELPER (QUARTER CIRCLE) +// ====================================================== +public static class QuarterGeometry +{ + private const double K = 0.5522847498307936; + + public static QuarterResult BuildQuarterTrajectory( + Point A, + double r, + Quadrant q + ) + { + Point P1, P2, C; + + switch (q) + { + case Quadrant.I: + P1 = new(A.X, A.Y + K * r); + P2 = new(A.X + K * r, A.Y + r); + C = new(A.X + r, A.Y + r); + break; + + case Quadrant.II: + P1 = new(A.X - K * r, A.Y); + P2 = new(A.X - r, A.Y + K * r); + C = new(A.X - r, A.Y + r); + break; + + case Quadrant.III: + P1 = new(A.X, A.Y - K * r); + P2 = new(A.X - K * r, A.Y - r); + C = new(A.X - r, A.Y - r); + break; + + case Quadrant.IV: + P1 = new(A.X + K * r, A.Y); + P2 = new(A.X + r, A.Y - K * r); + C = new(A.X + r, A.Y - r); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(q)); + } + + return new QuarterResult( + C, + new + { + degree = 3, + knotVector = new[] { 0, 0, 0, 0, 1, 1, 1, 1 }, + controlPoints = new[] + { + new { x = A.X, y = A.Y }, // P0 + new { x = P1.X, y = P1.Y }, // P1 + new { x = P2.X, y = P2.Y }, // P2 + new { x = C.X, y = C.Y } // P3 + } + } + ); + } +} +// ====================================================== +// ORDER MESSAGE +// ====================================================== +public class OrderMessage +{ + public int HeaderId { get; set; } + public string Timestamp { get; set; } = ""; + public string Version { get; set; } = "v1"; + public string Manufacturer { get; set; } = "PNKX"; + public string SerialNumber { get; set; } = "T800-002"; + public string OrderId { get; set; } = Guid.NewGuid().ToString(); + public int OrderUpdateId { get; set; } + public string? ZoneSetId { get; set; } + + public List Nodes { get; set; } = new(); + public List Edges { get; set; } = new(); + public static Node CreateCurveNode(Node startNode, UiEdge edge) + { + var A = new Point( + startNode.NodePosition.X, + startNode.NodePosition.Y + ); + + var result = QuarterGeometry.BuildQuarterTrajectory( + A, + edge.Radius, + edge.Quadrant + ); + + return new Node + { + NodeId = $"NODE_C_{Guid.NewGuid():N}".Substring(0, 12), + Released = true, + NodePosition = new NodePosition + { + X = result.EndPoint.X, + Y = result.EndPoint.Y, + Theta = startNode.NodePosition.Theta, + MapId = startNode.NodePosition.MapId + } + }; + } + + public object ToSchemaObject() + { + int seq = 0; + + return new + { + headerId = HeaderId++, + + timestamp = string.IsNullOrWhiteSpace(Timestamp) + ? DateTime.UtcNow.ToString("O") + : Timestamp, + + version = Version, + manufacturer = Manufacturer, + serialNumber = SerialNumber, + orderId = OrderId, + orderUpdateId = OrderUpdateId, + + zoneSetId = string.IsNullOrWhiteSpace(ZoneSetId) + ? null + : ZoneSetId, + + // ================= NODES ================= + nodes = Nodes.Select(n => new + { + nodeId = n.NodeId, + sequenceId = seq++, + released = n.Released, + + nodePosition = new + { + x = n.NodePosition.X, + y = n.NodePosition.Y, + theta = n.NodePosition.Theta, + + allowedDeviationXY = n.NodePosition.AllowedDeviationXY, + allowedDeviationTheta = n.NodePosition.AllowedDeviationTheta, + + mapId = string.IsNullOrWhiteSpace(n.NodePosition.MapId) + ? "MAP_01" + : n.NodePosition.MapId + }, + + actions = n.Actions.Select(a => new + { + actionId = a.ActionId, + actionType = a.ActionType, + blockingType = a.BlockingType, + + actionParameters = a.ActionParameters != null + ? a.ActionParameters + .Select(p => new { key = p.Key, value = p.Value }) + .ToArray() + : Array.Empty() + }).ToArray() + }).ToArray(), + + // ================= EDGES ================= + edges = Edges.Select(e => + { + // ---------- ĐƯỜNG THẲNG ---------- + if (e.Radius <= 0) + { + return new + { + edgeId = e.EdgeId, + sequenceId = seq++, + released = true, + startNodeId = e.StartNodeId, + endNodeId = e.EndNodeId, + actions = Array.Empty() + }; + } + + // ---------- ĐƯỜNG CONG 1/4 ---------- + var startNode = Nodes.First(n => n.NodeId == e.StartNodeId); + var A = new Point( + startNode.NodePosition.X, + startNode.NodePosition.Y + ); + + var result = QuarterGeometry.BuildQuarterTrajectory( + A, + e.Radius, + e.Quadrant + ); + + return new + { + edgeId = e.EdgeId, + sequenceId = seq++, + released = true, + + startNodeId = e.StartNodeId, + endNodeId = e.EndNodeId, + + trajectory = result.Trajectory, + actions = Array.Empty() + }; + }).ToArray() + }; + } +} + +// ====================================================== +// UI ACTION PARAM +// ====================================================== +public class UiActionParameter : ActionParameter +{ + [JsonIgnore] + public string ValueString + { + get => Value?.ToString() ?? ""; + set => Value = value; + } +} diff --git a/RobotApp/Hubs/RobotHub.cs b/RobotApp/Hubs/RobotHub.cs new file mode 100644 index 0000000..b10bca8 --- /dev/null +++ b/RobotApp/Hubs/RobotHub.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.SignalR; +using RobotApp.Common.Shares; +using RobotApp.VDA5050.State; +using System.Text.Json; + +namespace RobotApp.Hubs +{ + public class RobotHub : Hub + { + // Client gọi để theo dõi robot cụ thể + public async Task JoinRobot(string serialNumber) + { + await Groups.AddToGroupAsync(Context.ConnectionId, serialNumber); + Console.WriteLine($"Client {Context.ConnectionId} joined robot group: {serialNumber}"); + } + + public async Task LeaveRobot(string serialNumber) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, serialNumber); + } + + // Phương thức này sẽ được gọi từ service để broadcast + public async Task SendState(string serialNumber, StateMsg state) + { + var json = JsonSerializer.Serialize(state, JsonOptionExtends.Write); + await Clients.Group(serialNumber).SendAsync("ReceiveState", json); + } + } +} \ No newline at end of file diff --git a/RobotApp/Program.cs b/RobotApp/Program.cs index ac55b4d..7696d26 100644 --- a/RobotApp/Program.cs +++ b/RobotApp/Program.cs @@ -4,11 +4,13 @@ using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using NLog.Web; using RobotApp.Client; +using RobotApp.Client.Services; using RobotApp.Components; using RobotApp.Components.Account; using RobotApp.Data; using RobotApp.Hubs; using RobotApp.Services; +using RobotApp.Services.Robot; using RobotApp.Services.Robot.Simulation; var builder = WebApplication.CreateBuilder(args); @@ -58,8 +60,11 @@ builder.Services.AddRobotSimulation(); builder.Services.AddRobot(); // Add RobotMonitorService -builder.Services.AddSingleton(); -builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(sp => sp.GetRequiredService()); + +builder.Services.AddScoped(); +builder.Services.AddHostedService(); var app = builder.Build(); await app.Services.SeedApplicationDbAsync(); @@ -91,7 +96,7 @@ app.MapControllers(); // Map SignalR Hub app.MapHub("/hubs/robotMonitor"); - +app.MapHub("/hubs/robot"); app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() diff --git a/RobotApp/Services/Robot/RobotStatePublisher.cs b/RobotApp/Services/Robot/RobotStatePublisher.cs new file mode 100644 index 0000000..9f6026d --- /dev/null +++ b/RobotApp/Services/Robot/RobotStatePublisher.cs @@ -0,0 +1,175 @@ +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using RobotApp.Common.Shares; +using RobotApp.Hubs; +using RobotApp.Interfaces; +using RobotApp.Services.State; +using RobotApp.VDA5050.State; +using RobotApp.VDA5050.Type; +using RobotApp.VDA5050.Visualization; +using System.Text.Json; + +namespace RobotApp.Services.Robot; + +public class RobotStatePublisher : BackgroundService +{ + private readonly IHubContext _hubContext; + private readonly RobotConfiguration _robotConfig; + private readonly IOrder _orderManager; + private readonly IInstantActions _actionManager; + private readonly IPeripheral _peripheralManager; + private readonly IInfomation _infoManager; + private readonly IError _errorManager; + private readonly ILocalization _localizationManager; + private readonly IBattery _batteryManager; + private readonly ILoad _loadManager; + private readonly INavigation _navigationManager; + private readonly RobotStateMachine _stateManager; + + private uint _headerId = 0; + private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(1000)); // 1 giây/lần + + public RobotStatePublisher( + IHubContext hubContext, + RobotConfiguration robotConfig, + IOrder orderManager, + IInstantActions actionManager, + IPeripheral peripheralManager, + IInfomation infoManager, + IError errorManager, + ILocalization localizationManager, + IBattery batteryManager, + ILoad loadManager, + INavigation navigationManager, + RobotStateMachine stateManager) + { + _hubContext = hubContext; + _robotConfig = robotConfig; + _orderManager = orderManager; + _actionManager = actionManager; + _peripheralManager = peripheralManager; + _infoManager = infoManager; + _errorManager = errorManager; + _localizationManager = localizationManager; + _batteryManager = batteryManager; + _loadManager = loadManager; + _navigationManager = navigationManager; + _stateManager = stateManager; + } + + private StateMsg GetStateMsg() + { + return new StateMsg + { + HeaderId = _headerId++, + Timestamp = DateTime.UtcNow.ToString("o"), // ISO 8601 + Manufacturer = _robotConfig.VDA5050Setting.Manufacturer, + Version = _robotConfig.VDA5050Setting.Version, + SerialNumber = _robotConfig.SerialNumber, + Maps = [], + OrderId = _orderManager.OrderId, + OrderUpdateId = _orderManager.OrderUpdateId, + ZoneSetId = "", + LastNodeId = _orderManager.LastNodeId, + LastNodeSequenceId = _orderManager.LastNodeSequenceId, + Driving = Math.Abs(_navigationManager.VelocityX) > 0.01 || Math.Abs(_navigationManager.Omega) > 0.01, + Paused = false, + NewBaseRequest = true, + DistanceSinceLastNode = 0, + OperatingMode = _peripheralManager.PeripheralMode.ToString(), + NodeStates = _orderManager.NodeStates, + EdgeStates = _orderManager.EdgeStates, + ActionStates = _actionManager.ActionStates, + Information = [General, .. _infoManager.InformationState], + Errors = _errorManager.ErrorsState, + AgvPosition = new AgvPosition + { + X = _localizationManager.X, + Y = _localizationManager.Y, + Theta = _localizationManager.Theta, + LocalizationScore = _localizationManager.MatchingScore, + MapId = _localizationManager.CurrentActiveMap, + DeviationRange = _localizationManager.Reliability, + PositionInitialized = _localizationManager.IsReady, + }, + BatteryState = new BatteryState + { + Charging = _batteryManager.IsCharging, + BatteryHealth = _batteryManager.SOH, + Reach = 0, + BatteryVoltage = _batteryManager.Voltage, + BatteryCharge = _batteryManager.SOC, + }, + Loads = _loadManager.Load, + Velocity = new Velocity + { + Vx = _navigationManager.VelocityX, + Vy = _navigationManager.VelocityY, + Omega = _navigationManager.Omega, + }, + SafetyState = new SafetyState + { + FieldViolation = _peripheralManager.LidarBackProtectField || + _peripheralManager.LidarFrontProtectField || + _peripheralManager.LidarFrontTimProtectField, + EStop = (_peripheralManager.Emergency || _peripheralManager.Bumper) + ? EStop.AUTOACK.ToString() + : EStop.NONE.ToString(), + } + }; + } + + private Information General => new() + { + InfoType = InformationType.robot_general.ToString(), + InfoDescription = "Thông tin chung của robot", + InfoLevel = InfoLevel.INFO.ToString(), + InfoReferences = + [ + new InfomationReferences + { + ReferenceKey = InformationReferencesKey.robot_state.ToString(), + ReferenceValue = _stateManager.CurrentStateName, + } + ] + }; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + Console.WriteLine("[RobotStatePublisher] Started - Publishing state every 1 second via SignalR"); + + while (await _timer.WaitForNextTickAsync(stoppingToken) && !stoppingToken.IsCancellationRequested) + { + try + { + var state = GetStateMsg(); + var serialNumber = _robotConfig.SerialNumber; + + var json = JsonSerializer.Serialize(state, JsonOptionExtends.Write); + + // Push đến tất cả client đang theo dõi robot này + await _hubContext.Clients + .Group(serialNumber) + .SendAsync("ReceiveState", json, stoppingToken); + + Console.WriteLine($"[RobotStatePublisher] Published state for {serialNumber} | " + + $"HeaderId: {state.HeaderId} | " + + $"Pos: ({state.AgvPosition.X:F2}, {state.AgvPosition.Y:F2}) | " + + $"Battery: {state.BatteryState.BatteryCharge:F1}%"); + } + catch (Exception ex) + { + Console.WriteLine($"[RobotStatePublisher] Error publishing state: {ex.Message}"); + } + } + + Console.WriteLine("[RobotStatePublisher] Stopped."); + } + + public override void Dispose() + { + _timer?.Dispose(); + base.Dispose(); + } +} \ No newline at end of file