14 Commits

Author SHA1 Message Date
Đăng Nguyễn
b2eeb8cb3f update 2025-12-22 19:49:03 +07:00
7ce770404c save 2025-12-22 19:45:25 +07:00
Đăng Nguyễn
128600c4ed update 2025-12-22 19:44:08 +07:00
Đăng Nguyễn
b006c5b197 update console 2025-12-22 19:43:04 +07:00
Đăng Nguyễn
4ceec9abd5 update 2025-12-22 19:31:55 +07:00
Đăng Nguyễn
1289a6c331 update 2025-12-22 18:39:38 +07:00
Đăng Nguyễn
f1a7be15f2 update 2025-12-22 18:38:35 +07:00
d2cf86f34e update 2025-12-22 18:38:10 +07:00
Đăng Nguyễn
d4af3b8707 Merge remote-tracking branch 'origin/sonlt' into dangnv 2025-12-22 18:12:25 +07:00
909e147be1 save 2025-12-22 18:08:02 +07:00
dc837e5488 save 2025-12-22 17:48:48 +07:00
Đăng Nguyễn
7daad2dfaf update 2025-12-22 16:23:26 +07:00
Đăng Nguyễn
38858355e6 Merge remote-tracking branch 'origin/sonlt' into dangnv 2025-12-22 16:11:45 +07:00
7a6f813825 save 2025-12-22 14:35:55 +07:00
36 changed files with 572 additions and 185 deletions

66
.dockerignore Normal file
View File

@@ -0,0 +1,66 @@
# Build artifacts
**/bin/
**/obj/
**/out/
# Visual Studio files
**/.vs/
**/.vscode/
**/*.user
**/*.suo
**/*.userosscache
**/*.sln.docstates
# User-specific files
**/.user
**/.suo
**/.userosscache
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# NuGet packages
**/packages/
**/*.nupkg
**/*.snupkg
# Test results
**/[Tt]est[Rr]esult*/
**/[Bb]uild[Ll]og.*
# Docker files
Dockerfile*
docker-compose*
.dockerignore
# Git
.git/
.gitignore
.gitattributes
# IDE
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Data and logs (will be mounted as volumes)
data/
logs/

57
Dockerfile Normal file
View File

@@ -0,0 +1,57 @@
# Stage 1: Build
# Note: Project files specify net10.0, but using .NET 9.0 based on package versions (9.0.9)
# Adjust version if needed: 8.0 (LTS), 9.0 (current), or future 10.0
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy solution file
COPY RobotApp.sln .
# Copy project files
COPY RobotApp/RobotApp.csproj RobotApp/
COPY RobotApp.Client/RobotApp.Client.csproj RobotApp.Client/
COPY RobotApp.Common.Shares/RobotApp.Common.Shares.csproj RobotApp.Common.Shares/
COPY RobotApp.VDA5050/RobotApp.VDA5050.csproj RobotApp.VDA5050/
# Restore dependencies
RUN dotnet restore RobotApp.sln
# Copy all source files
COPY RobotApp/ RobotApp/
COPY RobotApp.Client/ RobotApp.Client/
COPY RobotApp.Common.Shares/ RobotApp.Common.Shares/
COPY RobotApp.VDA5050/ RobotApp.VDA5050/
RUN rm -rf ./RobotApp/RobotApp/bin
RUN rm -rf ./RobotApp/RobotApp/obj
RUN rm -rf ./RobotApp.Client/RobotApp.Client/bin
RUN rm -rf ./RobotApp.Client/RobotApp.Client/obj
RUN rm -rf ./RobotApp.Common.Shares/RobotApp.Common.Shares/bin
RUN rm -rf ./RobotApp.Common.Shares/RobotApp.Common.Shares/obj
RUN rm -rf ./RobotApp.VDA5050/RobotApp.VDA5050/bin
RUN rm -rf ./RobotApp.VDA5050/RobotApp.VDA5050/obj
# Build the solution
WORKDIR /src/RobotApp
RUN dotnet build -c Release -o /app/build
# Stage 2: Publish
FROM build AS publish
WORKDIR /src/RobotApp
RUN dotnet publish -c Release -o /app/publish /p:UseAppHost=false
# Copy published files
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish ./
# Set environment variables
#ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
# Run the application
ENTRYPOINT ["dotnet", "RobotApp.dll"]

View File

@@ -69,7 +69,7 @@
} }
public NavModel[] Navs = [ public NavModel[] Navs = [
new(){Icon = "mdi-view-dashboard", Path="/", Label = "Dashboard", Match = NavLinkMatch.All}, new(){Icon = "mdi-view-dashboard", Path="/dashboard", Label = "Dashboard", Match = NavLinkMatch.All},
// 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-state-machine", Path="/robot-order", Label = "order", Match = NavLinkMatch.All}, new(){Icon = "mdi-state-machine", Path="/robot-order", Label = "order", Match = NavLinkMatch.All},

View File

@@ -25,9 +25,9 @@
<span>X: @MonitorData.RobotPosition.X.ToString("F2")m | Y: @MonitorData.RobotPosition.Y.ToString("F2")m | θ: @((MonitorData.RobotPosition.Theta * 180 / Math.PI).ToString("F1"))°</span> <span>X: @MonitorData.RobotPosition.X.ToString("F2")m | Y: @MonitorData.RobotPosition.Y.ToString("F2")m | θ: @((MonitorData.RobotPosition.Theta * 180 / Math.PI).ToString("F1"))°</span>
</div> </div>
} }
<MudChip T="string" Color="@(IsConnected? Color.Success: Color.Error)" Size="Size.Small"> @* <MudChip T="string" Color="@(IsConnected? Color.Success: Color.Error)" Size="Size.Small">
@(IsConnected ? "Connected" : "Disconnected") @(IsConnected ? "Connected" : "Disconnected")
</MudChip> </MudChip> *@
</div> </div>
<div @ref="SvgContainerRef" class="svg-container"> <div @ref="SvgContainerRef" class="svg-container">
<svg @ref="SvgRef" <svg @ref="SvgRef"

View File

@@ -5,12 +5,11 @@
@using MudBlazor @using MudBlazor
@implements IDisposable @implements IDisposable
@attribute [Authorize]
@inject RobotStateClient RobotStateClient @inject RobotStateClient RobotStateClient
@rendermode InteractiveWebAssemblyNoPrerender @rendermode InteractiveWebAssemblyNoPrerender
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4"> <MudContainer MaxWidth="MaxWidth.False" Class="pa-4" Style="overflow-y:auto">
<!-- Header Dashboard --> <!-- Header Dashboard -->
<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>
@@ -38,15 +37,18 @@
@if (CurrentState != null) @if (CurrentState != null)
{ {
<MudChip T="string" <MudChip T="string"
Icon="@(IsConnected ? Icons.Material.Filled.CheckCircle : Icons.Material.Filled.Error)" Icon="@(IsConnected
Size="Size.Large" ? Icons.Material.Filled.CheckCircle
Color="@(IsConnected ? Color.Success : Color.Error)" : Icons.Material.Filled.Error)"
Variant="@Variant.Filled" Size="Size.Large"
Class="px-6 py-4 text-white" Color="@(IsConnected ? Color.Success : Color.Error)"
Style="font-weight: bold; font-size: 1.1rem;"> Variant="Variant.Filled"
@(IsConnected ? "ONLINE" : "OFFLINE") Class="px-6 py-4 text-white"
</MudChip> Style="font-weight: bold; font-size: 1.1rem;">
} @(IsConnected ? "ONLINE" : "OFFLINE")
</MudChip>
}
</MudPaper> </MudPaper>
@{ @{
@@ -133,7 +135,6 @@
</MudCard> </MudCard>
</MudItem> </MudItem>
<!-- BATTERY -->
<!-- BATTERY --> <!-- BATTERY -->
<MudItem xs="12" md="6" lg="4"> <MudItem xs="12" md="6" lg="4">
<MudCard Elevation="6" Class="h-100 rounded-lg"> <MudCard Elevation="6" Class="h-100 rounded-lg">
@@ -371,21 +372,35 @@
}; };
private StateMsg? CurrentState; private StateMsg? CurrentState;
private bool IsConnected => RobotStateClient.LatestStates.ContainsKey(RobotSerial); private bool IsConnected;
private readonly string RobotSerial = "T800-002"; private readonly string RobotSerial = "T800-002";
private List<MessageRow> MessageRows = new(); private List<MessageRow> MessageRows = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
RobotStateClient.OnStateReceived += OnRobotStateReceived; RobotStateClient.OnStateReceived += OnRobotStateReceived;
if (RobotStateClient.LatestStates.Count == 0) RobotStateClient.OnRobotConnectionChanged += OnRobotConnectionChanged;
if (RobotStateClient.ConnectionState == RobotClientState.Disconnected)
{ {
await RobotStateClient.StartAsync(); await RobotStateClient.StartAsync();
} }
await RobotStateClient.SubscribeRobotAsync(RobotSerial); await RobotStateClient.SubscribeRobotAsync(RobotSerial);
CurrentState = RobotStateClient.GetLatestState(RobotSerial); CurrentState = RobotStateClient.GetLatestState(RobotSerial);
IsConnected = RobotStateClient.IsRobotConnected;
UpdateMessageRows(); UpdateMessageRows();
} }
private void OnRobotConnectionChanged(bool connected)
{
InvokeAsync(() =>
{
IsConnected = connected;
StateHasChanged();
});
}
private void OnRobotStateReceived(string serialNumber, StateMsg state) private void OnRobotStateReceived(string serialNumber, StateMsg state)
@@ -421,6 +436,7 @@
public void Dispose() public void Dispose()
{ {
RobotStateClient.OnStateReceived -= OnRobotStateReceived; RobotStateClient.OnStateReceived -= OnRobotStateReceived;
RobotStateClient.OnRobotConnectionChanged -= OnRobotConnectionChanged;
} }
private record MessageRow(string Type, string Level, string Description, bool IsError); private record MessageRow(string Type, string Level, string Description, bool IsError);

View File

@@ -1,6 +1,5 @@
@page "/logs" @page "/logs"
@rendermode InteractiveWebAssemblyNoPrerender @rendermode InteractiveWebAssemblyNoPrerender
@attribute [Authorize]
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication @using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using RobotApp.Client.Models @using RobotApp.Client.Models

View File

@@ -2,8 +2,6 @@
@rendermode InteractiveWebAssemblyNoPrerender @rendermode InteractiveWebAssemblyNoPrerender
@attribute [Authorize]
<PageTitle>Map Manager</PageTitle> <PageTitle>Map Manager</PageTitle>
<div class="d-flex w-100 h-100 p-2 overflow-hidden flex-row"> <div class="d-flex w-100 h-100 p-2 overflow-hidden flex-row">

View File

@@ -89,7 +89,7 @@
</MudSelect> </MudSelect>
</MudItem> </MudItem>
<!-- Radius --> @* <!-- Radius -->
<MudItem xs="6"> <MudItem xs="6">
<MudNumericField T="double" <MudNumericField T="double"
Value="@edge.Radius" Value="@edge.Radius"
@@ -127,7 +127,7 @@
Apply Curve (generate node) Apply Curve (generate node)
</MudButton> </MudButton>
</MudItem> </MudItem>
} } *@
</MudGrid> </MudGrid>
</ChildContent> </ChildContent>

View File

@@ -10,12 +10,6 @@
<MudItem xs="12"> <MudItem xs="12">
<MudTextField @bind-Value="Node.NodeId" Label="Node ID" Required="true" /> <MudTextField @bind-Value="Node.NodeId" Label="Node ID" Required="true" />
</MudItem> </MudItem>
<MudItem xs="12">
<MudNumericField T="int" @bind-Value="Node.SequenceId" Label="Sequence ID" Required="true" />
</MudItem>
<MudItem xs="12">
<MudSwitch T="bool" @bind-Checked="Node.Released" Label="Released" />
</MudItem>
<MudItem xs="6"> <MudItem xs="6">
<MudNumericField T="double" @bind-Value="Node.NodePosition.X" Label="X" /> <MudNumericField T="double" @bind-Value="Node.NodePosition.X" Label="X" />
</MudItem> </MudItem>

View File

@@ -1,11 +1,21 @@
@using System.Text.Json @using System.Text.Json
@using MudBlazor @using MudBlazor
@using Microsoft.AspNetCore.Components.Forms
<MudDialog> <MudDialog>
<!-- ================= TITLE ================= -->
<TitleContent> <TitleContent>
<MudText Typo="Typo.h6">Import Order JSON</MudText> <MudStack Row AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.UploadFile"
Color="Color.Primary" />
<MudText Typo="Typo.h6">
Import Order JSON
</MudText>
</MudStack>
</TitleContent> </TitleContent>
<!-- ================= CONTENT ================= -->
<DialogContent> <DialogContent>
@if (ShowWarning) @if (ShowWarning)
@@ -18,6 +28,21 @@
</MudAlert> </MudAlert>
} }
<!-- ===== FILE INPUT (PURE BLAZOR) ===== -->
<div class="mb-4">
<label class="mud-button-root mud-button mud-button-outlined mud-button-outlined-primary"
style="cursor:pointer;">
<MudIcon Icon="@Icons.Material.Filled.AttachFile" Class="mr-2" />
Choose JSON file
<InputFile OnChange="OnFileSelected"
accept=".json"
style="display:none" />
</label>
</div>
<MudDivider Class="my-3" />
<!-- ===== PASTE JSON ===== -->
<MudTextField @bind-Value="JsonText" <MudTextField @bind-Value="JsonText"
Label="Paste Order JSON" Label="Paste Order JSON"
Lines="20" Lines="20"
@@ -34,29 +59,77 @@
</DialogContent> </DialogContent>
<!-- ================= ACTIONS ================= -->
<DialogActions> <DialogActions>
<MudButton OnClick="Cancel">Cancel</MudButton> <MudButton OnClick="Cancel">
Cancel
</MudButton>
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
OnClick="ValidateAndImport"> OnClick="ValidateAndImport">
Import Import
</MudButton> </MudButton>
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@code { @code {
[CascadingParameter] public IMudDialogInstance MudDialog { get; set; } = default!; [CascadingParameter]
public IMudDialogInstance MudDialog { get; set; } = default!;
public string JsonText { get; set; } = ""; public string JsonText { get; set; } = "";
public string? ErrorMessage; public string? ErrorMessage;
public bool ShowWarning { get; set; } = false; public bool ShowWarning { get; set; }
private void Cancel() => MudDialog.Cancel(); private void Cancel() => MudDialog.Cancel();
// ================= FILE HANDLER =================
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
ErrorMessage = null;
ShowWarning = false;
var file = e.File;
if (file == null)
return;
if (!file.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase) &&
!file.Name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase))
{
ShowWarning = true;
ErrorMessage = "Only .json or .txt files are supported.";
return;
}
try
{
using var stream = file.OpenReadStream(maxAllowedSize: 1_048_576);
using var reader = new StreamReader(stream);
JsonText = await reader.ReadToEndAsync();
StateHasChanged();
}
catch (Exception ex)
{
ShowWarning = true;
ErrorMessage = $"Failed to read file: {ex.Message}";
}
}
// ================= VALIDATE & IMPORT =================
private void ValidateAndImport() private void ValidateAndImport()
{ {
ErrorMessage = null; ErrorMessage = null;
ShowWarning = false; ShowWarning = false;
if (string.IsNullOrWhiteSpace(JsonText))
{
ShowWarning = true;
ErrorMessage = "JSON content is empty.";
return;
}
try try
{ {
using var doc = JsonDocument.Parse(JsonText); using var doc = JsonDocument.Parse(JsonText);
@@ -65,10 +138,11 @@
// ===== BASIC STRUCTURE CHECK ===== // ===== BASIC STRUCTURE CHECK =====
if (!root.TryGetProperty("nodes", out _) || if (!root.TryGetProperty("nodes", out _) ||
!root.TryGetProperty("edges", out _)) !root.TryGetProperty("edges", out _))
{
throw new Exception("Missing 'nodes' or 'edges' field."); throw new Exception("Missing 'nodes' or 'edges' field.");
}
var order = OrderMessage.FromSchemaObject(root); var order = OrderMessage.FromSchemaObject(root);
ValidateOrder(order); ValidateOrder(order);
MudDialog.Close(DialogResult.Ok(order)); MudDialog.Close(DialogResult.Ok(order));
@@ -85,19 +159,40 @@
} }
} }
// ================= DOMAIN VALIDATION =================
private void ValidateOrder(OrderMessage order) private void ValidateOrder(OrderMessage order)
{ {
if (order.Nodes.Count == 0) if (order.Nodes.Count == 0)
throw new Exception("Order must contain at least one node."); throw new Exception("Order must contain at least one node.");
var nodeIds = order.Nodes.Select(n => n.NodeId).ToHashSet(); if (order.Nodes.Count != order.Edges.Count + 1)
throw new Exception(
$"Invalid path structure: Nodes count ({order.Nodes.Count}) " +
$"must equal Edges count + 1 ({order.Edges.Count + 1})."
);
var nodeIds = order.Nodes
.Select(n => n.NodeId)
.ToHashSet(StringComparer.Ordinal);
foreach (var e in order.Edges) foreach (var e in order.Edges)
{ {
if (!nodeIds.Contains(e.StartNodeId) || if (string.IsNullOrWhiteSpace(e.StartNodeId) ||
!nodeIds.Contains(e.EndNodeId)) string.IsNullOrWhiteSpace(e.EndNodeId))
{
throw new Exception( throw new Exception(
$"Edge '{e.EdgeId}' references unknown node." $"Edge '{e.EdgeId}' must define both StartNodeId and EndNodeId."
);
}
if (!nodeIds.Contains(e.StartNodeId))
throw new Exception(
$"Edge '{e.EdgeId}' references unknown StartNodeId '{e.StartNodeId}'."
);
if (!nodeIds.Contains(e.EndNodeId))
throw new Exception(
$"Edge '{e.EdgeId}' references unknown EndNodeId '{e.EndNodeId}'."
); );
} }
} }

View File

@@ -1,4 +1,4 @@
<MudPaper Class="pa-4 h-100 d-flex flex-column" Elevation="2"> <MudPaper Class="pa-4 h-100 d-flex flex-column overflow-hidden" Elevation="2">
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" <MudStack Row AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween"
Class="mb-4 flex-shrink-0"> Class="mb-4 flex-shrink-0">
<MudText Typo="Typo.h6">📄 JSON Output (/order)</MudText> <MudText Typo="Typo.h6">📄 JSON Output (/order)</MudText>
@@ -14,6 +14,16 @@
Import JSON Import JSON
</MudButton> </MudButton>
<!-- CANCEL -->
<MudButton Variant="Variant.Filled"
Color="@CancelButtonColor"
StartIcon="@CancelButtonIcon"
Disabled="@DisableCancel"
OnClick="OnCancel">
@CancelButtonText
</MudButton>
<!-- SEND --> <!-- SEND -->
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="@SendButtonColor" Color="@SendButtonColor"
@@ -27,7 +37,6 @@
<MudTooltip Text="@(Copied ? "Copied!" : "Copy to clipboard")"> <MudTooltip Text="@(Copied ? "Copied!" : "Copy to clipboard")">
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="@(Copied ? Color.Success : Color.Primary)" Color="@(Copied ? Color.Success : Color.Primary)"
Size="Size.Small"
StartIcon="@(Copied ? Icons.Material.Filled.Check : Icons.Material.Filled.ContentCopy)" StartIcon="@(Copied ? Icons.Material.Filled.Check : Icons.Material.Filled.ContentCopy)"
OnClick="OnCopy"> OnClick="OnCopy">
@(Copied ? "Copied!" : "Copy") @(Copied ? "Copied!" : "Copy")
@@ -36,12 +45,13 @@
</div> </div>
</MudStack> </MudStack>
<div class="flex-grow-1" style="overflow:auto;"> <div class="flex-grow-1">
<MudTextField Value="@OrderJson" <MudTextField Value="@OrderJson"
ReadOnly T="string"
ValueChanged="OrderJsonChange"
Variant="Variant.Filled" Variant="Variant.Filled"
Lines="70" Immediate=true
Class="h-100" Lines="50"
Style="font-family: 'Roboto Mono', Consolas, monospace; Style="font-family: 'Roboto Mono', Consolas, monospace;
font-size: 0.85rem; font-size: 0.85rem;
background:#1e1e1e; background:#1e1e1e;
@@ -53,10 +63,15 @@
[Parameter] public string OrderJson { get; set; } = ""; [Parameter] public string OrderJson { get; set; } = "";
[Parameter] public bool Copied { get; set; } [Parameter] public bool Copied { get; set; }
[Parameter] public bool? SendSuccess { get; set; } [Parameter] public bool? SendSuccess { get; set; }
[Parameter] public bool DisableCancel { get; set; }
[Parameter] public bool? CancelSuccess { get; set; }
[Parameter] public EventCallback<string> OrderJsonChanged { get; set; }
[Parameter] public EventCallback OnCopy { get; set; } [Parameter] public EventCallback OnCopy { get; set; }
[Parameter] public EventCallback OnSend { get; set; } [Parameter] public EventCallback OnSend { get; set; }
[Parameter] public EventCallback OnImport { get; set; } [Parameter] public EventCallback OnImport { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
private string SendButtonText => private string SendButtonText =>
SendSuccess switch SendSuccess switch
@@ -81,4 +96,36 @@
false => Icons.Material.Filled.Error, false => Icons.Material.Filled.Error,
_ => Icons.Material.Filled.Send _ => Icons.Material.Filled.Send
}; };
private string CancelButtonText =>
CancelSuccess switch
{
true => "Done",
false => "Error",
_ => "Cancel"
};
private Color CancelButtonColor =>
CancelSuccess switch
{
true => Color.Success,
false => Color.Error,
_ => Color.Error
};
private string CancelButtonIcon =>
CancelSuccess switch
{
true => Icons.Material.Filled.CheckCircle,
false => Icons.Material.Filled.Error,
_ => Icons.Material.Filled.Cancel
};
private void OrderJsonChange(string value)
{
OrderJson = value;
OrderJsonChanged.InvokeAsync(OrderJson);
StateHasChanged();
}
} }

View File

@@ -44,21 +44,22 @@
</MudItem> </MudItem>
<!-- Sequence --> <!-- Sequence -->
<MudItem xs="12"> @* <MudItem xs="12">
<MudNumericField T="int" <MudNumericField T="int"
Value="@node.SequenceId" Value="@node.SequenceId"
ValueChanged="@((int v) => SetValue(() => node.SequenceId = v))" ValueChanged="@((int v) => SetValue(() => node.SequenceId = v))"
Immediate="true" Immediate="true"
Label="Sequence ID" /> Label="Sequence ID" />
</MudItem> </MudItem> *@
<!-- Released --> <!-- Released -->
<MudItem xs="12"> @* <MudItem xs="12">
<MudSwitch T="bool" <MudSwitch T="bool"
Checked="@node.Released" Checked="@node.Released"
CheckedChanged="@((bool v) => SetValue(() => node.Released = v))" CheckedChanged="@((bool v) => SetValue(() => node.Released = v))"
Label="Released" /> Label="Released" />
</MudItem>
</MudItem> *@
<!-- Position --> <!-- Position -->
<MudItem xs="6"> <MudItem xs="6">

View File

@@ -1,6 +1,5 @@
@page "/robot-order" @page "/robot-order"
@attribute [Authorize]
@rendermode InteractiveWebAssemblyNoPrerender @rendermode InteractiveWebAssemblyNoPrerender
@using System.Text.Json @using System.Text.Json
@@ -46,13 +45,14 @@
<!-- ================= RIGHT ================= --> <!-- ================= RIGHT ================= -->
<MudItem xs="12" md="5" Class="h-100"> <MudItem xs="12" md="5" Class="h-100">
<JsonOutputPanel OrderJson="@OrderJson" <JsonOutputPanel @bind-OrderJson="@OrderJson"
Copied="@copied" Copied="@copied"
SendSuccess="@sendSuccess" SendSuccess="@sendSuccess"
CancelSuccess="@cancelSuccess"
OnCopy="CopyJsonToClipboard" OnCopy="CopyJsonToClipboard"
OnSend="SendOrderToServer" OnSend="SendOrderToServer"
OnImport="OpenImportDialog" /> OnImport="OpenImportDialog"
OnCancel="CancelOrder" />
</MudItem> </MudItem>
</MudGrid> </MudGrid>
@@ -66,7 +66,7 @@
private string OrderJson = ""; // 🔥 CACHE JSON (QUAN TRỌNG) private string OrderJson = ""; // 🔥 CACHE JSON (QUAN TRỌNG)
private bool copied; private bool copied;
private bool? sendSuccess; private bool? sendSuccess;
private bool sending; private bool? cancelSuccess;
private CancellationTokenSource? _copyCts; private CancellationTokenSource? _copyCts;
// ================= INIT ================= // ================= INIT =================
@@ -227,7 +227,7 @@
sendSuccess = response.IsSuccessStatusCode; sendSuccess = response.IsSuccessStatusCode;
} }
catch (Exception ex) catch
{ {
sendSuccess = false; sendSuccess = false;
} }
@@ -246,6 +246,33 @@
}); });
} }
async Task CancelOrder()
{
// reset trạng thái trước khi gửi
cancelSuccess = null;
StateHasChanged();
try
{
var res = await Http.PostAsync("/api/order/cancel", null);
cancelSuccess = res.IsSuccessStatusCode;
}
catch
{
cancelSuccess = false;
}
StateHasChanged();
// 🔥 AUTO RESET SAU 2 GIÂY
_ = Task.Run(async () =>
{
await Task.Delay(2000);
cancelSuccess = null;
await InvokeAsync(StateHasChanged);
});
}
async Task CopyJsonToClipboard() async Task CopyJsonToClipboard()

View File

@@ -1,6 +1,5 @@
@page "/robot-config" @page "/robot-config"
@rendermode InteractiveWebAssemblyNoPrerender @rendermode InteractiveWebAssemblyNoPrerender
@attribute [Authorize]
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar

View File

@@ -1,6 +1,5 @@
@page "/robot-monitor" @page "/robot-monitor"
@rendermode InteractiveWebAssemblyNoPrerender @rendermode InteractiveWebAssemblyNoPrerender
@attribute [Authorize]
@inject RobotApp.Client.Services.RobotMonitorService MonitorService @inject RobotApp.Client.Services.RobotMonitorService MonitorService
@implements IAsyncDisposable @implements IAsyncDisposable
@@ -40,3 +39,5 @@
} }

View File

@@ -7,7 +7,7 @@ using System.Text.Json;
namespace RobotApp.Client.Services; namespace RobotApp.Client.Services;
// ================= CONNECTION STATE ================= // ================= SIGNALR CONNECTION STATE =================
public enum RobotClientState public enum RobotClientState
{ {
Disconnected, Disconnected,
@@ -16,7 +16,7 @@ public enum RobotClientState
Reconnecting Reconnecting
} }
// ================= CLIENT ================= // ================= ROBOT STATE CLIENT =================
public sealed class RobotStateClient : IAsyncDisposable public sealed class RobotStateClient : IAsyncDisposable
{ {
private readonly NavigationManager _nav; private readonly NavigationManager _nav;
@@ -25,11 +25,17 @@ public sealed class RobotStateClient : IAsyncDisposable
private readonly object _lock = new(); private readonly object _lock = new();
private bool _started; private bool _started;
// ================= STATE CACHE =================
public ConcurrentDictionary<string, StateMsg> LatestStates { get; } = new(); public ConcurrentDictionary<string, StateMsg> LatestStates { get; } = new();
// ================= ROBOT CONNECTION =================
private bool _isRobotConnected;
public bool IsRobotConnected => _isRobotConnected;
// ================= EVENTS ================= // ================= EVENTS =================
public event Action<string, StateMsg>? OnStateReceived; public event Action<string, StateMsg>? OnStateReceived;
public event Action<StateMsg>? OnStateReceivedAny; public event Action<StateMsg>? OnStateReceivedAny;
public event Action<bool>? OnRobotConnectionChanged;
public event Action<RobotClientState>? OnConnectionStateChanged; public event Action<RobotClientState>? OnConnectionStateChanged;
public RobotClientState ConnectionState { get; private set; } = RobotClientState.Disconnected; public RobotClientState ConnectionState { get; private set; } = RobotClientState.Disconnected;
@@ -43,7 +49,8 @@ public sealed class RobotStateClient : IAsyncDisposable
// ================= STATE HELPER ================= // ================= STATE HELPER =================
private void SetState(RobotClientState state) private void SetState(RobotClientState state)
{ {
if (ConnectionState == state) return; if (ConnectionState == state)
return;
ConnectionState = state; ConnectionState = state;
OnConnectionStateChanged?.Invoke(state); OnConnectionStateChanged?.Invoke(state);
@@ -82,9 +89,10 @@ public sealed class RobotStateClient : IAsyncDisposable
return Task.CompletedTask; return Task.CompletedTask;
}; };
_connection.Closed += async error => _connection.Closed += async _ =>
{ {
_started = false; _started = false;
SetState(RobotClientState.Disconnected);
if (_connection != null) if (_connection != null)
{ {
@@ -96,8 +104,14 @@ public sealed class RobotStateClient : IAsyncDisposable
} }
}; };
// ================= SIGNALR HANDLERS =================
// VDA5050 State
_connection.On<string>("ReceiveState", HandleState); _connection.On<string>("ReceiveState", HandleState);
// Robot connection (bool only)
_connection.On<bool>("ReceiveRobotConnection", HandleRobotConnection);
try try
{ {
await _connection.StartAsync(); await _connection.StartAsync();
@@ -136,6 +150,13 @@ public sealed class RobotStateClient : IAsyncDisposable
OnStateReceivedAny?.Invoke(state); OnStateReceivedAny?.Invoke(state);
} }
// ================= HANDLE ROBOT CONNECTION =================
private void HandleRobotConnection(bool isConnected)
{
_isRobotConnected = isConnected;
OnRobotConnectionChanged?.Invoke(isConnected);
}
// ================= SUBSCRIBE ================= // ================= SUBSCRIBE =================
public async Task SubscribeRobotAsync(string serialNumber) public async Task SubscribeRobotAsync(string serialNumber)
{ {
@@ -164,6 +185,7 @@ public sealed class RobotStateClient : IAsyncDisposable
} }
LatestStates.TryRemove(serialNumber, out _); LatestStates.TryRemove(serialNumber, out _);
_isRobotConnected = false;
} }
// ================= GET CACHE ================= // ================= GET CACHE =================
@@ -177,6 +199,7 @@ public sealed class RobotStateClient : IAsyncDisposable
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
_started = false; _started = false;
_isRobotConnected = false;
SetState(RobotClientState.Disconnected); SetState(RobotClientState.Disconnected);
if (_connection != null) if (_connection != null)

View File

@@ -131,7 +131,7 @@ public class OrderMessage
public string Version { get; set; } = "v1"; public string Version { get; set; } = "v1";
public string Manufacturer { get; set; } = "PNKX"; public string Manufacturer { get; set; } = "PNKX";
public string SerialNumber { get; set; } = "T800-002"; public string SerialNumber { get; set; } = "T800-002";
public string OrderId { get; set; } = Guid.NewGuid().ToString(); public string OrderId { get; set; }
public int OrderUpdateId { get; set; } public int OrderUpdateId { get; set; }
public string? ZoneSetId { get; set; } public string? ZoneSetId { get; set; }
@@ -270,31 +270,17 @@ public class OrderMessage
} }
public object ToSchemaObject() public object ToSchemaObject()
{ {
int seq = 0; // ================= SORT NODES BY UI SEQUENCE =================
var orderedNodes = Nodes
.OrderBy(n => n.SequenceId)
.ToList();
return new // ================= BUILD NODE OBJECTS =================
{ var nodeObjects = orderedNodes
headerId = HeaderId++, .Select((n, index) => new
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, nodeId = n.NodeId,
sequenceId = seq++, sequenceId = index * 2, // ✅ NODE = EVEN
released = n.Released, released = n.Released,
nodePosition = new nodePosition = new
@@ -311,36 +297,55 @@ public class OrderMessage
: n.NodePosition.MapId : n.NodePosition.MapId
}, },
actions = n.Actions.Select(a => new actions = n.Actions?
{ .Select(a => new
actionId = a.ActionId, {
actionType = a.ActionType, actionId = a.ActionId,
blockingType = a.BlockingType, actionType = a.ActionType,
blockingType = a.BlockingType,
actionParameters = a.ActionParameters != null actionParameters = a.ActionParameters?
? a.ActionParameters .Select(p => new
.Select(p => new { key = p.Key, value = p.Value }) {
key = p.Key,
value = p.Value
})
.ToArray() .ToArray()
: Array.Empty<object>() ?? Array.Empty<object>()
}).ToArray() })
}).ToArray(), .ToArray()
?? Array.Empty<object>()
})
.ToArray();
// ================= EDGES ================= // ================= BUILD EDGE OBJECTS =================
edges = Edges.Select<UiEdge, object>(e => var edgeObjects = Edges
.Select<UiEdge, object>((e, index) =>
{ {
int sequenceId = index * 2 + 1; // ✅ EDGE = ODD
// ---------- BASE ----------
var baseEdge = new
{
edgeId = e.EdgeId,
sequenceId,
released = true,
startNodeId = e.StartNodeId,
endNodeId = e.EndNodeId
};
// ================================================= // =================================================
// 1⃣ IMPORTED TRAJECTORY (ƯU TIÊN CAO NHẤT) // 1⃣ IMPORTED TRAJECTORY
// ================================================= // =================================================
if (e.HasTrajectory && e.Trajectory != null) if (e.HasTrajectory && e.Trajectory != null)
{ {
return new return new
{ {
edgeId = e.EdgeId, baseEdge.edgeId,
sequenceId = seq++, baseEdge.sequenceId,
released = true, baseEdge.released,
baseEdge.startNodeId,
startNodeId = e.StartNodeId, baseEdge.endNodeId,
endNodeId = e.EndNodeId,
trajectory = new trajectory = new
{ {
@@ -356,27 +361,25 @@ public class OrderMessage
} }
// ================================================= // =================================================
// 2⃣ STRAIGHT EDGE (KHÔNG CÓ CURVE) // 2⃣ STRAIGHT EDGE
// ================================================= // =================================================
if (e.Radius <= 0) if (e.Radius <= 0)
{ {
return new return new
{ {
edgeId = e.EdgeId, baseEdge.edgeId,
sequenceId = seq++, baseEdge.sequenceId,
released = true, baseEdge.released,
baseEdge.startNodeId,
startNodeId = e.StartNodeId, baseEdge.endNodeId,
endNodeId = e.EndNodeId,
actions = Array.Empty<object>() actions = Array.Empty<object>()
}; };
} }
// ================================================= // =================================================
// 3 EDITOR GENERATED CURVE (RADIUS + QUADRANT) // 3⃣ GENERATED CURVE (EDITOR)
// ================================================= // =================================================
var startNode = Nodes.First(n => n.NodeId == e.StartNodeId); var startNode = orderedNodes.First(n => n.NodeId == e.StartNodeId);
var A = new Point( var A = new Point(
startNode.NodePosition.X, startNode.NodePosition.X,
@@ -391,17 +394,40 @@ public class OrderMessage
return new return new
{ {
edgeId = e.EdgeId, baseEdge.edgeId,
sequenceId = seq++, baseEdge.sequenceId,
released = true, baseEdge.released,
baseEdge.startNodeId,
startNodeId = e.StartNodeId, baseEdge.endNodeId,
endNodeId = e.EndNodeId,
trajectory = result.Trajectory, trajectory = result.Trajectory,
actions = Array.Empty<object>() actions = Array.Empty<object>()
}; };
}).ToArray() })
.ToArray();
// ================= FINAL SCHEMA OBJECT =================
return new
{
headerId = HeaderId++,
timestamp = string.IsNullOrWhiteSpace(Timestamp)
? DateTime.UtcNow.ToString("O")
: Timestamp,
version = Version,
manufacturer = Manufacturer,
serialNumber = SerialNumber,
orderId = OrderId= Guid.NewGuid().ToString(),
orderUpdateId = OrderUpdateId,
zoneSetId = string.IsNullOrWhiteSpace(ZoneSetId)
? null
: ZoneSetId,
nodes = nodeObjects,
edges = edgeObjects
}; };
} }
} }

View File

@@ -59,3 +59,4 @@ window.robotMonitor = {
}; };

View File

@@ -3,8 +3,6 @@
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize]
@inject NavigationManager Nav @inject NavigationManager Nav
@code @code

View File

@@ -6,7 +6,8 @@ namespace RobotApp.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
[Authorize] //[Authorize]
[AllowAnonymous]
public class FileController(Services.Logger<FileController> Logger) : ControllerBase public class FileController(Services.Logger<FileController> Logger) : ControllerBase
{ {
private readonly string certificatesPath = "MqttCertificates"; private readonly string certificatesPath = "MqttCertificates";

View File

@@ -5,6 +5,7 @@ namespace RobotApp.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
//[Authorize]
[AllowAnonymous] [AllowAnonymous]
public class ImagesController(Services.Logger<ImagesController> Logger) : ControllerBase public class ImagesController(Services.Logger<ImagesController> Logger) : ControllerBase
{ {

View File

@@ -5,7 +5,8 @@ namespace RobotApp.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
[Authorize] //[Authorize]
[AllowAnonymous]
public class LogsManagerController(Services.Logger<LogsManagerController> Logger) : ControllerBase public class LogsManagerController(Services.Logger<LogsManagerController> Logger) : ControllerBase
{ {
private readonly string LoggerDirectory = "logs"; private readonly string LoggerDirectory = "logs";

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization;
using RobotApp.Services.Robot; using Microsoft.AspNetCore.Mvc;
using RobotApp.Interfaces;
using RobotApp.VDA5050.Order; using RobotApp.VDA5050.Order;
using System.Text.Json; using System.Text.Json;
@@ -7,30 +8,31 @@ namespace RobotApp.Controllers;
[ApiController] [ApiController]
[Route("api/order")] [Route("api/order")]
public class OrderController : ControllerBase //[Authorize]
[AllowAnonymous]
public class OrderController(IOrder robotOrderController, IInstantActions instantActions) : ControllerBase
{ {
private readonly RobotOrderController robotOrderController;
public OrderController(RobotOrderController robotOrderController)
{
this.robotOrderController = robotOrderController;
}
[HttpPost] [HttpPost]
public IActionResult SendOrder([FromBody] OrderMsg order) public IActionResult SendOrder([FromBody] OrderMsg order)
{ {
Console.WriteLine("===== ORDER RECEIVED =====");
Console.WriteLine(JsonSerializer.Serialize(order, new JsonSerializerOptions
{
WriteIndented = true
}));
robotOrderController.UpdateOrder(order); robotOrderController.UpdateOrder(order);
return Ok(new return Ok(new
{ {
success = true, success = true,
message = "Order received" message = "Order received"
}); });
} }
}
[HttpPost("cancel")]
public IActionResult CancelOrder()
{
robotOrderController.StopOrder();
instantActions.StopOrderAction();
return Ok(new
{
success = true,
message = "Order and actions have been cancelled"
});
}
}

View File

@@ -10,7 +10,8 @@ namespace RobotApp.Controllers;
[Route("api/[controller]")] [Route("api/[controller]")]
[ApiController] [ApiController]
[Authorize] //[Authorize]
[AllowAnonymous]
public class RobotConfigsController(Services.Logger<RobotConfigsController> Logger, ApplicationDbContext AppDb, RobotConfiguration RobotConfiguration) : ControllerBase public class RobotConfigsController(Services.Logger<RobotConfigsController> Logger, ApplicationDbContext AppDb, RobotConfiguration RobotConfiguration) : ControllerBase
{ {
[HttpGet] [HttpGet]

View File

@@ -125,7 +125,7 @@ public static class ApplicationDbExtensions
{ {
ConfigName = "Default", ConfigName = "Default",
Description = "Default robot simulation configuration", Description = "Default robot simulation configuration",
EnableSimulation = false, EnableSimulation = true,
SimulationMaxVelocity = 1.5, SimulationMaxVelocity = 1.5,
SimulationMaxAngularVelocity = 0.5, SimulationMaxAngularVelocity = 0.5,
SimulationAcceleration = 2, SimulationAcceleration = 2,
@@ -155,6 +155,7 @@ public static class ApplicationDbExtensions
VDA5050EnableTls = false, VDA5050EnableTls = false,
VDA5050UserName = "robotics", VDA5050UserName = "robotics",
VDA5050Password = "robotics", VDA5050Password = "robotics",
VDA5050TopicPrefix = "uagv/v2",
IsActive = true, IsActive = true,
CreatedAt = DateTime.Now, CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now, UpdatedAt = DateTime.Now,

View File

@@ -11,7 +11,7 @@ using RobotApp.Data;
namespace RobotApp.Data.Migrations namespace RobotApp.Data.Migrations
{ {
[DbContext(typeof(ApplicationDbContext))] [DbContext(typeof(ApplicationDbContext))]
[Migration("20251222025151_InitApplicationDb")] [Migration("20251222091852_InitApplicationDb")]
partial class InitApplicationDb partial class InitApplicationDb
{ {
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -11,3 +11,4 @@ public class RobotMonitorHub : Hub
} }

View File

@@ -6,22 +6,22 @@
"dotnetRunMessages": true, "dotnetRunMessages": true,
"launchBrowser": true, "launchBrowser": true,
"workingDirectory": "$(TargetDir)", "workingDirectory": "$(TargetDir)",
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", //"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5229", "applicationUrl": "http://localhost:5229",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
} }
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"workingDirectory": "$(TargetDir)",
//"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://0.0.0.0:7150;http://localhost:5229",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
} }
//"https": {
// "commandName": "Project",
// "dotnetRunMessages": true,
// "launchBrowser": true,
// "workingDirectory": "$(TargetDir)",
// //"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
// "applicationUrl": "https://0.0.0.0:7150;http://localhost:5229",
// "environmentVariables": {
// "ASPNETCORE_ENVIRONMENT": "Development"
// }
//}
} }
} }

View File

@@ -195,17 +195,6 @@ public class MQTTClient : IAsyncDisposable
arg.Chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority; arg.Chain.ChainPolicy.VerificationFlags = X509VerificationFlags.AllowUnknownCertificateAuthority;
var isValid = arg.Chain.Build((X509Certificate2)arg.Certificate); var isValid = arg.Chain.Build((X509Certificate2)arg.Certificate);
if (isValid)
{
Console.WriteLine("Broker CERTIFICATE VALID");
}
else
{
Console.WriteLine("Broker CERTIFICATE INVALID");
foreach (var status in arg.Chain.ChainStatus)
Console.WriteLine($" -> Chain error: {status.Status} - {status.StatusInformation}");
}
return isValid; return isValid;
} }
} }

View File

@@ -27,7 +27,6 @@ public class MQTTClientCertificatesProvider(string? CerFile, string? KeyFile) :
var cert = X509Certificate2.CreateFromPem(File.ReadAllText(certLocal), File.ReadAllText(keyLocal)); var cert = X509Certificate2.CreateFromPem(File.ReadAllText(certLocal), File.ReadAllText(keyLocal));
var pfxBytes = cert.Export(X509ContentType.Pfx); var pfxBytes = cert.Export(X509ContentType.Pfx);
var pfxCert = X509CertificateLoader.LoadPkcs12(pfxBytes, "", X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet); var pfxCert = X509CertificateLoader.LoadPkcs12(pfxBytes, "", X509KeyStorageFlags.Exportable | X509KeyStorageFlags.PersistKeySet);
Console.WriteLine($"Client cert loaded: {pfxCert.Subject}, HasPrivateKey: {pfxCert.HasPrivateKey}, PrivateKey Type: {pfxCert.GetRSAPrivateKey()?.GetType()}");
return [pfxCert]; return [pfxCert];
} }
} }

View File

@@ -96,7 +96,6 @@ public abstract class RobotAction(IServiceProvider serviceProvider) : IDisposabl
protected virtual Task StopAction() protected virtual Task StopAction()
{ {
Console.WriteLine($"StopAction {Type}");
Status = ActionStatus.FAILED; Status = ActionStatus.FAILED;
ResultDescription = "Action bị hủy bỏ."; ResultDescription = "Action bị hủy bỏ.";
return Task.CompletedTask; return Task.CompletedTask;

View File

@@ -26,6 +26,8 @@ public class RobotStatePublisher : BackgroundService
private readonly ILoad _loadManager; private readonly ILoad _loadManager;
private readonly INavigation _navigationManager; private readonly INavigation _navigationManager;
private readonly RobotStateMachine _stateManager; private readonly RobotStateMachine _stateManager;
private readonly RobotConnection _robotConnection;
private bool? _lastRobotConnectionState;
private uint _headerId = 0; private uint _headerId = 0;
private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(1000)); // 1 giây/lần private readonly PeriodicTimer _timer = new(TimeSpan.FromMilliseconds(1000)); // 1 giây/lần
@@ -42,7 +44,8 @@ public class RobotStatePublisher : BackgroundService
IBattery batteryManager, IBattery batteryManager,
ILoad loadManager, ILoad loadManager,
INavigation navigationManager, INavigation navigationManager,
RobotStateMachine stateManager) RobotStateMachine stateManager,
RobotConnection robotConnection)
{ {
_hubContext = hubContext; _hubContext = hubContext;
_robotConfig = robotConfig; _robotConfig = robotConfig;
@@ -56,6 +59,7 @@ public class RobotStatePublisher : BackgroundService
_loadManager = loadManager; _loadManager = loadManager;
_navigationManager = navigationManager; _navigationManager = navigationManager;
_stateManager = stateManager; _stateManager = stateManager;
_robotConnection = robotConnection;
} }
private StateMsg GetStateMsg() private StateMsg GetStateMsg()
@@ -137,36 +141,43 @@ public class RobotStatePublisher : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
Console.WriteLine("[RobotStatePublisher] Started - Publishing state every 1 second via SignalR"); while (await _timer.WaitForNextTickAsync(stoppingToken))
while (await _timer.WaitForNextTickAsync(stoppingToken) && !stoppingToken.IsCancellationRequested)
{ {
try try
{ {
var state = GetStateMsg();
var serialNumber = _robotConfig.SerialNumber; var serialNumber = _robotConfig.SerialNumber;
// ===== SEND STATE =====
var state = GetStateMsg();
var json = JsonSerializer.Serialize(state, JsonOptionExtends.Write); var json = JsonSerializer.Serialize(state, JsonOptionExtends.Write);
// Push đến tất cả client đang theo dõi robot này
await _hubContext.Clients await _hubContext.Clients
.Group(serialNumber) .Group(serialNumber)
.SendAsync("ReceiveState", json, stoppingToken); .SendAsync("ReceiveState", json, stoppingToken);
//Console.WriteLine($"[RobotStatePublisher] Published state for {serialNumber} | " + // ===== SEND ROBOT CONNECTION (ONLY WHEN CHANGED) =====
// $"HeaderId: {state.HeaderId} | " + var isConnected = _robotConnection.IsConnected;
// $"Pos: ({state.AgvPosition.X:F2}, {state.AgvPosition.Y:F2}) | " +
// $"Battery: {state.BatteryState.BatteryCharge:F1}%"); if (_lastRobotConnectionState != isConnected)
{
_lastRobotConnectionState = isConnected;
await _hubContext.Clients
.Group(serialNumber) // routing only
.SendAsync(
"ReceiveRobotConnection",
isConnected, // payload only bool
stoppingToken
);
}
} }
catch (Exception ex) catch
{ {
Console.WriteLine($"[RobotStatePublisher] Error publishing state: {ex.Message}");
} }
} }
Console.WriteLine("[RobotStatePublisher] Stopped.");
} }
public override void Dispose() public override void Dispose()
{ {
_timer?.Dispose(); _timer?.Dispose();

View File

@@ -158,14 +158,12 @@ public class SimulationNavigation : INavigation, IDisposable
public void Pause() public void Pause()
{ {
Console.WriteLine($"Nav Pause");
ResumeState = State; ResumeState = State;
NavState = NavigationState.Paused; NavState = NavigationState.Paused;
} }
public void Resume() public void Resume()
{ {
Console.WriteLine($"Nav Resume");
NavState = ResumeState; NavState = ResumeState;
} }

Binary file not shown.

35
docker-compose.yaml Normal file
View File

@@ -0,0 +1,35 @@
version: '3.8'
services:
robotapp:
build:
context: .
dockerfile: Dockerfile
container_name: robotapp
restart: unless-stopped
ports:
- "8080:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_URLS=http://+:8080
- ConnectionStrings__DefaultConnection=Data Source=/app/data/robot.db
volumes:
# Persist database
- ./data:/app/data
# Persist maps
- ./maps:/app/maps
# Persist logs (if needed)
- ./logs:/app/logs
networks:
- robotapp-network
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8080 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
robotapp-network:
driver: bridge