save
This commit is contained in:
parent
f52f0fd8da
commit
93599f5c95
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
159
RobotApp.Client/Services/RobotStateClient.cs
Normal file
159
RobotApp.Client/Services/RobotStateClient.cs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
257
RobotApp.Client/Services/UiEdge.cs
Normal file
257
RobotApp.Client/Services/UiEdge.cs
Normal 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
29
RobotApp/Hubs/RobotHub.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
175
RobotApp/Services/Robot/RobotStatePublisher.cs
Normal file
175
RobotApp/Services/Robot/RobotStatePublisher.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user