diff --git a/RobotApp.Client/Pages/Components/Monitor/RobotMonitorView.razor b/RobotApp.Client/Pages/Components/Monitor/RobotMonitorView.razor index 497d67a..ace1b8b 100644 --- a/RobotApp.Client/Pages/Components/Monitor/RobotMonitorView.razor +++ b/RobotApp.Client/Pages/Components/Monitor/RobotMonitorView.razor @@ -25,9 +25,9 @@ X: @MonitorData.RobotPosition.X.ToString("F2")m | Y: @MonitorData.RobotPosition.Y.ToString("F2")m | θ: @((MonitorData.RobotPosition.Theta * 180 / Math.PI).ToString("F1"))° } - + @* @(IsConnected ? "Connected" : "Disconnected") - + *@
- @(IsConnected ? "ONLINE" : "OFFLINE") - - } + Icon="@(IsConnected + ? Icons.Material.Filled.CheckCircle + : Icons.Material.Filled.Error)" + Size="Size.Large" + Color="@(IsConnected ? Color.Success : Color.Error)" + Variant="Variant.Filled" + Class="px-6 py-4 text-white" + Style="font-weight: bold; font-size: 1.1rem;"> + @(IsConnected ? "ONLINE" : "OFFLINE") + + } + @{ @@ -133,7 +136,6 @@ - @@ -371,21 +373,35 @@ }; private StateMsg? CurrentState; - private bool IsConnected => RobotStateClient.LatestStates.ContainsKey(RobotSerial); + private bool IsConnected; private readonly string RobotSerial = "T800-002"; private List MessageRows = new(); protected override async Task OnInitializedAsync() { RobotStateClient.OnStateReceived += OnRobotStateReceived; - if (RobotStateClient.LatestStates.Count == 0) + RobotStateClient.OnRobotConnectionChanged += OnRobotConnectionChanged; + + if (RobotStateClient.ConnectionState == RobotClientState.Disconnected) { await RobotStateClient.StartAsync(); } + await RobotStateClient.SubscribeRobotAsync(RobotSerial); + CurrentState = RobotStateClient.GetLatestState(RobotSerial); + IsConnected = RobotStateClient.IsRobotConnected; + UpdateMessageRows(); } + private void OnRobotConnectionChanged(bool connected) + { + InvokeAsync(() => + { + IsConnected = connected; + StateHasChanged(); + }); + } private void OnRobotStateReceived(string serialNumber, StateMsg state) @@ -421,6 +437,7 @@ public void Dispose() { RobotStateClient.OnStateReceived -= OnRobotStateReceived; + RobotStateClient.OnRobotConnectionChanged -= OnRobotConnectionChanged; } private record MessageRow(string Type, string Level, string Description, bool IsError); diff --git a/RobotApp.Client/Pages/Order/ImportOrderDialog.razor b/RobotApp.Client/Pages/Order/ImportOrderDialog.razor index 42258d6..007bee4 100644 --- a/RobotApp.Client/Pages/Order/ImportOrderDialog.razor +++ b/RobotApp.Client/Pages/Order/ImportOrderDialog.razor @@ -1,11 +1,21 @@ @using System.Text.Json @using MudBlazor +@using Microsoft.AspNetCore.Components.Forms + + - Import Order JSON + + + + Import Order JSON + + + @if (ShowWarning) @@ -18,6 +28,21 @@ } + +
+ +
+ + + + + - Cancel + + Cancel + + Import + + @code { - [CascadingParameter] public IMudDialogInstance MudDialog { get; set; } = default!; + [CascadingParameter] + public IMudDialogInstance MudDialog { get; set; } = default!; public string JsonText { get; set; } = ""; public string? ErrorMessage; - public bool ShowWarning { get; set; } = false; + public bool ShowWarning { get; set; } private void Cancel() => MudDialog.Cancel(); + // ================= FILE HANDLER ================= + private async Task OnFileSelected(InputFileChangeEventArgs e) + { + ErrorMessage = null; + ShowWarning = false; + + var file = e.File; + if (file == null) + return; + + if (!file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) && + !file.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) + { + ShowWarning = true; + ErrorMessage = "Only .json or .txt files are supported."; + return; + } + + try + { + using var stream = file.OpenReadStream(maxAllowedSize: 1_048_576); + using var reader = new StreamReader(stream); + + JsonText = await reader.ReadToEndAsync(); + StateHasChanged(); + } + catch (Exception ex) + { + ShowWarning = true; + ErrorMessage = $"Failed to read file: {ex.Message}"; + } + } + + // ================= VALIDATE & IMPORT ================= private void ValidateAndImport() { ErrorMessage = null; ShowWarning = false; + if (string.IsNullOrWhiteSpace(JsonText)) + { + ShowWarning = true; + ErrorMessage = "JSON content is empty."; + return; + } + try { using var doc = JsonDocument.Parse(JsonText); @@ -65,10 +138,11 @@ // ===== BASIC STRUCTURE CHECK ===== if (!root.TryGetProperty("nodes", out _) || !root.TryGetProperty("edges", out _)) + { throw new Exception("Missing 'nodes' or 'edges' field."); + } var order = OrderMessage.FromSchemaObject(root); - ValidateOrder(order); MudDialog.Close(DialogResult.Ok(order)); @@ -85,19 +159,40 @@ } } + // ================= DOMAIN VALIDATION ================= private void ValidateOrder(OrderMessage order) { if (order.Nodes.Count == 0) throw new Exception("Order must contain at least one node."); - var nodeIds = order.Nodes.Select(n => n.NodeId).ToHashSet(); + if (order.Nodes.Count != order.Edges.Count + 1) + throw new Exception( + $"Invalid path structure: Nodes count ({order.Nodes.Count}) " + + $"must equal Edges count + 1 ({order.Edges.Count + 1})." + ); + + var nodeIds = order.Nodes + .Select(n => n.NodeId) + .ToHashSet(StringComparer.Ordinal); foreach (var e in order.Edges) { - if (!nodeIds.Contains(e.StartNodeId) || - !nodeIds.Contains(e.EndNodeId)) + if (string.IsNullOrWhiteSpace(e.StartNodeId) || + string.IsNullOrWhiteSpace(e.EndNodeId)) + { throw new Exception( - $"Edge '{e.EdgeId}' references unknown node." + $"Edge '{e.EdgeId}' must define both StartNodeId and EndNodeId." + ); + } + + if (!nodeIds.Contains(e.StartNodeId)) + throw new Exception( + $"Edge '{e.EdgeId}' references unknown StartNodeId '{e.StartNodeId}'." + ); + + if (!nodeIds.Contains(e.EndNodeId)) + throw new Exception( + $"Edge '{e.EdgeId}' references unknown EndNodeId '{e.EndNodeId}'." ); } } diff --git a/RobotApp.Client/Pages/Order/JsonOutputPanel.razor b/RobotApp.Client/Pages/Order/JsonOutputPanel.razor index 513e27c..900a4ac 100644 --- a/RobotApp.Client/Pages/Order/JsonOutputPanel.razor +++ b/RobotApp.Client/Pages/Order/JsonOutputPanel.razor @@ -1,4 +1,4 @@ - + 📄 JSON Output (/order) @@ -27,7 +27,6 @@ @(Copied ? "Copied!" : "Copy") @@ -36,12 +35,11 @@
-
+
+ @* - + *@ - + @* - + + *@ diff --git a/RobotApp.Client/Services/RobotStateClient.cs b/RobotApp.Client/Services/RobotStateClient.cs index 5ca88e2..ece5fa0 100644 --- a/RobotApp.Client/Services/RobotStateClient.cs +++ b/RobotApp.Client/Services/RobotStateClient.cs @@ -7,7 +7,7 @@ using System.Text.Json; namespace RobotApp.Client.Services; -// ================= CONNECTION STATE ================= +// ================= SIGNALR CONNECTION STATE ================= public enum RobotClientState { Disconnected, @@ -16,7 +16,7 @@ public enum RobotClientState Reconnecting } -// ================= CLIENT ================= +// ================= ROBOT STATE CLIENT ================= public sealed class RobotStateClient : IAsyncDisposable { private readonly NavigationManager _nav; @@ -25,11 +25,17 @@ public sealed class RobotStateClient : IAsyncDisposable private readonly object _lock = new(); private bool _started; + // ================= STATE CACHE ================= public ConcurrentDictionary LatestStates { get; } = new(); + // ================= ROBOT CONNECTION ================= + private bool _isRobotConnected; + public bool IsRobotConnected => _isRobotConnected; + // ================= EVENTS ================= public event Action? OnStateReceived; public event Action? OnStateReceivedAny; + public event Action? OnRobotConnectionChanged; public event Action? OnConnectionStateChanged; public RobotClientState ConnectionState { get; private set; } = RobotClientState.Disconnected; @@ -43,7 +49,8 @@ public sealed class RobotStateClient : IAsyncDisposable // ================= STATE HELPER ================= private void SetState(RobotClientState state) { - if (ConnectionState == state) return; + if (ConnectionState == state) + return; ConnectionState = state; OnConnectionStateChanged?.Invoke(state); @@ -82,9 +89,10 @@ public sealed class RobotStateClient : IAsyncDisposable return Task.CompletedTask; }; - _connection.Closed += async error => + _connection.Closed += async _ => { _started = false; + SetState(RobotClientState.Disconnected); if (_connection != null) { @@ -96,8 +104,14 @@ public sealed class RobotStateClient : IAsyncDisposable } }; + // ================= SIGNALR HANDLERS ================= + + // VDA5050 State _connection.On("ReceiveState", HandleState); + // Robot connection (bool only) + _connection.On("ReceiveRobotConnection", HandleRobotConnection); + try { await _connection.StartAsync(); @@ -136,6 +150,13 @@ public sealed class RobotStateClient : IAsyncDisposable OnStateReceivedAny?.Invoke(state); } + // ================= HANDLE ROBOT CONNECTION ================= + private void HandleRobotConnection(bool isConnected) + { + _isRobotConnected = isConnected; + OnRobotConnectionChanged?.Invoke(isConnected); + } + // ================= SUBSCRIBE ================= public async Task SubscribeRobotAsync(string serialNumber) { @@ -164,6 +185,7 @@ public sealed class RobotStateClient : IAsyncDisposable } LatestStates.TryRemove(serialNumber, out _); + _isRobotConnected = false; } // ================= GET CACHE ================= @@ -177,6 +199,7 @@ public sealed class RobotStateClient : IAsyncDisposable public async ValueTask DisposeAsync() { _started = false; + _isRobotConnected = false; SetState(RobotClientState.Disconnected); if (_connection != null) diff --git a/RobotApp.Client/Services/UiEdge.cs b/RobotApp.Client/Services/UiEdge.cs index 061fe7a..987a580 100644 --- a/RobotApp.Client/Services/UiEdge.cs +++ b/RobotApp.Client/Services/UiEdge.cs @@ -270,31 +270,17 @@ public class OrderMessage } public object ToSchemaObject() { - int seq = 0; + // ================= SORT NODES BY UI SEQUENCE ================= + var orderedNodes = Nodes + .OrderBy(n => n.SequenceId) + .ToList(); - 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 + // ================= BUILD NODE OBJECTS ================= + var nodeObjects = orderedNodes + .Select((n, index) => new { nodeId = n.NodeId, - sequenceId = seq++, + sequenceId = index * 2, // ✅ NODE = EVEN released = n.Released, nodePosition = new @@ -311,36 +297,55 @@ public class OrderMessage : n.NodePosition.MapId }, - actions = n.Actions.Select(a => new - { - actionId = a.ActionId, - actionType = a.ActionType, - blockingType = a.BlockingType, + 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 }) + actionParameters = a.ActionParameters? + .Select(p => new + { + key = p.Key, + value = p.Value + }) .ToArray() - : Array.Empty() - }).ToArray() - }).ToArray(), + ?? Array.Empty() + }) + .ToArray() + ?? Array.Empty() + }) + .ToArray(); - // ================= EDGES ================= - edges = Edges.Select(e => + // ================= BUILD EDGE OBJECTS ================= + var edgeObjects = Edges + .Select((e, index) => { + int sequenceId = index * 2 + 1; // ✅ EDGE = ODD + + // ---------- BASE ---------- + var baseEdge = new + { + edgeId = e.EdgeId, + sequenceId, + released = true, + startNodeId = e.StartNodeId, + endNodeId = e.EndNodeId + }; + // ================================================= - // 1️⃣ IMPORTED TRAJECTORY (ƯU TIÊN CAO NHẤT) + // 1️⃣ IMPORTED TRAJECTORY // ================================================= if (e.HasTrajectory && e.Trajectory != null) { return new { - edgeId = e.EdgeId, - sequenceId = seq++, - released = true, - - startNodeId = e.StartNodeId, - endNodeId = e.EndNodeId, + baseEdge.edgeId, + baseEdge.sequenceId, + baseEdge.released, + baseEdge.startNodeId, + baseEdge.endNodeId, trajectory = new { @@ -356,27 +361,25 @@ public class OrderMessage } // ================================================= - // 2️⃣ STRAIGHT EDGE (KHÔNG CÓ CURVE) + // 2️⃣ STRAIGHT EDGE // ================================================= if (e.Radius <= 0) { return new { - edgeId = e.EdgeId, - sequenceId = seq++, - released = true, - - startNodeId = e.StartNodeId, - endNodeId = e.EndNodeId, - + baseEdge.edgeId, + baseEdge.sequenceId, + baseEdge.released, + baseEdge.startNodeId, + baseEdge.endNodeId, actions = Array.Empty() }; } // ================================================= - // 3️⃣ EDITOR GENERATED CURVE (RADIUS + QUADRANT) + // 3️⃣ GENERATED CURVE (EDITOR) // ================================================= - var startNode = Nodes.First(n => n.NodeId == e.StartNodeId); + var startNode = orderedNodes.First(n => n.NodeId == e.StartNodeId); var A = new Point( startNode.NodePosition.X, @@ -391,17 +394,40 @@ public class OrderMessage return new { - edgeId = e.EdgeId, - sequenceId = seq++, - released = true, - - startNodeId = e.StartNodeId, - endNodeId = e.EndNodeId, + baseEdge.edgeId, + baseEdge.sequenceId, + baseEdge.released, + baseEdge.startNodeId, + baseEdge.endNodeId, trajectory = result.Trajectory, actions = Array.Empty() }; - }).ToArray() + }) + .ToArray(); + + // ================= FINAL SCHEMA OBJECT ================= + 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 = nodeObjects, + edges = edgeObjects }; } } diff --git a/RobotApp/Services/Robot/RobotStatePublisher.cs b/RobotApp/Services/Robot/RobotStatePublisher.cs index 9e8cd34..2d8d7ab 100644 --- a/RobotApp/Services/Robot/RobotStatePublisher.cs +++ b/RobotApp/Services/Robot/RobotStatePublisher.cs @@ -26,6 +26,8 @@ public class RobotStatePublisher : BackgroundService private readonly ILoad _loadManager; private readonly INavigation _navigationManager; private readonly RobotStateMachine _stateManager; + private readonly RobotConnection _robotConnection; + private bool? _lastRobotConnectionState; private uint _headerId = 0; private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(1000)); // 1 giây/lần @@ -42,7 +44,8 @@ public class RobotStatePublisher : BackgroundService IBattery batteryManager, ILoad loadManager, INavigation navigationManager, - RobotStateMachine stateManager) + RobotStateMachine stateManager, + RobotConnection robotConnection) { _hubContext = hubContext; _robotConfig = robotConfig; @@ -56,6 +59,7 @@ public class RobotStatePublisher : BackgroundService _loadManager = loadManager; _navigationManager = navigationManager; _stateManager = stateManager; + _robotConnection = robotConnection; } private StateMsg GetStateMsg() @@ -137,36 +141,48 @@ public class RobotStatePublisher : BackgroundService 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) + while (await _timer.WaitForNextTickAsync(stoppingToken)) { try { - var state = GetStateMsg(); var serialNumber = _robotConfig.SerialNumber; + // ===== SEND STATE ===== + var state = GetStateMsg(); 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}%"); + // ===== SEND ROBOT CONNECTION (ONLY WHEN CHANGED) ===== + var isConnected = _robotConnection.IsConnected; + + if (_lastRobotConnectionState != isConnected) + { + _lastRobotConnectionState = isConnected; + + await _hubContext.Clients + .Group(serialNumber) // routing only + .SendAsync( + "ReceiveRobotConnection", + isConnected, // payload only bool + stoppingToken + ); + + Console.WriteLine( + $"[RobotStatePublisher] Robot connection changed → {(isConnected ? "ONLINE" : "OFFLINE")}" + ); + } } catch (Exception ex) { - Console.WriteLine($"[RobotStatePublisher] Error publishing state: {ex.Message}"); + Console.WriteLine(ex); } } - - Console.WriteLine("[RobotStatePublisher] Stopped."); } + public override void Dispose() { _timer?.Dispose(); diff --git a/RobotApp/robot.db b/RobotApp/robot.db deleted file mode 100644 index 95eba7c..0000000 Binary files a/RobotApp/robot.db and /dev/null differ