This commit is contained in:
sonlt 2025-12-21 11:33:11 +07:00
parent f52f0fd8da
commit 93599f5c95
9 changed files with 876 additions and 302 deletions

View File

@ -73,7 +73,7 @@
// new(){Icon = "mdi-map-legend", Path="/maps-manager", Label = "Mapping", Match = NavLinkMatch.All}, // 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-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-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; private bool collapseNavMenu = true;

View File

@ -1,6 +1,7 @@
@page "/" @page "/"
@using System.Text.Json @using System.Text.Json
@using System.Text.Json.Serialization @using System.Text.Json.Serialization
@using RobotApp.Client.Services
@using RobotApp.VDA5050.InstantAction @using RobotApp.VDA5050.InstantAction
@using RobotApp.VDA5050.Order @using RobotApp.VDA5050.Order
@using RobotApp.VDA5050.Type @using RobotApp.VDA5050.Type
@ -41,7 +42,7 @@
<MudExpansionPanels MultiExpansion="true"> <MudExpansionPanels MultiExpansion="true">
@foreach (var node in Order.Nodes) @foreach (var node in Order.Nodes)
{ {
<MudExpansionPanel @key="node"> <MudExpansionPanel @key="node.NodeId">
<!-- ===== HEADER ===== --> <!-- ===== HEADER ===== -->
<TitleContent> <TitleContent>
@ -226,51 +227,107 @@
<!-- Cuộn nội bộ chỉ cho phần danh sách Edges --> <!-- Cuộn nội bộ chỉ cho phần danh sách Edges -->
<div class="flex-grow-1" style="overflow:auto;"> <div class="flex-grow-1" style="overflow:auto;">
<MudExpansionPanels MultiExpansion="true"> <MudExpansionPanels MultiExpansion="true">
@foreach (var edge in Order.Edges) @foreach (var edge in Order.Edges)
{ {
<MudExpansionPanel Text="@($"{edge.EdgeId} ({edge.StartNodeId} → {edge.EndNodeId})")" @key="edge"> <MudExpansionPanel Text="@($"{edge.EdgeId} ({edge.StartNodeId} → {edge.EndNodeId})")"
<MudGrid Spacing="3"> @key="edge.EdgeId">
<MudItem xs="12"><MudTextField @bind-Value="edge.EdgeId" Label="Edge ID" /></MudItem>
<MudItem xs="12">
<MudSelect T="string"
Label="Start Node"
Dense="true"
Required="true"
@bind-Value="edge.StartNodeId">
@foreach (var node in Order.Nodes)
{
<MudSelectItem Value="@node.NodeId">
@node.NodeId
</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12"> <ChildContent>
<MudSelect T="string" <MudGrid Spacing="3">
Label="End Node"
Dense="true" <!-- Edge ID -->
Required="true" <MudItem xs="12">
@bind-Value="edge.EndNodeId"> <MudTextField @bind-Value="edge.EdgeId"
@foreach (var node in Order.Nodes) Label="Edge ID" />
{ </MudItem>
<MudSelectItem Value="@node.NodeId"
Disabled="@(node.NodeId == edge.StartNodeId)"> <!-- Start Node -->
@node.NodeId <MudItem xs="12">
</MudSelectItem> <MudSelect T="string"
} Label="Start Node"
</MudSelect> Dense="true"
</MudItem> Required="true"
@bind-Value="edge.StartNodeId">
@foreach (var node in Order.Nodes)
{
<MudSelectItem Value="@node.NodeId">
@node.NodeId
</MudSelectItem>
}
</MudSelect>
</MudItem>
<!-- End Node -->
<MudItem xs="12">
<MudSelect T="string"
Label="End Node"
Dense="true"
Required="true"
@bind-Value="edge.EndNodeId">
@foreach (var node in Order.Nodes)
{
<MudSelectItem Value="@node.NodeId"
Disabled="@(node.NodeId == edge.StartNodeId)">
@node.NodeId
</MudSelectItem>
}
</MudSelect>
</MudItem>
<!-- Radius: =0 → đường thẳng -->
<MudItem xs="6">
<MudNumericField T="double"
@bind-Value="edge.Radius"
Label="Bán kính (0 = đường thẳng)"
Min="0"
Step="0.1" />
</MudItem>
<!-- Quadrant chỉ hiện khi Radius > 0 -->
@if (edge.Radius > 0)
{
<MudItem xs="6">
<MudSelect T="Quadrant"
@bind-Value="edge.Quadrant"
Label="Góc phần tư">
<MudSelectItem Value="Quadrant.I">Góc I</MudSelectItem>
<MudSelectItem Value="Quadrant.II">Góc II</MudSelectItem>
<MudSelectItem Value="Quadrant.III">Góc III</MudSelectItem>
<MudSelectItem Value="Quadrant.IV">Góc IV</MudSelectItem>
</MudSelect>
</MudItem>
}
@if (edge.Radius > 0 && !edge.Expanded)
{
<MudItem xs="12">
<MudButton Color="Color.Primary"
Variant="Variant.Outlined"
StartIcon="@Icons.Material.Filled.Merge"
OnClick="@(() => ApplyCurve(edge))">
Apply Curve (tạo node)
</MudButton>
</MudItem>
}
<MudItem xs="12" Class="mt-4">
<MudButton Color="Color.Error"
Variant="Variant.Text"
StartIcon="@Icons.Material.Filled.Delete"
OnClick="@(() => RemoveEdge(edge))">
Remove Edge
</MudButton>
</MudItem>
</MudGrid>
</ChildContent>
<MudItem xs="12" Class="mt-4">
<MudButton Color="Color.Error" Variant="Variant.Text" StartIcon="@Icons.Material.Filled.Delete"
OnClick="@(() => RemoveEdge(edge))">Remove Edge</MudButton>
</MudItem>
</MudGrid>
</MudExpansionPanel> </MudExpansionPanel>
} }
</MudExpansionPanels> </MudExpansionPanels>
</div> </div>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@ -369,6 +426,25 @@
bool IsEditNodeOpen; bool IsEditNodeOpen;
Node? EditingNode; Node? EditingNode;
OrderMessage Order = new(); 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() async Task SendOrderToServer()
{ {
@ -413,7 +489,7 @@
NodeId = $"NODE_{Order.Nodes.Count + 1}", NodeId = $"NODE_{Order.Nodes.Count + 1}",
SequenceId = Order.Nodes.Count, SequenceId = Order.Nodes.Count,
Released = true, Released = true,
NodePosition = new NodePosition() NodePosition = new NodePosition { MapId = "MAP_01" }
}); });
} }
@ -469,7 +545,7 @@
{ {
if (Order.Nodes.Count < 2) return; 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}", EdgeId = $"EDGE_{Order.Edges.Count + 1}",
StartNodeId = Order.Nodes[^2].NodeId, 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); 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<Node> Nodes { get; set; } = new();
public List<VDA5050.Order.Edge> 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<object>()
})
.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<object>()
})
.ToArray() // ✅ QUAN TRỌNG
};
}
}
private async Task OpenEditNodeDialog(Node node) private async Task OpenEditNodeDialog(Node node)
{ {
var parameters = new DialogParameters<EditNodeDialog> var parameters = new DialogParameters<EditNodeDialog>

View File

@ -1,8 +1,9 @@
@page "/robot-state" @page "/robot-state"
@using RobotApp.Client.Services
@using RobotApp.VDA5050.State @using RobotApp.VDA5050.State
@rendermode InteractiveWebAssemblyNoPrerender @inject RobotStateClient RobotStateClient
@implements IDisposable
@inject StateMsg RobotState @rendermode InteractiveWebAssembly
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4"> <MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
<!-- ===================================================== --> <!-- ===================================================== -->
@ -11,34 +12,46 @@
<MudPaper Class="pa-6 mb-4 d-flex align-center justify-space-between" Elevation="3"> <MudPaper Class="pa-6 mb-4 d-flex align-center justify-space-between" Elevation="3">
<div> <div>
<MudText Typo="Typo.h4">🤖 VDA 5050 Robot Dashboard</MudText> <MudText Typo="Typo.h4">🤖 VDA 5050 Robot Dashboard</MudText>
<MudText Typo="Typo.subtitle2" Color="Color.Secondary"> @if (CurrentState != null)
@RobotState.Version • {
@RobotState.Manufacturer <MudText Typo="Typo.subtitle2" Color="Color.Secondary">
@RobotState.SerialNumber @CurrentState.Version •
</MudText> @CurrentState.Manufacturer •
@CurrentState.SerialNumber
</MudText>
}
else
{
<MudText Typo="Typo.subtitle2" Color="Color.Secondary">
Connecting to robot...
</MudText>
}
</div> </div>
@* @if (RobotState.Current != null)
@if (CurrentState != null)
{ {
<MudChip T="string" Size="Size.Large" <MudChip T="string" Size="Size.Large"
Color="@(RobotState.Current ? Color.Success : Color.Error)"> Color="@(IsConnected ? Color.Success : Color.Error)"
@(RobotState.Current.IsOnline ? "ONLINE" : "OFFLINE") Variant="Variant.Filled">
@(IsConnected ? "ONLINE" : "OFFLINE")
</MudChip> </MudChip>
} *@ }
</MudPaper> </MudPaper>
@if (RobotState == null) @if (CurrentState == null)
{ {
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined"> <MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
Waiting for robot state (VDA5050)... <MudAlertTitle>Waiting for robot state data...</MudAlertTitle>
Connecting to SignalR hub and subscribing to robot updates.
<MudProgressLinear Indeterminate Class="mt-3" /> <MudProgressLinear Indeterminate Class="mt-3" />
</MudAlert> </MudAlert>
} }
else else
{ {
var msg = RobotState; var msg = CurrentState;
<!-- ===================================================== --> <!-- ===================================================== -->
<!-- 📨 MESSAGE META (DEBUG / TRACE) --> <!-- MESSAGE META -->
<!-- ===================================================== --> <!-- ===================================================== -->
<MudPaper Class="pa-3 mb-4" Elevation="1"> <MudPaper Class="pa-3 mb-4" Elevation="1">
<MudGrid Spacing="2"> <MudGrid Spacing="2">
@ -56,9 +69,7 @@
</MudItem> </MudItem>
<MudItem xs="12" md="2"> <MudItem xs="12" md="2">
<MudText Typo="Typo.caption">OrderUpdateId</MudText> <MudText Typo="Typo.caption">OrderUpdateId</MudText>
<MudChip T="string" Color="Color.Primary"> <MudChip T="string" Color="Color.Primary">@msg.OrderUpdateId</MudChip>
@msg.OrderUpdateId
</MudChip>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
</MudPaper> </MudPaper>
@ -104,7 +115,7 @@
</MudItem> </MudItem>
<MudItem xs="6" Class="d-flex justify-end"> <MudItem xs="6" Class="d-flex justify-end">
<MudText Typo="Typo.caption"> <MudText Typo="Typo.caption">
DeviationRange: <b>@msg.AgvPosition.DeviationRange</b> Deviation: <b>@msg.AgvPosition.DeviationRange</b>
</MudText> </MudText>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@ -141,7 +152,7 @@
<MudText><b>@msg.BatteryState.BatteryVoltage.ToString("F1")</b> V</MudText> <MudText><b>@msg.BatteryState.BatteryVoltage.ToString("F1")</b> V</MudText>
</MudItem> </MudItem>
<MudItem xs="4"> <MudItem xs="4">
<MudText Typo="Typo.caption">Health</MudText> <MudText Typo="Typo.caption">Health (SOH)</MudText>
<MudText><b>@msg.BatteryState.BatteryHealth</b> %</MudText> <MudText><b>@msg.BatteryState.BatteryHealth</b> %</MudText>
</MudItem> </MudItem>
<MudItem xs="4"> <MudItem xs="4">
@ -164,16 +175,14 @@
Last Node: <b>@msg.LastNodeId</b> Last Node: <b>@msg.LastNodeId</b>
<MudText Typo="Typo.caption" Inline="true">Seq: @msg.LastNodeSequenceId</MudText> <MudText Typo="Typo.caption" Inline="true">Seq: @msg.LastNodeSequenceId</MudText>
</MudText> </MudText>
<MudText>Distance: @msg.DistanceSinceLastNode:F1 m</MudText> <MudText>Distance since last node: @msg.DistanceSinceLastNode:F1 m</MudText>
<MudDivider Class="my-3" /> <MudDivider Class="my-3" />
@{ @{
var nodeReleased = msg.NodeStates?.Count(n => n.Released) ?? 0; var nodeReleased = msg.NodeStates?.Count(n => n.Released) ?? 0;
var nodeTotal = msg.NodeStates?.Length ?? 0; var nodeTotal = msg.NodeStates?.Length ?? 0;
var edgeReleased = msg.EdgeStates?.Count(e => e.Released) ?? 0; var edgeReleased = msg.EdgeStates?.Count(e => e.Released) ?? 0;
var edgeTotal = msg.EdgeStates?.Length ?? 0; var edgeTotal = msg.EdgeStates?.Length ?? 0;
} }
<div class="d-flex align-center flex-wrap gap-2"> <div class="d-flex align-center flex-wrap gap-2">
<MudChip T="string" Color="Color.Info">Nodes: @nodeReleased / @nodeTotal</MudChip> <MudChip T="string" Color="Color.Info">Nodes: @nodeReleased / @nodeTotal</MudChip>
<MudChip T="string" Color="Color.Info">Edges: @edgeReleased / @edgeTotal</MudChip> <MudChip T="string" Color="Color.Info">Edges: @edgeReleased / @edgeTotal</MudChip>
@ -190,175 +199,89 @@
<!-- ERRORS + INFORMATION --> <!-- ERRORS + INFORMATION -->
<MudItem xs="12" md="12" lg="6"> <MudItem xs="12" md="12" lg="6">
<MudPaper Class="pa-5 h-100" Elevation="2"> <MudPaper Class="pa-5 h-100" Elevation="2">
<MudText Typo="Typo.h6">🚨 Errors & Information</MudText> <MudText Typo="Typo.h6">🚨 Errors & Information</MudText>
<MudDivider Class="my-3" /> <MudDivider Class="my-3" />
<MudTable Items="@MessageRows"
@{
var rows = new List<MessageRow>();
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();
}
<MudTable Items="@sortedMessages"
Dense="true" Dense="true"
Hover="true" Hover="true"
Bordered="true" Bordered="true"
Elevation="0" Elevation="0"
Height="180px" Height="200px"
Breakpoint="Breakpoint.Sm" FixedHeader="true">
HorizontalScrollbar="true">
<ColGroup> <ColGroup>
<col style="width: 35%" /> <col style="width: 35%" />
<col style="width: 20%" /> <col style="width: 20%" />
<col /> <col />
</ColGroup> </ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>Type</MudTh> <MudTh>Type</MudTh>
<MudTh>Level</MudTh> <MudTh>Level</MudTh>
<MudTh>Description</MudTh> <MudTh>Description</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Type"> <MudTd>
<MudText Class="@(context.IsError ? "text-error" : "text-info")"> <MudText Class="@(context.IsError ? "text-error" : "text-info")">
<b>@context.Type</b> <b>@context.Type</b>
</MudText> </MudText>
</MudTd> </MudTd>
<MudTd>
<MudTd DataLabel="Level"> <MudChip T="string" Size="Size.Small"
<MudChip T="string" Color="@(context.Level.Contains("ERROR", StringComparison.OrdinalIgnoreCase) ? Color.Error :
Size="Size.Small" context.Level.Contains("WARN", StringComparison.OrdinalIgnoreCase) ? Color.Warning :
Color="@(context.Level == "ERROR" Color.Info)">
? Color.Error @context.Level
: context.Level == "WARNING" </MudChip>
? Color.Warning </MudTd>
: Color.Info)"> <MudTd>
@context.Level <MudText Typo="Typo.caption" Class="text-truncate" Title="@context.Description">
</MudChip>
</MudTd>
<MudTd DataLabel="Description">
<MudText Typo="Typo.caption"
Class="text-truncate"
Title="@context.Description">
@context.Description @context.Description
</MudText> </MudText>
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>
<NoRecordsContent> <NoRecordsContent>
<MudText Typo="Typo.caption" <MudText Typo="Typo.caption" Color="Color.Secondary" Class="pa-4 text-center">
Color="Color.Secondary"
Class="pa-4 text-center">
No errors or information messages No errors or information messages
</MudText> </MudText>
</NoRecordsContent> </NoRecordsContent>
</MudTable> </MudTable>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
<!-- ACTIONS --> <!-- ACTIONS -->
<MudItem xs="12" md="6" lg="3"> <MudItem xs="12" md="6" lg="3">
<MudPaper Class="pa-5 h-100" Elevation="2"> <MudPaper Class="pa-5 h-100" Elevation="2">
<MudText Typo="Typo.h6">⚙️ Actions</MudText> <MudText Typo="Typo.h6">⚙️ Actions</MudText>
<MudDivider Class="my-3" /> <MudDivider Class="my-3" />
<MudTable Items="msg.ActionStates" <MudTable Items="msg.ActionStates"
Dense="true" Dense="true"
Hover="true" Hover="true"
Bordered="true" Bordered="true"
Elevation="0" Elevation="0"
Height="160px" Height="180px"
Breakpoint="Breakpoint.Sm"
HorizontalScrollbar="true"
FixedHeader="true"> FixedHeader="true">
<ColGroup>
<col style="width: 40%" />
<col style="width: 35%" />
<col style="width: 25%" />
</ColGroup>
<HeaderContent> <HeaderContent>
<MudTh>Action</MudTh> <MudTh>Action</MudTh>
<MudTh>Action ID</MudTh> <MudTh>Action ID</MudTh>
<MudTh class="text-right">Status</MudTh> <MudTh class="text-right">Status</MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd DataLabel="Action"> <MudTd><MudText Typo="Typo.body2" Class="text-truncate" Title="@context.ActionType">@context.ActionType</MudText></MudTd>
<MudText Typo="Typo.body2" <MudTd><MudText Typo="Typo.caption">@context.ActionId</MudText></MudTd>
Class="text-truncate" <MudTd Class="text-right">
Title="@context.ActionType"> <MudChip T="string" Size="Size.Small"
@context.ActionType Color="@(context.ActionStatus == "RUNNING" ? Color.Info :
</MudText> context.ActionStatus == "FINISHED" ? Color.Success :
context.ActionStatus == "FAILED" ? Color.Error : Color.Default)">
@context.ActionStatus
</MudChip>
</MudTd> </MudTd>
</RowTemplate>
<MudTd DataLabel="Action ID">
<MudText Typo="Typo.caption">
@context.ActionId
</MudText>
</MudTd>
<MudTd DataLabel="Status" Class="text-right">
<MudChip T="string"
Size="Size.Small"
Variant="Variant.Filled"
Color="@(context.ActionStatus == "RUNNING"
? Color.Info
: context.ActionStatus == "FINISHED"
? Color.Success
: Color.Error)">
@context.ActionStatus
</MudChip>
</MudTd>
</RowTemplate>
<NoRecordsContent> <NoRecordsContent>
<MudText Typo="Typo.caption" <MudText Typo="Typo.caption" Color="Color.Secondary" Class="pa-4 text-center">
Color="Color.Secondary"
Class="pa-4 text-center">
No active actions No active actions
</MudText> </MudText>
</NoRecordsContent> </NoRecordsContent>
</MudTable> </MudTable>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
@ -377,21 +300,75 @@
</MudChip> </MudChip>
</MudPaper> </MudPaper>
</MudItem> </MudItem>
</MudGrid> </MudGrid>
} }
</MudContainer> </MudContainer>
@code { @code {
private StateMsg? CurrentState;
private bool IsConnected => RobotStateClient.LatestStates.ContainsKey(RobotSerial);
private async Task OnStateChanged() // Thay bằng serial number thật của robot bạn muốn theo dõi
=> await InvokeAsync(StateHasChanged); private readonly string RobotSerial = "T800-002";
private List<MessageRow> MessageRows = new();
private record MessageRow( protected override async Task OnInitializedAsync()
string Type, {
string Level, // Subscribe sự kiện nhận state
string Description, RobotStateClient.OnStateReceived += OnRobotStateReceived;
bool IsError
); // 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);
} }

View File

@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using MudBlazor.Services; using MudBlazor.Services;
using RobotApp.Client.Services;
using System.Globalization; using System.Globalization;
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US"); 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(_ => new HttpClient() { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
builder.Services.AddScoped<RobotApp.Client.Services.RobotMonitorService>(); builder.Services.AddScoped<RobotApp.Client.Services.RobotMonitorService>();
builder.Services.AddScoped<RobotApp.Client.Services.RobotStateClient>();
builder.Services.AddMudServices(config => builder.Services.AddMudServices(config =>
{ {
config.SnackbarConfiguration.VisibleStateDuration = 2000; config.SnackbarConfiguration.VisibleStateDuration = 2000;

View File

@ -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<string, StateMsg> LatestStates { get; } = new();
public event Action<string, StateMsg>? OnStateReceived;
public event Action<StateMsg>? 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<string>("ReceiveState", stateJson =>
{
try
{
var state = JsonSerializer.Deserialize<StateMsg>(
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");
}
}
}

View File

@ -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<Node> Nodes { get; set; } = new();
public List<UiEdge> 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<object>()
}).ToArray()
}).ToArray(),
// ================= EDGES =================
edges = Edges.Select<UiEdge, object>(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<object>()
};
}
// ---------- ĐƯỜ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<object>()
};
}).ToArray()
};
}
}
// ======================================================
// UI ACTION PARAM
// ======================================================
public class UiActionParameter : ActionParameter
{
[JsonIgnore]
public string ValueString
{
get => Value?.ToString() ?? "";
set => Value = value;
}
}

29
RobotApp/Hubs/RobotHub.cs Normal file
View File

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

View File

@ -4,11 +4,13 @@ using Microsoft.EntityFrameworkCore;
using MudBlazor.Services; using MudBlazor.Services;
using NLog.Web; using NLog.Web;
using RobotApp.Client; using RobotApp.Client;
using RobotApp.Client.Services;
using RobotApp.Components; using RobotApp.Components;
using RobotApp.Components.Account; using RobotApp.Components.Account;
using RobotApp.Data; using RobotApp.Data;
using RobotApp.Hubs; using RobotApp.Hubs;
using RobotApp.Services; using RobotApp.Services;
using RobotApp.Services.Robot;
using RobotApp.Services.Robot.Simulation; using RobotApp.Services.Robot.Simulation;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -58,8 +60,11 @@ builder.Services.AddRobotSimulation();
builder.Services.AddRobot(); builder.Services.AddRobot();
// Add RobotMonitorService // Add RobotMonitorService
builder.Services.AddSingleton<RobotMonitorService>(); builder.Services.AddSingleton<RobotApp.Services.RobotMonitorService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RobotMonitorService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<RobotApp.Services.RobotMonitorService>());
builder.Services.AddScoped<RobotStateClient>();
builder.Services.AddHostedService<RobotStatePublisher>();
var app = builder.Build(); var app = builder.Build();
await app.Services.SeedApplicationDbAsync(); await app.Services.SeedApplicationDbAsync();
@ -91,7 +96,7 @@ app.MapControllers();
// Map SignalR Hub // Map SignalR Hub
app.MapHub<RobotMonitorHub>("/hubs/robotMonitor"); app.MapHub<RobotMonitorHub>("/hubs/robotMonitor");
app.MapHub<RobotHub>("/hubs/robot");
app.MapRazorComponents<App>() app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode() .AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode() .AddInteractiveWebAssemblyRenderMode()

View File

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