diff --git a/RobotApp.Client/MainLayout.razor b/RobotApp.Client/MainLayout.razor index fe69899..3052e0e 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-state-machine", Path="/robot-state", Label = "state", Match = NavLinkMatch.All}, + new(){Icon = "mdi-state-machine", Path="/robot-order", Label = "order", Match = NavLinkMatch.All}, ]; private bool collapseNavMenu = true; diff --git a/RobotApp.Client/Pages/Home.razor b/RobotApp.Client/Pages/Home.razor index 47da492..e5a2b14 100644 --- a/RobotApp.Client/Pages/Home.razor +++ b/RobotApp.Client/Pages/Home.razor @@ -1,621 +1,422 @@ @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 -@using System.ComponentModel.DataAnnotations -@attribute [Authorize] -@rendermode InteractiveWebAssemblyNoPrerender -@inject IJSRuntime JS +@using RobotApp.VDA5050.State +@using MudBlazor +@inject RobotStateClient RobotStateClient +@implements IDisposable +@rendermode InteractiveWebAssembly -@inject IDialogService DialogService - - -
- - - - - 🧾 VDA5050 Order Editor - - - - - - - - - - - - - 📍 Nodes - - Add Node - - - - -
- - @foreach (var node in Order.Nodes) - { - - - - -
- - @node.NodeId - - - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Actions - - - @foreach (var act in node.Actions) - { - - - - - @foreach (var at in Enum.GetValues()) - { - - @at - - } - - - - - - NONE - SOFT - HARD - - - - - - - - - - Action Parameters - - - @foreach (var p in act.ActionParameters.Cast()) - { - - - - - - - - - - - - } - - - Add Parameter - - - - - - Remove Action - - - } - - - Add Action - - - - - -
- } -
-
- -
-
- - - - - - 🔗 Edges - - Add Edge - - - - -
- - - @foreach (var edge in Order.Edges) - { - - - - - - - - - - - - - - @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 - - - - - - - - } - - -
- -
-
-
-
- - - - - 📄 JSON Output (/order) - - - Send - - @if (!string.IsNullOrEmpty(sendResult)) - { - - @sendResult - - } - - - - @if (copied) - { - Copied! - } - else - { - Copy - } - - - - - -
- -
-
-
- -
-
-
-
- - - -@code { - [Inject] HttpClient Http { get; set; } = default!; - - bool sending; - string? sendResult; - 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() - { - if (sending) - return; - - sending = true; - sendResult = null; - StateHasChanged(); - - try - { - var response = await Http.PostAsJsonAsync( - "/api/order", - JsonSerializer.Deserialize(OrderJson) - ); - - if (response.IsSuccessStatusCode) + + + +
+ Robot Dashboard + @if (CurrentState != null) { - sendResult = "✅ Done!"; + + + @CurrentState.Version • + + @CurrentState.Manufacturer • + + @CurrentState.SerialNumber + } else { - sendResult = $"❌ Failed: {response.StatusCode}"; + + + Connecting to robot... + } +
+ + @if (CurrentState != null) + { + + @(IsConnected ? "ONLINE" : "OFFLINE") + } - catch (Exception ex) - { - sendResult = $"❌ Error: {ex.Message}"; - } - finally - { - sending = false; - StateHasChanged(); - } +
+ + @{ + var msg = CurrentState ?? EmptyState; } - void AddNode() + + + + + Header ID + @msg.HeaderId + + + Timestamp (UTC) + @msg.Timestamp + + + Version + @msg.Version + + + Order Update ID + + @msg.OrderUpdateId + + + + + + + + + + + + + + Position & Velocity + + + + + NewBase: @(msg.NewBaseRequest ? "TRUE" : "FALSE") + + + + + + + X: @msg.AgvPosition.X.ToString("F2") m + Y: @msg.AgvPosition.Y.ToString("F2") m + θ: @msg.AgvPosition.Theta.ToString("F2") rad + + + Vx: @msg.Velocity.Vx.ToString("F2") m/s + Vy: @msg.Velocity.Vy.ToString("F2") m/s + Ω: @msg.Velocity.Omega.ToString("F3") rad/s + + + +
+ + @(msg.AgvPosition.PositionInitialized ? "Initialized" : "Not Initialized") + + + Deviation: @msg.AgvPosition.DeviationRange + +
+ + + Localization Score: @(msg.AgvPosition.LocalizationScore * 100)% + +
+
+
+ + + + + + + + + + Battery Status + + + + + + + +
+ + @msg.BatteryState.BatteryCharge:F1% + + + + @(msg.BatteryState.Charging ? "Charging" : "Discharging") + +
+ + + + + Voltage + @msg.BatteryState.BatteryVoltage:F1 V + + + Health (SOH) + @msg.BatteryState.BatteryHealth% + + + Reach + @((int)msg.BatteryState.Reach) m + + +
+
+
+ + + + + + + + Order & Path + + + + + Order ID: @(msg.OrderId ?? "—") + Update ID: @msg.OrderUpdateId + + Last Node: @msg.LastNodeId (Seq: @msg.LastNodeSequenceId) + Distance since last: @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 + + @(msg.Driving ? "DRIVING" : "STOPPED") + + + @(msg.Paused ? "PAUSED" : "ACTIVE") + +
+
+
+
+ + + + + + + + + Errors & Information + + + + + + + + + + + + Type + Level + Description + + + + + @context.Type + + + + + @context.Level + + + + + @context.Description + + + + + + No errors or information messages + + + + + + + + + + + + + + + Active Actions + + + + + + + Action + ID + Status + + + @context.ActionType + @context.ActionId + + + @context.ActionStatus + + + + + No active actions + + + + + + + + + + + + + + Safety State + + + + + + E-STOP: @msg.SafetyState.EStop + + + Field Violation: @(msg.SafetyState.FieldViolation ? "YES" : "NO") + + + + +
+
+ +@code { + private static readonly StateMsg EmptyState = new() { - Order.Nodes.Add(new Node - { - NodeId = $"NODE_{Order.Nodes.Count + 1}", - SequenceId = Order.Nodes.Count, - Released = true, - NodePosition = new NodePosition { MapId = "MAP_01" } - }); - } - - void RemoveNode(Node node) - { - Order.Nodes.Remove(node); - - Order.Edges.RemoveAll(e => - e.StartNodeId == node.NodeId || - e.EndNodeId == node.NodeId); - - for (int i = 0; i < Order.Nodes.Count; i++) - Order.Nodes[i].SequenceId = i; - } - - void RemoveAction(Node node, VDA5050.InstantAction.Action act) - { - node.Actions = node.Actions - .Where(a => a != act) - .ToArray(); - } - - - void AddAction(Node node) - { - node.Actions = node.Actions - .Append(new VDA5050.InstantAction.Action - { - ActionId = Guid.NewGuid().ToString(), - ActionType = ActionType.startPause.ToString(), - BlockingType = "NONE", - ActionParameters = Array.Empty() - }) - .ToArray(); - } - - - private void RemoveActionParameter(VDA5050.InstantAction.Action act, ActionParameter param) - { - if (act.ActionParameters == null) return; - act.ActionParameters = act.ActionParameters - .Where(p => p != param) - .ToArray(); - } - void AddActionParameter(VDA5050.InstantAction.Action act) - { - var newList = (act.ActionParameters ?? Array.Empty()).ToList(); - newList.Add(new UiActionParameter()); - act.ActionParameters = newList.ToArray(); - } - - void AddEdge() - { - if (Order.Nodes.Count < 2) return; - - Order.Edges.Add(new RobotApp.Client.Services.UiEdge - { - EdgeId = $"EDGE_{Order.Edges.Count + 1}", - StartNodeId = Order.Nodes[^2].NodeId, - EndNodeId = Order.Nodes[^1].NodeId - }); - } - - bool copied = false; - CancellationTokenSource? _copyCts; - - async Task CopyJsonToClipboard() - { - _copyCts?.Cancel(); - _copyCts = new(); - - var success = await JS.InvokeAsync( - "copyText", - OrderJson - ); - - if (!success) - return; - - copied = true; - StateHasChanged(); - - try - { - await Task.Delay(1500, _copyCts.Token); - } - catch { } - - copied = false; - StateHasChanged(); - } - - - void RemoveEdge(RobotApp.Client.Services.UiEdge edge) - { - Order.Edges.Remove(edge); - } - - string OrderJson => - JsonSerializer.Serialize( - Order.ToSchemaObject(), - new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }); - - - private async Task OpenEditNodeDialog(Node node) - { - var parameters = new DialogParameters - { - { x => x.Node, node } // Truyền trực tiếp reference gốc }; - var options = new DialogOptions + private StateMsg? CurrentState; + private bool IsConnected => RobotStateClient.LatestStates.ContainsKey(RobotSerial); + private readonly string RobotSerial = "T800-002"; + private List MessageRows = new(); + + protected override async Task OnInitializedAsync() + { + RobotStateClient.OnStateReceived += OnRobotStateReceived; + if (RobotStateClient.LatestStates.Count == 0) { - CloseButton = true, - MaxWidth = MaxWidth.Large, - FullWidth = true, - CloseOnEscapeKey = true - }; - - var dialog = await DialogService.ShowAsync($"Edit Node: {node.NodeId}", parameters, options); - await dialog.Result; // Đợi dialog đóng - - StateHasChanged(); + await RobotStateClient.StartAsync(); + } + await RobotStateClient.SubscribeRobotAsync(RobotSerial); + 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/Pages/Order/EdgesPanel.razor b/RobotApp.Client/Pages/Order/EdgesPanel.razor new file mode 100644 index 0000000..5cc40ae --- /dev/null +++ b/RobotApp.Client/Pages/Order/EdgesPanel.razor @@ -0,0 +1,170 @@ + + + 🔗 Edges + + + Add Edge + + + +
+ + @foreach (var edge in Order.Edges) + { + + + +
+ + +
+ + @edge.EdgeId + + + + @edge.StartNodeId → @edge.EndNodeId + +
+ + + +
+
+ + + + + + + + + + + + + + @foreach (var n in Order.Nodes) + { + @n.NodeId + } + + + + + + + @foreach (var n in Order.Nodes) + { + + @n.NodeId + + } + + + + + + + + + + @if (edge.Radius > 0) + { + + + I + II + III + IV + + + } + + + @if (edge.Radius > 0 && !edge.Expanded) + { + + + Apply Curve (tạo node) + + + } + + + +
+ } +
+
+
+ +@code { + [Parameter] public OrderMessage Order { get; set; } = default!; + + [Parameter] public EventCallback OnAddEdge { get; set; } + [Parameter] public EventCallback OnRemoveEdge { get; set; } + [Parameter] public EventCallback OnApplyCurve { get; set; } + + [Parameter] public EventCallback OnOrderChanged { get; set; } + + private async Task SetValue(System.Action setter) + { + setter(); + await OnOrderChanged.InvokeAsync(); + } + + private async Task AddEdgeAsync() + { + await OnAddEdge.InvokeAsync(); + await OnOrderChanged.InvokeAsync(); + } + + private async Task RemoveEdgeAsync(UiEdge edge) + { + await OnRemoveEdge.InvokeAsync(edge); + await OnOrderChanged.InvokeAsync(); + } + + private async Task ApplyCurveAsync(UiEdge edge) + { + await OnApplyCurve.InvokeAsync(edge); + await OnOrderChanged.InvokeAsync(); + } +} diff --git a/RobotApp.Client/Pages/EditNodeDialog.razor b/RobotApp.Client/Pages/Order/EditNodeDialog.razor similarity index 98% rename from RobotApp.Client/Pages/EditNodeDialog.razor rename to RobotApp.Client/Pages/Order/EditNodeDialog.razor index 248c2c2..4d0520e 100644 --- a/RobotApp.Client/Pages/EditNodeDialog.razor +++ b/RobotApp.Client/Pages/Order/EditNodeDialog.razor @@ -1,9 +1,4 @@ @inherits MudComponentBase -@using RobotApp.VDA5050.InstantAction -@using System.Text.Json -@using System.Text.Json.Serialization -@using RobotApp.VDA5050.Order -@using RobotApp.VDA5050.Type diff --git a/RobotApp.Client/Pages/Order/JsonOutputPanel.razor b/RobotApp.Client/Pages/Order/JsonOutputPanel.razor new file mode 100644 index 0000000..5cb2ccd --- /dev/null +++ b/RobotApp.Client/Pages/Order/JsonOutputPanel.razor @@ -0,0 +1,75 @@ + + + 📄 JSON Output (/order) + +
+ + + + @SendButtonText + + + + + + @(Copied ? "Copied!" : "Copy") + + +
+
+ + +
+ +
+
+ +@code { + [Parameter] public string OrderJson { get; set; } = ""; + [Parameter] public bool Copied { get; set; } + [Parameter] public bool? SendSuccess { get; set; } + [Parameter] public EventCallback OnCopy { get; set; } + [Parameter] public EventCallback OnSend { get; set; } + + // ================= SEND BUTTON STATE ================= + private string SendButtonText => + SendSuccess switch + { + true => "Done", + false => "Error", + _ => "Send" + }; + + private Color SendButtonColor => + SendSuccess switch + { + true => Color.Success, + false => Color.Error, + _ => Color.Success + }; + + private string SendButtonIcon => + SendSuccess switch + { + true => Icons.Material.Filled.CheckCircle, + false => Icons.Material.Filled.Error, + _ => Icons.Material.Filled.Send + }; +} diff --git a/RobotApp.Client/Pages/Order/NodesPanel.razor b/RobotApp.Client/Pages/Order/NodesPanel.razor new file mode 100644 index 0000000..5a25871 --- /dev/null +++ b/RobotApp.Client/Pages/Order/NodesPanel.razor @@ -0,0 +1,275 @@ + + + 📍 Nodes + + Add Node + + + +
+ + @foreach (var node in Order.Nodes) + { + + +
+ @node.NodeId +
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Actions + + @foreach (var act in node.Actions ?? Array.Empty()) + { + + + + + + @foreach (var at in Enum.GetValues()) + { + @at + } + + + + + + NONE + SOFT + HARD + + + + + + + + + Action Parameters + + @foreach (var p in act.ActionParameters.Cast()) + { + + + + + + + + + + + + + + } + + + Add Parameter + + + + + + Remove Action + + + } + + + Add Action + + + + +
+ } +
+
+
+ +@code { + [Parameter] public OrderMessage Order { get; set; } = default!; + [Parameter] public EventCallback OnAddNode { get; set; } + [Parameter] public EventCallback OnRemoveNode { get; set; } + [Parameter] public EventCallback OnEditNode { get; set; } + [Parameter] public EventCallback OnAddAction { get; set; } + [Parameter] public EventCallback OnRemoveAction { get; set; } + [Parameter] public EventCallback OnAddActionParameter { get; set; } + [Parameter] public EventCallback OnRemoveActionParameter { get; set; } + [Parameter] public EventCallback OnOrderChanged { get; set; } + + // 🔥 helper realtime – KHÔNG ambiguous + private async Task SetValue(System.Action setter) + { + setter(); + await OnOrderChanged.InvokeAsync(); + } + + private async Task AddNodeAsync() + { + await OnAddNode.InvokeAsync(); + await OnOrderChanged.InvokeAsync(); + } + + private async Task RemoveNodeAsync(Node node) + { + await OnRemoveNode.InvokeAsync(node); + await OnOrderChanged.InvokeAsync(); + } + + private async Task EditNodeAsync(Node node) + { + await OnEditNode.InvokeAsync(node); + await OnOrderChanged.InvokeAsync(); + } + + private async Task AddActionAsync(Node node) + { + await OnAddAction.InvokeAsync(node); + await OnOrderChanged.InvokeAsync(); + } + + private async Task RemoveActionAsync(Node node, VDA5050.InstantAction.Action action) + { + await OnRemoveAction.InvokeAsync(new NodeActionWrapper(node, action)); + await OnOrderChanged.InvokeAsync(); + } + + private async Task AddActionParameterAsync(VDA5050.InstantAction.Action act) + { + await OnAddActionParameter.InvokeAsync(act); + await OnOrderChanged.InvokeAsync(); + } + + private async Task RemoveActionParameterAsync(VDA5050.InstantAction.Action act, ActionParameter param) + { + await OnRemoveActionParameter.InvokeAsync(new ActionParamWrapper(act, param)); + await OnOrderChanged.InvokeAsync(); + } + + public record NodeActionWrapper(Node Node, VDA5050.InstantAction.Action Action); + public record ActionParamWrapper(VDA5050.InstantAction.Action Action, ActionParameter Parameter); +} diff --git a/RobotApp.Client/Pages/Order/OrderMess.razor b/RobotApp.Client/Pages/Order/OrderMess.razor new file mode 100644 index 0000000..93a3b15 --- /dev/null +++ b/RobotApp.Client/Pages/Order/OrderMess.razor @@ -0,0 +1,267 @@ +@page "/robot-order" + +@attribute [Authorize] +@rendermode InteractiveWebAssemblyNoPrerender + +@using System.Text.Json +@using System.Text.Json.Serialization + +@inject IJSRuntime JS +@inject IDialogService DialogService +@inject HttpClient Http + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +@code { + // ================= STATE ================= + private OrderMessage Order { get; set; } = new(); + private string OrderJson = ""; // 🔥 CACHE JSON (QUAN TRỌNG) + private bool copied; + private bool? sendSuccess; + private bool sending; + private CancellationTokenSource? _copyCts; + + // ================= INIT ================= + protected override void OnInitialized() + { + RebuildOrderJson(); + } + + // ================= CORE FIX ================= + private void RebuildOrderJson() + { + OrderJson = JsonSerializer.Serialize( + Order.ToSchemaObject(), + new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }); + } + + private void OnOrderChanged() + { + RebuildOrderJson(); // 🔥 JSON luôn rebuild + StateHasChanged(); // 🔥 ép render + } + + // ================= NODE ================= + void AddNode() + { + Order.Nodes.Add(new Node + { + NodeId = $"NODE_{Order.Nodes.Count + 1}", + SequenceId = Order.Nodes.Count, + Released = true, + NodePosition = new VDA5050.Order.NodePosition { MapId = "MAP_01" } + }); + } + + void RemoveNode(Node node) + { + Order.Nodes.Remove(node); + Order.Edges.RemoveAll(e => e.StartNodeId == node.NodeId || e.EndNodeId == node.NodeId); + ResequenceNodes(); + } + + void ResequenceNodes() + { + for (int i = 0; i < Order.Nodes.Count; i++) + Order.Nodes[i].SequenceId = i; + } + + // ================= EDGE ================= + void AddEdge() + { + if (Order.Nodes.Count == 0) + return; + + var start = Order.Nodes[0].NodeId; + var end = Order.Nodes.Count > 1 + ? Order.Nodes[1].NodeId + : start; // 👈 1 node thì start = end + + Order.Edges.Add(new UiEdge + { + EdgeId = $"EDGE_{Order.Edges.Count + 1}", + StartNodeId = start, + EndNodeId = end + }); + } + + + void RemoveEdge(UiEdge edge) + { + Order.Edges.Remove(edge); + } + + void ApplyCurve(UiEdge edge) + { + if (edge.Radius <= 0 || edge.Expanded) return; + + var startNode = Order.Nodes.First(n => n.NodeId == edge.StartNodeId); + var newNode = OrderMessage.CreateCurveNode(startNode, edge); + + Order.Nodes.Add(newNode); + edge.EndNodeId = newNode.NodeId; + edge.Expanded = true; + + ResequenceNodes(); + } + + // ================= ACTION ================= + void AddAction(Node node) + { + var list = node.Actions?.ToList() ?? new(); + list.Add(new VDA5050.InstantAction.Action + { + ActionId = Guid.NewGuid().ToString(), + ActionType = ActionType.startPause.ToString(), + BlockingType = "NONE", + ActionParameters = Array.Empty() + }); + node.Actions = list.ToArray(); + } + + void RemoveAction(Node node, VDA5050.InstantAction.Action action) + { + node.Actions = node.Actions?.Where(a => a != action).ToArray() + ?? Array.Empty(); + } + + void AddActionParameter(VDA5050.InstantAction.Action act) + { + var list = (act.ActionParameters ?? Array.Empty()).ToList(); + list.Add(new UiActionParameter()); + act.ActionParameters = list.ToArray(); + } + + void RemoveActionParameter(VDA5050.InstantAction.Action act, ActionParameter param) + { + act.ActionParameters = + act.ActionParameters?.Where(p => p != param).ToArray() + ?? Array.Empty(); + } + + // ================= SEND / COPY ================= + async Task SendOrderToServer() + { + // reset trạng thái trước khi gửi + sendSuccess = null; + StateHasChanged(); + + try + { + var response = await Http.PostAsJsonAsync( + "/api/order", + JsonSerializer.Deserialize(OrderJson) + ); + + sendSuccess = response.IsSuccessStatusCode; + + } + catch (Exception ex) + { + sendSuccess = false; + } + + StateHasChanged(); + + // 🔥 AUTO RESET SAU 2 GIÂY + _ = Task.Run(async () => + { + await Task.Delay(2000); + + // quay về trạng thái Send + sendSuccess = null; + + await InvokeAsync(StateHasChanged); + }); + } + + + + async Task CopyJsonToClipboard() + { + _copyCts?.Cancel(); + _copyCts = new(); + + await JS.InvokeVoidAsync("navigator.clipboard.writeText", OrderJson); + + copied = true; + StateHasChanged(); + + try { await Task.Delay(1500, _copyCts.Token); } catch { } + copied = false; + StateHasChanged(); + } + + // ================= DIALOG ================= + async Task OpenEditNodeDialog(Node node) + { + var parameters = new DialogParameters + { + { x => x.Node, node } + }; + + var options = new DialogOptions + { + CloseButton = true, + FullWidth = true, + MaxWidth = MaxWidth.Large + }; + + var dialog = await DialogService.ShowAsync( + $"Edit Node: {node.NodeId}", parameters, options); + + await dialog.Result; + OnOrderChanged(); // 🔥 cập nhật JSON sau dialog + } +} diff --git a/RobotApp.Client/Pages/State.razor b/RobotApp.Client/Pages/State.razor deleted file mode 100644 index da16797..0000000 --- a/RobotApp.Client/Pages/State.razor +++ /dev/null @@ -1,374 +0,0 @@ -@page "/robot-state" -@using RobotApp.Client.Services -@using RobotApp.VDA5050.State -@inject RobotStateClient RobotStateClient -@implements IDisposable -@rendermode InteractiveWebAssembly - - - - - - -
- 🤖 VDA 5050 Robot Dashboard - @if (CurrentState != null) - { - - @CurrentState.Version • - @CurrentState.Manufacturer • - @CurrentState.SerialNumber - - } - else - { - - Connecting to robot... - - } -
- - @if (CurrentState != null) - { - - @(IsConnected ? "ONLINE" : "OFFLINE") - - } -
- - @if (CurrentState == null) - { - - Waiting for robot state data... - Connecting to SignalR hub and subscribing to robot updates. - - - } - else - { - var msg = CurrentState; - - - - - - - - HeaderId - @msg.HeaderId - - - Timestamp (UTC) - @msg.Timestamp - - - Version - @msg.Version - - - OrderUpdateId - @msg.OrderUpdateId - - - - - - - - - - - - - - 📍 Position & Velocity - - - - NewBase: @(msg.NewBaseRequest ? "TRUE" : "FALSE") - - - - - - - X: @msg.AgvPosition.X.ToString("F2") m - Y: @msg.AgvPosition.Y.ToString("F2") m - θ: @msg.AgvPosition.Theta.ToString("F2") rad - - - Vx: @msg.Velocity.Vx.ToString("F2") m/s - Vy: @msg.Velocity.Vy.ToString("F2") m/s - Ω: @msg.Velocity.Omega.ToString("F3") rad/s - - - - - - - Initialized: @(msg.AgvPosition.PositionInitialized ? "TRUE" : "FALSE") - - - - - Deviation: @msg.AgvPosition.DeviationRange - - - - - - Localization Score: @(msg.AgvPosition.LocalizationScore * 100) % - - - - - - - - 🔋 Battery - - - - @msg.BatteryState.BatteryCharge:F1 % - - - @(msg.BatteryState.Charging ? "⚡ Charging" : "Discharging") - - - - - Voltage - @msg.BatteryState.BatteryVoltage.ToString("F1") V - - - Health (SOH) - @msg.BatteryState.BatteryHealth % - - - Reach - @((int)msg.BatteryState.Reach) m - - - - - - - - - 🧭 Order & Path - - Order ID: @(msg.OrderId ?? "—") - Update ID: @msg.OrderUpdateId - - - Last Node: @msg.LastNodeId - Seq: @msg.LastNodeSequenceId - - 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 - - @(msg.Driving ? "DRIVING" : "STOPPED") - - - @(msg.Paused ? "PAUSED" : "ACTIVE") - -
-
-
- - - - - 🚨 Errors & Information - - - - - - - - - Type - Level - Description - - - - - @context.Type - - - - - @context.Level - - - - - @context.Description - - - - - - No errors or information messages - - - - - - - - - - ⚙️ Actions - - - - Action - Action ID - Status - - - @context.ActionType - @context.ActionId - - - @context.ActionStatus - - - - - - No active actions - - - - - - - - - - 🛑 Safety - - - E-STOP: @msg.SafetyState.EStop - - - Field Violation: @(msg.SafetyState.FieldViolation ? "YES" : "NO") - - - -
- } -
- -@code { - private StateMsg? CurrentState; - private bool IsConnected => RobotStateClient.LatestStates.ContainsKey(RobotSerial); - - // 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(); - - 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/Pages/_Imports.razor b/RobotApp.Client/Pages/_Imports.razor index 112934e..74cf003 100644 --- a/RobotApp.Client/Pages/_Imports.razor +++ b/RobotApp.Client/Pages/_Imports.razor @@ -15,4 +15,12 @@ @using RobotApp.Common.Shares @using RobotApp.Common.Shares.Dtos @using Excubo.Blazor.Canvas -@using Excubo.Blazor.Canvas.Contexts \ No newline at end of file +@using Excubo.Blazor.Canvas.Contexts +@using System.Text.Json +@using System.Text.Json.Serialization +@using RobotApp.Client.Pages.Order +@using RobotApp.Client.Services +@using RobotApp.VDA5050.InstantAction +@using RobotApp.VDA5050.Order +@using RobotApp.VDA5050.Type +@using System.ComponentModel.DataAnnotations \ No newline at end of file diff --git a/RobotApp.Client/RobotApp.Client.csproj b/RobotApp.Client/RobotApp.Client.csproj index 8fc69d8..2e8b302 100644 --- a/RobotApp.Client/RobotApp.Client.csproj +++ b/RobotApp.Client/RobotApp.Client.csproj @@ -9,6 +9,7 @@ + diff --git a/RobotApp.Client/Services/RobotStateClient.cs b/RobotApp.Client/Services/RobotStateClient.cs index ecc7005..5ca88e2 100644 --- a/RobotApp.Client/Services/RobotStateClient.cs +++ b/RobotApp.Client/Services/RobotStateClient.cs @@ -7,40 +7,61 @@ using System.Text.Json; namespace RobotApp.Client.Services; -public class RobotStateClient : IAsyncDisposable +// ================= CONNECTION STATE ================= +public enum RobotClientState +{ + Disconnected, + Connecting, + Connected, + Reconnecting +} + +// ================= CLIENT ================= +public sealed class RobotStateClient : IAsyncDisposable { private readonly NavigationManager _nav; private HubConnection? _connection; - private bool _started; + private readonly object _lock = new(); + private bool _started; + public ConcurrentDictionary LatestStates { get; } = new(); + // ================= EVENTS ================= public event Action? OnStateReceived; public event Action? OnStateReceivedAny; + public event Action? OnConnectionStateChanged; + public RobotClientState ConnectionState { get; private set; } = RobotClientState.Disconnected; + + // ================= CTOR ================= public RobotStateClient(NavigationManager nav) { _nav = nav; } + // ================= STATE HELPER ================= + private void SetState(RobotClientState state) + { + if (ConnectionState == state) return; + + ConnectionState = state; + OnConnectionStateChanged?.Invoke(state); + } + + // ================= START ================= public async Task StartAsync(string hubPath = "/hubs/robot") { lock (_lock) { - if (_started) - return; - + 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}"); + SetState(RobotClientState.Connecting); _connection = new HubConnectionBuilder() - .WithUrl(hubUri) + .WithUrl(_nav.ToAbsoluteUri(hubPath)) .WithAutomaticReconnect(new[] { TimeSpan.Zero, @@ -49,11 +70,21 @@ public class RobotStateClient : IAsyncDisposable }) .Build(); + _connection.Reconnecting += _ => + { + SetState(RobotClientState.Reconnecting); + return Task.CompletedTask; + }; + + _connection.Reconnected += _ => + { + SetState(RobotClientState.Connected); + return Task.CompletedTask; + }; + _connection.Closed += async error => { - Console.WriteLine($"[SIGNALR] Connection closed: {error?.Message}"); _started = false; - await Task.Delay(3000); if (_connection != null) { @@ -65,95 +96,93 @@ public class RobotStateClient : IAsyncDisposable } }; - _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}"); - } - }); + _connection.On("ReceiveState", HandleState); try { await _connection.StartAsync(); - Console.WriteLine("[SIGNALR] Connected successfully!"); + SetState(RobotClientState.Connected); } - catch (Exception ex) + catch { _started = false; - Console.WriteLine($"❌ [SIGNALR] Connection failed: {ex.Message}"); + SetState(RobotClientState.Disconnected); } } + // ================= HANDLE STATE ================= + private void HandleState(string stateJson) + { + StateMsg? state; + + try + { + state = JsonSerializer.Deserialize( + stateJson, + JsonOptionExtends.Read + ); + } + catch + { + return; + } + + if (state?.SerialNumber == null) + return; + + LatestStates[state.SerialNumber] = state; + + OnStateReceived?.Invoke(state.SerialNumber, state); + OnStateReceivedAny?.Invoke(state); + } + + // ================= SUBSCRIBE ================= public async Task SubscribeRobotAsync(string serialNumber) { if (_connection?.State != HubConnectionState.Connected) - throw new InvalidOperationException("SignalR is not connected"); + return; - await _connection.InvokeAsync("JoinRobot", serialNumber); - Console.WriteLine($"[SIGNALR] Subscribed to {serialNumber}"); + try + { + await _connection.InvokeAsync("JoinRobot", serialNumber); + } + catch + { + // ignore – reconnect sẽ tự join lại + } } public async Task UnsubscribeRobotAsync(string serialNumber) { if (_connection?.State == HubConnectionState.Connected) { - await _connection.InvokeAsync("LeaveRobot", serialNumber); + try + { + await _connection.InvokeAsync("LeaveRobot", serialNumber); + } + catch { } } LatestStates.TryRemove(serialNumber, out _); - Console.WriteLine($"[SIGNALR] Unsubscribed from {serialNumber}"); } + // ================= GET CACHE ================= public StateMsg? GetLatestState(string serialNumber) { LatestStates.TryGetValue(serialNumber, out var state); return state; } + // ================= DISPOSE ================= public async ValueTask DisposeAsync() { _started = false; + SetState(RobotClientState.Disconnected); if (_connection != null) { await _connection.DisposeAsync(); _connection = null; - Console.WriteLine("[SIGNALR] Disposed"); } } } diff --git a/RobotApp.Client/_Imports.razor b/RobotApp.Client/_Imports.razor index 4aaabf4..fa71590 100644 --- a/RobotApp.Client/_Imports.razor +++ b/RobotApp.Client/_Imports.razor @@ -12,4 +12,7 @@ @using Microsoft.AspNetCore.Authorization @using MudBlazor @using RobotApp.Common.Shares.Dtos -@using RobotApp.Common.Shares.Enums \ No newline at end of file +@using RobotApp.Common.Shares.Enums +@using Blazored.LocalStorage +@using RobotApp.Client.Services +@using RobotApp.VDA5050.State diff --git a/RobotApp.sln b/RobotApp.sln index db8a2ad..7ae5b88 100644 --- a/RobotApp.sln +++ b/RobotApp.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 -VisualStudioVersion = 18.0.11205.157 d18.0 +VisualStudioVersion = 18.0.11205.157 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RobotApp", "RobotApp\RobotApp.csproj", "{BF0BB137-2EF9-4E1B-944E-9BF41C5284F7}" EndProject diff --git a/RobotApp/Services/Robot/RobotStatePublisher.cs b/RobotApp/Services/Robot/RobotStatePublisher.cs index 9f6026d..9e8cd34 100644 --- a/RobotApp/Services/Robot/RobotStatePublisher.cs +++ b/RobotApp/Services/Robot/RobotStatePublisher.cs @@ -153,10 +153,10 @@ public class RobotStatePublisher : BackgroundService .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}%"); + //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) {