Initial commit

This commit is contained in:
2026-07-13 09:25:40 +07:00
parent c08ff54676
commit bccfb156d7
1938 changed files with 641646 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;
using RobotNet10.FleetManager.Components.Account.Pages;
using RobotNet10.FleetManager.Data;
using System.Security.Claims;
using System.Text.Json;
namespace Microsoft.AspNetCore.Routing
{
internal static class IdentityComponentsEndpointRouteBuilderExtensions
{
// These endpoints are required by the Identity Razor components defined in the /Components/Account/Pages directory of this project.
public static IEndpointConventionBuilder MapAdditionalIdentityEndpoints(this IEndpointRouteBuilder endpoints)
{
ArgumentNullException.ThrowIfNull(endpoints);
var accountGroup = endpoints.MapGroup("/Account");
accountGroup.MapPost("/Logout", async (
ClaimsPrincipal user,
[FromServices] SignInManager<ApplicationUser> signInManager,
[FromForm] string returnUrl) =>
{
await signInManager.SignOutAsync();
return TypedResults.LocalRedirect($"~/{returnUrl}");
});
return accountGroup;
}
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using RobotNet10.FleetManager.Data;
namespace RobotNet10.FleetManager.Components.Account
{
// Remove the "else if (EmailSender is IdentityNoOpEmailSender)" block from RegisterConfirmation.razor after updating with a real implementation.
internal sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
{
private readonly IEmailSender emailSender = new NoOpEmailSender();
public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>.");
public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>.");
public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
}
}

View File

@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Identity;
using RobotNet10.FleetManager.Data;
namespace RobotNet10.FleetManager.Components.Account
{
internal sealed class IdentityRedirectManager(NavigationManager navigationManager)
{
public const string StatusCookieName = "Identity.StatusMessage";
private static readonly CookieBuilder StatusCookieBuilder = new()
{
SameSite = SameSiteMode.Strict,
HttpOnly = true,
IsEssential = true,
MaxAge = TimeSpan.FromSeconds(5),
};
public void RedirectTo(string? uri)
{
uri ??= "";
// Prevent open redirects.
if (!Uri.IsWellFormedUriString(uri, UriKind.Relative))
{
uri = navigationManager.ToBaseRelativePath(uri);
}
navigationManager.NavigateTo(uri);
}
public void RedirectTo(string uri, Dictionary<string, object?> queryParameters)
{
var uriWithoutQuery = navigationManager.ToAbsoluteUri(uri).GetLeftPart(UriPartial.Path);
var newUri = navigationManager.GetUriWithQueryParameters(uriWithoutQuery, queryParameters);
RedirectTo(newUri);
}
public void RedirectToWithStatus(string uri, string message, HttpContext context)
{
context.Response.Cookies.Append(StatusCookieName, message, StatusCookieBuilder.Build(context));
RedirectTo(uri);
}
private string CurrentPath => navigationManager.ToAbsoluteUri(navigationManager.Uri).GetLeftPart(UriPartial.Path);
public void RedirectToCurrentPage() => RedirectTo(CurrentPath);
public void RedirectToCurrentPageWithStatus(string message, HttpContext context)
=> RedirectToWithStatus(CurrentPath, message, context);
public void RedirectToInvalidUser(UserManager<ApplicationUser> userManager, HttpContext context)
=> RedirectToWithStatus("Account/InvalidUser", $"Error: Unable to load user with ID '{userManager.GetUserId(context.User)}'.", context);
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using RobotNet10.FleetManager.Data;
using System.Security.Claims;
namespace RobotNet10.FleetManager.Components.Account
{
// This is a server-side AuthenticationStateProvider that revalidates the security stamp for the connected user
// every 30 minutes an interactive circuit is connected.
internal sealed class IdentityRevalidatingAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory,
IOptions<IdentityOptions> options)
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
{
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30);
protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
await using var scope = scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
}
private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return false;
}
else if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
else
{
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}
}
}

View File

@@ -0,0 +1,125 @@
@page "/Account/Login"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@using RobotNet10.FleetManager.Data
@inject UserManager<ApplicationUser> UserManager
@inject SignInManager<ApplicationUser> SignInManager
@inject RobotNet10.FleetManager.Services.Logger<Login> Logger
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
<PageTitle>Log in</PageTitle>
<div class="w-100 h-100 d-flex flex-column justify-content-center align-items-center">
<h1>Log in</h1>
@if (!string.IsNullOrEmpty(errorMessage))
{
var statusMessageClass = errorMessage.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass" role="alert">
@errorMessage
</div>
}
<EditForm Model="Input" method="post" OnValidSubmit="LoginUser" FormName="login" style="width: 300px;">
<DataAnnotationsValidator />
<hr />
<ValidationSummary class="text-danger" role="alert" />
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Username" class="form-control" autocomplete="username" aria-required="true" />
<label for="username" class="form-label">Username</label>
<ValidationMessage For="() => Input.Username" class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" />
<label for="password" class="form-label">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger" />
</div>
<div class="checkbox mb-3">
<label class="form-label">
<InputCheckbox @bind-Value="Input.RememberMe" class="darker-border-checkbox form-check-input" />
Remember me
</label>
</div>
<div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
</EditForm>
</div>
@code {
private string? errorMessage;
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
[SupplyParameterFromForm]
private InputModel Input { get; set; } = null!;
[SupplyParameterFromQuery]
private string? ReturnUrl { get; set; }
protected override void OnInitialized()
{
Input ??= new();
base.OnInitialized();
}
protected override async Task OnInitializedAsync()
{
if (HttpMethods.IsGet(HttpContext.Request.Method))
{
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
}
errorMessage = HttpContext.Request.Cookies[IdentityRedirectManager.StatusCookieName];
if (errorMessage is not null)
{
HttpContext.Response.Cookies.Delete(IdentityRedirectManager.StatusCookieName);
}
}
public async Task LoginUser()
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await SignInManager.PasswordSignInAsync(Input.Username, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
Logger.Info("User logged in.");
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.RequiresTwoFactor)
{
RedirectManager.RedirectTo(
"Account/LoginWith2fa",
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
}
else if (result.IsLockedOut)
{
Logger.Warning("User account locked out.");
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
errorMessage = "Error: Invalid login attempt.";
}
}
private sealed class InputModel
{
[Required]
public string Username { get; set; } = "";
[Required]
[DataType(DataType.Password)]
public string Password { get; set; } = "";
[Display(Name = "Remember me?")]
public bool RememberMe { get; set; }
}
}

View File

@@ -0,0 +1 @@
@attribute [ExcludeFromInteractiveRouting]

View File

@@ -0,0 +1,8 @@
namespace RobotNet10.FleetManager.Components.Account
{
public class PasskeyInputModel
{
public string? CredentialJson { get; set; }
public string? Error { get; set; }
}
}

View File

@@ -0,0 +1,8 @@
namespace RobotNet10.FleetManager.Components.Account
{
public enum PasskeyOperation
{
Create = 0,
Request = 1,
}
}

View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["_content/RobotNet10.Components/styles.css"]" />
<link rel="stylesheet" href="@Assets["RobotNet10.FleetManager.styles.css"]" />
<link rel="stylesheet" href="@Assets["_content/RobotNet10.ScriptEditor/styles.css"]" />
<ImportMap />
<link rel="icon" type="image/svg+xml" href="_content/RobotNet10.Components/images/favicon.svg" />
<HeadOutlet />
</head>
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="@Assets["_content/BlazorMonaco/jsInterop.js"]"></script>
<script src="@Assets["_content/BlazorMonaco/lib/monaco-editor/min/vs/loader.js"]"></script>
<script src="@Assets["_content/BlazorMonaco/lib/monaco-editor/min/vs/editor/editor.main.js"]"></script>
<script src="@Assets["_content/RobotNet10.Components/scripts.js"]"></script>
<script src="@Assets["_content/RobotNet10.CustomConfigurationEditor/js/downloadFile.js"]"></script>
<script src="@Assets["_content/RobotNet10.ScriptEditor/scripts.js"]"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<button id="components-resume-button" class="components-pause-visible">
Resume
</button>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please reload the page.
</p>
</div>
</dialog>

View File

@@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
location.reload();
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View File

@@ -0,0 +1,27 @@
@page "/"
@using Microsoft.AspNetCore.Authorization
@rendermode InteractiveServer
@attribute [Authorize]
<PageTitle>Home</PageTitle>
<MudDialogProvider />
<MudSnackbarProvider />
<h1>Hello</h1>
<AuthorizeView>
<NotAuthorized>
Vui lòng đăng nhập
</NotAuthorized>
<Authorized>
Hello @context.User.Identity?.Name!
</Authorized>
</AuthorizeView>
@code {
}

View File

@@ -0,0 +1,4 @@
@page "/not-found"
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,12 @@
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly" AdditionalAssemblies="new[] { typeof(Client._Imports).Assembly }" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(RobotNet10.Components.Layout)">
<NotAuthorized>
<RedirectToLogin />
</NotAuthorized>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>

View File

@@ -0,0 +1,14 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using MudBlazor
@using RobotNet10.FleetManager
@using RobotNet10.FleetManager.Client
@using RobotNet10.FleetManager.Components
@using RobotNet10.FleetManager.Components.Layout

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace RobotNet10.FleetManager.Controllers;
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class LogsManagerController(Services.Logger<LogsManagerController> Logger) : ControllerBase
{
private readonly string LoggerDirectory = Path.Combine(AppContext.BaseDirectory, "logs");
[HttpGet]
public async Task<IEnumerable<string>> GetLogs([FromQuery(Name = "date")] DateTime date)
{
string temp = "";
try
{
string fileName = $"{date:yyyy-MM-dd}.log";
string path = Path.Combine(LoggerDirectory, fileName);
if (!Path.GetFullPath(path).StartsWith(Path.GetFullPath(LoggerDirectory)))
{
Logger.Warning($"GetLogs: Invalid path detected.");
return [];
}
if (!System.IO.File.Exists(path))
{
Logger.Warning($"GetLogs: Log file not found for date {date:d} - {path}.");
return [];
}
temp = Path.Combine(LoggerDirectory, $"{Guid.NewGuid()}.log");
System.IO.File.Copy(path, temp);
return await System.IO.File.ReadAllLinesAsync(temp);
}
catch (Exception ex)
{
Logger.Warning($"GetLogs: System error occurred - {ex.Message}");
return [];
}
finally
{
if (System.IO.File.Exists(temp)) System.IO.File.Delete(temp);
}
}
}

View File

@@ -0,0 +1,254 @@
using Microsoft.AspNetCore.Mvc;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Services;
using RobotNet10.FleetManager.Shared.DTOs.Robot;
using RobotNet10.FleetManager.Shared.DTOs.Responses;
namespace RobotNet10.FleetManager.Controllers;
/// <summary>
/// API controller for managing robots.
/// Provides RESTful endpoints for CRUD operations on robots.
/// </summary>
/// <remarks>
/// All endpoints return appropriate HTTP status codes and error responses.
/// Supports filtering by model ID and map ID.
/// </remarks>
[ApiController]
[Route("api/robots")]
public class RobotController(IRobotService robotService, Services.Logger<RobotController> logger) : ControllerBase
{
private readonly IRobotService _robotService = robotService;
private readonly Services.Logger<RobotController> _logger = logger;
/// <summary>
/// Get all robots with optional filters
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<RobotDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<RobotDto>>> GetAll([FromQuery] Guid? modelId, [FromQuery] Guid? mapId)
{
try
{
var robots = await _robotService.GetAllAsync(modelId, mapId);
var dtos = robots.Select(r => MapToDto(r)).ToList();
return Ok(dtos);
}
catch (Exception ex)
{
_logger.Error($"Error getting all robots: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving robots." });
}
}
/// <summary>
/// Get robot by ID
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(RobotDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RobotDto>> GetById(Guid id)
{
try
{
var robot = await _robotService.GetByIdAsync(id);
if (robot == null)
{
return NotFound(new ErrorResponseDto { Error = $"Robot with ID {id} not found." });
}
return Ok(MapToDto(robot));
}
catch (Exception ex)
{
_logger.Error($"Error getting robot {id}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving the robot." });
}
}
/// <summary>
/// Get robot by RobotId (string identifier)
/// </summary>
[HttpGet("robotId/{robotId}")]
[ProducesResponseType(typeof(RobotDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RobotDto>> GetByRobotId(string robotId)
{
try
{
var robot = await _robotService.GetByRobotIdAsync(robotId);
if (robot == null)
{
return NotFound(new ErrorResponseDto { Error = $"Robot with RobotId '{robotId}' not found." });
}
return Ok(MapToDto(robot));
}
catch (Exception ex)
{
_logger.Error($"Error getting robot with RobotId '{robotId}': {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving the robot." });
}
}
/// <summary>
/// Search robots by query string
/// </summary>
[HttpGet("search")]
[ProducesResponseType(typeof(List<RobotDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<RobotDto>>> Search([FromQuery] string query)
{
try
{
var robots = await _robotService.SearchAsync(query);
var dtos = robots.Select(r => MapToDto(r)).ToList();
return Ok(dtos);
}
catch (Exception ex)
{
_logger.Error($"Error searching robots with query '{query}': {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while searching robots." });
}
}
/// <summary>
/// Get all robots by model ID
/// </summary>
[HttpGet("model/{modelId}")]
[ProducesResponseType(typeof(List<RobotDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<RobotDto>>> GetByModelId(Guid modelId)
{
try
{
var robots = await _robotService.GetByModelIdAsync(modelId);
var dtos = robots.Select(r => MapToDto(r)).ToList();
return Ok(dtos);
}
catch (Exception ex)
{
_logger.Error($"Error getting robots by model {modelId}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving robots." });
}
}
/// <summary>
/// Create a new robot
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(RobotDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<RobotDto>> Create([FromBody] CreateRobotRequest request)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(new ErrorResponseDto
{
Error = "Validation failed",
Details = ModelState.ToDictionary(
kvp => kvp.Key,
kvp => (object)(kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty<string>()))
});
}
var robot = await _robotService.CreateAsync(request);
var dto = MapToDto(robot);
return CreatedAtAction(nameof(GetById), new { id = robot.Id }, dto);
}
catch (InvalidOperationException ex)
{
return BadRequest(new ErrorResponseDto { Error = ex.Message });
}
catch (KeyNotFoundException ex)
{
return BadRequest(new ErrorResponseDto { Error = ex.Message });
}
catch (Exception ex)
{
_logger.Error($"Error creating robot: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while creating the robot." });
}
}
/// <summary>
/// Update an existing robot
/// </summary>
[HttpPut("{id}")]
[ProducesResponseType(typeof(RobotDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<RobotDto>> Update(Guid id, [FromBody] UpdateRobotRequest request)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(new ErrorResponseDto
{
Error = "Validation failed",
Details = ModelState.ToDictionary(
kvp => kvp.Key,
kvp => (object)(kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty<string>()))
});
}
var robot = await _robotService.UpdateAsync(id, request);
var dto = MapToDto(robot);
return Ok(dto);
}
catch (KeyNotFoundException ex)
{
return NotFound(new ErrorResponseDto { Error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new ErrorResponseDto { Error = ex.Message });
}
catch (Exception ex)
{
_logger.Error($"Error updating robot {id}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while updating the robot." });
}
}
/// <summary>
/// Delete a robot
/// </summary>
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(Guid id)
{
try
{
var deleted = await _robotService.DeleteAsync(id);
if (!deleted)
{
return NotFound(new ErrorResponseDto { Error = $"Robot with ID {id} not found." });
}
return NoContent();
}
catch (Exception ex)
{
_logger.Error($"Error deleting robot {id}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while deleting the robot." });
}
}
private static RobotDto MapToDto(Robot robot)
{
return new RobotDto
{
Id = robot.Id,
RobotId = robot.RobotId,
Name = robot.Name,
ModelId = robot.ModelId,
ModelName = robot.Model?.ModelName,
MapId = robot.MapId,
CreatedDate = robot.CreatedDate,
UpdatedDate = robot.UpdatedDate
};
}
}

View File

@@ -0,0 +1,149 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Services;
using RobotNet10.FleetManager.Services.RobotManager;
using RobotNet10.FleetManager.Services.RobotManager.Models;
using RobotNet10.FleetManager.Shared.Models;
using RobotNet10.MapManager.Services;
using RobotNet10.Shared;
namespace RobotNet10.FleetManager.Controllers;
[Route("api/[controller]")]
[ApiController]
[AllowAnonymous]
public class RobotManagerController(IRobotService RobotService, ILayoutDataService LayoutManager, IRobotManagerService RobotManager, Services.Logger<RobotManagerController> Logger) : ControllerBase
{
[HttpPost]
[Route("MoveToNode")]
public async Task<MessageResult> MoveToNode([FromBody] RobotMoveToNodeModel model)
{
try
{
if (string.IsNullOrEmpty(model.NodeName)) return new(false, "NodeName cannot be empty..");
var robot = await RobotService.GetByRobotIdAsync(model.RobotId);
if (robot is null) return new(false, "RobotId does not exist.");
if (robot.MapId is null || robot.MapId == Guid.Empty) return new(false, "The robot has not been assigned a map.");
var robotController = RobotManager.GetRobotController(model.RobotId);
if (robotController is null || !robotController.IsOnline) return new(false, "The robot is not online.");
if (robotController.Data is null || robotController.Data.State is null) return new(false, "The robot is broken connection.");
var map = await LayoutManager.GetLayoutDataAsync(robot.MapId ?? Guid.Empty);
var node = map.Nodes.FirstOrDefault(n => n.NodeName == model.NodeName && n.LevelId == map.LayoutLevelId);
if (node is null) return new(false, "This Node does not exist.");
if (!robotController.IsReady) return new(false, "The robot is busy.");
var move = await robotController.MoveToNodeAsync(model.NodeName, model.LastAngle);
if (move.IsSuccess) return new(true);
Logger.Warning($"RobotManager API: MoveToNode for robot {model.RobotId} to node {model.NodeName} failed: {move.Message}");
return new(false, "Request failed.");
}
catch (Exception ex)
{
Logger.Error($"RobotManager API: MoveToNode for robot {model.RobotId} to node {model.NodeName} error: {ex.Message}");
return new(false, "An error occurred.");
}
}
[HttpDelete]
[Route("MoveToNode/{robotId}")]
public async Task<MessageResult> Cancel(string robotId)
{
try
{
var robot = await RobotService.GetByRobotIdAsync(robotId);
if (robot is null) return new(false, "RobotId does not exist.");
var robotController = RobotManager.GetRobotController(robotId);
if (robotController is null || !robotController.IsOnline) return new(false, "The robot is not online.");
if (robotController.Data is null || robotController.Data.State is null) return new(false, "The robot is broken connection.");
var cancel = await robotController.CancelOrderAsync();
if (cancel.IsSuccess) return new(true);
Logger.Warning($"RobotManager API: Cancel order for robot {robotId} failed: {cancel.Message}");
return new(false, "Request failed.");
}
catch (Exception ex)
{
Logger.Error($"RobotManager API: Cancel order for robot {robotId} error: {ex.Message}");
return new(false, "An error occurred.");
}
}
[HttpPost]
[Route("InstantActions")]
public async Task<MessageResult> InstantAction([FromBody] RobotInstantActionModel model)
{
try
{
var robot = await RobotService.GetByRobotIdAsync(model.RobotId);
if (robot is null) return new(false, "RobotId does not exist.");
if (robot.MapId is null || robot.MapId == Guid.Empty) return new(false, "The robot has not been assigned a map.");
var robotController = RobotManager.GetRobotController(model.RobotId);
if (robotController is null || !robotController.IsOnline) return new(false, "The robot is not online.");
if (robotController.Data is null || robotController.Data.State is null) return new(false, "The robot is broken connection.");
var instantAction = await robotController.SendInstantActionAsync(model.Action);
if (instantAction.IsSuccess) return new(true);
Logger.Warning($"RobotManager API: Send instant action for robot {model.RobotId} failed: {instantAction.Message}");
return new(false, "Request failed.");
}
catch (Exception ex)
{
Logger.Error($"RobotManager API: Send instant action for robot {model.RobotId}, action type {model.Action.ActionType} error: {ex.Message}");
return new(false, "An error occurred.");
}
}
[HttpGet]
[Route("State/{robotId}")]
public async Task<MessageResult<RobotData>> GetState(string robotId)
{
try
{
var robot = await RobotService.GetByRobotIdAsync(robotId);
if (robot is null) return new(false, null, "RobotId does not exist.");
if (robot.MapId is null || robot.MapId == Guid.Empty) return new(false, null, "The robot has not been assigned a map.");
var robotController = RobotManager.GetRobotController(robotId);
if (robotController is null || !robotController.IsOnline) return new(false, null, "The robot is not online.");
if (robotController.Data is null || robotController.Data.State is null) return new(false, null, "The robot is broken connection.");
return new(true, robotController.Data, "");
}
catch (Exception ex)
{
Logger.Error($"RobotManager API: GetState for robot {robotId} error: {ex.Message}");
return new(false, null, "An error occurred.");
}
}
[HttpGet]
[Route("OnlineStatus/{robotId}")]
public async Task<MessageResult<bool>> GetOnlineStatus(string robotId)
{
try
{
var robot = await RobotService.GetByRobotIdAsync(robotId);
if (robot is null) return new(false, false, "RobotId does not exist.");
var robotController = RobotManager.GetRobotController(robotId);
var isOnline = robotController?.IsOnline ?? false;
return new(true, isOnline, "");
}
catch (Exception ex)
{
Logger.Error($"RobotManager API: GetOnlineStatus for robot {robotId} error: {ex.Message}");
return new(false, false, "An error occurred.");
}
}
}

View File

@@ -0,0 +1,239 @@
using Microsoft.AspNetCore.Mvc;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Services;
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
using RobotNet10.FleetManager.Shared.DTOs.Responses;
namespace RobotNet10.FleetManager.Controllers;
/// <summary>
/// API controller for managing robot models.
/// Provides RESTful endpoints for CRUD operations on robot models.
/// </summary>
/// <remarks>
/// All endpoints return appropriate HTTP status codes and error responses.
/// Image operations are handled separately via RobotModelImagesController.
/// </remarks>
[ApiController]
[Route("api/robot-models")]
public class RobotModelController(IRobotModelService robotModelService, Services.Logger<RobotModelController> logger) : ControllerBase
{
private readonly IRobotModelService _robotModelService = robotModelService;
private readonly Services.Logger<RobotModelController> _logger = logger;
/// <summary>
/// Get all robot models
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(List<RobotModelDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<RobotModelDto>>> GetAll()
{
try
{
var models = await _robotModelService.GetAllAsync();
var dtos = models.Select(m => MapToDto(m)).ToList();
return Ok(dtos);
}
catch (Exception ex)
{
_logger.Error($"Error getting all robot models: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving robot models." });
}
}
/// <summary>
/// Get robot model by ID
/// </summary>
[HttpGet("{id}")]
[ProducesResponseType(typeof(RobotModelDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RobotModelDto>> GetById(Guid id)
{
try
{
var model = await _robotModelService.GetByIdAsync(id);
if (model == null)
{
return NotFound(new ErrorResponseDto { Error = $"Robot model with ID {id} not found." });
}
return Ok(MapToDto(model));
}
catch (Exception ex)
{
_logger.Error($"Error getting robot model {id}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving the robot model." });
}
}
/// <summary>
/// Search robot models by query string
/// </summary>
[HttpGet("search")]
[ProducesResponseType(typeof(List<RobotModelDto>), StatusCodes.Status200OK)]
public async Task<ActionResult<List<RobotModelDto>>> Search([FromQuery] string query)
{
try
{
var models = await _robotModelService.SearchAsync(query);
var dtos = models.Select(m => MapToDto(m)).ToList();
return Ok(dtos);
}
catch (Exception ex)
{
_logger.Error($"Error searching robot models with query '{query}': {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while searching robot models." });
}
}
/// <summary>
/// Get usage information for a robot model
/// </summary>
[HttpGet("{id}/usage")]
[ProducesResponseType(typeof(RobotModelUsageInfoDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<RobotModelUsageInfoDto>> GetUsageInfo(Guid id)
{
try
{
var usageInfo = await _robotModelService.GetUsageInfoAsync(id);
return Ok(usageInfo);
}
catch (KeyNotFoundException ex)
{
return NotFound(new ErrorResponseDto { Error = ex.Message });
}
catch (Exception ex)
{
_logger.Error($"Error getting usage info for robot model {id}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving usage information." });
}
}
/// <summary>
/// Create a new robot model
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(RobotModelDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<RobotModelDto>> Create([FromBody] CreateRobotModelRequest request)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(new ErrorResponseDto
{
Error = "Validation failed",
Details = ModelState.ToDictionary(
kvp => kvp.Key,
kvp => (object)(kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty<string>()))
});
}
var model = await _robotModelService.CreateAsync(request);
var dto = MapToDto(model);
return CreatedAtAction(nameof(GetById), new { id = model.Id }, dto);
}
catch (InvalidOperationException ex)
{
return BadRequest(new ErrorResponseDto { Error = ex.Message });
}
catch (Exception ex)
{
_logger.Error($"Error creating robot model: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while creating the robot model." });
}
}
/// <summary>
/// Update an existing robot model
/// </summary>
[HttpPut("{id}")]
[ProducesResponseType(typeof(RobotModelDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<RobotModelDto>> Update(Guid id, [FromBody] UpdateRobotModelRequest request)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(new ErrorResponseDto
{
Error = "Validation failed",
Details = ModelState.ToDictionary(
kvp => kvp.Key,
kvp => (object)(kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray() ?? Array.Empty<string>()))
});
}
var model = await _robotModelService.UpdateAsync(id, request);
var dto = MapToDto(model);
return Ok(dto);
}
catch (KeyNotFoundException ex)
{
return NotFound(new ErrorResponseDto { Error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new ErrorResponseDto { Error = ex.Message });
}
catch (Exception ex)
{
_logger.Error($"Error updating robot model {id}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while updating the robot model." });
}
}
/// <summary>
/// Delete a robot model
/// </summary>
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Delete(Guid id)
{
try
{
var deleted = await _robotModelService.DeleteAsync(id);
if (!deleted)
{
return NotFound(new ErrorResponseDto { Error = $"Robot model with ID {id} not found." });
}
return NoContent();
}
catch (InvalidOperationException ex)
{
return BadRequest(new ErrorResponseDto { Error = ex.Message });
}
catch (Exception ex)
{
_logger.Error($"Error deleting robot model {id}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while deleting the robot model." });
}
}
private static RobotModelDto MapToDto(RobotModel model)
{
return new RobotModelDto
{
Id = model.Id,
ModelName = model.ModelName,
Length = model.Length,
Width = model.Width,
ImageWidth = model.ImageWidth,
ImageHeight = model.ImageHeight,
NavigationPointX = model.NavigationPointX,
NavigationPointY = model.NavigationPointY,
NavigationType = model.NavigationType,
VehicleTypeId = model.VehicleTypeId,
CreatedDate = model.CreatedDate,
UpdatedDate = model.UpdatedDate,
RobotCount = model.Robots?.Count ?? 0
};
}
}

View File

@@ -0,0 +1,157 @@
using Microsoft.AspNetCore.Mvc;
using RobotNet10.FleetManager.Services;
using RobotNet10.FleetManager.Shared.DTOs.Responses;
namespace RobotNet10.FleetManager.Controllers;
/// <summary>
/// API controller for managing robot model images
/// </summary>
[ApiController]
[Route("api/robot-models/{robotModelId}/image")]
public class RobotModelImagesController(
IRobotModelImageStorageService imageStorageService,
IRobotModelService robotModelService,
Services.Logger<RobotModelImagesController> logger) : ControllerBase
{
private readonly IRobotModelImageStorageService _imageStorageService = imageStorageService;
private readonly IRobotModelService _robotModelService = robotModelService;
private readonly Services.Logger<RobotModelImagesController> _logger = logger;
private const long MaxFileSize = 10 * 1024 * 1024; // 10MB
/// <summary>
/// Get robot model image
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetImage(Guid robotModelId)
{
try
{
// Verify robot model exists
var model = await _robotModelService.GetByIdAsync(robotModelId);
if (model == null)
{
return NotFound(new ErrorResponseDto { Error = $"Robot model with ID {robotModelId} not found." });
}
var imageStream = await _imageStorageService.GetImageAsync(robotModelId);
if (imageStream == null)
{
return NotFound(new ErrorResponseDto { Error = $"Image for robot model {robotModelId} not found." });
}
return File(imageStream, "image/png", $"{robotModelId}.png");
}
catch (Exception ex)
{
_logger.Error($"Error getting image for robot model {robotModelId}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while retrieving the image." });
}
}
/// <summary>
/// Upload robot model image
/// </summary>
[HttpPost]
[RequestSizeLimit(MaxFileSize)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UploadImage(Guid robotModelId, IFormFile file)
{
try
{
// Verify robot model exists
var model = await _robotModelService.GetByIdAsync(robotModelId);
if (model == null)
{
return NotFound(new ErrorResponseDto { Error = $"Robot model with ID {robotModelId} not found." });
}
// Validate file
if (file == null || file.Length == 0)
{
return BadRequest(new ErrorResponseDto { Error = "No file provided." });
}
if (file.Length > MaxFileSize)
{
return BadRequest(new ErrorResponseDto { Error = $"File size exceeds maximum allowed size of {MaxFileSize / (1024 * 1024)}MB." });
}
// Validate file extension
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
if (extension != ".png")
{
return BadRequest(new ErrorResponseDto { Error = "Only PNG files are allowed." });
}
// Read image dimensions
using (var stream = file.OpenReadStream())
{
var (width, height) = await _imageStorageService.GetImageDimensionsAsync(stream);
// Save image
stream.Position = 0;
await _imageStorageService.SaveImageAsync(robotModelId, stream);
// Update robot model with image dimensions
await _robotModelService.UpdateAsync(robotModelId, new Shared.DTOs.RobotModel.UpdateRobotModelRequest
{
ImageWidth = width,
ImageHeight = height
});
}
return Ok(new { Error = "Image uploaded successfully." });
}
catch (InvalidOperationException ex)
{
return BadRequest(new ErrorResponseDto { Error = ex.Message });
}
catch (KeyNotFoundException ex)
{
return NotFound(new ErrorResponseDto { Error = ex.Message });
}
catch (Exception ex)
{
_logger.Error($"Error uploading image for robot model {robotModelId}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while uploading the image." });
}
}
/// <summary>
/// Delete robot model image
/// </summary>
[HttpDelete]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteImage(Guid robotModelId)
{
try
{
// Verify robot model exists
var model = await _robotModelService.GetByIdAsync(robotModelId);
if (model == null)
{
return NotFound(new ErrorResponseDto { Error = $"Robot model with ID {robotModelId} not found." });
}
var deleted = await _imageStorageService.DeleteImageAsync(robotModelId);
if (!deleted)
{
return NotFound(new ErrorResponseDto { Error = $"Image for robot model {robotModelId} not found." });
}
return NoContent();
}
catch (Exception ex)
{
_logger.Error($"Error deleting image for robot model {robotModelId}: {ex.Message}");
return StatusCode(500, new ErrorResponseDto { Error = "An error occurred while deleting the image." });
}
}
}

View File

@@ -0,0 +1,59 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace RobotNet10.FleetManager.Data
{
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ApplicationUser>(options)
{
/// <summary>
/// Robot models
/// </summary>
public DbSet<RobotModel> RobotModels { get; set; }
/// <summary>
/// Robots
/// </summary>
public DbSet<Robot> Robots { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure RobotModel
modelBuilder.Entity<RobotModel>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.ModelName).HasDatabaseName("IX_RobotModels_ModelName");
entity.HasIndex(e => e.NavigationType).HasDatabaseName("IX_RobotModels_NavigationType");
entity.HasIndex(e => e.VehicleTypeId).HasDatabaseName("IX_RobotModels_VehicleTypeId");
entity.Property(e => e.ModelName).IsRequired().HasMaxLength(256);
entity.Property(e => e.Length).HasPrecision(18, 2);
entity.Property(e => e.Width).HasPrecision(18, 2);
entity.Property(e => e.NavigationPointX).HasPrecision(18, 2);
entity.Property(e => e.NavigationPointY).HasPrecision(18, 2);
entity.Property(e => e.CreatedDate).IsRequired();
});
// Configure Robot
modelBuilder.Entity<Robot>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.RobotId).IsUnique().HasDatabaseName("UX_Robots_RobotId");
entity.HasIndex(e => e.ModelId).HasDatabaseName("IX_Robots_ModelId");
entity.HasIndex(e => e.MapId).HasDatabaseName("IX_Robots_MapId");
entity.Property(e => e.RobotId).IsRequired().HasMaxLength(64);
entity.Property(e => e.Name).IsRequired().HasMaxLength(256);
entity.Property(e => e.ModelId).IsRequired();
entity.Property(e => e.CreatedDate).IsRequired();
// Foreign key relationship
entity.HasOne(e => e.Model)
.WithMany(m => m.Robots)
.HasForeignKey(e => e.ModelId)
.OnDelete(DeleteBehavior.Restrict);
});
}
}
}

View File

@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace RobotNet10.FleetManager.Data;
public static class ApplicationDbExtensions
{
extension(IServiceProvider serviceProvider)
{
public async Task SeedApplicationDbAsync()
{
using var scope = serviceProvider.CreateScope();
using var appDb = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await appDb.Database.MigrateAsync();
await appDb.Database.EnsureCreatedAsync();
await appDb.SaveChangesAsync();
await scope.ServiceProvider.SeedRolesAsync();
await scope.ServiceProvider.SeedUsersAsync();
}
private async Task SeedRolesAsync()
{
var roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
if (!await roleManager.RoleExistsAsync("Administrator"))
{
await roleManager.CreateAsync(new IdentityRole()
{
Name = "Administrator",
NormalizedName = "ADMINISTRATOR",
});
}
if (!await roleManager.RoleExistsAsync("Distributor"))
{
await roleManager.CreateAsync(new IdentityRole()
{
Name = "Distributor",
NormalizedName = "DISTRIBUTOR",
});
}
if (!await roleManager.RoleExistsAsync("Operator"))
{
await roleManager.CreateAsync(new IdentityRole()
{
Name = "Operator",
NormalizedName = "OPERATOR",
});
}
}
private async Task SeedUsersAsync()
{
using var userManager = serviceProvider.GetRequiredService<UserManager<ApplicationUser>>();
if (await userManager.FindByNameAsync("admin") is null)
{
var admin = new ApplicationUser()
{
UserName = "admin",
Email = "administrator@phenikaa-x.com",
NormalizedUserName = "ADMINISTRATOR",
NormalizedEmail = "ADMINISTRATOR@PHENIKAA-X.COM",
EmailConfirmed = true,
};
await userManager.CreateAsync(admin, "robotics");
await userManager.AddToRoleAsync(admin, "Administrator");
}
if (await userManager.FindByNameAsync("distributor") is null)
{
var admin = new ApplicationUser()
{
UserName = "distributor",
Email = "distributor@phenikaa-x.com",
NormalizedUserName = "DISTRIBUTOR",
NormalizedEmail = "DISTRIBUTOR@PHENIKAA-X.COM",
EmailConfirmed = true,
};
await userManager.CreateAsync(admin, "distributor");
await userManager.AddToRoleAsync(admin, "Distributor");
}
if (await userManager.FindByNameAsync("Operator") is null)
{
var admin = new ApplicationUser()
{
UserName = "Operator",
Email = "Operator@phenikaa-x.com",
NormalizedUserName = "OPERATOR",
NormalizedEmail = "OPERATOR@PHENIKAA-X.COM",
EmailConfirmed = true,
};
await userManager.CreateAsync(admin, "Operator");
await userManager.AddToRoleAsync(admin, "Operator");
}
}
}
}

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Identity;
namespace RobotNet10.FleetManager.Data
{
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
}
}

View File

@@ -0,0 +1,397 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.FleetManager.Data;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.AppDb
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260209025755_AddAppDb")]
partial class AddAppDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.Robot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<Guid?>("MapId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ModelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("RobotId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime?>("UpdatedDate")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("MapId")
.HasDatabaseName("IX_Robots_MapId");
b.HasIndex("ModelId")
.HasDatabaseName("IX_Robots_ModelId");
b.HasIndex("RobotId")
.IsUnique()
.HasDatabaseName("UX_Robots_RobotId");
b.ToTable("Robots");
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.RobotModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<int>("ImageHeight")
.HasColumnType("int");
b.Property<int>("ImageWidth")
.HasColumnType("int");
b.Property<double>("Length")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.Property<string>("ModelName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<double>("NavigationPointX")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.Property<double>("NavigationPointY")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.Property<int>("NavigationType")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedDate")
.HasColumnType("datetime2");
b.Property<Guid?>("VehicleTypeId")
.HasColumnType("uniqueidentifier");
b.Property<double>("Width")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.HasKey("Id");
b.HasIndex("ModelName")
.HasDatabaseName("IX_RobotModels_ModelName");
b.HasIndex("NavigationType")
.HasDatabaseName("IX_RobotModels_NavigationType");
b.HasIndex("VehicleTypeId")
.HasDatabaseName("IX_RobotModels_VehicleTypeId");
b.ToTable("RobotModels");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.Robot", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.RobotModel", "Model")
.WithMany("Robots")
.HasForeignKey("ModelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Model");
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.RobotModel", b =>
{
b.Navigation("Robots");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,306 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.AppDb
{
/// <inheritdoc />
public partial class AddAppDb : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RobotModels",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ModelName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Length = table.Column<double>(type: "float(18)", precision: 18, scale: 2, nullable: false),
Width = table.Column<double>(type: "float(18)", precision: 18, scale: 2, nullable: false),
ImageWidth = table.Column<int>(type: "int", nullable: false),
ImageHeight = table.Column<int>(type: "int", nullable: false),
NavigationPointX = table.Column<double>(type: "float(18)", precision: 18, scale: 2, nullable: false),
NavigationPointY = table.Column<double>(type: "float(18)", precision: 18, scale: 2, nullable: false),
NavigationType = table.Column<int>(type: "int", nullable: false),
VehicleTypeId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedDate = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RobotModels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Robots",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RobotId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
ModelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MapId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedDate = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Robots", x => x.Id);
table.ForeignKey(
name: "FK_Robots_RobotModels_ModelId",
column: x => x.ModelId,
principalTable: "RobotModels",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true,
filter: "[NormalizedName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true,
filter: "[NormalizedUserName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_RobotModels_ModelName",
table: "RobotModels",
column: "ModelName");
migrationBuilder.CreateIndex(
name: "IX_RobotModels_NavigationType",
table: "RobotModels",
column: "NavigationType");
migrationBuilder.CreateIndex(
name: "IX_RobotModels_VehicleTypeId",
table: "RobotModels",
column: "VehicleTypeId");
migrationBuilder.CreateIndex(
name: "IX_Robots_MapId",
table: "Robots",
column: "MapId");
migrationBuilder.CreateIndex(
name: "IX_Robots_ModelId",
table: "Robots",
column: "ModelId");
migrationBuilder.CreateIndex(
name: "UX_Robots_RobotId",
table: "Robots",
column: "RobotId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "Robots");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable(
name: "RobotModels");
}
}
}

View File

@@ -0,0 +1,394 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.FleetManager.Data;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.AppDb
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.Robot", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<Guid?>("MapId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("ModelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("RobotId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime?>("UpdatedDate")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("MapId")
.HasDatabaseName("IX_Robots_MapId");
b.HasIndex("ModelId")
.HasDatabaseName("IX_Robots_ModelId");
b.HasIndex("RobotId")
.IsUnique()
.HasDatabaseName("UX_Robots_RobotId");
b.ToTable("Robots");
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.RobotModel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<int>("ImageHeight")
.HasColumnType("int");
b.Property<int>("ImageWidth")
.HasColumnType("int");
b.Property<double>("Length")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.Property<string>("ModelName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<double>("NavigationPointX")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.Property<double>("NavigationPointY")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.Property<int>("NavigationType")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedDate")
.HasColumnType("datetime2");
b.Property<Guid?>("VehicleTypeId")
.HasColumnType("uniqueidentifier");
b.Property<double>("Width")
.HasPrecision(18, 2)
.HasColumnType("float(18)");
b.HasKey("Id");
b.HasIndex("ModelName")
.HasDatabaseName("IX_RobotModels_ModelName");
b.HasIndex("NavigationType")
.HasDatabaseName("IX_RobotModels_NavigationType");
b.HasIndex("VehicleTypeId")
.HasDatabaseName("IX_RobotModels_VehicleTypeId");
b.ToTable("RobotModels");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.Robot", b =>
{
b.HasOne("RobotNet10.FleetManager.Data.RobotModel", "Model")
.WithMany("Robots")
.HasForeignKey("ModelId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Model");
});
modelBuilder.Entity("RobotNet10.FleetManager.Data.RobotModel", b =>
{
b.Navigation("Robots");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,710 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.MapManager.Data;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.MapDb
{
[DbContext(typeof(MapDbContext))]
[Migration("20260305070805_AddMapDb")]
partial class AddMapDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("EdgeDescription")
.HasColumnType("nvarchar(max)");
b.Property<string>("EdgeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("EdgeName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<Guid>("EndNodeId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("StartNodeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EdgeId");
b.HasIndex("EndNodeId");
b.HasIndex("StartNodeId");
b.HasIndex("LevelId", "EdgeId")
.IsUnique();
b.ToTable("Edges", t =>
{
t.HasCheckConstraint("CK_Edges_DifferentNodes", "[StartNodeId] <> [EndNodeId]");
});
});
modelBuilder.Entity("RobotNet10.MapManager.Data.EdgeVehicleProperty", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Actions")
.HasColumnType("nvarchar(max)");
b.Property<double?>("CorridorLeftWidth")
.HasColumnType("float");
b.Property<int?>("CorridorRefPoint")
.HasColumnType("int");
b.Property<double?>("CorridorRightWidth")
.HasColumnType("float");
b.Property<Guid>("EdgeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoadRestriction_LoadSetNames")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("LoadRestriction_Loaded")
.HasColumnType("bit");
b.Property<bool?>("LoadRestriction_Unloaded")
.HasColumnType("bit");
b.Property<double?>("MaxHeight")
.HasColumnType("float");
b.Property<double?>("MaxRotationSpeed")
.HasColumnType("float");
b.Property<double?>("MaxSpeed")
.HasColumnType("float");
b.Property<double?>("MinHeight")
.HasColumnType("float");
b.Property<int?>("OrientationType")
.HasColumnType("int");
b.Property<bool?>("RotationAllowed")
.HasColumnType("bit");
b.Property<int?>("RotationAtEndNodeAllowed")
.HasColumnType("int");
b.Property<int?>("RotationAtStartNodeAllowed")
.HasColumnType("int");
b.Property<double?>("TrajectoryControlPoint1X")
.HasColumnType("float");
b.Property<double?>("TrajectoryControlPoint1Y")
.HasColumnType("float");
b.Property<double?>("TrajectoryControlPoint2X")
.HasColumnType("float");
b.Property<double?>("TrajectoryControlPoint2Y")
.HasColumnType("float");
b.Property<int?>("TrajectoryDegree")
.HasColumnType("int");
b.Property<double?>("VehicleOrientation")
.HasColumnType("float");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EdgeId");
b.HasIndex("VehicleTypeId");
b.HasIndex("EdgeId", "VehicleTypeId")
.IsUnique();
b.ToTable("EdgeVehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Layout", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("LayoutId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("LayoutName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ModifiedBy")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("IsActive");
b.HasIndex("LayoutId")
.IsUnique();
b.ToTable("Layouts");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("LayoutLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("LevelOrder")
.HasColumnType("int");
b.Property<Guid>("VersionId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("LevelOrder");
b.HasIndex("VersionId", "LayoutLevelId")
.IsUnique();
b.ToTable("LayoutLevels");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevelEditorSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<double?>("BoundsMaxX")
.HasColumnType("float");
b.Property<double?>("BoundsMaxY")
.HasColumnType("float");
b.Property<double?>("BoundsMinX")
.HasColumnType("float");
b.Property<double?>("BoundsMinY")
.HasColumnType("float");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<double>("EdgeMinLengthCreate")
.HasColumnType("float");
b.Property<bool>("EdgeNameAutoGenerate")
.HasColumnType("bit");
b.Property<double?>("ImageHeight")
.HasColumnType("float");
b.Property<double?>("ImageWidth")
.HasColumnType("float");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<bool>("NodeNameAutoGenerate")
.HasColumnType("bit");
b.Property<double>("NodeProximityRadius")
.HasColumnType("float");
b.Property<double>("OriginX")
.HasColumnType("float");
b.Property<double>("OriginY")
.HasColumnType("float");
b.Property<double>("Resolution")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("LevelId")
.IsUnique();
b.ToTable("LayoutLevelEditorSettings");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("LayoutDescription")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("LayoutId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("IsActive");
b.HasIndex("LayoutId", "Version")
.IsUnique();
b.ToTable("LayoutVersions");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Node", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("MapId")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NodeDescription")
.HasColumnType("nvarchar(max)");
b.Property<string>("NodeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NodeName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<double>("X")
.HasColumnType("float");
b.Property<double>("Y")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("MapId");
b.HasIndex("NodeId");
b.HasIndex("LevelId", "NodeId")
.IsUnique();
b.HasIndex("X", "Y");
b.ToTable("Nodes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.NodeVehicleProperty", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Actions")
.HasColumnType("nvarchar(max)");
b.Property<double?>("AllowedDeviationTheta")
.HasColumnType("float");
b.Property<double?>("AllowedDeviationXY")
.HasColumnType("float");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<double?>("Theta")
.HasColumnType("float");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("NodeId");
b.HasIndex("VehicleTypeId");
b.HasIndex("NodeId", "VehicleTypeId")
.IsUnique();
b.ToTable("NodeVehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Station", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("StationDescription")
.HasColumnType("nvarchar(max)");
b.Property<double?>("StationHeight")
.HasColumnType("float");
b.Property<string>("StationId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("StationName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<double?>("Theta")
.HasColumnType("float");
b.Property<double>("X")
.HasColumnType("float");
b.Property<double>("Y")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("StationId");
b.HasIndex("LevelId", "StationId")
.IsUnique();
b.ToTable("Stations");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.StationInteractionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("StationId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("NodeId");
b.HasIndex("StationId");
b.HasIndex("StationId", "NodeId")
.IsUnique();
b.ToTable("StationInteractionNodes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.VehicleType", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Actions")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Specifications")
.HasColumnType("nvarchar(max)");
b.Property<string>("VehicleTypeId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("VehicleTypeName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("IsActive");
b.HasIndex("VehicleTypeId")
.IsUnique();
b.ToTable("VehicleTypes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Node", "EndNode")
.WithMany("IncomingEdges")
.HasForeignKey("EndNodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithMany("Edges")
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.Node", "StartNode")
.WithMany("OutgoingEdges")
.HasForeignKey("StartNodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("EndNode");
b.Navigation("Level");
b.Navigation("StartNode");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.EdgeVehicleProperty", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Edge", "Edge")
.WithMany("VehicleProperties")
.HasForeignKey("EdgeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.VehicleType", "VehicleType")
.WithMany("EdgeVehicleProperties")
.HasForeignKey("VehicleTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Edge");
b.Navigation("VehicleType");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevel", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutVersion", "Version")
.WithMany("Levels")
.HasForeignKey("VersionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Version");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevelEditorSettings", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithOne("EditorSettings")
.HasForeignKey("RobotNet10.MapManager.Data.LayoutLevelEditorSettings", "LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Layout", "Layout")
.WithMany("Versions")
.HasForeignKey("LayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Layout");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Node", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithMany("Nodes")
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.NodeVehicleProperty", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Node", "Node")
.WithMany("VehicleProperties")
.HasForeignKey("NodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.VehicleType", "VehicleType")
.WithMany("NodeVehicleProperties")
.HasForeignKey("VehicleTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Node");
b.Navigation("VehicleType");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Station", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithMany("Stations")
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.StationInteractionNode", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Node", "Node")
.WithMany("StationInteractions")
.HasForeignKey("NodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.Station", "Station")
.WithMany("InteractionNodes")
.HasForeignKey("StationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Node");
b.Navigation("Station");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.Navigation("VehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Layout", b =>
{
b.Navigation("Versions");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevel", b =>
{
b.Navigation("Edges");
b.Navigation("EditorSettings");
b.Navigation("Nodes");
b.Navigation("Stations");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.Navigation("Levels");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Node", b =>
{
b.Navigation("IncomingEdges");
b.Navigation("OutgoingEdges");
b.Navigation("StationInteractions");
b.Navigation("VehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Station", b =>
{
b.Navigation("InteractionNodes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.VehicleType", b =>
{
b.Navigation("EdgeVehicleProperties");
b.Navigation("NodeVehicleProperties");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,501 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.MapDb
{
/// <inheritdoc />
public partial class AddMapDb : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Layouts",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LayoutId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
LayoutName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
ModifiedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ModifiedBy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Layouts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "VehicleTypes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
VehicleTypeId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
VehicleTypeName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
Specifications = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
Actions = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VehicleTypes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LayoutVersions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LayoutId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Version = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
LayoutDescription = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LayoutVersions", x => x.Id);
table.ForeignKey(
name: "FK_LayoutVersions_Layouts_LayoutId",
column: x => x.LayoutId,
principalTable: "Layouts",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LayoutLevels",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
VersionId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LayoutLevelId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
LevelOrder = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LayoutLevels", x => x.Id);
table.ForeignKey(
name: "FK_LayoutLevels_LayoutVersions_VersionId",
column: x => x.VersionId,
principalTable: "LayoutVersions",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LayoutLevelEditorSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EdgeMinLengthCreate = table.Column<double>(type: "float", nullable: false),
EdgeNameAutoGenerate = table.Column<bool>(type: "bit", nullable: false),
NodeNameAutoGenerate = table.Column<bool>(type: "bit", nullable: false),
NodeProximityRadius = table.Column<double>(type: "float", nullable: false),
OriginX = table.Column<double>(type: "float", nullable: false),
OriginY = table.Column<double>(type: "float", nullable: false),
Resolution = table.Column<double>(type: "float", nullable: false),
BoundsMinX = table.Column<double>(type: "float", nullable: true),
BoundsMaxX = table.Column<double>(type: "float", nullable: true),
BoundsMinY = table.Column<double>(type: "float", nullable: true),
BoundsMaxY = table.Column<double>(type: "float", nullable: true),
ImageWidth = table.Column<double>(type: "float", nullable: true),
ImageHeight = table.Column<double>(type: "float", nullable: true),
CreatedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
ModifiedDate = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LayoutLevelEditorSettings", x => x.Id);
table.ForeignKey(
name: "FK_LayoutLevelEditorSettings_LayoutLevels_LevelId",
column: x => x.LevelId,
principalTable: "LayoutLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Nodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
NodeId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
NodeName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NodeDescription = table.Column<string>(type: "nvarchar(max)", nullable: true),
MapId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
X = table.Column<double>(type: "float", nullable: false),
Y = table.Column<double>(type: "float", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Nodes", x => x.Id);
table.ForeignKey(
name: "FK_Nodes_LayoutLevels_LevelId",
column: x => x.LevelId,
principalTable: "LayoutLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Stations",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
StationId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
StationName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
StationDescription = table.Column<string>(type: "nvarchar(max)", nullable: true),
StationHeight = table.Column<double>(type: "float", nullable: true),
X = table.Column<double>(type: "float", nullable: false),
Y = table.Column<double>(type: "float", nullable: false),
Theta = table.Column<double>(type: "float", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Stations", x => x.Id);
table.ForeignKey(
name: "FK_Stations_LayoutLevels_LevelId",
column: x => x.LevelId,
principalTable: "LayoutLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Edges",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LevelId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EdgeId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
StartNodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EndNodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EdgeName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EdgeDescription = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Edges", x => x.Id);
table.CheckConstraint("CK_Edges_DifferentNodes", "[StartNodeId] <> [EndNodeId]");
table.ForeignKey(
name: "FK_Edges_LayoutLevels_LevelId",
column: x => x.LevelId,
principalTable: "LayoutLevels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Edges_Nodes_EndNodeId",
column: x => x.EndNodeId,
principalTable: "Nodes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_Edges_Nodes_StartNodeId",
column: x => x.StartNodeId,
principalTable: "Nodes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NodeVehicleProperties",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
NodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
VehicleTypeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Theta = table.Column<double>(type: "float", nullable: true),
Actions = table.Column<string>(type: "nvarchar(max)", nullable: true),
AllowedDeviationXY = table.Column<double>(type: "float", nullable: true),
AllowedDeviationTheta = table.Column<double>(type: "float", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NodeVehicleProperties", x => x.Id);
table.ForeignKey(
name: "FK_NodeVehicleProperties_Nodes_NodeId",
column: x => x.NodeId,
principalTable: "Nodes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_NodeVehicleProperties_VehicleTypes_VehicleTypeId",
column: x => x.VehicleTypeId,
principalTable: "VehicleTypes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "StationInteractionNodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
StationId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
NodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_StationInteractionNodes", x => x.Id);
table.ForeignKey(
name: "FK_StationInteractionNodes_Nodes_NodeId",
column: x => x.NodeId,
principalTable: "Nodes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_StationInteractionNodes_Stations_StationId",
column: x => x.StationId,
principalTable: "Stations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "EdgeVehicleProperties",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EdgeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
VehicleTypeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
VehicleOrientation = table.Column<double>(type: "float", nullable: true),
OrientationType = table.Column<int>(type: "int", nullable: true),
RotationAllowed = table.Column<bool>(type: "bit", nullable: true),
RotationAtStartNodeAllowed = table.Column<int>(type: "int", nullable: true),
RotationAtEndNodeAllowed = table.Column<int>(type: "int", nullable: true),
MaxSpeed = table.Column<double>(type: "float", nullable: true),
MaxRotationSpeed = table.Column<double>(type: "float", nullable: true),
MinHeight = table.Column<double>(type: "float", nullable: true),
MaxHeight = table.Column<double>(type: "float", nullable: true),
LoadRestriction_Unloaded = table.Column<bool>(type: "bit", nullable: true),
LoadRestriction_Loaded = table.Column<bool>(type: "bit", nullable: true),
LoadRestriction_LoadSetNames = table.Column<string>(type: "nvarchar(max)", nullable: true),
TrajectoryDegree = table.Column<int>(type: "int", nullable: true),
TrajectoryControlPoint1X = table.Column<double>(type: "float", nullable: true),
TrajectoryControlPoint1Y = table.Column<double>(type: "float", nullable: true),
TrajectoryControlPoint2X = table.Column<double>(type: "float", nullable: true),
TrajectoryControlPoint2Y = table.Column<double>(type: "float", nullable: true),
Actions = table.Column<string>(type: "nvarchar(max)", nullable: true),
CorridorLeftWidth = table.Column<double>(type: "float", nullable: true),
CorridorRightWidth = table.Column<double>(type: "float", nullable: true),
CorridorRefPoint = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EdgeVehicleProperties", x => x.Id);
table.ForeignKey(
name: "FK_EdgeVehicleProperties_Edges_EdgeId",
column: x => x.EdgeId,
principalTable: "Edges",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EdgeVehicleProperties_VehicleTypes_VehicleTypeId",
column: x => x.VehicleTypeId,
principalTable: "VehicleTypes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Edges_EdgeId",
table: "Edges",
column: "EdgeId");
migrationBuilder.CreateIndex(
name: "IX_Edges_EndNodeId",
table: "Edges",
column: "EndNodeId");
migrationBuilder.CreateIndex(
name: "IX_Edges_LevelId_EdgeId",
table: "Edges",
columns: new[] { "LevelId", "EdgeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Edges_StartNodeId",
table: "Edges",
column: "StartNodeId");
migrationBuilder.CreateIndex(
name: "IX_EdgeVehicleProperties_EdgeId",
table: "EdgeVehicleProperties",
column: "EdgeId");
migrationBuilder.CreateIndex(
name: "IX_EdgeVehicleProperties_EdgeId_VehicleTypeId",
table: "EdgeVehicleProperties",
columns: new[] { "EdgeId", "VehicleTypeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_EdgeVehicleProperties_VehicleTypeId",
table: "EdgeVehicleProperties",
column: "VehicleTypeId");
migrationBuilder.CreateIndex(
name: "IX_LayoutLevelEditorSettings_LevelId",
table: "LayoutLevelEditorSettings",
column: "LevelId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LayoutLevels_LevelOrder",
table: "LayoutLevels",
column: "LevelOrder");
migrationBuilder.CreateIndex(
name: "IX_LayoutLevels_VersionId_LayoutLevelId",
table: "LayoutLevels",
columns: new[] { "VersionId", "LayoutLevelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Layouts_IsActive",
table: "Layouts",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_Layouts_LayoutId",
table: "Layouts",
column: "LayoutId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_LayoutVersions_IsActive",
table: "LayoutVersions",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_LayoutVersions_LayoutId_Version",
table: "LayoutVersions",
columns: new[] { "LayoutId", "Version" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Nodes_LevelId_NodeId",
table: "Nodes",
columns: new[] { "LevelId", "NodeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Nodes_MapId",
table: "Nodes",
column: "MapId");
migrationBuilder.CreateIndex(
name: "IX_Nodes_NodeId",
table: "Nodes",
column: "NodeId");
migrationBuilder.CreateIndex(
name: "IX_Nodes_X_Y",
table: "Nodes",
columns: new[] { "X", "Y" });
migrationBuilder.CreateIndex(
name: "IX_NodeVehicleProperties_NodeId",
table: "NodeVehicleProperties",
column: "NodeId");
migrationBuilder.CreateIndex(
name: "IX_NodeVehicleProperties_NodeId_VehicleTypeId",
table: "NodeVehicleProperties",
columns: new[] { "NodeId", "VehicleTypeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NodeVehicleProperties_VehicleTypeId",
table: "NodeVehicleProperties",
column: "VehicleTypeId");
migrationBuilder.CreateIndex(
name: "IX_StationInteractionNodes_NodeId",
table: "StationInteractionNodes",
column: "NodeId");
migrationBuilder.CreateIndex(
name: "IX_StationInteractionNodes_StationId",
table: "StationInteractionNodes",
column: "StationId");
migrationBuilder.CreateIndex(
name: "IX_StationInteractionNodes_StationId_NodeId",
table: "StationInteractionNodes",
columns: new[] { "StationId", "NodeId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Stations_LevelId_StationId",
table: "Stations",
columns: new[] { "LevelId", "StationId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Stations_StationId",
table: "Stations",
column: "StationId");
migrationBuilder.CreateIndex(
name: "IX_VehicleTypes_IsActive",
table: "VehicleTypes",
column: "IsActive");
migrationBuilder.CreateIndex(
name: "IX_VehicleTypes_VehicleTypeId",
table: "VehicleTypes",
column: "VehicleTypeId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EdgeVehicleProperties");
migrationBuilder.DropTable(
name: "LayoutLevelEditorSettings");
migrationBuilder.DropTable(
name: "NodeVehicleProperties");
migrationBuilder.DropTable(
name: "StationInteractionNodes");
migrationBuilder.DropTable(
name: "Edges");
migrationBuilder.DropTable(
name: "VehicleTypes");
migrationBuilder.DropTable(
name: "Stations");
migrationBuilder.DropTable(
name: "Nodes");
migrationBuilder.DropTable(
name: "LayoutLevels");
migrationBuilder.DropTable(
name: "LayoutVersions");
migrationBuilder.DropTable(
name: "Layouts");
}
}
}

View File

@@ -0,0 +1,707 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.MapManager.Data;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.MapDb
{
[DbContext(typeof(MapDbContext))]
partial class MapDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("EdgeDescription")
.HasColumnType("nvarchar(max)");
b.Property<string>("EdgeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("EdgeName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<Guid>("EndNodeId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("StartNodeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EdgeId");
b.HasIndex("EndNodeId");
b.HasIndex("StartNodeId");
b.HasIndex("LevelId", "EdgeId")
.IsUnique();
b.ToTable("Edges", t =>
{
t.HasCheckConstraint("CK_Edges_DifferentNodes", "[StartNodeId] <> [EndNodeId]");
});
});
modelBuilder.Entity("RobotNet10.MapManager.Data.EdgeVehicleProperty", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Actions")
.HasColumnType("nvarchar(max)");
b.Property<double?>("CorridorLeftWidth")
.HasColumnType("float");
b.Property<int?>("CorridorRefPoint")
.HasColumnType("int");
b.Property<double?>("CorridorRightWidth")
.HasColumnType("float");
b.Property<Guid>("EdgeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoadRestriction_LoadSetNames")
.HasColumnType("nvarchar(max)");
b.Property<bool?>("LoadRestriction_Loaded")
.HasColumnType("bit");
b.Property<bool?>("LoadRestriction_Unloaded")
.HasColumnType("bit");
b.Property<double?>("MaxHeight")
.HasColumnType("float");
b.Property<double?>("MaxRotationSpeed")
.HasColumnType("float");
b.Property<double?>("MaxSpeed")
.HasColumnType("float");
b.Property<double?>("MinHeight")
.HasColumnType("float");
b.Property<int?>("OrientationType")
.HasColumnType("int");
b.Property<bool?>("RotationAllowed")
.HasColumnType("bit");
b.Property<int?>("RotationAtEndNodeAllowed")
.HasColumnType("int");
b.Property<int?>("RotationAtStartNodeAllowed")
.HasColumnType("int");
b.Property<double?>("TrajectoryControlPoint1X")
.HasColumnType("float");
b.Property<double?>("TrajectoryControlPoint1Y")
.HasColumnType("float");
b.Property<double?>("TrajectoryControlPoint2X")
.HasColumnType("float");
b.Property<double?>("TrajectoryControlPoint2Y")
.HasColumnType("float");
b.Property<int?>("TrajectoryDegree")
.HasColumnType("int");
b.Property<double?>("VehicleOrientation")
.HasColumnType("float");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("EdgeId");
b.HasIndex("VehicleTypeId");
b.HasIndex("EdgeId", "VehicleTypeId")
.IsUnique();
b.ToTable("EdgeVehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Layout", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("LayoutId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("LayoutName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("ModifiedBy")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("IsActive");
b.HasIndex("LayoutId")
.IsUnique();
b.ToTable("Layouts");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("LayoutLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<int>("LevelOrder")
.HasColumnType("int");
b.Property<Guid>("VersionId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("LevelOrder");
b.HasIndex("VersionId", "LayoutLevelId")
.IsUnique();
b.ToTable("LayoutLevels");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevelEditorSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<double?>("BoundsMaxX")
.HasColumnType("float");
b.Property<double?>("BoundsMaxY")
.HasColumnType("float");
b.Property<double?>("BoundsMinX")
.HasColumnType("float");
b.Property<double?>("BoundsMinY")
.HasColumnType("float");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<double>("EdgeMinLengthCreate")
.HasColumnType("float");
b.Property<bool>("EdgeNameAutoGenerate")
.HasColumnType("bit");
b.Property<double?>("ImageHeight")
.HasColumnType("float");
b.Property<double?>("ImageWidth")
.HasColumnType("float");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("datetime2");
b.Property<bool>("NodeNameAutoGenerate")
.HasColumnType("bit");
b.Property<double>("NodeProximityRadius")
.HasColumnType("float");
b.Property<double>("OriginX")
.HasColumnType("float");
b.Property<double>("OriginY")
.HasColumnType("float");
b.Property<double>("Resolution")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("LevelId")
.IsUnique();
b.ToTable("LayoutLevelEditorSettings");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("LayoutDescription")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("LayoutId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("IsActive");
b.HasIndex("LayoutId", "Version")
.IsUnique();
b.ToTable("LayoutVersions");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Node", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("MapId")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NodeDescription")
.HasColumnType("nvarchar(max)");
b.Property<string>("NodeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("NodeName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<double>("X")
.HasColumnType("float");
b.Property<double>("Y")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("MapId");
b.HasIndex("NodeId");
b.HasIndex("LevelId", "NodeId")
.IsUnique();
b.HasIndex("X", "Y");
b.ToTable("Nodes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.NodeVehicleProperty", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Actions")
.HasColumnType("nvarchar(max)");
b.Property<double?>("AllowedDeviationTheta")
.HasColumnType("float");
b.Property<double?>("AllowedDeviationXY")
.HasColumnType("float");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<double?>("Theta")
.HasColumnType("float");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("NodeId");
b.HasIndex("VehicleTypeId");
b.HasIndex("NodeId", "VehicleTypeId")
.IsUnique();
b.ToTable("NodeVehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Station", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("LevelId")
.HasColumnType("uniqueidentifier");
b.Property<string>("StationDescription")
.HasColumnType("nvarchar(max)");
b.Property<double?>("StationHeight")
.HasColumnType("float");
b.Property<string>("StationId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("StationName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<double?>("Theta")
.HasColumnType("float");
b.Property<double>("X")
.HasColumnType("float");
b.Property<double>("Y")
.HasColumnType("float");
b.HasKey("Id");
b.HasIndex("StationId");
b.HasIndex("LevelId", "StationId")
.IsUnique();
b.ToTable("Stations");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.StationInteractionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("StationId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("NodeId");
b.HasIndex("StationId");
b.HasIndex("StationId", "NodeId")
.IsUnique();
b.ToTable("StationInteractionNodes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.VehicleType", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Actions")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedDate")
.HasColumnType("datetime2");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Specifications")
.HasColumnType("nvarchar(max)");
b.Property<string>("VehicleTypeId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("VehicleTypeName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("IsActive");
b.HasIndex("VehicleTypeId")
.IsUnique();
b.ToTable("VehicleTypes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Node", "EndNode")
.WithMany("IncomingEdges")
.HasForeignKey("EndNodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithMany("Edges")
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.Node", "StartNode")
.WithMany("OutgoingEdges")
.HasForeignKey("StartNodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("EndNode");
b.Navigation("Level");
b.Navigation("StartNode");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.EdgeVehicleProperty", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Edge", "Edge")
.WithMany("VehicleProperties")
.HasForeignKey("EdgeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.VehicleType", "VehicleType")
.WithMany("EdgeVehicleProperties")
.HasForeignKey("VehicleTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Edge");
b.Navigation("VehicleType");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevel", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutVersion", "Version")
.WithMany("Levels")
.HasForeignKey("VersionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Version");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevelEditorSettings", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithOne("EditorSettings")
.HasForeignKey("RobotNet10.MapManager.Data.LayoutLevelEditorSettings", "LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Layout", "Layout")
.WithMany("Versions")
.HasForeignKey("LayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Layout");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Node", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithMany("Nodes")
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.NodeVehicleProperty", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Node", "Node")
.WithMany("VehicleProperties")
.HasForeignKey("NodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.VehicleType", "VehicleType")
.WithMany("NodeVehicleProperties")
.HasForeignKey("VehicleTypeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Node");
b.Navigation("VehicleType");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Station", b =>
{
b.HasOne("RobotNet10.MapManager.Data.LayoutLevel", "Level")
.WithMany("Stations")
.HasForeignKey("LevelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Level");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.StationInteractionNode", b =>
{
b.HasOne("RobotNet10.MapManager.Data.Node", "Node")
.WithMany("StationInteractions")
.HasForeignKey("NodeId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("RobotNet10.MapManager.Data.Station", "Station")
.WithMany("InteractionNodes")
.HasForeignKey("StationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Node");
b.Navigation("Station");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.Navigation("VehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Layout", b =>
{
b.Navigation("Versions");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutLevel", b =>
{
b.Navigation("Edges");
b.Navigation("EditorSettings");
b.Navigation("Nodes");
b.Navigation("Stations");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.Navigation("Levels");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Node", b =>
{
b.Navigation("IncomingEdges");
b.Navigation("OutgoingEdges");
b.Navigation("StationInteractions");
b.Navigation("VehicleProperties");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.Station", b =>
{
b.Navigation("InteractionNodes");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.VehicleType", b =>
{
b.Navigation("EdgeVehicleProperties");
b.Navigation("NodeVehicleProperties");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,77 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.ScriptEngine.Data;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.ScriptDb
{
[DbContext(typeof(ScriptEngineDbContext))]
[Migration("20260209025906_AddScriptDb")]
partial class AddScriptDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("RobotNet10.ScriptEngine.Data.InstanceMission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Log")
.HasColumnType("nvarchar(max)")
.HasColumnName("Log");
b.Property<string>("MissionName")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasColumnName("MissionName");
b.Property<string>("Parameters")
.HasColumnType("nvarchar(max)")
.HasColumnName("Parameters");
b.Property<int>("Score")
.HasColumnType("int")
.HasColumnName("Score");
b.Property<int>("State")
.HasColumnType("int")
.HasColumnName("State");
b.Property<DateTime>("StoppedAt")
.HasColumnType("datetime2")
.HasColumnName("StoppedAt");
b.Property<int>("TotalScore")
.HasColumnType("int")
.HasColumnName("TotalScore");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("InstanceMissions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,46 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.ScriptDb
{
/// <inheritdoc />
public partial class AddScriptDb : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "InstanceMissions",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MissionName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Parameters = table.Column<string>(type: "nvarchar(max)", nullable: true),
TotalScore = table.Column<int>(type: "int", nullable: false),
State = table.Column<int>(type: "int", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
StoppedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
Log = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_InstanceMissions", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_InstanceMissions_CreatedAt",
table: "InstanceMissions",
column: "CreatedAt");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InstanceMissions");
}
}
}

View File

@@ -0,0 +1,74 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.ScriptEngine.Data;
#nullable disable
namespace RobotNet10.FleetManager.Data.Migrations.ScriptDb
{
[DbContext(typeof(ScriptEngineDbContext))]
partial class ScriptEngineDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("RobotNet10.ScriptEngine.Data.InstanceMission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Log")
.HasColumnType("nvarchar(max)")
.HasColumnName("Log");
b.Property<string>("MissionName")
.IsRequired()
.HasColumnType("nvarchar(max)")
.HasColumnName("MissionName");
b.Property<string>("Parameters")
.HasColumnType("nvarchar(max)")
.HasColumnName("Parameters");
b.Property<int>("Score")
.HasColumnType("int")
.HasColumnName("Score");
b.Property<int>("State")
.HasColumnType("int")
.HasColumnName("State");
b.Property<DateTime>("StoppedAt")
.HasColumnType("datetime2")
.HasColumnName("StoppedAt");
b.Property<int>("TotalScore")
.HasColumnType("int")
.HasColumnName("TotalScore");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("InstanceMissions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,58 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.FleetManager.Data;
/// <summary>
/// Robot entity - Individual robot information
/// </summary>
[Table("Robots")]
public class Robot
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Robot identifier (serial number or unique identifier)
/// </summary>
[Required]
[StringLength(64)]
public string RobotId { get; set; } = string.Empty;
/// <summary>
/// Display name of the robot
/// </summary>
[Required]
[StringLength(256)]
public string Name { get; set; } = string.Empty;
/// <summary>
/// Foreign key to RobotModel
/// </summary>
[Required]
public Guid ModelId { get; set; }
/// <summary>
/// Foreign key to Map (optional)
/// </summary>
public Guid? MapId { get; set; }
/// <summary>
/// Created date
/// </summary>
[Required]
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
/// <summary>
/// Updated date
/// </summary>
public DateTime? UpdatedDate { get; set; }
// Navigation properties
/// <summary>
/// Robot model this robot belongs to
/// </summary>
[ForeignKey(nameof(ModelId))]
public virtual RobotModel? Model { get; set; }
}

View File

@@ -0,0 +1,88 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using RobotNet10.FleetManager.Shared.Enums;
namespace RobotNet10.FleetManager.Data;
/// <summary>
/// Robot model entity - Master data for robot types
/// </summary>
[Table("RobotModels")]
public class RobotModel
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; set; }
/// <summary>
/// Model name
/// </summary>
[Required]
[StringLength(256)]
public string ModelName { get; set; } = string.Empty;
/// <summary>
/// Robot length in meters
/// </summary>
[Required]
public double Length { get; set; }
/// <summary>
/// Robot width in meters
/// </summary>
[Required]
public double Width { get; set; }
/// <summary>
/// Image width in pixels
/// </summary>
[Required]
public int ImageWidth { get; set; }
/// <summary>
/// Image height in pixels
/// </summary>
[Required]
public int ImageHeight { get; set; }
/// <summary>
/// Navigation point X coordinate (relative to robot center) in meters
/// </summary>
[Required]
public double NavigationPointX { get; set; }
/// <summary>
/// Navigation point Y coordinate (relative to robot center) in meters
/// </summary>
[Required]
public double NavigationPointY { get; set; }
/// <summary>
/// Navigation type
/// </summary>
[Required]
public NavigationType NavigationType { get; set; }
/// <summary>
/// Foreign key to VehicleType (in MapManager database)
/// Links RobotModel to VehicleType for map filtering
/// </summary>
public Guid? VehicleTypeId { get; set; }
/// <summary>
/// Created date
/// </summary>
[Required]
public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
/// <summary>
/// Updated date
/// </summary>
public DateTime? UpdatedDate { get; set; }
// Navigation properties
/// <summary>
/// Collection of robots using this model
/// </summary>
public virtual ICollection<Robot> Robots { get; set; } = [];
}

View File

@@ -0,0 +1,53 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "connection",
"description": "The last will message of the AGV. Has to be sent with retain flag.\nOnce the AGV comes online, it has to send this message on its connect topic, with the connectionState enum set to \"ONLINE\".\n The last will message is to be configured with the connection state set to \"CONNECTIONBROKEN\".\nThus, if the AGV disconnects from the broker, master control gets notified via the topic \"connection\".\nIf the AGV is disconnecting in an orderly fashion (e.g. shutting down, sleeping), the AGV is to publish a message on this topic with the connectionState set to \"DISCONNECTED\".",
"subtopic": "/connection",
"type": "object",
"required": [
"headerId",
"timestamp",
"version",
"manufacturer",
"serialNumber",
"connectionState"
],
"properties": {
"headerId": {
"type": "integer",
"description": "Header ID of the message. The headerId is defined per topic and incremented by 1 with each sent (but not necessarily received) message."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "Timestamp in ISO8601 format (YYYY-MM-DDTHH:mm:ss.ssZ).",
"examples": [
"1991-03-11T11:40:03.12Z"
]
},
"version": {
"type": "string",
"description": "Version of the protocol [Major].[Minor].[Patch]",
"examples": [
"1.3.2"
]
},
"manufacturer": {
"type": "string",
"description": "Manufacturer of the AGV."
},
"serialNumber": {
"type": "string",
"description": "Serial number of the AGV."
},
"connectionState": {
"type": "string",
"enum": [
"ONLINE",
"OFFLINE",
"CONNECTIONBROKEN"
],
"description": "ONLINE: connection between AGV and broker is active. OFFLINE: connection between AGV and broker has gone offline in a coordinated way. CONNECTIONBROKEN: The connection between AGV and broker has unexpectedly ended."
}
}
}

View File

@@ -0,0 +1,831 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "AGV Factsheet",
"description": "The factsheet provides basic information about a specific AGV type series. This information allows comparison of different AGV types and can be applied for the planning, dimensioning and simulation of an AGV system. The factsheet also includes information about AGV communication interfaces which are required for the integration of an AGV type series into a VD[M]A-5050-compliant master control.",
"required": [
"headerId",
"timestamp",
"version",
"manufacturer",
"serialNumber",
"typeSpecification",
"physicalParameters",
"protocolLimits",
"protocolFeatures",
"agvGeometry",
"loadSpecification"
],
"subtopic": "/factsheet",
"type": "object",
"properties":{
"headerId": {
"type": "integer",
"description": "header ID of the message. The headerId is defined per topic and incremented by 1 with each sent (but not necessarily received) message.",
"minimum": 0
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "Timestamp in ISO8601 format (YYYY-MM-DDTHH:mm:ss.ffZ).",
"examples": [
"1991-03-11T11:40:03.12Z"
]
},
"version": {
"type": "string",
"description": "Version of the VD[M]A-5050 protocol [Major].[Minor].[Patch] (e.g. 1.3.2)",
"examples": [
"2.0.0"
]
},
"manufacturer": {
"type": "string",
"description": "Manufacturer of the AGV"
},
"serialNumber": {
"type": "string",
"description": "Serial number of the AGV"
},
"typeSpecification": {
"type": "object",
"required": [
"seriesName",
"agvKinematic",
"agvClass",
"maxLoadMass",
"localizationTypes",
"navigationTypes"
],
"description": "These parameters generally specify the class and the capabilities of the AGV",
"properties": {
"seriesName": {
"type": "string",
"description": "Free text generalized series name as specified by manufacturer"
},
"seriesDescription": {
"type": "string",
"description": "Free text human readable description of the AGV type series"
},
"agvKinematic": {
"type": "string",
"description": "simplified description of AGV kinematics-type.",
"enum": [
"DIFF",
"OMNI",
"THREEWHEEL"
]
},
"agvClass": {
"type": "string",
"description": "Simplified description of AGV class.",
"enum": [
"FORKLIFT",
"CONVEYOR",
"TUGGER",
"CARRIER"
]
},
"maxLoadMass": {
"type": "number",
"description": "maximum loadable mass",
"unit": "kg",
"minimum": 0
},
"localizationTypes": {
"type": "array",
"description": "simplified description of localization type",
"items": {
"type": "string",
"enum": [
"NATURAL",
"REFLECTOR",
"RFID",
"DMC",
"SPOT",
"GRID"
]
}
},
"navigationTypes": {
"type": "array",
"description": "List of path planning types supported by the AGV, sorted by priority",
"items": {
"type": "string",
"enum": [
"PHYSICAL_LINE_GUIDED",
"VIRTUAL_LINE_GUIDED",
"AUTONOMOUS"
]
}
}
}
},
"physicalParameters": {
"type": "object",
"required": [
"speedMin",
"speedMax",
"accelerationMax",
"decelerationMax",
"heightMax",
"width",
"length"
],
"description": "These parameters specify the basic physical properties of the AGV",
"properties": {
"speedMin": {
"type": "number",
"description": "minimal controlled continuous speed of the AGV",
"unit": "m/s"
},
"speedMax": {
"type": "number",
"description": "maximum speed of the AGV",
"unit": "m/s"
},
"accelerationMax": {
"type": "number",
"description": "maximum acceleration with maximum load",
"unit": "m/s^2"
},
"decelerationMax": {
"type": "number",
"description": "maximum deceleration with maximum load",
"unit": "m/s^2"
},
"heightMin": {
"type": "number",
"description": "minimum height of AGV",
"unit": "m"
},
"heightMax": {
"type": "number",
"description": "maximum height of AGV",
"unit": "m"
},
"width": {
"type": "number",
"description": "width of AGV",
"unit": "m"
},
"length": {
"type": "number",
"description": "length of AGV",
"unit": "m"
}
}
},
"protocolLimits": {
"type": "object",
"required": [
"maxStringLens",
"maxArrayLens",
"timing"
],
"description": "This JSON-object describes the protocol limitations of the AGV. If a parameter is not defined or set to zero then there is no explicit limit for this parameter.",
"properties": {
"maxStringLens": {
"type": "object",
"description": "maximum lengths of strings",
"properties": {
"msgLen": {
"type": "integer",
"description": "maximum MQTT Message length"
},
"topicSerialLen": {
"type": "integer",
"description": "maximum length of serial-number part in MQTT-topics. Affected Parameters: order.serialNumber, instantActions.serialNumber, state.SerialNumber, visualization.serialNumber, connection.serialNumber"
},
"topicElemLen": {
"type": "integer",
"description": "maximum length of all other parts in MQTT-topics. Affected parameters: order.timestamp, order.version, order.manufacturer, instantActions.timestamp, instantActions.version, instantActions.manufacturer, state.timestamp, state.version, state.manufacturer, visualization.timestamp, visualization.version, visualization.manufacturer, connection.timestamp, connection.version, connection.manufacturer"
},
"idLen": {
"type": "integer",
"description": "maximum length of ID-Strings. Affected parameters: order.orderId, order.zoneSetId, node.nodeId, nodePosition.mapId, action.actionId, edge.edgeId, edge.startNodeId, edge.endNodeId"
},
"idNumericalOnly": {
"type": "boolean",
"description": "If true ID-strings need to contain numerical values only"
},
"enumLen": {
"type": "integer",
"description": "maximum length of ENUM- and Key-Strings. Affected parameters: action.actionType, action.blockingType, edge.direction, actionParameter.key, state.operatingMode, load.loadPosition, load.loadType, actionState.actionStatus, error.errorType, error.errorLevel, errorReference.referenceKey, info.infoType, info.infoLevel, safetyState.eStop, connection.connectionState"
},
"loadIdLen": {
"type": "integer",
"description": "maximum length of loadId Strings"
}
}
},
"maxArrayLens": {
"type": "object",
"description": "maximum lengths of arrays",
"properties": {
"order.nodes": {
"type": "integer",
"description": "maximum number of nodes per order processable by the AGV"
},
"order.edges": {
"type": "integer",
"description": "maximum number of edges per order processable by the AGV"
},
"node.actions": {
"type": "integer",
"description": "maximum number of actions per node processable by the AGV"
},
"edge.actions": {
"type": "integer",
"description": "maximum number of actions per edge processable by the AGV"
},
"actions.actionsParameters": {
"type": "integer",
"description": "maximum number of parameters per action processable by the AGV"
},
"instantActions": {
"type": "integer",
"description": "maximum number of instant actions per message processable by the AGV"
},
"trajectory.knotVector": {
"type": "integer",
"description": "maximum number of knots per trajectory processable by the AGV"
},
"trajectory.controlPoints": {
"type": "integer",
"description": "maximum number of control points per trajectory processable by the AGV"
},
"state.nodeStates": {
"type": "integer",
"description": "maximum number of nodeStates sent by the AGV, maximum number of nodes in base of AGV"
},
"state.edgeStates": {
"type": "integer",
"description": "maximum number of edgeStates sent by the AGV, maximum number of edges in base of AGV"
},
"state.loads": {
"type": "integer",
"description": "maximum number of load-objects sent by the AGV"
},
"state.actionStates": {
"type": "integer",
"description": "maximum number of actionStates sent by the AGV"
},
"state.errors": {
"type": "integer",
"description": "maximum number of errors sent by the AGV in one state-message"
},
"state.information": {
"type": "integer",
"description": "maximum number of information objects sent by the AGV in one state-message"
},
"error.errorReferences": {
"type": "integer",
"description": "maximum number of error references sent by the AGV for each error"
},
"information.infoReferences": {
"type": "integer",
"description": "maximum number of info references sent by the AGV for each information"
}
}
},
"timing": {
"type": "object",
"required": [
"minOrderInterval",
"minStateInterval"
],
"description": "timing information",
"properties": {
"minOrderInterval": {
"type": "number",
"description": "minimum interval sending order messages to the AGV",
"unit": "s"
},
"minStateInterval": {
"type": "number",
"description": "minimum interval for sending state-messages",
"unit": "s"
},
"defaultStateInterval": {
"type": "number",
"description": "default interval for sending state-messages if not defined, the default value from the main document is used",
"unit": "s"
},
"visualizationInterval": {
"type": "number",
"description": "default interval for sending messages on visualization topic",
"unit": "s"
}
}
}
}
},
"protocolFeatures": {
"type": "object",
"required": [
"optionalParameters",
"agvActions"
],
"description": "Supported features of VDA5050 protocol",
"properties": {
"optionalParameters": {
"type": "array",
"description": "list of supported and/or required optional parameters. Optional parameters, that are not listed here, are assumed to be not supported by the AGV.",
"items": {
"type": "object",
"required": [
"parameter",
"support"
],
"properties": {
"parameter": {
"type": "string",
"description": "full name of optional parameter, e.g. “order.nodes.nodePosition.allowedDeviationTheta”"
},
"support": {
"type": "string",
"description": "type of support for the optional parameter, the following values are possible: SUPPORTED: optional parameter is supported like specified. REQUIRED: optional parameter is required for proper AGV-operation.",
"enum": [
"SUPPORTED",
"REQUIRED"
]
},
"description": {
"type": "string",
"description": "free text. Description of optional parameter. E.g. Reason, why the optional parameter direction is necessary for this AGV-type and which values it can contain. The parameter nodeMarker must contain unsigned interger-numbers only. Nurbs-Support is limited to straight lines and circle segments."
}
}
}
},
"agvActions": {
"type": "array",
"description": "list of all actions with parameters supported by this AGV. This includes standard actions specified in VDA5050 and manufacturer-specific actions",
"items": {
"required": [
"actionType",
"actionScopes"
],
"type": "object",
"properties": {
"actionType": {
"type": "string",
"description": "unique actionType corresponding to action.actionType"
},
"actionDescription": {
"type": "string",
"description": "free text: description of the action"
},
"actionScopes": {
"type": "array",
"description": "list of allowed scopes for using this action-type. INSTANT: usable as instantAction, NODE: usable on nodes, EDGE: usable on edges.",
"items": {
"type": "string",
"enum": [
"INSTANT",
"NODE",
"EDGE"
]
}
},
"actionParameters": {
"type": "array",
"description": "list of parameters. if not defined, the action has no parameters",
"items": {
"type": "object",
"required": [
"key",
"valueDataType"
],
"properties": {
"key": {
"type": "string",
"description": "key-String for Parameter"
},
"valueDataType": {
"type": "string",
"description": "data type of Value, possible data types are: BOOL, NUMBER, INTEGER, FLOAT, STRING, OBJECT, ARRAY",
"enum": [
"BOOL",
"NUMBER",
"INTEGER",
"FLOAT",
"STRING",
"OBJECT",
"ARRAY"
]
},
"description": {
"type": "string",
"description": "free text: description of the parameter"
},
"isOptional": {
"type": "boolean",
"description": "True: optional parameter"
}
}
}
},
"resultDescription": {
"type": "string",
"description": "free text: description of the resultDescription"
},
"blockingTypes": {
"type": "array",
"description": "Array of possible blocking types for defined action.",
"items": {
"type":"string",
"enum": [
"NONE",
"SOFT",
"HARD"
]
}
}
}
}
}
}
},
"agvGeometry": {
"type": "object",
"description": "Detailed definition of AGV geometry",
"properties": {
"wheelDefinitions": {
"type": "array",
"description": "list of wheels, containing wheel-arrangement and geometry",
"items": {
"type": "object",
"required": [
"type",
"isActiveDriven",
"isActiveSteered",
"position",
"diameter",
"width"
],
"properties": {
"type": {
"type": "string",
"description": "wheel type. DRIVE, CASTER, FIXED, MECANUM",
"enum": [
"DRIVE",
"CASTER",
"FIXED",
"MECANUM"
]
},
"isActiveDriven": {
"type": "boolean",
"description": "True: wheel is actively driven (de: angetrieben)"
},
"isActiveSteered": {
"type": "boolean",
"description": "True: wheel is actively steered (de: aktiv gelenkt)"
},
"position": {
"type": "object",
"required": [
"x",
"y"
],
"properties": {
"x": {
"type": "number",
"description": "[m] x-position in AGV-coordinate system",
"unit": "m"
},
"y": {
"type": "number",
"description": "y-position in AGV-coordinate system",
"unit": "m"
},
"theta": {
"type": "number",
"description": "orientation of wheel in AGV-coordinate system Necessary for fixed wheels",
"unit": "rad"
}
}
},
"diameter": {
"type": "number",
"description": "nominal diameter of wheel",
"unit": "m"
},
"width": {
"type": "number",
"description": "nominal width of wheel",
"unit": "m"
},
"centerDisplacement": {
"type": "number",
"unit": "m",
"description": "nominal displacement of the wheels center to the rotation point (necessary for caster wheels). If the parameter is not defined, it is assumed to be 0"
},
"constraints": {
"type": "string",
"description": "free text: can be used by the manufacturer to define constraints"
}
}
}
},
"envelopes2d": {
"type": "array",
"items": {
"type": "object",
"required": [
"set",
"polygonPoints"
],
"properties": {
"set": {
"type": "string",
"description": "name of the envelope curve set"
},
"polygonPoints": {
"type": "array",
"description": "envelope curve as a x/y-polygon polygon is assumed as closed and must be non-self-intersecting",
"items": {
"type": "object",
"required": [
"x",
"y"
],
"properties": {
"x": {
"type": "number",
"description": "x-position of polygon-point",
"unit": "m"
},
"y": {
"type": "number",
"description": " y-position of polygon-point",
"unit": "m"
}
}
}
},
"description": {
"type": "string",
"description": "free text: description of envelope curve set"
}
}
}
},
"envelopes3d": {
"type": "array",
"description": "list of AGV-envelope curves in 3D (german: „Hüllkurven“)",
"items": {
"type": "object",
"required": [
"set",
"format"
],
"properties": {
"set": {
"type": "string",
"description": "name of the envelope curve set"
},
"format": {
"type": "string",
"description": "format of data e.g. DXF"
},
"data": {
"type": "object",
"description": "3D-envelope curve data, format specified in format"
},
"url": {
"type": "string",
"description": "protocol and url-definition for downloading the 3D-envelope curve data e.g. ftp://xxx.yyy.com/ac4dgvhoif5tghji"
},
"description": {
"type": "string",
"description": "free text: description of envelope curve set"
}
}
}
}
}
},
"loadSpecification": {
"type": "object",
"description": "Abstract specification of load capabilities",
"properties": {
"loadPositions": {
"type": "array",
"description": "list of load positions / load handling devices. This lists contains the valid values for the parameter “state.loads[].loadPosition” and for the action parameter “lhd” of the actions pick and drop. If this list doesnt exist or is empty, the AGV has no load handling device.",
"items": {
"type": "string"
}
},
"loadSets": {
"type": "array",
"description": "list of load-sets that can be handled by the AGV",
"items": {
"type": "object",
"required": [
"setName",
"loadType"
],
"properties": {
"setName": {
"type": "string",
"description": "Unique name of the load set, e.g. DEFAULT, SET1, ..."
},
"loadType": {
"type": "string",
"description": "type of load e.g. EPAL, XLT1200, …."
},
"loadPositions": {
"type": "array",
"description": "list of load positions btw. load handling devices, this load-set is valid for. If this parameter does not exist or is empty, this load-set is valid for all load handling devices on this AGV.",
"items": {
"type": "string"
}
},
"boundingBoxReference": {
"type": "object",
"required": [
"x",
"y",
"z"
],
"description": "bounding box reference as defined in parameter loads[] in state-message",
"properties": {
"x": {
"type": "number",
"description": "x-coordinate of the point of reference."
},
"y": {
"type": "number",
"description": "y-coordinate of the point of reference."
},
"z": {
"type": "number",
"description": "z-coordinate of the point of reference."
},
"theta": {
"type": "number",
"description": "Orientation of the loads bounding box. Important for tugger trains, etc."
}
}
},
"loadDimensions": {
"type": "object",
"required": [
"length",
"width"
],
"properties": {
"length": {
"type": "number",
"description": "Absolute length of the load´s bounding box."
},
"width": {
"type": "number",
"description": "Absolute width of the load´s bounding bo"
},
"height": {
"type": "number",
"description": "Absolute height of the load´s bounding box. Optional: Set value only if known."
}
}
},
"maxWeight": {
"type": "number",
"description": "maximum weight of loadtype",
"unit": "kg"
},
"minLoadhandlingHeight": {
"type": "number",
"unit": "m",
"description": "minimum allowed height for handling of this load-type and weight. References to boundingBoxReference"
},
"maxLoadhandlingHeight": {
"type": "number",
"unit": "m",
"description": "maximum allowed height for handling of this load-type and weight. references to boundingBoxReference"
},
"minLoadhandlingDepth": {
"type": "number",
"unit": "m",
"description": "minimum allowed depth for this load-type and weight. references to boundingBoxReference"
},
"maxLoadhandlingDepth": {
"type": "number",
"unit": "m",
"description": "maximum allowed depth for this load-type and weight. references to boundingBoxReference"
},
"minLoadhandlingTilt": {
"type": "number",
"unit": "rad",
"description": "minimum allowed tilt for this load-type and weight"
},
"maxLoadhandlingTilt": {
"type": "number",
"unit": "rad",
"description": "maximum allowed tilt for this load-type and weight"
},
"agvSpeedLimit": {
"type": "number",
"unit": "m/s^2",
"description": "maximum allowed speed for this load-type and weight"
},
"agvAccelerationLimit": {
"type": "number",
"unit": "m/s^2",
"description": "maximum allowed acceleration for this load-type and weight"
},
"agvDecelerationLimit": {
"type": "number",
"unit": "m/s^2",
"description": "maximum allowed deceleration for this load-type and weight"
},
"pickTime": {
"type": "number",
"unit": "s",
"description": "approx. time for picking up the load"
},
"dropTime": {
"type": "number",
"unit": "s",
"description": "approx. time for dropping the load"
},
"description": {
"type": "string",
"description": "free text description of the load handling set"
}
}
}
}
}
},
"vehicleConfig": {
"type": "object",
"properties": {
"versions": {
"type": "array",
"description": "Array containing various hardware and software versions running on the vehicle.",
"items": {
"title": "version",
"type": "object",
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"description": "The key of the version.",
"examples": [
"softwareVersion",
"cameraVersion",
"plcSoftChecksum"
]
},
"value": {
"type": "string",
"description": "The value of the action parameter.",
"examples": [
"v1.03.2",
"0620NL51805A0",
"0x4297F30C"
]
}
}
}
},
"network": {
"type": "object",
"properties": {
"dnsServers": {
"type": "array",
"description": "List of DNS servers used by the vehicle.",
"items": {
"type": "string"
}
},
"localIpAddress": {
"type": "string",
"description": "A priori assigned IP address of the vehicle used to communicate with the MQTT broker. Note that this IP address should not be modified/changed during operations."
},
"ntpServers": {
"type": "array",
"description": "List of NTP servers used by the vehicle.",
"items": {
"type": "string"
}
},
"netmask": {
"type": "string",
"description": "Network subnet mask."
},
"defaultGateway": {
"type": "string",
"description": "Default gateway used by the vehicle."
}
}
}
}
}
}
}

View File

@@ -0,0 +1,126 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "instantActions",
"description": "JSON Schema for publishing instantActions that the AGV is to execute as soon as they arrive.",
"required": [
"headerId",
"timestamp",
"version",
"manufacturer",
"serialNumber",
"actions"
],
"subtopic": "/instantActions",
"type": "object",
"properties": {
"headerId": {
"title": "headerId",
"type": "integer",
"description": "headerId of the message. The headerId is defined per topic and incremented by 1 with each sent (but not necessarily received) message."
},
"timestamp": {
"title": "timestamp",
"type": "string",
"format": "date-time",
"description": "Timestamp in ISO8601 format (YYYY-MM-DDTHH:mm:ss.ffZ).",
"examples": [
"1991-03-11T11:40:03.12Z"
]
},
"version": {
"title": "Version",
"type": "string",
"description": "Version of the protocol [Major].[Minor].[Patch]",
"examples": [
"1.3.2"
]
},
"manufacturer": {
"type": "string",
"description": "Manufacturer of the AGV"
},
"serialNumber": {
"type": "string",
"description": "Serial number of the AGV."
},
"actions": {
"type": "array",
"items": {
"type": "object",
"description": "Describes an action that the AGV can perform.",
"required": [
"actionId",
"actionType",
"blockingType"
],
"properties": {
"actionType": {
"type": "string",
"description": "Name of action as described in the first column of \"Actions and Parameters\". Identifies the function of the action."
},
"actionId": {
"type": "string",
"description": "Unique ID to identify the action and map them to the actionState in the state. Suggestion: Use UUIDs."
},
"actionDescription": {
"type": "string",
"description": "Additional information on the action."
},
"blockingType": {
"type": "string",
"description": "Regulates if the action is allowed to be executed during movement and/or parallel to other actions.\nnone: action can happen in parallel with others, including movement.\nsoft: action can happen simultaneously with others, but not while moving.\nhard: no other actions can be performed while this action is running.",
"enum": [
"NONE",
"SOFT",
"HARD"
]
},
"actionParameters": {
"type": "array",
"description": "Array of actionParameter-objects for the indicated action e. g. deviceId, loadId, external Triggers.",
"items": {
"title": "actionParameter",
"type": "object",
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"description": "The key of the action parameter.",
"examples": [
"duration",
"direction",
"signal"
]
},
"value": {
"type": [
"array",
"boolean",
"number",
"string",
"object"
],
"description": "The value of the action parameter",
"examples": [
103.2,
"left",
true,
[
"arrays",
"are",
"also",
"valid"
]
]
}
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,394 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Order Message",
"description": "The message schema to communicate orders from master control to the AGV.",
"subtopic": "/order",
"type": "object",
"required": [
"headerId",
"timestamp",
"version",
"manufacturer",
"serialNumber",
"orderId",
"orderUpdateId",
"nodes",
"edges"
],
"properties": {
"headerId": {
"type": "integer",
"description": "headerId of the message. The headerId is defined per topic and incremented by 1 with each sent (but not necessarily received) message."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "Timestamp in ISO8601 format (YYYY-MM-DDTHH:mm:ss.ffZ).",
"examples": [
"1991-03-11T11:40:03.12Z"
]
},
"version": {
"type": "string",
"description": "Version of the protocol [Major].[Minor].[Patch]",
"examples": [
"1.3.2"
]
},
"manufacturer": {
"type": "string",
"description": "Manufacturer of the AGV"
},
"serialNumber": {
"type": "string",
"description": "Serial number of the AGV."
},
"orderId": {
"description": "Order Identification. This is to be used to identify multiple order messages that belong to the same order.",
"type": "string"
},
"orderUpdateId": {
"description": "orderUpdate identification. Is unique per orderId. If an order update is rejected, this field is to be passed in the rejection message.",
"type": "integer",
"minimum": 0
},
"zoneSetId": {
"description": "Unique identifier of the zone set that the AGV has to use for navigation or that was used by MC for planning.\nOptional: Some MC systems do not use zones. Some AGVs do not understand zones. Do not add to message if no zones are used.",
"type": "string"
},
"nodes": {
"description": "Array of nodes objects to be traversed for fulfilling the order. One node is enough for a valid order. Leave edge list empty for that case.",
"type": "array",
"items": {
"type": "object",
"title": "node",
"required": [
"nodeId",
"sequenceId",
"released",
"actions"
],
"properties": {
"nodeId": {
"type": "string",
"description": "Unique node identification",
"examples": [
"pumpenhaus_1",
"MONTAGE"
]
},
"sequenceId": {
"type": "integer",
"minimum": 0,
"description": "Number to track the sequence of nodes and edges in an order and to simplify order updates.\nThe main purpose is to distinguish between a node which is passed more than once within one orderId. The variable sequenceId runs across all nodes and edges of the same order and is reset when a new orderId is issued."
},
"nodeDescription": {
"type": "string",
"description": "Additional information on the node."
},
"released": {
"type": "boolean",
"description": "True indicates that the node is part of the base. False indicates that the node is part of the horizon."
},
"nodePosition": {
"description": "Defines the position on a map in world coordinates. Each floor has its own map. All maps must use the same project specific global origin. \nOptional for vehicle-types that do not require the node position (e.g., line-guided vehicles).",
"type": "object",
"required": [
"x",
"y",
"mapId"
],
"properties": {
"x": {
"type": "number",
"description": "X-position on the map in reference to the map coordinate system. Precision is up to the specific implementation."
},
"y": {
"type": "number",
"description":"Y-position on the map in reference to the map coordinate system. Precision is up to the specific implementation."
},
"theta": {
"type": "number",
"description": "Absolute orientation of the AGV on the node. \nOptional: vehicle can plan the path by itself.\nIf defined, the AGV has to assume the theta angle on this node. If previous edge disallows rotation, the AGV must rotate on the node. If following edge has a differing orientation defined but disallows rotation, the AGV is to rotate on the node to the edges desired rotation before entering the edge.",
"minimum": -3.14159265359,
"maximum": 3.14159265359
},
"allowedDeviationXY": {
"type": "number",
"description": "Indicates how exact an AGV has to drive over a node in order for it to count as traversed.\nIf = 0: no deviation is allowed (no deviation means within the normal tolerance of the AGV manufacturer).\nIf > 0: allowed deviation-radius in meters. If the AGV passes a node within the deviation-radius, the node is considered to have been traversed.",
"minimum": 0
},
"allowedDeviationTheta": {
"type": "number",
"minimum": 0.0,
"maximum": 3.141592654,
"description": "Indicates how big the deviation of theta angle can be. \nThe lowest acceptable angle is theta - allowedDeviationTheta and the highest acceptable angle is theta + allowedDeviationTheta."
},
"mapId": {
"description": "Unique identification of the map in which the position is referenced.\nEach map has the same origin of coordinates. When an AGV uses an elevator, e.g., leading from a departure floor to a target floor, it will disappear off the map of the departure floor and spawn in the related lift node on the map of the target floor.",
"type": "string"
},
"mapDescription": {
"description": "Additional information on the map.",
"type": "string"
}
}
},
"actions": {
"description": "Array of actions to be executed on a node. Empty array, if no actions required.",
"type": "array",
"items": {
"$ref": "#/definitions/action"
}
}
}
}
},
"edges": {
"type": "array",
"description": "Directional connection between two nodes. Array of edge objects to be traversed for fulfilling the order. One node is enough for a valid order. Leave edge list empty for that case.",
"items": {
"type": "object",
"title": "edge",
"required": [
"edgeId",
"sequenceId",
"released",
"startNodeId",
"endNodeId",
"actions"
],
"properties": {
"edgeId": {
"type": "string",
"description": "Unique edge identification"
},
"sequenceId": {
"type": "integer",
"minimum": 0,
"description": "Number to track the sequence of nodes and edges in an order and to simplify order updates. The variable sequenceId runs across all nodes and edges of the same order and is reset when a new orderId is issued."
},
"edgeDescription": {
"type": "string",
"description": "Additional information on the edge."
},
"released": {
"type": "boolean",
"description": "True indicates that the edge is part of the base. False indicates that the edge is part of the horizon."
},
"startNodeId": {
"type": "string",
"description": "The nodeId of the start node."
},
"endNodeId": {
"type": "string",
"description": "The nodeId of the end node."
},
"maxSpeed": {
"type": "number",
"description": "Permitted maximum speed on the edge in m/s. Speed is defined by the fastest measurement of the vehicle."
},
"maxHeight": {
"type": "number",
"description": "Permitted maximum height of the vehicle, including the load, on edge in meters."
},
"minHeight": {
"type": "number",
"description": "Permitted minimal height of the load handling device on the edge in meters"
},
"orientation": {
"type": "number",
"description": "Orientation of the AGV on the edge. The value orientationType defines if it has to be interpreted relative to the global project specific map coordinate system or tangential to the edge. In case of interpreted tangential to the edge 0.0 = forwards and PI = backwards. Example: orientation Pi/2 rad will lead to a rotation of 90 degrees. \nIf AGV starts in different orientation, rotate the vehicle on the edge to the desired orientation if rotationAllowed is set to True. If rotationAllowed is False, rotate before entering the edge. If that is not possible, reject the order. \nIf no trajectory is defined, apply the rotation to the direct path between the two connecting nodes of the edge. If a trajectory is defined for the edge, apply the orientation to the trajectory.",
"minimum": -3.14159265359,
"maximum": 3.14159265359
},
"orientationType":{
"type": "string",
"description": "Enum {GLOBAL, TANGENTIAL}: \n\"GLOBAL\"- relative to the global project specific map coordinate system; \n\"TANGENTIAL\"- tangential to the edge. \nIf not defined, the default value is \"TANGENTIAL\"."
},
"direction": {
"type": "string",
"description": "Sets direction at junctions for line-guided or wire-guided vehicles, to be defined initially (vehicle-individual)."
},
"rotationAllowed": {
"type": "boolean",
"description": "True: rotation is allowed on the edge. False: rotation is not allowed on the edge. \nOptional: No limit, if not set."
},
"maxRotationSpeed": {
"type": "number",
"description": "Maximum rotation speed in rad/s. \nOptional: No limit, if not set."
},
"length": {
"type": "number",
"description": "Distance of the path from startNode to endNode in meters. \nOptional: This value is used by line-guided AGVs to decrease their speed before reaching a stop position."
},
"trajectory": {
"type": "object",
"description": "Trajectory JSON-object for this edge as a NURBS. Defines the curve, on which the AGV should move between startNode and endNode. \nOptional: Can be omitted, if AGV cannot process trajectories or if AGV plans its own trajectory.",
"required": [
"degree",
"knotVector",
"controlPoints"
],
"properties": {
"degree": {
"type": "integer",
"description": "Defines the number of control points that influence any given point on the curve. Increasing the degree increases continuity. If not defined, the default value is 1.",
"minimum": 1
},
"knotVector": {
"type": "array",
"description": "Sequence of parameter values that determines where and how the control points affect the NURBS curve. knotVector has size of number of control points + degree + 1.",
"items": {
"type": "number",
"maximum": 1,
"minimum": 0
}
},
"controlPoints": {
"type": "array",
"description": "List of JSON controlPoint objects defining the control points of the NURBS, which includes the beginning and end point.",
"items": {
"type": "object",
"title": "controlPoint",
"properties": {
"x": {
"type": "number",
"description": "X coordinate described in the world coordinate system."
},
"y": {
"type": "number",
"description": "Y coordinate described in the world coordinate system."
},
"weight": {
"type": "number",
"minimum": 0,
"description": "The weight, with which this control point pulls on the curve. When not defined, the default will be 1.0."
}
},
"required": [
"x",
"y"
]
}
}
}
},
"corridor": {
"description": "Definition of boundaries in which a vehicle can deviate from its trajectory, e. g. to avoid obstacles.",
"type": "object",
"required": [
"leftWidth",
"rightWidth"
],
"properties": {
"leftWidth": {
"type": "number",
"description": "Defines the width of the corridor in meters to the left related to the trajectory of the vehicle.",
"minimum": 0.0
},
"rightWidth": {
"type": "number",
"description":"Defines the width of the corridor in meters to the right related to the trajectory of the vehicle.",
"minimum": 0.0
},
"corridorRefPoint": {
"type": "string",
"description": "Defines whether the boundaries are valid for the kinematic center or the contour of the vehicle.",
"enum": [
"KINEMATICCENTER",
"CONTOUR"
]
}
}
},
"actions": {
"description": "Array of action objects with detailed information.",
"type": "array",
"items": {
"$ref": "#/definitions/action"
}
}
}
}
}
},
"definitions": {
"action": {
"type": "object",
"description": "Describes an action that the AGV can perform.",
"required": [
"actionId",
"actionType",
"blockingType"
],
"properties": {
"actionType": {
"type": "string",
"description": "Name of action as described in the first column of \"Actions and Parameters\". Identifies the function of the action."
},
"actionId": {
"type": "string",
"description": "Unique ID to identify the action and map them to the actionState in the state. Suggestion: Use UUIDs."
},
"actionDescription": {
"type": "string",
"description": "Additional information on the action."
},
"blockingType": {
"type": "string",
"description": "Regulates if the action is allowed to be executed during movement and/or parallel to other actions.\nnone: action can happen in parallel with others, including movement.\nsoft: action can happen simultaneously with others, but not while moving.\nhard: no other actions can be performed while this action is running.",
"enum": [
"NONE",
"SOFT",
"HARD"
]
},
"actionParameters": {
"type": "array",
"description": "Array of actionParameter-objects for the indicated action e. g. deviceId, loadId, external Triggers.",
"items": {
"title": "actionParameter",
"type": "object",
"required": [
"key",
"value"
],
"properties": {
"key": {
"type": "string",
"description": "The key of the action parameter.",
"examples": [
"duration",
"direction",
"signal"
]
},
"value": {
"type": [
"array",
"boolean",
"number",
"string",
"object"
],
"description": "The value of the action parameter",
"examples": [
103.2,
"left",
true,
[
"arrays",
"are",
"also",
"valid"
]
]
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,615 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "state",
"description": "all encompassing state of the AGV.",
"subtopic": "/state",
"type": "object",
"required": [
"headerId",
"timestamp",
"version",
"manufacturer",
"serialNumber",
"orderId",
"orderUpdateId",
"lastNodeId",
"lastNodeSequenceId",
"nodeStates",
"edgeStates",
"driving",
"actionStates",
"batteryState",
"operatingMode",
"errors",
"safetyState"
],
"properties": {
"headerId": {
"type": "integer",
"description": "headerId of the message. The headerId is defined per topic and incremented by 1 with each sent (but not necessarily received) message."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "Timestamp in ISO8601 format (YYYY-MM-DDTHH:mm:ss.ffZ).",
"examples": [
"1991-03-11T11:40:03.12Z"
]
},
"version": {
"type": "string",
"description": "Version of the protocol [Major].[Minor].[Patch]",
"examples": [
"1.3.2"
]
},
"manufacturer": {
"type": "string",
"description": "Manufacturer of the AGV"
},
"serialNumber": {
"type": "string",
"description": "Serial number of the AGV."
},
"maps":{
"type": "array",
"description": "Array of map-objects that are currently stored on the vehicle.",
"items": {
"type": "object",
"title": "map",
"required": [
"mapId",
"mapVersion",
"mapStatus"
],
"properties": {
"mapId": {
"type": "string",
"description": "ID of the map describing a defined area of the vehicle's workspace."
},
"mapVersion": {
"type": "string",
"description": "Version of the map."
},
"mapDescription": {
"type": "string",
"description": "Additional information on the map."
},
"mapStatus": {
"type": "string",
"description": "Information on the status of the map indicating, if a map version is currently used on the vehicle. ENABLED: Indicates this map is currently active / used on the AGV. At most one map with the same mapId can have its status set to ENABLED.<br>DISABLED: Indicates this map version is currently not enabled on the AGV and thus could be enabled or deleted by request.",
"enum": [
"ENABLED",
"DISABLED"
]
}
}
}
},
"orderId": {
"type": "string",
"description": "Unique order identification of the current order or the previous finished order. The orderId is kept until a new order is received. Empty string (\"\") if no previous orderId is available. "
},
"orderUpdateId": {
"type": "integer",
"description": "Order Update Identification to identify that an order update has been accepted by the AGV. \"0\" if no previous orderUpdateId is available."
},
"zoneSetId": {
"type": "string",
"description": "Unique ID of the zone set that the AGV currently uses for path planning. Must be the same as the one used in the order, otherwise the AGV is to reject the order.\nOptional: If the AGV does not use zones, this field can be omitted."
},
"lastNodeId": {
"type": "string",
"description": "nodeID of last reached node or, if AGV is currently on a node, current node (e.g., \"node7\"). Empty string (\"\") if no lastNodeId is available."
},
"lastNodeSequenceId": {
"type": "integer",
"description": "sequenceId of the last reached node or, if the AGV is currently on a node, sequenceId of current node. \"0\" if no lastNodeSequenceId is available. "
},
"driving": {
"type": "boolean",
"description": "True: indicates that the AGV is driving and/or rotating. Other movements of the AGV (e.g., lift movements) are not included here.\nFalse: indicates that the AGV is neither driving nor rotating "
},
"paused": {
"type": "boolean",
"description": "True: AGV is currently in a paused state, either because of the push of a physical button on the AGV or because of an instantAction. The AGV can resume the order.\nFalse: The AGV is currently not in a paused state."
},
"newBaseRequest": {
"type": "boolean",
"description": "True: AGV is almost at the end of the base and will reduce speed if no new base is transmitted. Trigger for master control to send new base\nFalse: no base update required."
},
"distanceSinceLastNode": {
"type": "number",
"description": "Used by line guided vehicles to indicate the distance it has been driving past the \"lastNodeId\".\nDistance is in meters."
},
"operatingMode": {
"type": "string",
"description": "Current operating mode of the AGV.",
"enum": [
"AUTOMATIC",
"SEMIAUTOMATIC",
"MANUAL",
"SERVICE",
"TEACHIN"
]
},
"nodeStates": {
"type": "array",
"description": "Array of nodeState-Objects, that need to be traversed for fulfilling the order. Empty list if idle.",
"items": {
"type": "object",
"title": "nodeState",
"required": [
"nodeId",
"sequenceId",
"released"
],
"properties": {
"nodeId": {
"type": "string",
"description": "Unique node identification"
},
"sequenceId": {
"type": "integer",
"description": "sequenceId to discern multiple nodes with same nodeId."
},
"nodeDescription": {
"type": "string",
"description": "Additional information on the node."
},
"released": {
"type": "boolean",
"description": "True: indicates that the node is part of the base. False: indicates that the node is part of the horizon."
},
"nodePosition": {
"type": "object",
"required": [
"x",
"y",
"mapId"
],
"description": "Node position. The object is defined in chapter 5.4 Topic: Order (from master control to AGV).\nOptional:Master control has this information. Can be sent additionally, e.g., for debugging purposes. ",
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"theta": {
"type": "number"
},
"mapId": {
"type": "string"
}
}
}
}
}
},
"edgeStates": {
"type": "array",
"description": "Array of edgeState-Objects, that need to be traversed for fulfilling the order, empty list if idle.",
"items": {
"type": "object",
"required": [
"edgeId",
"sequenceId",
"released"
],
"properties": {
"edgeId": {
"type": "string",
"description": "Unique edge identification"
},
"sequenceId": {
"type": "integer",
"description": "sequenceId of the edge."
},
"edgeDescription": {
"type": "string",
"description": "Additional information on the edge."
},
"released": {
"type": "boolean",
"description": "True indicates that the edge is part of the base. False indicates that the edge is part of the horizon."
},
"trajectory": {
"type": "object",
"description": "The trajectory is to be communicated as a NURBS and is defined in chapter 6.7 Implementation of the Order message.\nTrajectory segments reach from the point, where the AGV starts to enter the edge to the point where it reports that the next node was traversed. ",
"required": [
"degree",
"knotVector",
"controlPoints"
],
"properties": {
"degree": {
"type": "integer",
"description": "Defines the number of control points that influence any given point on the curve. Increasing the degree increases continuity. If not defined, the default value is 1."
},
"knotVector": {
"type": "array",
"description": "Sequence of parameter values that determine where and how the control points affect the NURBS curve. knotVector has size of number of control points + degree + 1",
"items": {
"type": "number",
"maximum": 1.0,
"minimum": 0.0
}
},
"controlPoints": {
"type": "array",
"description": "List of JSON controlPoint objects defining the control points of the NURBS, which includes the beginning and end point.",
"items": {
"type": "object",
"required": [
"x",
"y"
],
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"weight": {
"type": "number",
"description": "The weight, with which this control point pulls on the curve.\nWhen not defined, the default will be 1.0."
}
}
}
}
}
}
}
}
},
"agvPosition": {
"type": "object",
"required": [
"x",
"y",
"theta",
"mapId",
"positionInitialized"
],
"description": "Defines the position on a map in world coordinates. Each floor has its own map.",
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"theta": {
"type": "number"
},
"mapId": {
"type": "string"
},
"mapDescription": {
"type": "string"
},
"positionInitialized": {
"type": "boolean",
"description": "True: position is initialized. False: position is not initizalized."
},
"localizationScore": {
"type": "number",
"description": "Describes the quality of the localization and therefore, can be used, e.g., by SLAM-AGV to describe how accurate the current position information is.\n0.0: position unknown\n1.0: position known\nOptional for vehicles that cannot estimate their localization score.\nOnly for logging and visualization purposes",
"minimum": 0.0,
"maximum": 1.0
},
"deviationRange": {
"type": "number",
"description": "Value for position deviation range in meters. Optional for vehicles that cannot estimate their deviation, e.g., grid-based localization. Only for logging and visualization purposes."
}
}
},
"velocity": {
"type": "object",
"description": "The AGVs velocity in vehicle coordinates",
"properties": {
"vx": {
"type": "number",
"description":"The AVGs velocity in its x direction"
},
"vy": {
"type": "number",
"description":"The AVGs velocity in its y direction"
},
"omega": {
"type": "number",
"description":"The AVGs turning speed around its z axis."
}
}
},
"loads": {
"type": "array",
"description": "Loads, that are currently handled by the AGV. Optional: If AGV cannot determine load state, leave the array out of the state. If the AGV can determine the load state, but the array is empty, the AGV is considered unloaded.",
"items": {
"type": "object",
"required": [],
"description": "Load object that describes the load if the AGV has information about it.",
"title": "load",
"properties": {
"loadId": {
"type": "string",
"description": "Unique identification number of the load (e.g., barcode or RFID). Empty field, if the AGV can identify the load, but did not identify the load yet. Optional, if the AGV cannot identify the load."
},
"loadType": {
"type": "string",
"description":"Type of load."
},
"loadPosition": {
"type": "string",
"description": "Indicates, which load handling/carrying unit of the AGV is used, e.g., in case the AGV has multiple spots/positions to carry loads. Optional for vehicles with only one loadPosition.",
"examples":[
"front", "back", "positionC1"
]
},
"boundingBoxReference": {
"type": "object",
"required": [
"x",
"y",
"z"
],
"description": "Point of reference for the location of the bounding box. The point of reference is always the center of the bounding box bottom surface (at height = 0) and is described in coordinates of the AGV coordinate system.",
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"z": {
"type": "number"
},
"theta": {
"type": "number",
"description":"Orientation of the loads bounding box. Important for tugger, trains, etc."
}
}
},
"loadDimensions": {
"type": "object",
"required": [
"length",
"width"
],
"description": "Dimensions of the loads bounding box in meters.",
"properties": {
"length": {
"type": "number",
"description": "Absolute length of the loads bounding box in meter."
},
"width": {
"type": "number",
"description": "Absolute width of the loads bounding box in meter."
},
"height": {
"type": "number",
"description": "Absolute height of the loads bounding box in meter.\nOptional:\nSet value only if known."
}
}
},
"weight": {
"type": "number",
"description": "Absolute weight of the load measured in kg.",
"minimum":0.0
}
}
}
},
"actionStates": {
"type": "array",
"description": "Contains a list of the current actions and the actions which are yet to be finished. This may include actions from previous nodes that are still in progress\nWhen an action is completed, an updated state message is published with actionStatus set to finished and if applicable with the corresponding resultDescription. The actionStates are kept until a new order is received.",
"items": {
"type": "object",
"required": [
"actionId",
"actionStatus"
],
"title": "actionState",
"properties": {
"actionId": {
"type": "string",
"description": "Unique actionId",
"examples": [
"blink_123jdaimoim234"
]
},
"actionType": {
"type": "string",
"description": "actionType of the action.\nOptional: Only for informational or visualization purposes. Order knows the type."
},
"actionDescription": {
"type": "string",
"description": "Additional information on the current action."
},
"actionStatus": {
"type": "string",
"description": "WAITING: waiting for the trigger (passing the mode, entering the edge) PAUSED: paused by instantAction or external trigger FAILED: action could not be performed.",
"enum": [
"WAITING",
"INITIALIZING",
"RUNNING",
"PAUSED",
"FINISHED",
"FAILED"
]
},
"resultDescription": {
"type": "string",
"description": "Description of the result, e.g., the result of a RFID-read. Errors will be transmitted in errors."
}
}
}
},
"batteryState": {
"type": "object",
"required": [
"batteryCharge",
"charging"
],
"description": "Contains all battery-related information.",
"properties": {
"batteryCharge": {
"type": "number",
"description": "State of Charge in %:\nIf AGV only provides values for good or bad battery levels, these will be indicated as 20% (bad) and 80% (good)."
},
"batteryVoltage": {
"type": "number",
"description": "Battery voltage"
},
"batteryHealth": {
"type": "number",
"description": "State of health in percent.",
"minimum":0,
"maximum":100
},
"charging": {
"type": "boolean",
"description": "True: charging in progress. False: AGV is currently not charging."
},
"reach": {
"type": "number",
"description": "Estimated reach with current State of Charge in meter.",
"minimum": 0
}
}
},
"errors": {
"type": "array",
"description": "Array of error-objects. All active errors of the AGV should be in the list. An empty array indicates that the AGV has no active errors.",
"items": {
"type": "object",
"required": [
"errorType",
"errorLevel"
],
"title": "Error",
"properties": {
"errorType": {
"type": "string",
"description": "Type/name of error."
},
"errorReferences": {
"type": "array",
"items": {
"type": "object",
"title": "errorReference",
"description": "Array of references (e.g. nodeId, edgeId, orderId, actionId, etc.) to provide more information related to the error.",
"properties": {
"referenceKey": {
"type": "string",
"description":"Specifies the type of reference used (e.g. nodeId, edgeId, orderId, actionId, etc.)."
},
"referenceValue": {
"type": "string",
"description":"The value that belongs to the reference key. For example, the id of the node where the error occurred."
}
},
"required": [
"referenceKey",
"referenceValue"
]
}
},
"errorDescription": {
"type": "string",
"description": "Verbose description providing details and possible causes of the error."
},
"errorHint": {
"type": "string",
"description": "Hint on how to approach or solve the reported error."
},
"errorLevel": {
"type": "string",
"description": "WARNING: AGV is ready to start (e.g., maintenance cycle expiration warning). FATAL: AGV is not in running condition, user intervention required (e.g., laser scanner is contaminated).",
"enum": [
"WARNING",
"FATAL"
]
}
}
}
},
"information": {
"type": "array",
"description": "Array of info-objects. An empty array indicates, that the AGV has no information. This should only be used for visualization or debugging it must not be used for logic in master control.",
"items": {
"type": "object",
"required": [
"infoType",
"infoLevel"
],
"properties": {
"infoType": {
"type": "string",
"description": "Type/name of information."
},
"infoReferences": {
"type": "array",
"items": {
"type": "object",
"required": [
"referenceKey",
"referenceValue"
],
"title": "infoReference",
"description": "Array of references.",
"properties": {
"referenceKey": {
"type": "string",
"description":"References the type of reference (e.g., headerId, orderId, actionId, etc.)."
},
"referenceValue": {
"type": "string",
"description":"References the value, which belongs to the reference key."
}
}
}
},
"infoDescription": {
"type": "string",
"description": "Info of description."
},
"infoLevel": {
"type": "string",
"description": "DEBUG: used for debugging. INFO: used for visualization.",
"enum": [
"INFO",
"DEBUG"
]
}
}
}
},
"safetyState": {
"type": "object",
"required": [
"eStop",
"fieldViolation"
],
"description": "Contains all safety-related information.",
"properties": {
"eStop": {
"type": "string",
"description": "Acknowledge-Type of eStop: AUTOACK: auto-acknowledgeable e-stop is activated, e.g., by bumper or protective field. MANUAL: e-stop hast to be acknowledged manually at the vehicle. REMOTE: facility e-stop has to be acknowledged remotely. NONE: no e-stop activated.",
"enum": [
"AUTOACK",
"MANUAL",
"REMOTE",
"NONE"
]
},
"fieldViolation": {
"type": "boolean",
"description": "Protective field violation. True: field is violated. False: field is not violated."
}
}
}
}
}

View File

@@ -0,0 +1,95 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "visualization",
"description": "AGV position and/or velocity for visualization purposes. Can be published at a higher rate if wanted. Since bandwidth may be expensive depening on the update rate for this topic, all fields are optional.",
"subtopic": "/visualization",
"type": "object",
"properties": {
"headerId": {
"type": "integer",
"description": "headerId of the message. The headerId is defined per topic and incremented by 1 with each sent (but not necessarily received) message."
},
"timestamp": {
"type": "string",
"format": "date-time",
"description": "Timestamp in ISO8601 format (YYYY-MM-DDTHH:mm:ss.ffZ).",
"examples": [
"1991-03-11T11:40:03.12Z"
]
},
"version": {
"type": "string",
"description": "Version of the protocol [Major].[Minor].[Patch]",
"examples": [
"1.3.2"
]
},
"manufacturer": {
"type": "string",
"description": "Manufacturer of the AGV"
},
"serialNumber": {
"type": "string",
"description": "Serial number of the AGV."
},
"agvPosition": {
"type": "object",
"title": "agvPosition",
"description": "The AGVs position",
"required": [
"x",
"y",
"theta",
"mapId",
"positionInitialized"
],
"properties": {
"x": {
"type": "number"
},
"y": {
"type": "number"
},
"theta": {
"type": "number"
},
"mapId": {
"type": "string"
},
"mapDescription": {
"type": "string"
},
"positionInitialized": {
"type": "boolean",
"description": "True if the AGVs position is initialized, false, if position is not initizalized."
},
"localizationScore": {
"type": "number",
"description": "Localization score for SLAM based vehicles, if the AGV can communicate it.",
"minimum": 0.0,
"maximum": 1.0
},
"deviationRange": {
"type": "number",
"description": "Value for position deviation range in meters. Can be used if the AGV is able to derive it."
}
}
},
"velocity": {
"type": "object",
"title": "velocity",
"description": "The AGVs velocity in vehicle coordinates",
"properties": {
"vx": {
"type": "number"
},
"vy": {
"type": "number"
},
"omega": {
"type": "number"
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
using RobotNet.VDA5050.Type;
namespace RobotNet10.FleetManager.Events.Events;
public class ConnectionStateChangedEvent : EventArgs
{
public string RobotId { get; set; } = string.Empty;
public ConnectionState ConnectionState { get; set; }
}

View File

@@ -0,0 +1,9 @@
using RobotNet.VDA5050.Factsheet;
namespace RobotNet10.FleetManager.Events.Events;
public class FactsheetMessageReceivedEvent : EventArgs
{
public string RobotId { get; set; } = string.Empty;
public FactSheetMsg FactsheetMessage { get; set; } = new();
}

View File

@@ -0,0 +1,23 @@
namespace RobotNet10.FleetManager.Events.Events;
/// <summary>
/// Event raised when a Robot's ModelId is changed
/// </summary>
public class RobotModelIdChangedEvent : EventArgs
{
/// <summary>
/// Robot ID whose model was changed
/// </summary>
public string RobotId { get; set; } = string.Empty;
/// <summary>
/// Previous ModelId
/// </summary>
public Guid? PreviousModelId { get; set; }
/// <summary>
/// New ModelId
/// </summary>
public Guid? NewModelId { get; set; }
}

View File

@@ -0,0 +1,18 @@
namespace RobotNet10.FleetManager.Events.Events;
/// <summary>
/// Event raised when a RobotModel is updated
/// </summary>
public class RobotModelUpdatedEvent : EventArgs
{
/// <summary>
/// RobotModel ID that was updated
/// </summary>
public Guid ModelId { get; set; }
/// <summary>
/// List of robot IDs that use this model (for cache invalidation)
/// </summary>
public List<string> AffectedRobotIds { get; set; } = new();
}

View File

@@ -0,0 +1,9 @@
using RobotNet.VDA5050.State;
namespace RobotNet10.FleetManager.Events.Events;
public class StateMessageReceivedEvent : EventArgs
{
public string RobotId { get; set; } = string.Empty;
public StateMsg StateMessage { get; set; } = new();
}

View File

@@ -0,0 +1,9 @@
using RobotNet.VDA5050.Visualization;
namespace RobotNet10.FleetManager.Events.Events;
public class VisualizationMessageReceivedEvent : EventArgs
{
public string RobotId { get; set; } = string.Empty;
public VisualizationMsg VisualizationMessage { get; set; } = new();
}

View File

@@ -0,0 +1,33 @@
using RobotNet.VDA5050.Factsheet;
using RobotNet.VDA5050.State;
using RobotNet.VDA5050.Type;
using RobotNet.VDA5050.Visualization;
using RobotNet10.FleetManager.Events.Events;
namespace RobotNet10.FleetManager.Events;
/// <summary>
/// In-memory event bus for VDA5050 robot messages and domain events
/// </summary>
public interface IRobotEventBus
{
// VDA5050 Events
event EventHandler<StateMessageReceivedEvent>? StateMessageReceived;
event EventHandler<ConnectionStateChangedEvent>? ConnectionStateChanged;
event EventHandler<VisualizationMessageReceivedEvent>? VisualizationMessageReceived;
event EventHandler<FactsheetMessageReceivedEvent>? FactsheetMessageReceived;
// Domain Events
event EventHandler<RobotModelUpdatedEvent>? RobotModelUpdated;
event EventHandler<RobotModelIdChangedEvent>? RobotModelIdChanged;
// VDA5050 Event Publishers
void PublishStateMessageReceived(string robotId, StateMsg stateMsg);
void PublishConnectionStateChanged(string robotId, ConnectionState connectionState);
void PublishVisualizationMessageReceived(string robotId, VisualizationMsg visualizationMsg);
void PublishFactsheetMessageReceived(string robotId, FactSheetMsg factsheetMsg);
// Domain Event Publishers
void PublishRobotModelUpdated(RobotModelUpdatedEvent e);
void PublishRobotModelIdChanged(RobotModelIdChangedEvent e);
}

View File

@@ -0,0 +1,52 @@
using RobotNet.VDA5050.Factsheet;
using RobotNet.VDA5050.State;
using RobotNet.VDA5050.Type;
using RobotNet.VDA5050.Visualization;
using RobotNet10.FleetManager.Events.Events;
namespace RobotNet10.FleetManager.Events;
/// <summary>
/// In-memory event bus implementation for VDA5050 robot messages
/// </summary>
public class RobotEventBus : IRobotEventBus
{
public event EventHandler<StateMessageReceivedEvent>? StateMessageReceived;
public event EventHandler<ConnectionStateChangedEvent>? ConnectionStateChanged;
public event EventHandler<VisualizationMessageReceivedEvent>? VisualizationMessageReceived;
public event EventHandler<FactsheetMessageReceivedEvent>? FactsheetMessageReceived;
public void PublishStateMessageReceived(string robotId, StateMsg stateMsg)
{
StateMessageReceived?.Invoke(this, new StateMessageReceivedEvent { RobotId = robotId, StateMessage = stateMsg });
}
public void PublishConnectionStateChanged(string robotId, ConnectionState connectionState)
{
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEvent { RobotId = robotId, ConnectionState = connectionState });
}
public void PublishVisualizationMessageReceived(string robotId, VisualizationMsg visualizationMsg)
{
VisualizationMessageReceived?.Invoke(this, new VisualizationMessageReceivedEvent { RobotId = robotId, VisualizationMessage = visualizationMsg });
}
public void PublishFactsheetMessageReceived(string robotId, FactSheetMsg factsheetMsg)
{
FactsheetMessageReceived?.Invoke(this, new FactsheetMessageReceivedEvent { RobotId = robotId, FactsheetMessage = factsheetMsg });
}
// Domain Events
public event EventHandler<RobotModelUpdatedEvent>? RobotModelUpdated;
public event EventHandler<RobotModelIdChangedEvent>? RobotModelIdChanged;
public void PublishRobotModelUpdated(RobotModelUpdatedEvent e)
{
RobotModelUpdated?.Invoke(this, e);
}
public void PublishRobotModelIdChanged(RobotModelIdChangedEvent e)
{
RobotModelIdChanged?.Invoke(this, e);
}
}

View File

@@ -0,0 +1,89 @@
using Microsoft.AspNetCore.SignalR;
namespace RobotNet10.FleetManager.Hubs;
/// <summary>
/// SignalR Hub for real-time robot state and visualization updates.
/// Manages client subscriptions to robot groups for receiving VDA5050 messages.
/// </summary>
/// <remarks>
/// Clients subscribe to specific robots using SubscribeToRobot method.
/// Messages are broadcast to groups named "robot:{robotId}".
/// </remarks>
public class RobotStateHub(
Services.Logger<RobotStateHub> logger,
RobotStateHubContext hubContext) : Hub
{
private readonly Services.Logger<RobotStateHub> _logger = logger;
private readonly RobotStateHubContext _hubContext = hubContext;
/// <summary>
/// Subscribe to receive updates for a specific robot
/// </summary>
/// <param name="robotId">Robot identifier (serialNumber)</param>
public async Task SubscribeToRobot(string robotId)
{
var groupName = GetGroupName(robotId);
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
}
/// <summary>
/// Unsubscribe from updates for a specific robot
/// </summary>
/// <param name="robotId">Robot identifier (serialNumber)</param>
public async Task UnsubscribeFromRobot(string robotId)
{
var groupName = GetGroupName(robotId);
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
}
private static string GetGroupName(string robotId)
{
return $"robot:{robotId}";
}
/// <summary>
/// Subscribe to receive monitor updates for a specific levelId
/// Each connection can only subscribe to one levelId at a time.
/// Maximum 5 connections per levelId (FIFO).
/// </summary>
/// <param name="levelId">Level identifier (LayoutLevel.Id)</param>
public async Task SubscribeToLevelForMonitor(Guid levelId)
{
var evictedConnectionId = _hubContext.SubscribeToLevel(Context.ConnectionId, levelId);
// If a connection was evicted, notify it
if (evictedConnectionId != null)
{
_logger.Info($"Connection {evictedConnectionId} evicted from level {levelId} (max 5 connections reached)");
await Clients.Client(evictedConnectionId).SendAsync("OnMonitorDeactivated");
}
await Task.CompletedTask;
}
/// <summary>
/// Unsubscribe from monitor updates for the current level
/// </summary>
public async Task UnsubscribeFromLevelForMonitor()
{
var removed = _hubContext.UnsubscribeFromLevel(Context.ConnectionId);
if (removed)
{
_logger.Info($"Client {Context.ConnectionId} unsubscribed from level for monitor");
}
await Task.CompletedTask;
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
// Remove connection from subscription manager
_hubContext.RemoveConnection(Context.ConnectionId);
if (exception != null)
{
_logger.Warning($"Client {Context.ConnectionId} disconnected with error: {exception.Message}");
}
await base.OnDisconnectedAsync(exception);
}
}

View File

@@ -0,0 +1,680 @@
using Microsoft.AspNetCore.SignalR;
using RobotNet.VDA5050.Order;
using RobotNet.VDA5050.State;
using RobotNet.VDA5050.Visualization;
using RobotNet10.Common;
using RobotNet10.Common.Models;
using RobotNet10.FleetManager.Services;
using RobotNet10.FleetManager.Services.RobotManager;
using RobotNet10.FleetManager.Services.TrafficControl;
using RobotNet10.FleetManager.Services.TrafficControl.Models;
using RobotNet10.FleetManager.Shared.DTOs.Robot;
using System.Collections.Concurrent;
namespace RobotNet10.FleetManager.Hubs;
/// <summary>
/// Hosted service for broadcasting robot state and visualization updates via SignalR.
/// Broadcasts updates at 1Hz (every 1 second) to all subscribed clients.
/// Also manages LevelId subscriptions for Monitor page.
/// </summary>
/// <remarks>
/// This service runs as a BackgroundService and periodically broadcasts
/// StateMsg and VisualizationMsg updates from RobotManagerService to subscribed clients.
/// </remarks>
public class RobotStateHubContext(
IHubContext<RobotStateHub> hubContext,
IRobotManagerService robotManagerService,
IServiceScopeFactory serviceScopeFactory,
Services.Logger<RobotStateHubContext> logger,
IOrderControlService orderControlService,
ILoggerFactory loggerFactory) : BackgroundService
{
private readonly IHubContext<RobotStateHub> _hubContext = hubContext;
private readonly IRobotManagerService _robotManagerService = robotManagerService;
private readonly IServiceScopeFactory _serviceScopeFactory = serviceScopeFactory;
private readonly Services.Logger<RobotStateHubContext> _logger = logger;
private readonly ILoggerFactory _loggerFactory = loggerFactory;
private readonly IOrderControlService _orderControlService = orderControlService;
private WatchTimerAsync<RobotStateHubContext>? _broadcastTimer;
private const int BroadcastIntervalMs = 500; // 1Hz = 1000ms
// ===== MONITOR LEVEL SUBSCRIPTION MANAGEMENT =====
private readonly ConcurrentDictionary<Guid, Queue<string>> _levelSubscriptions = new();
private readonly ConcurrentDictionary<string, Guid> _connectionToLevel = new();
private readonly Lock _subscriptionLock = new();
private const int MaxConnectionsPerLevel = 5;
/// <summary>
/// Subscribe a connection to a levelId for monitor updates.
/// If connection already subscribed to another level, unsubscribe from old level first.
/// If level has 5 connections, the oldest connection will be evicted.
/// </summary>
/// <returns>ConnectionId that was evicted (if any), null otherwise</returns>
public string? SubscribeToLevel(string connectionId, Guid levelId)
{
lock (_subscriptionLock)
{
// If connection already subscribed to another level, unsubscribe first
if (_connectionToLevel.TryGetValue(connectionId, out var oldLevelId))
{
if (oldLevelId == levelId)
{
// Already subscribed to this level, no change needed
return null;
}
UnsubscribeFromLevelInternal(connectionId, oldLevelId);
}
// Get or create queue for this level
var queue = _levelSubscriptions.GetOrAdd(levelId, _ => new Queue<string>());
// If queue is full, evict oldest connection
string? evictedConnectionId = null;
if (queue.Count >= MaxConnectionsPerLevel)
{
evictedConnectionId = queue.Dequeue();
_connectionToLevel.TryRemove(evictedConnectionId, out _);
}
// Add new connection to queue
queue.Enqueue(connectionId);
_connectionToLevel[connectionId] = levelId;
return evictedConnectionId;
}
}
/// <summary>
/// Unsubscribe a connection from its current level
/// </summary>
public bool UnsubscribeFromLevel(string connectionId)
{
lock (_subscriptionLock)
{
if (!_connectionToLevel.TryGetValue(connectionId, out var levelId))
{
return false; // Not subscribed
}
return UnsubscribeFromLevelInternal(connectionId, levelId);
}
}
private bool UnsubscribeFromLevelInternal(string connectionId, Guid levelId)
{
if (!_levelSubscriptions.TryGetValue(levelId, out var queue))
{
return false;
}
// Remove connection from queue
var tempQueue = new Queue<string>();
bool found = false;
while (queue.Count > 0)
{
var id = queue.Dequeue();
if (id != connectionId)
{
tempQueue.Enqueue(id);
}
else
{
found = true;
}
}
// Replace queue
while (tempQueue.Count > 0)
{
queue.Enqueue(tempQueue.Dequeue());
}
// Remove connection mapping
_connectionToLevel.TryRemove(connectionId, out _);
// If queue is empty, remove level entry
if (queue.Count == 0)
{
_levelSubscriptions.TryRemove(levelId, out _);
}
return found;
}
/// <summary>
/// Get all connectionIds subscribed to a levelId
/// </summary>
public IReadOnlyList<string> GetConnectionsForLevel(Guid levelId)
{
lock (_subscriptionLock)
{
if (!_levelSubscriptions.TryGetValue(levelId, out var queue))
{
return [];
}
return [.. queue];
}
}
/// <summary>
/// Get levelId that a connection is subscribed to
/// </summary>
public Guid? GetLevelForConnection(string connectionId)
{
_connectionToLevel.TryGetValue(connectionId, out var levelId);
return levelId;
}
/// <summary>
/// Remove connection (called on disconnect)
/// </summary>
public void RemoveConnection(string connectionId)
{
UnsubscribeFromLevel(connectionId);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
// Start broadcast timer at 1Hz
_broadcastTimer = new WatchTimerAsync<RobotStateHubContext>(
BroadcastIntervalMs,
BroadcastAllRobots,
_loggerFactory.CreateLogger<RobotStateHubContext>()
);
_broadcastTimer.Start();
_logger.Info("Started robot state broadcast service at 1Hz");
}
private async Task BroadcastAllRobots()
{
try
{
// Get all robot data from RobotManagerService
var allRobotData = _robotManagerService.GetAllRobotData();
foreach (var (robotId, robotData) in allRobotData)
{
// Broadcast State if available
if (robotData.State != null)
{
await BroadcastStateUpdate(robotId, robotData.State);
}
// Broadcast Visualization if available
if (robotData.Visualization != null && robotData.State != null)
{
await BroadcastMonitorDataUpdate(robotId, robotData.Visualization, robotData.State);
}
}
}
catch (Exception ex)
{
_logger.Error($"Error in broadcast cycle: {ex.Message}");
}
}
/// <summary>
/// Broadcast state update to all clients subscribed to the robot
/// </summary>
/// <param name="robotId">Robot identifier (serialNumber)</param>
/// <param name="state">VDA5050 State message</param>
private async Task BroadcastStateUpdate(string robotId, StateMsg state)
{
try
{
var groupName = $"robot:{robotId}";
await _hubContext.Clients.Group(groupName).SendAsync("OnStateUpdate", state);
}
catch (Exception ex)
{
_logger.Error($"Error broadcasting state update for robot {robotId}: {ex.Message}");
}
}
/// <summary>
/// Broadcast visualization update to clients subscribed to the robot's levelId (for Monitor)
/// Only broadcasts to connections that have subscribed to the robot's levelId.
/// </summary>
/// <param name="robotId">Robot identifier (serialNumber)</param>
/// <param name="visualization">VDA5050 Visualization message</param>
/// <param name="state">VDA5050 State message</param>
private async Task BroadcastMonitorDataUpdate(string robotId, VisualizationMsg? visualization, StateMsg? state)
{
try
{
if (visualization is null || state is null) return;
// Get robot's MapId (LevelId) from database
Guid? levelId = await GetRobotLevelIdAsync(robotId);
if (!levelId.HasValue)
{
// Robot has no MapId assigned, skip broadcast
return;
}
// Get all connections subscribed to this levelId
var connectionIds = GetConnectionsForLevel(levelId.Value);
if (connectionIds.Count == 0)
{
// No connections subscribed to this levelId, skip broadcast
return;
}
// Get robot path
var robotPath = GetRobotPath(robotId, visualization.AgvPosition.X, visualization.AgvPosition.Y, state.NodeStates, state.LastNodeId);
// Prepare broadcast data
RobotMonitorBoardcastData data = new()
{
RobotId = robotId,
AgvPosition = visualization.AgvPosition,
AgvVelocity = visualization.Velocity,
Battery = state.BatteryState,
Errors = state.Errors,
Infomations = state.Information,
Loads = state.Loads,
Path = robotPath,
};
// Broadcast to specific connections (not groups)
await _hubContext.Clients.Clients(connectionIds).SendAsync("OnMonitorUpdate", data);
}
catch (Exception ex)
{
_logger.Error($"Error broadcasting visualization update for robot {robotId}: {ex.Message}");
}
}
/// <summary>
/// Get robot's LevelId (MapId) from database
/// </summary>
private async Task<Guid?> GetRobotLevelIdAsync(string robotId)
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
var robot = await robotService.GetByRobotIdAsync(robotId);
return robot?.MapId; // MapId is the LevelId
}
catch (Exception ex)
{
_logger.Error($"Error getting LevelId for robot {robotId}: {ex.Message}");
return null;
}
}
/// <summary>
/// Split edge at robot's current position, returning remaining edge segment(s)
/// </summary>
private static NavigationPathEdge[] SplitChecking(double robotX, double robotY, Node lastNode, Node nearLastNode, Edge edge)
{
List<NavigationPathEdge> pathEdges = [];
var spaceEdge = new SpaceEdge
{
StartX = lastNode.NodePosition?.X ?? 0,
StartY = lastNode.NodePosition?.Y ?? 0,
EndX = nearLastNode.NodePosition?.X ?? 0,
EndY = nearLastNode.NodePosition?.Y ?? 0,
Degree = edge.Trajectory?.Degree ?? 1,
ControlPoint1X = edge.Trajectory?.ControlPoints.Length > 1 ? edge.Trajectory.ControlPoints[1].X : 0,
ControlPoint1Y = edge.Trajectory?.ControlPoints.Length > 1 ? edge.Trajectory.ControlPoints[1].Y : 0,
ControlPoint2X = edge.Trajectory?.ControlPoints.Length > 2 ? edge.Trajectory.ControlPoints[2].X : 0,
ControlPoint2Y = edge.Trajectory?.ControlPoints.Length > 2 ? edge.Trajectory.ControlPoints[2].Y : 0,
};
// Get projection point on edge and the time parameter
var (projX, projY, _, projTime) = SpaceCompute.GetProjectionOnEdge(robotX, robotY, spaceEdge);
// Clamp projTime to [0, 1]
projTime = Math.Max(0, Math.Min(1, projTime));
// If edge is degree 1, create a single linear edge
if (spaceEdge.Degree == 1)
{
var linearEdge = new NavigationPathEdge
{
StartX = projX,
StartY = projY,
EndX = nearLastNode.NodePosition?.X ?? 0,
EndY = nearLastNode.NodePosition?.Y ?? 0,
Degree = 1
};
pathEdges.Add(linearEdge);
}
else
{
// For degree 2 or 3: sample points along the curve from projection point to end
// Resolution: 0.2m per segment
const double resolution = 0.2;
// Sample points along the curve
List<(double x, double y)> samplePoints = [];
samplePoints.Add((projX, projY)); // Start from projection point
double currentTime = projTime;
var prevPoint = new SpaceNode(projX, projY);
while (currentTime < 1.0)
{
// Find next sample point at resolution distance along the curve
double nextTime = FindNextSampleTime(spaceEdge, currentTime, resolution);
nextTime = Math.Min(1.0, nextTime);
var nextPoint = SpaceCompute.BezierPoint(nextTime, spaceEdge);
// Check if we've moved enough distance
double segmentLength = Math.Sqrt(Math.Pow(nextPoint.X - prevPoint.X, 2) +
Math.Pow(nextPoint.Y - prevPoint.Y, 2));
if (segmentLength >= resolution * 0.5 || nextTime >= 1.0)
{
samplePoints.Add((nextPoint.X, nextPoint.Y));
prevPoint = nextPoint;
currentTime = nextTime;
}
else
{
// If segment is too short, advance time slightly and try again
currentTime = Math.Min(1.0, currentTime + 0.01);
}
// Safety check to avoid infinite loop
if (nextTime >= 1.0 || currentTime >= 1.0)
{
break;
}
}
// Ensure the last point is the end node
if (samplePoints.Count == 0 ||
Math.Abs(samplePoints[^1].x - nearLastNode.NodePosition?.X ?? 0) > 1e-6 ||
Math.Abs(samplePoints[^1].y - nearLastNode.NodePosition?.Y ?? 0) > 1e-6)
{
samplePoints.Add((nearLastNode.NodePosition?.X ?? 0, nearLastNode.NodePosition?.Y ?? 0));
}
// Create linear edges between consecutive sample points
for (int i = 0; i < samplePoints.Count - 1; i++)
{
var linearEdge = new NavigationPathEdge
{
StartX = samplePoints[i].x,
StartY = samplePoints[i].y,
EndX = samplePoints[i + 1].x,
EndY = samplePoints[i + 1].y,
Degree = 1
};
pathEdges.Add(linearEdge);
}
}
return [.. pathEdges];
}
/// <summary>
/// Find next sample time at approximately resolution distance from current time along the curve
/// </summary>
private static double FindNextSampleTime(SpaceEdge edge, double currentTime, double resolution)
{
// Use binary search to find the time where distance from current point is approximately resolution
double low = currentTime;
double high = 1.0;
double targetDistance = resolution;
double tolerance = 0.01; // 1cm tolerance
var startPoint = SpaceCompute.BezierPoint(currentTime, edge);
// Binary search
for (int iter = 0; iter < 20; iter++)
{
double mid = (low + high) / 2;
var midPoint = SpaceCompute.BezierPoint(mid, edge);
double distance = Math.Sqrt(Math.Pow(midPoint.X - startPoint.X, 2) +
Math.Pow(midPoint.Y - startPoint.Y, 2));
if (Math.Abs(distance - targetDistance) < tolerance)
{
return mid;
}
if (distance < targetDistance)
{
low = mid;
}
else
{
high = mid;
}
}
return (low + high) / 2;
}
/// <summary>
/// Get robot navigation path (Base and Full) from current position
/// </summary>
private NavigationPath GetRobotPath(string robotId, double robotX, double robotY, NodeState[] nodestates, string lastNodeId)
{
try
{
var robotRoute = _orderControlService.GetRobotRoute(robotId);
if (robotRoute is null || robotRoute.FullRoute.Count == 0)
{
return new NavigationPath { NavigationState = "NoRoute" };
}
if (nodestates.Length == 0 || string.IsNullOrEmpty(lastNodeId))
{
return new NavigationPath { NavigationState = "NoRoute" };
}
var fullRoute = robotRoute.FullRoute;
// Find last node segment index
var lastNodeIndex = fullRoute.FindIndex(r => r.VdaNode != null && r.VdaNode.NodeId == lastNodeId);
if (lastNodeIndex == -1)
{
// Last node not found, return full route from start
return ConvertRouteToNavigationPath(robotRoute, 0, false, robotX, robotY);
}
// Check if robot is on an edge (between lastNodeIndex and next node)
bool isOnEdge = false;
int startIndex = lastNodeIndex;
// Check if there's an edge segment after last node
if (lastNodeIndex + 1 < fullRoute.Count && fullRoute[lastNodeIndex + 1].IsEdge)
{
var edgeSegment = fullRoute[lastNodeIndex + 1];
var lastNode = fullRoute[lastNodeIndex].VdaNode!;
// Find next node segment
var nextNodeIndex = lastNodeIndex + 2;
if (nextNodeIndex < fullRoute.Count && fullRoute[nextNodeIndex].IsNode)
{
var nextNode = fullRoute[nextNodeIndex].VdaNode!;
// Check if robot is between last node and next node (on the edge)
// Simple check: if robot is closer to edge than to either node
var distToLastNode = Math.Sqrt(Math.Pow(robotX - lastNode.NodePosition?.X ?? 0, 2) + Math.Pow(robotY - lastNode.NodePosition?.Y ?? 0, 2));
var distToNextNode = Math.Sqrt(Math.Pow(robotX - nextNode.NodePosition?.X ?? 0, 2) + Math.Pow(robotY - nextNode.NodePosition?.Y ?? 0, 2));
// Get edge length
var edgeLength = Math.Sqrt(Math.Pow(nextNode.NodePosition?.X ?? 0 - lastNode.NodePosition?.X ?? 0, 2) +
Math.Pow(nextNode.NodePosition?.Y ?? 0 - lastNode.NodePosition?.Y ?? 0, 2));
// If robot is closer to edge than to nodes, and not too far from edge
if (edgeLength > 0.1 && distToLastNode > 0.1 && distToNextNode > 0.1)
{
isOnEdge = true;
startIndex = lastNodeIndex + 1; // Start from edge segment
}
}
}
// Convert route to navigation path starting from startIndex
return ConvertRouteToNavigationPath(robotRoute, startIndex, isOnEdge, robotX, robotY, lastNodeIndex);
}
catch (Exception ex)
{
_logger.Error($"Error getting robot path for {robotId}: {ex.Message}");
return new NavigationPath { NavigationState = "Error" };
}
}
/// <summary>
/// Convert RobotRoute segments to NavigationPath (Base and Full)
/// </summary>
private static NavigationPath ConvertRouteToNavigationPath(
RobotRoute robotRoute,
int startIndex,
bool isOnEdge,
double robotX,
double robotY,
int? lastNodeIndex = null)
{
var fullPath = new List<NavigationPathEdge>();
var basePath = new List<NavigationPathEdge>();
var fullRoute = robotRoute.FullRoute;
if (startIndex >= fullRoute.Count)
{
return new NavigationPath { NavigationState = "Complete" };
}
// If robot is on edge, split the edge
if (isOnEdge && startIndex < fullRoute.Count && fullRoute[startIndex].IsEdge)
{
var edgeSegment = fullRoute[startIndex];
if (lastNodeIndex.HasValue && lastNodeIndex.Value >= 0 && lastNodeIndex.Value < fullRoute.Count)
{
var lastNode = fullRoute[lastNodeIndex.Value].VdaNode;
var nextNodeIndex = startIndex + 1;
if (nextNodeIndex < fullRoute.Count && fullRoute[nextNodeIndex].IsNode && lastNode != null)
{
var nextNode = fullRoute[nextNodeIndex].VdaNode;
if (nextNode != null && edgeSegment.VdaEdge != null)
{
// Split edge at robot position
var splitEdges = SplitChecking(robotX, robotY, lastNode, nextNode, edgeSegment.VdaEdge);
fullPath.AddRange(splitEdges);
// Add to base path if segment is released
if (edgeSegment.Released)
{
basePath.AddRange(splitEdges);
}
startIndex = nextNodeIndex + 1; // Move to segment after next node (skip the edge and next node)
}
}
}
}
else if (!isOnEdge && startIndex < fullRoute.Count && fullRoute[startIndex].IsNode)
{
// Robot is at a node, skip the node segment and start from next edge
startIndex += 2;
}
// Convert remaining segments
for (int i = startIndex; i < fullRoute.Count; i++)
{
var segment = fullRoute[i];
if (segment.IsEdge && segment.VdaEdge != null)
{
// Find start and end nodes for this edge
Node? startNode = null;
Node? endNode = null;
// Start node: previous segment should be a node
if (i > 0 && fullRoute[i - 1].IsNode)
{
startNode = fullRoute[i - 1].VdaNode;
}
// End node: next segment should be a node
if (i + 1 < fullRoute.Count && fullRoute[i + 1].IsNode)
{
endNode = fullRoute[i + 1].VdaNode;
}
if (startNode != null && endNode != null)
{
var edge = ConvertEdgeSegmentToNavigationPathEdge(segment, startNode, endNode);
if (edge != null)
{
fullPath.Add(edge);
if (segment.Released)
{
basePath.Add(edge);
}
}
}
}
// Note: Node segments don't create NavigationPathEdge, only edges do
}
return new NavigationPath
{
NavigationState = robotRoute.Base.Count > 0 ? "Active" : "Planning",
RobotPath = [.. fullPath],
RobotBasePath = [.. basePath]
};
}
/// <summary>
/// Convert RouteSegment (Edge) to NavigationPathEdge
/// </summary>
private static NavigationPathEdge? ConvertEdgeSegmentToNavigationPathEdge(RouteSegment segment, Node? startNode, Node? endNode)
{
if (!segment.IsEdge || segment.VdaEdge == null) return null;
var edge = segment.VdaEdge;
var trajectory = edge.Trajectory;
// Get start and end positions from node positions (not from trajectory control points)
if (startNode == null || endNode == null) return null;
var navEdge = new NavigationPathEdge
{
StartX = startNode.NodePosition?.X ?? 0,
StartY = startNode.NodePosition?.Y ?? 0,
EndX = endNode.NodePosition?.X ?? 0,
EndY = endNode.NodePosition?.Y ?? 0,
Degree = trajectory?.Degree ?? 1,
};
// Set control points from trajectory (if available)
if (trajectory != null && trajectory.ControlPoints.Length > 1)
{
// ControlPoints[1] is first control point, ControlPoints[2] is second control point (for cubic bezier)
if (trajectory.ControlPoints.Length > 1)
{
navEdge.ControlPoint1X = trajectory.ControlPoints[1].X;
navEdge.ControlPoint1Y = trajectory.ControlPoints[1].Y;
}
if (trajectory.ControlPoints.Length > 2)
{
navEdge.ControlPoint2X = trajectory.ControlPoints[2].X;
navEdge.ControlPoint2Y = trajectory.ControlPoints[2].Y;
}
}
return navEdge;
}
public override Task StopAsync(CancellationToken cancellationToken)
{
_broadcastTimer?.Dispose();
_logger.Info("Stopped robot state broadcast service");
return base.StopAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,15 @@
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Script.Shared;
namespace RobotNet10.FleetManager.Models;
/// <summary>
/// FleetManager Script Globals - Provides APIs for scripts to interact with FleetManager
/// </summary>
public class FleetManagerScriptGlobals(IRobotManager robotManager, ILayoutManager layoutManager) : IFleetManagerScriptGlobals
{
public IRobotManager RobotManager { get; } = robotManager;
public ILayoutManager LayoutManager { get; } = layoutManager;
}

View File

@@ -0,0 +1,238 @@
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using MudBlazor.Services;
using NLog.Web;
using RobotNet10.CustomConfiguration.Extensions;
using RobotNet10.FleetManager.Client;
using RobotNet10.FleetManager.Components;
using RobotNet10.FleetManager.Components.Account;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Events;
using RobotNet10.FleetManager.Hubs;
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Services;
using RobotNet10.FleetManager.Services.ConfigManager;
using RobotNet10.FleetManager.Services.OpenACS;
using RobotNet10.FleetManager.Services.RobotConnections;
using RobotNet10.FleetManager.Services.RobotManager;
using RobotNet10.FleetManager.Services.Script;
using RobotNet10.FleetManager.Services.TrafficControl;
using RobotNet10.FleetManager.Services.TrafficControl.ACS;
using RobotNet10.FleetManager.Services.TrafficControl.Services;
using RobotNet10.GlobalPathPlanner;
using RobotNet10.MapManager.Extensions;
using RobotNet10.ScriptEngine;
using RobotNet10.ScriptEngine.Helpers;
using RobotNet10.StorageManager;
using System.Diagnostics;
using System.Globalization;
CultureInfo.DefaultThreadCurrentCulture = new CultureInfo("en-US");
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseNLog();
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents()
.AddAuthenticationStateSerialization();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddScoped<IdentityRedirectManager>();
builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>();
builder.Services.AddAuthentication()
.AddBearerToken(IdentityConstants.BearerScheme);
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found.");
builder.Services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.SignIn.RequireConfirmedAccount = true;
options.Lockout.AllowedForNewUsers = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequireLowercase = false;
options.Password.RequireDigit = false;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();
var mapConnectionString = builder.Configuration.GetConnectionString("MapEditorConnection") ?? throw new InvalidOperationException("Connection string 'MapEditorConnection' not found.");
builder.Services.AddMapManager(builder.Configuration, options => options.UseSqlServer(mapConnectionString, b => b.MigrationsAssembly("RobotNet10.FleetManager").UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)), "LayoutImage");
builder.Services.AddMudServices();
builder.Services.AddNavigationMenu(builder.Configuration["APP_VERSION"] ?? "dev");
builder.Services.AddControllers();
// Register VDA5050 Event Bus (must be registered before services that use it)
builder.Services.AddSingleton<IRobotEventBus, RobotEventBus>();
// Register StorageConfig for RobotModelImages
builder.Services.Configure<StorageConfig>("RobotModelImages", options =>
{
builder.Configuration.GetSection("RobotModelImage").Bind(options);
});
// Register Robot Management Services (with event bus for cache invalidation)
builder.Services.AddScoped<IRobotModelService, RobotModelService>();
builder.Services.AddScoped<IRobotService, RobotService>();
builder.Services.AddScoped<IRobotModelImageStorageService, RobotModelImageStorageService>();
builder.Services.AddScoped<IRobotModelMapService, RobotModelMapService>();
// Register SignalR Hub Context as BackgroundService (broadcasts at 1Hz)
builder.Services.AddSingleton<RobotStateHubContext>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<RobotStateHubContext>());
// Register Logger service (factory pattern)
builder.Services.AddSingleton(typeof(RobotNet10.FleetManager.Services.Logger<>));
// Register ConfigManager
builder.Services.AddSingleton<IConnectionConfig, ConnectionConfig>();
builder.Services.AddSingleton<ITrafficConfig, TrafficConfig>();
builder.Services.AddSingleton<IACSTrafficConfig, ACSTrafficConfig>();
// Register OpenACS Services
builder.Services.AddSingleton<TrafficACS>();
builder.Services.AddSingleton<OpenACSPublisher>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<OpenACSPublisher>());
// Register ACS Order Control
builder.Services.AddSingleton<IOrderControlService, OrderACSControl>();
// Note: IRobotEventBus is registered above with Robot Management Services
// Register VDA5050 RobotConnections Service
builder.Services.AddSingleton<IRobotConnectionsService, RobotConnectionsService>();
// Register VDA5050 RobotManager Service
builder.Services.AddSingleton<RobotManagerService>();
builder.Services.AddSingleton<IRobotManagerService>(sp => sp.GetRequiredService<RobotManagerService>());
builder.Services.AddHostedService(sp => sp.GetRequiredService<RobotManagerService>());
// Register Path Planner Factory
builder.Services.AddSingleton<IPathPlannerFactory, PathPlannerFactory>();
// Register TrafficControl Sub-Services (order matters due to dependencies)
// 1. RouteStorageService - No dependencies
builder.Services.AddSingleton<IRouteStorageService, RouteStorageService>();
// 2. PriorityService - Only needs Logger
builder.Services.AddSingleton<IPriorityService, PriorityService>();
// 3. RobotInfoService - Needs IServiceScopeFactory, IRobotManagerService, IRobotEventBus
builder.Services.AddSingleton<IRobotInfoService, RobotInfoService>();
// 4. EdgeReservationService - Needs INodeService, IEdgeService, IServiceScopeFactory, IRobotInfoService
builder.Services.AddSingleton<IEdgeReservationService, EdgeReservationService>();
// 5. OrderUpdateService - Needs IRobotManagerService
builder.Services.AddSingleton<IOrderUpdateService, OrderUpdateService>();
// 6. ConflictDetectionService - Needs many services
builder.Services.AddSingleton<IConflictDetectionService, ConflictDetectionService>();
// 7. ConflictResolutionService - Needs many services
builder.Services.AddSingleton<IConflictResolutionService, ConflictResolutionService>();
// 8. BaseHorizonManagementService - Needs many services
builder.Services.AddSingleton<IBaseHorizonManagementService, BaseHorizonManagementService>();
// 9. RoutePlanningService - Needs many services
builder.Services.AddSingleton<IRoutePlanningService, RoutePlanningService>();
// Register TrafficControl Service (Orchestrator) - Must be registered after all sub-services
builder.Services.AddSingleton<TrafficControlService>();
builder.Services.AddSingleton<ITrafficControlService , TrafficControlService>(sp => sp.GetRequiredService<TrafficControlService>());
//builder.Services.AddHostedService(sp => sp.GetRequiredService<TrafficControlService>());
// Add SignalR
builder.Services.AddSignalR();
// Add Custom Configuration
builder.Services.AddCustomConfiguration(builder.Configuration, "StorageConfig");
var scriptConnectionString = builder.Configuration.GetConnectionString("ScriptEngineConnection") ?? throw new InvalidOperationException("Connection string 'ScriptEngineConnection' not found.");
builder.Services.AddScriptEngine<ScriptEngineResource>(options => options.UseSqlServer(scriptConnectionString, b => b.MigrationsAssembly("RobotNet10.FleetManager")));
// Add Script Services
builder.Services.AddSingleton<IRobotManager, ScriptRobotmanager>();
builder.Services.AddSingleton<ILayoutManager, ScriptLayoutManager>();
#if DEBUG
var dllPath = builder.Configuration["ScriptEngine:RuntimeDllFolder"] ?? "dlls";
if (!Directory.Exists(dllPath))
{
Directory.CreateDirectory(dllPath);
}
var netCoreDir = Path.GetDirectoryName(typeof(object).Assembly.Location);
if (!string.IsNullOrEmpty(netCoreDir))
{
var coreSrc = Path.Combine(netCoreDir, "System.Private.CoreLib.dll");
var runtimeSrc = Path.Combine(netCoreDir, "System.Runtime.dll");
var linqSrc = Path.Combine(netCoreDir, "System.Linq.Expressions.dll");
var scriptSrc = typeof(RobotNet10.Script.ILogger).Assembly.Location;
var fleetSrc = typeof(RobotNet10.FleetManager.Script.IRobot).Assembly.Location;
var coreDest = Path.Combine(dllPath, "System.Private.CoreLib.dll");
var runtimeDest = Path.Combine(dllPath, "System.Runtime.dll");
var linqDest = Path.Combine(dllPath, "System.Linq.Expressions.dll");
var scriptDest = Path.Combine(dllPath, "RobotNet10.Script.dll");
var fleetDest = Path.Combine(dllPath, "RobotNet10.FleetManager.Script.dll");
if (!File.Exists(coreDest)) File.Copy(coreSrc, coreDest);
if (!File.Exists(runtimeDest)) File.Copy(runtimeSrc, runtimeDest);
if (!File.Exists(linqDest)) File.Copy(linqSrc, linqDest);
File.Copy(scriptSrc, scriptDest, true);
File.Copy(fleetSrc, fleetDest, true);
}
#endif
var app = builder.Build();
await app.Services.SeedApplicationDbAsync();
await app.Services.SeedScriptEngineDbAsync();
await app.Services.SeedMapManagerAsync();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
app.UseMigrationsEndPoint();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(RobotNet10.FleetManager.Client._Imports).Assembly);
// Map API controllers
app.MapControllers();
// Map SignalR Hubs
app.MapHub<RobotStateHub>("/hubs/robot-state");
// Add additional endpoints required by the Identity /Account Razor components.
app.MapAdditionalIdentityEndpoints();
app.MapScriptEngineHubs();
app.Run();

View File

@@ -0,0 +1,27 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"workingDirectory": "$(TargetDir)",
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://robotnet10_fleetmanager.dev.localhost:5240",
"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://robotnet10_fleetmanager.dev.localhost:7139;http://robotnet10_fleetmanager.dev.localhost:5240",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"dependencies": {
"mssql1": {
"type": "mssql",
"connectionId": "ConnectionStrings:DefaultConnection"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"dependencies": {
"mssql1": {
"type": "mssql.local",
"connectionId": "ConnectionStrings:DefaultConnection"
}
}
}

View File

@@ -0,0 +1,41 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>aspnet-RobotNet10_FleetManager-c155333e-f9e9-4e5d-b044-b289ee9762af</UserSecretsId>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<ProjectReference Include="..\..\Commons\RobotNet10.Common\RobotNet10.Common.csproj" />
<ProjectReference Include="..\..\Commons\RobotNet10.CustomConfiguration\RobotNet10.CustomConfiguration.csproj" />
<ProjectReference Include="..\..\Commons\RobotNet10.GlobalPathPlanner\RobotNet10.GlobalPathPlanner.csproj" />
<ProjectReference Include="..\..\Commons\RobotNet10.MapManager\RobotNet10.MapManager.csproj" />
<ProjectReference Include="..\..\Commons\RobotNet10.MqttConnection\RobotNet10.MqttConnection.csproj" />
<ProjectReference Include="..\..\Commons\RobotNet10.ScriptEngine\RobotNet10.ScriptEngine.csproj" />
<ProjectReference Include="..\..\Components\RobotNet10.Components\RobotNet10.Components.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet.VDA5050\RobotNet.VDA5050.csproj" />
<ProjectReference Include="..\..\Shared\RobotNet10.Shared\RobotNet10.Shared.csproj" />
<ProjectReference Include="..\RobotNet10.FleetManager.Client\RobotNet10.FleetManager.Client.csproj" />
<ProjectReference Include="..\RobotNet10.FleetManager.Shared\RobotNet10.FleetManager.Shared.csproj" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.3" />
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
</ItemGroup>
<ItemGroup>
<Folder Include="Data\Migrations\Appdb\" />
<Folder Include="Data\Migrations\MapDb\" />
<Folder Include="Data\Migrations\ScriptDb\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,386 @@
using RobotNet10.CustomConfiguration.Models;
using RobotNet10.CustomConfiguration.Services;
namespace RobotNet10.FleetManager.Services.ConfigManager;
/// <summary>
/// Service implementation for managing ACSTraffic configurations
/// </summary>
public class ACSTrafficConfig : IACSTrafficConfig
{
private readonly IConfigManager _configManager;
private readonly Logger<ACSTrafficConfig> _logger;
private readonly Lock _lockObject = new();
private bool _configLoaded = false;
/// <summary>
/// Event triggered when ACSTraffic configuration is changed/reloaded
/// </summary>
public event EventHandler? ConfigChanged;
private bool _trafficEnable = false;
private int _trafficInterval = 1000;
private string _trafficURL = string.Empty;
private Dictionary<string, string> _acsZoneMaping = [];
private Dictionary<string, string> _acsOutMaping = [];
private bool _publishEnable = false;
private string _publishURL = string.Empty;
private int _publishInterval = 1000;
private const string ACS_TRAFFIC_CONFIG_TYPE = "ACSTrafficConfig";
public ACSTrafficConfig(IConfigManager configManager, Logger<ACSTrafficConfig> logger)
{
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
_logger = logger;
// Subscribe to config changes
_configManager.ConfigChanged += OnConfigChanged;
}
public bool TrafficEnable
{
get
{
EnsureConfigLoaded();
return _trafficEnable;
}
}
public int TrafficInterval
{
get
{
EnsureConfigLoaded();
return _trafficInterval;
}
}
public string TrafficURL
{
get
{
EnsureConfigLoaded();
return _trafficURL;
}
}
public Dictionary<string, string> ACSZoneMaping
{
get
{
EnsureConfigLoaded();
return _acsZoneMaping;
}
}
public bool PublishEnable
{
get
{
EnsureConfigLoaded();
return _publishEnable;
}
}
public string PublishURL
{
get
{
EnsureConfigLoaded();
return _publishURL;
}
}
public int PublishInterval
{
get
{
EnsureConfigLoaded();
return _publishInterval;
}
}
public Dictionary<string, string> ACSOutMaping
{
get
{
EnsureConfigLoaded();
return _acsOutMaping;
}
}
private void EnsureConfigLoaded()
{
if (!_configLoaded)
{
lock (_lockObject)
{
if (!_configLoaded)
{
LoadACSTrafficConfigAsync().GetAwaiter().GetResult();
}
}
}
}
private async Task LoadACSTrafficConfigAsync()
{
try
{
var configFile = await _configManager.GetConfigByTypeAsync(ACS_TRAFFIC_CONFIG_TYPE);
if (configFile == null)
{
_logger.Warning("ACSTraffic configuration not found, using defaults");
_trafficEnable = false;
_trafficInterval = 1000;
_trafficURL = string.Empty;
_acsZoneMaping = [];
_acsOutMaping = [];
_publishEnable = false;
_publishURL = string.Empty;
_publishInterval = 1000;
}
else
{
MapConfigVariablesToProperties(configFile.Variables);
_logger.Info("ACSTraffic configuration loaded successfully");
}
}
catch (Exception ex)
{
_logger.Error($"Error loading ACSTraffic configuration, using defaults: {ex.Message}");
_trafficEnable = false;
_trafficInterval = 1000;
_trafficURL = string.Empty;
_acsZoneMaping = [];
_acsOutMaping = [];
_publishEnable = false;
_publishURL = string.Empty;
_publishInterval = 1000;
}
finally
{
_configLoaded = true;
// Trigger ConfigChanged event after config is loaded/reloaded
OnConfigChanged();
}
}
private void OnConfigChanged(object? sender, RobotNet10.CustomConfiguration.Events.ConfigChangedEventArgs e)
{
// Reload config if our config type changed
if (e.ConfigType == ACS_TRAFFIC_CONFIG_TYPE)
{
lock (_lockObject)
{
_configLoaded = false;
}
_logger.Info($"ACSTraffic configuration changed ({e.ConfigType}), will reload on next access");
}
}
/// <summary>
/// Trigger ConfigChanged event to notify subscribers that config has been reloaded
/// </summary>
private void OnConfigChanged()
{
ConfigChanged?.Invoke(this, EventArgs.Empty);
}
private void MapConfigVariablesToProperties(List<ConfigVariable> variables)
{
foreach (var variable in variables)
{
if (variable.Value == null)
continue;
try
{
switch (variable.Name)
{
case nameof(TrafficEnable):
_trafficEnable = (bool)ConvertValue(variable.Value, typeof(bool))!;
break;
case nameof(TrafficInterval):
_trafficInterval = (int)ConvertValue(variable.Value, typeof(int))!;
break;
case nameof(TrafficURL):
_trafficURL = (string)ConvertValue(variable.Value, typeof(string))!;
break;
case nameof(ACSZoneMaping):
_acsZoneMaping = (Dictionary<string, string>)ConvertValue(variable.Value, typeof(Dictionary<string, string>))!;
break;
case nameof(ACSOutMaping):
_acsOutMaping = (Dictionary<string, string>)ConvertValue(variable.Value, typeof(Dictionary<string, string>))!;
break;
case nameof(PublishEnable):
_publishEnable = (bool)ConvertValue(variable.Value, typeof(bool))!;
break;
case nameof(PublishURL):
_publishURL = (string)ConvertValue(variable.Value, typeof(string))!;
break;
case nameof(PublishInterval):
_publishInterval = (int)ConvertValue(variable.Value, typeof(int))!;
break;
default:
_logger.Warning($"Unknown variable name: {variable.Name}");
break;
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to set property {variable.Name} from variable: {ex.Message}");
}
}
}
private object? ConvertValue(object? value, Type targetType)
{
if (value == null)
return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
// If value is already of the correct type, return it
if (targetType.IsInstanceOfType(value))
return value;
try
{
// Handle nullable types
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
// Convert based on target type
if (underlyingType == typeof(string))
{
return value.ToString() ?? string.Empty;
}
else if (underlyingType == typeof(int))
{
if (value is int i) return i;
if (int.TryParse(value.ToString(), out var parsed)) return parsed;
if (value is double d) return (int)d;
throw new InvalidCastException($"Cannot convert {value.GetType()} to int");
}
else if (underlyingType == typeof(double))
{
if (value is double d) return d;
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
if (value is int i) return i;
throw new InvalidCastException($"Cannot convert {value.GetType()} to double");
}
else if (underlyingType == typeof(bool))
{
if (value is bool b) return b;
if (bool.TryParse(value.ToString(), out var parsed)) return parsed;
// Handle numeric values: 0/1, "0"/"1", etc.
if (value.ToString()?.Trim() == "0" || value.ToString()?.Trim().ToLower() == "false") return false;
if (value.ToString()?.Trim() == "1" || value.ToString()?.Trim().ToLower() == "true") return true;
throw new InvalidCastException($"Cannot convert {value.GetType()} to bool");
}
else if (underlyingType.IsEnum)
{
var enumValue = Enum.Parse(underlyingType, value.ToString() ?? "", true);
return enumValue;
}
else if (underlyingType.IsGenericType && underlyingType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
{
// Handle Dictionary types - try to parse from JSON string
return ConvertDictionary(value, underlyingType);
}
else
{
// Try standard conversion
return Convert.ChangeType(value, underlyingType);
}
}
catch (Exception ex)
{
_logger.Warning($"Error converting value {value} of type {value.GetType()} to {targetType}: {ex.Message}");
throw;
}
}
/// <summary>
/// Convert value to Dictionary type (supports Dictionary&lt;string, string&gt;)
/// </summary>
private object ConvertDictionary(object value, Type dictionaryType)
{
try
{
// Get key and value types
var genericArgs = dictionaryType.GetGenericArguments();
var keyType = genericArgs[0];
var valueType = genericArgs[1];
// If value is already the correct Dictionary type, return it
if (dictionaryType.IsInstanceOfType(value))
{
return value;
}
// If value is Dictionary<string, object>, try to convert
if (value is Dictionary<string, object> stringDict)
{
var result = Activator.CreateInstance(dictionaryType);
var addMethod = dictionaryType.GetMethod("Add");
if (addMethod != null && result != null)
{
foreach (var kvp in stringDict)
{
var key = ConvertValue(kvp.Key, keyType);
var val = ConvertValue(kvp.Value, valueType);
addMethod.Invoke(result, [key, val]);
}
return result;
}
}
// Try parse as JSON string
var jsonString = value.ToString();
if (!string.IsNullOrEmpty(jsonString))
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(jsonString);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
{
var result = Activator.CreateInstance(dictionaryType);
var addMethod = dictionaryType.GetMethod("Add");
if (addMethod != null && result != null)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
// Convert key
var key = ConvertValue(prop.Name, keyType);
// Convert value
var val = ConvertValue(prop.Value.GetString() ?? prop.Value.GetRawText(), valueType);
addMethod.Invoke(result, [key, val]);
}
return result;
}
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to parse Dictionary from JSON string: {ex.Message}");
}
}
// If all else fails, return default (empty dictionary)
_logger.Warning($"Cannot convert {value.GetType()} to {dictionaryType}, using default (empty dictionary)");
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
}
catch (Exception ex)
{
_logger.Warning($"Error converting Dictionary: {ex.Message}");
// Return default (empty dictionary)
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
}
}
}

View File

@@ -0,0 +1,232 @@
using System.Reflection;
using RobotNet10.CustomConfiguration.Events;
using RobotNet10.CustomConfiguration.Models;
using RobotNet10.CustomConfiguration.Services;
using RobotNet10.FleetManager.Services.RobotConnections.Models;
using RobotNet10.MqttConnection;
namespace RobotNet10.FleetManager.Services.ConfigManager;
/// <summary>
/// Service implementation for managing application configurations
/// </summary>
public class ConnectionConfig : IConnectionConfig
{
private readonly IConfigManager _configManager;
private readonly Logger<ConnectionConfig> _logger;
private readonly Lock _lockObject = new();
private VDA5050ProtocolConfig? _vda5050Config;
private MQTTConfig? _mqttConfig;
private bool _vda5050ConfigLoaded = false;
private bool _mqttConfigLoaded = false;
private const string VDA5050_PROTOCOL_CONFIG_TYPE = "VDA5050ProtocolConfig";
private const string MQTT_CONFIG_TYPE = "MQTTConfig";
public ConnectionConfig(IConfigManager configManager, Logger<ConnectionConfig> logger)
{
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
_logger = logger;
// Subscribe to config changes
_configManager.ConfigChanged += OnConfigChanged;
}
public VDA5050ProtocolConfig GetVDA5050Config()
{
if (!_vda5050ConfigLoaded)
{
lock (_lockObject)
{
if (!_vda5050ConfigLoaded)
{
LoadVDA5050ConfigAsync().GetAwaiter().GetResult();
}
}
}
return _vda5050Config ?? throw new InvalidOperationException("VDA5050 configuration not loaded");
}
public MQTTConfig GetMqttConfig()
{
if (!_mqttConfigLoaded)
{
lock (_lockObject)
{
if (!_mqttConfigLoaded)
{
LoadMqttConfigAsync().GetAwaiter().GetResult();
}
}
}
return _mqttConfig ?? throw new InvalidOperationException("MQTT configuration not loaded");
}
private async Task LoadVDA5050ConfigAsync()
{
try
{
var configFile = await _configManager.GetConfigByTypeAsync(VDA5050_PROTOCOL_CONFIG_TYPE);
if (configFile == null)
{
_logger.Warning("VDA5050 Protocol configuration not found, using defaults");
_vda5050Config = new VDA5050ProtocolConfig
{
Manufacturer = "RobotNet",
Version = "2.1.0",
TopicPrefix = "uagv/v2"
};
}
else
{
_vda5050Config = MapConfigVariablesToObject<VDA5050ProtocolConfig>(configFile.Variables);
_logger.Info("VDA5050 Protocol configuration loaded successfully");
}
}
catch (Exception ex)
{
_logger.Error($"Error loading VDA5050 Protocol configuration, using defaults: {ex.Message}");
_vda5050Config = new VDA5050ProtocolConfig
{
Manufacturer = "RobotNet",
Version = "2.1.0",
TopicPrefix = "uagv/v2"
};
}
finally
{
_vda5050ConfigLoaded = true;
}
}
private async Task LoadMqttConfigAsync()
{
var configFile = await _configManager.GetConfigByTypeAsync(MQTT_CONFIG_TYPE) ?? throw new InvalidOperationException($"MQTT configuration (ConfigType: {MQTT_CONFIG_TYPE}) not found");
_mqttConfig = MapConfigVariablesToObject<MQTTConfig>(configFile.Variables);
if (string.IsNullOrEmpty(_mqttConfig.Host))
{
throw new InvalidOperationException("MQTT configuration: Host is required");
}
if (string.IsNullOrEmpty(_mqttConfig.ClientId))
{
throw new InvalidOperationException("MQTT configuration: ClientId is required");
}
_mqttConfigLoaded = true;
_logger.Info("MQTT configuration loaded successfully");
}
private void OnConfigChanged(object? sender, ConfigChangedEventArgs e)
{
// Reload config if it's one of our config types
if (e.ConfigType == VDA5050_PROTOCOL_CONFIG_TYPE)
{
lock (_lockObject)
{
_vda5050ConfigLoaded = false;
_vda5050Config = null;
}
_logger.Info("VDA5050 Protocol configuration changed, will reload on next access");
}
else if (e.ConfigType == MQTT_CONFIG_TYPE)
{
lock (_lockObject)
{
_mqttConfigLoaded = false;
_mqttConfig = null;
}
_logger.Info("MQTT configuration changed, will reload on next access");
}
}
private T MapConfigVariablesToObject<T>(List<ConfigVariable> variables) where T : new()
{
var obj = new T();
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty);
foreach (var property in properties)
{
// Find variable by exact name match (case-sensitive)
var variable = variables.FirstOrDefault(v => v.Name == property.Name);
if (variable != null && variable.Value != null)
{
try
{
var convertedValue = ConvertValue(variable.Value, property.PropertyType, variable.Type);
property.SetValue(obj, convertedValue);
}
catch (Exception ex)
{
_logger.Warning($"Failed to set property {property.Name} from variable {variable.Name}: {ex.Message}");
}
}
}
return obj;
}
private object? ConvertValue(object? value, Type targetType, ConfigVariableType variableType)
{
if (value == null)
return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
// If value is already of the correct type, return it
if (targetType.IsInstanceOfType(value))
return value;
try
{
// Handle nullable types
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
// Convert based on target type
if (underlyingType == typeof(string))
{
return value.ToString() ?? string.Empty;
}
else if (underlyingType == typeof(int))
{
if (value is int i) return i;
if (int.TryParse(value.ToString(), out var parsed)) return parsed;
if (value is double d) return (int)d;
throw new InvalidCastException($"Cannot convert {value.GetType()} to int");
}
else if (underlyingType == typeof(double))
{
if (value is double d) return d;
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
if (value is int i) return i;
throw new InvalidCastException($"Cannot convert {value.GetType()} to double");
}
else if (underlyingType == typeof(bool))
{
if (value is bool b) return b;
if (bool.TryParse(value.ToString(), out var parsed)) return parsed;
// Handle numeric values: 0/1, "0"/"1", etc.
if (value.ToString()?.Trim() == "0" || value.ToString()?.Trim().ToLower() == "false") return false;
if (value.ToString()?.Trim() == "1" || value.ToString()?.Trim().ToLower() == "true") return true;
throw new InvalidCastException($"Cannot convert {value.GetType()} to bool");
}
else if (underlyingType.IsEnum)
{
var enumValue = Enum.Parse(underlyingType, value.ToString() ?? "", true);
return enumValue;
}
else
{
// Try standard conversion
return Convert.ChangeType(value, underlyingType);
}
}
catch (Exception ex)
{
_logger.Warning($"Error converting value {value} of type {value.GetType()} to {targetType}: {ex.Message}");
throw;
}
}
}

View File

@@ -0,0 +1,52 @@
namespace RobotNet10.FleetManager.Services.ConfigManager;
/// <summary>
/// Service for managing ACSTraffic configurations
/// </summary>
public interface IACSTrafficConfig
{
/// <summary>
/// Event triggered when ACSTraffic configuration is changed/reloaded
/// </summary>
event EventHandler? ConfigChanged;
/// <summary>
/// Enable ACS Traffic control
/// </summary>
bool TrafficEnable { get; }
/// <summary>
/// Traffic interval time in milliseconds
/// </summary>
int TrafficInterval { get; }
/// <summary>
/// Traffic URL
/// </summary>
string TrafficURL { get; }
/// <summary>
/// ACS Zone mapping dictionary
/// </summary>
Dictionary<string, string> ACSZoneMaping { get; }
/// <summary>
/// ACS Out zone mapping with node
/// </summary>
Dictionary<string, string> ACSOutMaping { get; }
/// <summary>
/// Enable publish
/// </summary>
bool PublishEnable { get; }
/// <summary>
/// Publish URL
/// </summary>
string PublishURL { get; }
/// <summary>
/// Publish interval in milliseconds
/// </summary>
int PublishInterval { get; }
}

View File

@@ -0,0 +1,20 @@
using RobotNet10.FleetManager.Services.RobotConnections.Models;
using RobotNet10.MqttConnection;
namespace RobotNet10.FleetManager.Services.ConfigManager;
/// <summary>
/// Service for managing application configurations
/// </summary>
public interface IConnectionConfig
{
/// <summary>
/// Get VDA5050 Protocol configuration
/// </summary>
VDA5050ProtocolConfig GetVDA5050Config();
/// <summary>
/// Get MQTT configuration
/// </summary>
MQTTConfig GetMqttConfig();
}

View File

@@ -0,0 +1,14 @@
using RobotNet10.FleetManager.Services.TrafficControl.Models;
namespace RobotNet10.FleetManager.Services.ConfigManager;
/// <summary>
/// Service for managing TrafficControl configurations
/// </summary>
public interface ITrafficConfig
{
/// <summary>
/// Get TrafficControl configuration
/// </summary>
TrafficControlConfig GetTrafficControlConfig();
}

View File

@@ -0,0 +1,329 @@
using System.Reflection;
using RobotNet10.CustomConfiguration.Events;
using RobotNet10.CustomConfiguration.Models;
using RobotNet10.CustomConfiguration.Services;
using RobotNet10.FleetManager.Services.TrafficControl.Models;
namespace RobotNet10.FleetManager.Services.ConfigManager;
/// <summary>
/// Service implementation for managing TrafficControl configurations
/// </summary>
public class TrafficConfig : ITrafficConfig
{
private readonly IConfigManager _configManager;
private readonly Logger<TrafficConfig> _logger;
private readonly Lock _lockObject = new();
private TrafficControlConfig? _trafficControlConfig;
private bool _configLoaded = false;
private const string CONFLICT_DETECTION_CONFIG_TYPE = "TrafficConflictDetectionConfig";
private const string BASE_HORIZON_CONFIG_TYPE = "TrafficBaseHorizonConfig";
private const string CONFLICT_RESOLUTION_CONFIG_TYPE = "TrafficConflictResolutionConfig";
private const string PRIORITY_CONFIG_TYPE = "TrafficPriorityConfig";
private const string PATH_PLANNING_CONFIG_TYPE = "TrafficPathPlanningConfig";
public TrafficConfig(IConfigManager configManager, Logger<TrafficConfig> logger)
{
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
_logger = logger;
// Subscribe to config changes
_configManager.ConfigChanged += OnConfigChanged;
}
public TrafficControlConfig GetTrafficControlConfig()
{
if (!_configLoaded)
{
lock (_lockObject)
{
if (!_configLoaded)
{
LoadTrafficControlConfigAsync().GetAwaiter().GetResult();
}
}
}
return _trafficControlConfig ?? throw new InvalidOperationException("TrafficControl configuration not loaded");
}
private async Task LoadTrafficControlConfigAsync()
{
try
{
// Load all nested configs
var conflictDetectionConfig = await LoadNestedConfigAsync<ConflictDetectionConfig>(CONFLICT_DETECTION_CONFIG_TYPE);
var baseHorizonConfig = await LoadNestedConfigAsync<BaseHorizonConfig>(BASE_HORIZON_CONFIG_TYPE);
var conflictResolutionConfig = await LoadNestedConfigAsync<ConflictResolutionConfig>(CONFLICT_RESOLUTION_CONFIG_TYPE);
var priorityConfig = await LoadNestedConfigAsync<PriorityConfig>(PRIORITY_CONFIG_TYPE);
var pathPlanningConfig = await LoadNestedConfigAsync<PathPlanningConfig>(PATH_PLANNING_CONFIG_TYPE);
// Combine into TrafficControlConfig
_trafficControlConfig = new TrafficControlConfig
{
ConflictDetection = conflictDetectionConfig,
BaseHorizon = baseHorizonConfig,
ConflictResolution = conflictResolutionConfig,
Priority = priorityConfig,
PathPlanning = pathPlanningConfig
};
_configLoaded = true;
_logger.Info("TrafficControl configuration loaded successfully");
}
catch (Exception ex)
{
_logger.Error($"Error loading TrafficControl configuration, using defaults: {ex.Message}");
_trafficControlConfig = new TrafficControlConfig();
_configLoaded = true;
}
}
private async Task<T> LoadNestedConfigAsync<T>(string configType) where T : new()
{
try
{
var configFile = await _configManager.GetConfigByTypeAsync(configType);
if (configFile == null)
{
_logger.Warning($"{configType} configuration not found, using defaults");
return new T();
}
var config = MapConfigVariablesToObject<T>(configFile.Variables);
_logger.Info($"{configType} configuration loaded successfully");
return config;
}
catch (Exception ex)
{
_logger.Warning($"Error loading {configType} configuration, using defaults: {ex.Message}");
return new T();
}
}
private void OnConfigChanged(object? sender, ConfigChangedEventArgs e)
{
// Reload config if any of our config types changed
if (e.ConfigType == CONFLICT_DETECTION_CONFIG_TYPE ||
e.ConfigType == BASE_HORIZON_CONFIG_TYPE ||
e.ConfigType == CONFLICT_RESOLUTION_CONFIG_TYPE ||
e.ConfigType == PRIORITY_CONFIG_TYPE ||
e.ConfigType == PATH_PLANNING_CONFIG_TYPE)
{
lock (_lockObject)
{
_configLoaded = false;
_trafficControlConfig = null;
}
_logger.Info($"TrafficControl configuration changed ({e.ConfigType}), will reload on next access");
}
}
private T MapConfigVariablesToObject<T>(List<ConfigVariable> variables) where T : new()
{
var obj = new T();
var properties = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty);
foreach (var property in properties)
{
// Find variable by exact name match (case-sensitive)
var variable = variables.FirstOrDefault(v => v.Name == property.Name);
if (variable != null && variable.Value != null)
{
try
{
var convertedValue = ConvertValue(variable.Value, property.PropertyType);
property.SetValue(obj, convertedValue);
}
catch (Exception ex)
{
_logger.Warning($"Failed to set property {property.Name} from variable {variable.Name}: {ex.Message}");
}
}
}
return obj;
}
private object? ConvertValue(object? value, Type targetType)
{
if (value == null)
return targetType.IsValueType ? Activator.CreateInstance(targetType) : null;
// If value is already of the correct type, return it
if (targetType.IsInstanceOfType(value))
return value;
try
{
// Handle nullable types
var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
// Convert based on target type
if (underlyingType == typeof(string))
{
return value.ToString() ?? string.Empty;
}
else if (underlyingType == typeof(int))
{
if (value is int i) return i;
if (int.TryParse(value.ToString(), out var parsed)) return parsed;
if (value is double d) return (int)d;
throw new InvalidCastException($"Cannot convert {value.GetType()} to int");
}
else if (underlyingType == typeof(double))
{
if (value is double d) return d;
if (double.TryParse(value.ToString(), out var parsed)) return parsed;
if (value is int i) return i;
throw new InvalidCastException($"Cannot convert {value.GetType()} to double");
}
else if (underlyingType == typeof(bool))
{
if (value is bool b) return b;
if (bool.TryParse(value.ToString(), out var parsed)) return parsed;
// Handle numeric values: 0/1, "0"/"1", etc.
if (value.ToString()?.Trim() == "0" || value.ToString()?.Trim().ToLower() == "false") return false;
if (value.ToString()?.Trim() == "1" || value.ToString()?.Trim().ToLower() == "true") return true;
throw new InvalidCastException($"Cannot convert {value.GetType()} to bool");
}
else if (underlyingType.IsEnum)
{
var enumValue = Enum.Parse(underlyingType, value.ToString() ?? "", true);
return enumValue;
}
else if (underlyingType.IsGenericType && underlyingType.GetGenericTypeDefinition() == typeof(Dictionary<,>))
{
// Handle Dictionary types - try to parse from JSON string
return ConvertDictionary(value, underlyingType);
}
else
{
// Try standard conversion
return Convert.ChangeType(value, underlyingType);
}
}
catch (Exception ex)
{
_logger.Warning($"Error converting value {value} of type {value.GetType()} to {targetType}: {ex.Message}");
throw;
}
}
/// <summary>
/// Convert value to Dictionary type (supports Dictionary&lt;NavigationType, PathPlanningMethod&gt;)
/// </summary>
private object ConvertDictionary(object value, Type dictionaryType)
{
try
{
// Get key and value types
var genericArgs = dictionaryType.GetGenericArguments();
var keyType = genericArgs[0];
var valueType = genericArgs[1];
// If value is already the correct Dictionary type, return it
if (dictionaryType.IsInstanceOfType(value))
{
return value;
}
// If value is Dictionary<string, object>, try to convert
if (value is Dictionary<string, object> stringDict)
{
var result = Activator.CreateInstance(dictionaryType);
var addMethod = dictionaryType.GetMethod("Add");
if (addMethod != null && result != null)
{
foreach (var kvp in stringDict)
{
var key = ConvertValue(kvp.Key, keyType);
var val = ConvertValue(kvp.Value, valueType);
addMethod.Invoke(result, [key, val]);
}
return result;
}
}
// Try parse as JSON string
var jsonString = value.ToString();
if (!string.IsNullOrEmpty(jsonString))
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(jsonString);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Object)
{
var result = Activator.CreateInstance(dictionaryType);
var addMethod = dictionaryType.GetMethod("Add");
if (addMethod != null && result != null)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
// Convert key (e.g., "Differential" -> NavigationType.Differential)
var key = ConvertEnumKey(prop.Name, keyType);
// Convert value (e.g., "Basic" -> PathPlanningMethod.Basic)
var val = ConvertEnumValue(prop.Value.GetString() ?? prop.Value.GetRawText(), valueType);
addMethod.Invoke(result, [key, val]);
}
return result;
}
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to parse Dictionary from JSON string: {ex.Message}");
}
}
// If all else fails, return default (empty dictionary)
_logger.Warning($"Cannot convert {value.GetType()} to {dictionaryType}, using default (empty dictionary)");
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
}
catch (Exception ex)
{
_logger.Warning($"Error converting Dictionary: {ex.Message}");
// Return default (empty dictionary)
return Activator.CreateInstance(dictionaryType) ?? throw new InvalidOperationException($"Cannot create instance of {dictionaryType}");
}
}
/// <summary>
/// Convert string to enum key (e.g., "Differential" -> NavigationType.Differential)
/// </summary>
private static object ConvertEnumKey(string keyString, Type enumType)
{
if (enumType.IsEnum)
{
if (Enum.TryParse(enumType, keyString, true, out var enumValue))
{
return enumValue;
}
}
// If not enum, try standard conversion
return Convert.ChangeType(keyString, enumType);
}
/// <summary>
/// Convert string to enum value (e.g., "Basic" -> PathPlanningMethod.Basic)
/// </summary>
private static object ConvertEnumValue(string valueString, Type enumType)
{
if (enumType.IsEnum)
{
if (Enum.TryParse(enumType, valueString, true, out var enumValue))
{
return enumValue;
}
}
// If not enum, try standard conversion
return Convert.ChangeType(valueString, enumType);
}
}

View File

@@ -0,0 +1,13 @@
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Interface for robot model image storage operations
/// </summary>
public interface IRobotModelImageStorageService
{
Task SaveImageAsync(Guid robotModelId, Stream imageStream, CancellationToken cancellationToken = default);
Task<Stream?> GetImageAsync(Guid robotModelId, CancellationToken cancellationToken = default);
Task<bool> DeleteImageAsync(Guid robotModelId, CancellationToken cancellationToken = default);
Task<bool> ImageExistsAsync(Guid robotModelId, CancellationToken cancellationToken = default);
Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,53 @@
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
using RobotNet10.MapManager.Data;
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Service for managing robot model map data based on VehicleType filtering
/// </summary>
public interface IRobotModelMapService
{
/// <summary>
/// Get filtered nodes for a robot model based on VehicleType properties
/// Returns only nodes that have NodeVehicleProperties for the robot model's VehicleType
/// </summary>
Task<List<Node>> GetFilteredNodesAsync(Guid robotModelId);
/// <summary>
/// Get filtered edges for a robot model based on VehicleType properties
/// Returns only edges that have EdgeVehicleProperties for the robot model's VehicleType
/// </summary>
Task<List<Edge>> GetFilteredEdgesAsync(Guid robotModelId);
/// <summary>
/// Get filtered nodes for a robot model based on VehicleType properties and LevelId
/// Returns only nodes that have NodeVehicleProperties for the robot model's VehicleType and belong to the specified level
/// </summary>
Task<List<Node>> GetFilteredNodesByLevelAsync(Guid robotModelId, Guid levelId);
/// <summary>
/// Get filtered edges for a robot model based on VehicleType properties and LevelId
/// Returns only edges that have EdgeVehicleProperties for the robot model's VehicleType and belong to the specified level
/// </summary>
Task<List<Edge>> GetFilteredEdgesByLevelAsync(Guid robotModelId, Guid levelId);
/// <summary>
/// Get validated map data (nodes + edges) for a robot model
/// Returns only valid nodes/edges after validation (nodes with edges, edges with both nodes)
/// </summary>
Task<RobotModelMapDataDto> GetValidatedMapDataAsync(Guid robotModelId);
/// <summary>
/// Validate map data for a robot model
/// Returns validation result with errors/warnings
/// </summary>
Task<MapValidationResultDto> ValidateMapForRobotModelAsync(Guid robotModelId);
/// <summary>
/// Check if robot model has valid map configuration
/// Returns true if VehicleTypeId and MapId are set and map data is valid
/// </summary>
Task<bool> HasValidMapConfigurationAsync(Guid robotModelId);
}

View File

@@ -0,0 +1,19 @@
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Service for managing robot models
/// </summary>
public interface IRobotModelService
{
Task<RobotModel> CreateAsync(CreateRobotModelRequest request);
Task<List<RobotModel>> GetAllAsync();
Task<RobotModel?> GetByIdAsync(Guid id);
Task<RobotModel> UpdateAsync(Guid id, UpdateRobotModelRequest request);
Task<bool> DeleteAsync(Guid id);
Task<bool> ExistsAsync(string modelName);
Task<List<RobotModel>> SearchAsync(string query);
Task<RobotModelUsageInfoDto> GetUsageInfoAsync(Guid id);
}

View File

@@ -0,0 +1,21 @@
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Shared.DTOs.Robot;
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Service for managing robots
/// </summary>
public interface IRobotService
{
Task<Robot> CreateAsync(CreateRobotRequest request);
Task<List<Robot>> GetAllAsync(Guid? modelId = null, Guid? mapId = null);
Task<Robot?> GetByIdAsync(Guid id);
Task<Robot?> GetByRobotIdAsync(string robotId);
Task<Robot> UpdateAsync(Guid id, UpdateRobotRequest request);
Task<bool> DeleteAsync(Guid id);
Task<List<Robot>> GetByModelIdAsync(Guid modelId);
Task<List<Robot>> GetByModelNameAsync(string modelName);
Task<List<Robot>> SearchAsync(string query);
Task<bool> ExistsAsync(string robotId);
}

View File

@@ -0,0 +1,108 @@
namespace RobotNet10.FleetManager.Services;
public class Logger<T>(ILogger<T> Logger) where T : class
{
public event Action? LoggerUpdate;
public void Write(string message, LogLevel level)
{
switch (level)
{
case LogLevel.Trace:
if(Logger.IsEnabled(LogLevel.Trace))Logger.LogTrace("{mes}", message);
break;
case LogLevel.Debug:
if (Logger.IsEnabled(LogLevel.Debug)) Logger.LogDebug("{mes}", message);
break;
case LogLevel.Information:
if (Logger.IsEnabled(LogLevel.Information)) Logger.LogInformation("{mes}", message);
break;
case LogLevel.Warning:
if (Logger.IsEnabled(LogLevel.Warning)) Logger.LogWarning("{mes}", message);
break;
case LogLevel.Error:
if (Logger.IsEnabled(LogLevel.Error)) Logger.LogError("{mes}", message);
break;
case LogLevel.Critical:
if (Logger.IsEnabled(LogLevel.Critical)) Logger.LogCritical("{mes}", message);
break;
}
LoggerUpdate?.Invoke();
}
public void Write(string message)
{
Write(message, LogLevel.Information);
}
public async Task WriteAsync(string message)
{
var write = Task.Run(() => Write(message));
await write.WaitAsync(CancellationToken.None);
}
public async Task TraceAsync(string message)
{
var write = Task.Run(() => Write(message, LogLevel.Trace));
await write.WaitAsync(CancellationToken.None);
}
public async Task DebugAsync(string message)
{
var write = Task.Run(() => Write(message, LogLevel.Debug));
await write.WaitAsync(CancellationToken.None);
}
public async Task InfoAsync(string message)
{
var write = Task.Run(() => Write(message, LogLevel.Information));
await write.WaitAsync(CancellationToken.None);
}
public async Task WarningAsync(string message)
{
var write = Task.Run(() => Write(message, LogLevel.Warning));
await write.WaitAsync(CancellationToken.None);
}
public async Task ErrorAsync(string message)
{
var write = Task.Run(() => Write(message, LogLevel.Error));
await write.WaitAsync(CancellationToken.None);
}
public async Task CriticalAsync(string message)
{
var write = Task.Run(() => Write(message, LogLevel.Critical));
await write.WaitAsync(CancellationToken.None);
}
public void Trace(string message)
{
Write(message, LogLevel.Trace);
}
public void Debug(string message)
{
Write(message, LogLevel.Debug);
}
public void Info(string message)
{
Write(message, LogLevel.Information);
}
public void Warning(string message)
{
Write(message, LogLevel.Warning);
}
public void Error(string message)
{
Write(message, LogLevel.Error);
}
public void Critical(string message)
{
Write(message, LogLevel.Critical);
}
}

View File

@@ -0,0 +1,16 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class ACSHeader(string messageName, string time)
{
[JsonPropertyName("msgname")]
[Required]
public string MessageName { get; set; } = messageName;
[JsonPropertyName("time")]
[Required]
public string Time { get; set; } = time;
}

View File

@@ -0,0 +1,82 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class ACSPublishModel
{
[JsonPropertyName("header")]
[Required]
public ACSHeader Header { get; set; } = new("AGV_STATUS", DateTime.Today.ToString("yyyy-MM-dd HH:mm:ss.fff"));
[JsonPropertyName("body")]
[Required]
public RobotPublishStatusBody Body { get; set; } = new();
}
public class RobotPublishStatusBody
{
[JsonPropertyName("agv_id")]
[Required]
public string Id { get; set; } = "";
[JsonPropertyName("state")]
[Required]
public string State { get; set; } = "-1";
[JsonPropertyName("site_code")]
[Required]
public string SiteCode { get; set; } = "";
[JsonPropertyName("area_code")]
[Required]
public string AreaCode { get; set; } = "";
[JsonPropertyName("area_name")]
[Required]
public string AreaName { get; set; } = "";
[JsonPropertyName("location")]
[Required]
public AGVLocation Location { get; set; } = new();
[JsonPropertyName("marker_id")]
[Required]
public string? MarkerId { get; set; }
[JsonPropertyName("battery_level")]
[Required]
public string BatteryLevel { get; set; } = "0";
[JsonPropertyName("battery_voltage")]
[Required]
public string BatteryVoltage { get; set; } = "0";
[JsonPropertyName("battery_current")]
[Required]
public string BatteryCurrent { get; set; } = "0";
[JsonPropertyName("battery_temperature")]
[Required]
public string BatteryTemprature { get; set; } = "0";
[JsonPropertyName("battery_id")]
[Required]
public string? BatteryId { get; set; }
[JsonPropertyName("battery_soh")]
[Required]
public string? BatterySOH { get; set; } = "0";
[JsonPropertyName("loading")]
[Required]
public string Loading { get; set; } = "0";
[JsonPropertyName("error_code")]
[Required]
public string? ErrorCode { get; set; }
[JsonPropertyName("station_id")]
[Required]
public string? StationId { get; set; }
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class ACSStatusBodyResponse
{
[JsonPropertyName("result")]
[Required]
public string Result { get; set; } = string.Empty;
}
public class ACSStatusResponse
{
[JsonPropertyName("header")]
[Required]
public ACSHeader? Header { get; set; }
[JsonPropertyName("body")]
[Required]
public ACSStatusBodyResponse? Body { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class AGVLocation
{
[JsonPropertyName("world_x")]
[Required]
public string X { get; set; } = "0";
[JsonPropertyName("world_y")]
[Required]
public string Y { get; set; } = "0";
[JsonPropertyName("world_z")]
[Required]
public string Z { get; set; } = "0";
[JsonPropertyName("direction")]
[Required]
public string Direction { get; set; } = "0";
}

View File

@@ -0,0 +1,15 @@
namespace RobotNet10.FleetManager.Services.OpenACS;
public enum AGVState
{
Offline = -1,
Error = 0,
Idle = 1,
Processing = 2,
Pause = 3,
DockingFail = 4,
NoPose = 5,
Charging = 6,
Run = 7,
Stop = 8,
}

View File

@@ -0,0 +1,8 @@
namespace RobotNet10.FleetManager.Services.OpenACS;
public class OpenACSException : Exception
{
public OpenACSException() { }
public OpenACSException(string message) : base(message) { }
public OpenACSException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,192 @@
using RobotNet.VDA5050.State;
using RobotNet.VDA5050.Type;
using RobotNet10.Common;
using RobotNet10.FleetManager.Services.ConfigManager;
using RobotNet10.FleetManager.Services.RobotManager;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class OpenACSPublisher(IConfiguration configuration,
Logger<OpenACSPublisher> Logger,
ILogger<OpenACSPublisher> ILogger,
IRobotManagerService RobotManager,
IACSTrafficConfig ACSTrafficConfig) : BackgroundService
{
public int PublishCount { get; private set; }
private WatchTimerAsync<OpenACSPublisher>? Timer;
private readonly string ACSSiteCode = configuration["ACSStatusConfig:SiteCode"] ?? "VN03";
private readonly string ACSAreaCode = configuration["ACSStatusConfig:AreaCode"] ?? "DA3_FL1";
private readonly string ACSAreaName = configuration["ACSStatusConfig:AreaName"] ?? "DA3_WM";
private readonly double ACSExtendX = configuration.GetValue<double>("ACSStatusConfig:ExtendX");
private readonly double ACSExtendY = configuration.GetValue<double>("ACSStatusConfig:ExtendY");
private readonly double ACSExtendTheta = configuration.GetValue<double>("ACSStatusConfig:ExtendTheta");
private readonly SemaphoreSlim _timerLock = new(1, 1);
private async Task TimerHandler()
{
if (ACSTrafficConfig.PublishEnable && !string.IsNullOrEmpty(ACSTrafficConfig.PublishURL))
{
try
{
using var HttpClient = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) };
var robotControllers = RobotManager.GetAllRobotControllers();
foreach (var robot in robotControllers)
{
var startTime = DateTime.Now;
if (robot.Value.Data.State == null || robot.Value.Data.State.AgvPosition == null) continue;
if ((startTime - robot.Value.Data.State.Timestamp).TotalMilliseconds > ACSTrafficConfig.PublishInterval) continue;
int batLevel = (int)robot.Value.Data.State.BatteryState.BatteryHealth;
if (batLevel <= 0) batLevel = 85;
int batVol = (int)(robot.Value.Data.State.BatteryState.BatteryVoltage ?? 0);
if (batVol <= 0) batVol = 24;
var status = new ACSPublishModel()
{
Header = new("AGV_STATUS", robot.Value.Data.State.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")),
Body = new()
{
Id = robot.Key,
Location = new()
{
X = (robot.Value.Data.State.AgvPosition.X + ACSExtendX).ToString(),
Y = (robot.Value.Data.State.AgvPosition.Y + ACSExtendY).ToString(),
Z = "0",
Direction = (robot.Value.Data.State.AgvPosition.Theta + ACSExtendTheta).ToString(),
},
SiteCode = ACSSiteCode,
AreaCode = ACSAreaCode,
AreaName = ACSAreaName,
MarkerId = string.IsNullOrEmpty(robot.Value.Data.State.LastNodeId) ? null : robot.Value.Data.State.LastNodeId,
BatteryId = null,
BatteryLevel = batLevel.ToString(),
BatteryVoltage = batVol.ToString(),
BatterySOH = null,
BatteryCurrent = "1.0",
BatteryTemprature = "30",
StationId = null,
Loading = robot.Value.Data.State.Loads.Length != 0 ? "1" : "0",
ErrorCode = GetErrorCode(robot.Value.Data.State.Errors ?? [])?.ToString() ?? null,
State = GetStatus(robot.Value.Data.State).ToString(),
}
};
var response = await HttpClient.PostAsJsonAsync(ACSTrafficConfig.PublishURL, status);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ACSStatusResponse>();
if (result == null)
{
Logger.Error("Failed to convert response.Content to ACSStatusResponse");
}
else if (result.Header?.MessageName == "AGV_STATUS_ACK" && result.Body?.Result == "OK")
{
PublishCount++;
}
else
{
Logger.Warning($"ACS response is not OK: {System.Text.Json.JsonSerializer.Serialize(result)}");
}
}
else
{
Logger.Warning($"ACS publish to {ACSTrafficConfig.PublishURL} failed: {response.StatusCode}");
}
}
}
catch (Exception ex)
{
Logger.Warning($"ACS publish to {ACSTrafficConfig.PublishURL} error: {ex.Message}");
}
}
}
private static int GetStatus(StateMsg state)
{
if (GetError(state) == ErrorLevel.FATAL || GetError(state) == ErrorLevel.WARNING) return (int)AGVState.Error;
else if (state.BatteryState.Charging) return (int)AGVState.Charging;
else if (state.Paused) return (int)AGVState.Pause;
else if (IsIdle(state)) return (int)AGVState.Idle;
else if (IsWorking(state)) return (int)AGVState.Run;
else return (int)AGVState.Stop;
}
private static string? GetErrorCode(Error[] errors)
{
var error = errors.FirstOrDefault();
if (error is not null && int.TryParse(error.ErrorType, out int errorCode)) return errorCode.ToString();
return null;
}
private static bool IsIdle(StateMsg state)
{
if (state.NodeStates.Length != 0 || state.EdgeStates.Length != 0) return false;
return true;
}
private static bool IsWorking(StateMsg state)
{
if (state.NodeStates.Length != 0 || state.EdgeStates.Length != 0) return true;
return false;
}
private static ErrorLevel GetError(StateMsg state)
{
if (state.Errors is not null)
{
if (state.Errors.Any(error => error.ErrorLevel == ErrorLevel.FATAL)) return ErrorLevel.FATAL;
if (state.Errors.Any(error => error.ErrorLevel == ErrorLevel.WARNING)) return ErrorLevel.WARNING;
}
return ErrorLevel.NONE;
}
private async Task InitializeTimerAsync()
{
if (ACSTrafficConfig.PublishInterval == Timer?.Interval) return;
if (_timerLock.Wait(1000))
{
try
{
Timer?.Dispose();
Timer = new(ACSTrafficConfig.PublishInterval, TimerHandler, ILogger);
Timer.Start();
}
finally
{
_timerLock.Release();
}
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
while (!stoppingToken.IsCancellationRequested)
{
try
{
ACSTrafficConfig.ConfigChanged += ConfigChanged;
await InitializeTimerAsync();
break;
}
catch (Exception ex)
{
Logger.Warning($"ACS Publisher: Initialization error: {ex.Message}");
await Task.Delay(2000, stoppingToken);
}
}
}
public async void ConfigChanged(object? sender, EventArgs e)
{
await InitializeTimerAsync();
}
public override Task StopAsync(CancellationToken cancellationToken)
{
ACSTrafficConfig.ConfigChanged -= ConfigChanged;
Timer?.Dispose();
_timerLock?.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,78 @@
using RobotNet10.FleetManager.Services.ConfigManager;
using RobotNet10.Shared;
using System.Text.Json;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class TrafficACS(IACSTrafficConfig OpenACSManager, Logger<TrafficACS> Logger)
{
private static readonly JsonSerializerOptions jsonSerializeOptions = new() {
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
public async Task<MessageResult<bool>> RequestIn(string robotId, string zoneId)
{
var model = new TrafficACSRequest(robotId, zoneId, TrafficRequestType.IN);
TrafficACSResponse? response = null;
HttpResponseMessage? responseStr = null;
try
{
if (!OpenACSManager.TrafficEnable) return new(true, true, "Kết nối với hệ thống traffic ACS không được bật");
using var HttpClient = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) };
responseStr = await HttpClient.PostAsJsonAsync(OpenACSManager.TrafficURL, model);
response = await responseStr.Content.ReadFromJsonAsync<TrafficACSResponse>() ?? throw new OpenACSException("Lỗi giao tiếp với hệ thống traffic ACS");
if (response.AgvId != robotId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS agv_id trả về {response.AgvId} không trùng với dữ liệu gửi đi {robotId}");
if (response.TrafficZoneId != zoneId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS traffic_zone_id trả về {response.TrafficZoneId} không trùng với dữ liệu gửi đi {zoneId}");
if (response.InOut != TrafficRequestType.IN) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS inout trả về {response.InOut} không trùng với dữ liệu gửi đi in");
if (response.Result != TrafficACSResult.GO) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS result trả về {response.Result} - không cho phép đi vào vùng {zoneId}");
Logger.Info($"{robotId} request into traffic zone {zoneId} succeeded");
return new(true, true, "Request into traffic zone succeeded");
}
catch (OpenACSException ex)
{
Logger.Warning($"{robotId} request in error: {ex.Message}. \nRequest: {JsonSerializer.Serialize(model, jsonSerializeOptions)}\n Response: {(response is null ? "" : JsonSerializer.Serialize(response, jsonSerializeOptions))}, Raw: {(responseStr is null ? "" : await responseStr.Content.ReadAsStringAsync())}");
return new(false, false, ex.Message);
}
catch (Exception ex)
{
Logger.Warning($"{robotId} request In error: {ex.Message} \nRaw: {(responseStr is null ? "" : await responseStr.Content.ReadAsStringAsync())}");
return new(false, false, "Traffic ACS communication error");
}
}
public async Task<MessageResult<bool>> RequestOut(string robotId, string zoneId)
{
var model = new TrafficACSRequest(robotId, zoneId, TrafficRequestType.OUT);
TrafficACSResponse? response = null;
try
{
if (!OpenACSManager.TrafficEnable) return new(true, true, "Kết nối với hệ thống traffic ACS không được bật");
using var HttpClient = new HttpClient() { Timeout = TimeSpan.FromSeconds(15) };
response = await (await HttpClient.PostAsJsonAsync(OpenACSManager.TrafficURL, model)).Content.ReadFromJsonAsync<TrafficACSResponse>() ??
throw new OpenACSException("Lỗi giao tiếp với hệ thống traffic ACS");
if (response.AgvId != robotId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS agv_id trả về {response.AgvId} không trùng với dữ liệu gửi đi {robotId}");
if (response.TrafficZoneId != zoneId) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS traffic_zone_id trả về {response.TrafficZoneId} không trùng với dữ liệu gửi đi {zoneId}");
if (response.InOut != TrafficRequestType.OUT) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS inout trả về {response.InOut} không trùng với dữ liệu gửi đi out");
if (response.Result != TrafficACSResult.GO) throw new OpenACSException($"Dữ liệu hệ thống traffic ACS result trả về {response.Result} - không cho phép xóa bỏ vùng {zoneId}");
Logger.Info($"{robotId} request out of traffic zone {zoneId} succeeded");
return new(true, true, "Request out of traffic zone succeeded");
}
catch (OpenACSException ex)
{
Logger.Warning($"{robotId} request out error: {ex.Message}. \nRequest: {JsonSerializer.Serialize(model, jsonSerializeOptions)}\n Response: {(response is null ? "" : JsonSerializer.Serialize(response, jsonSerializeOptions))}");
return new(false, false, ex.Message);
}
catch (Exception ex)
{
Logger.Warning($"{robotId} request Out error: {ex.Message}");
return new(false, false, "Traffic ACS communication error");
}
}
}

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class TrafficRequestType
{
public static string IN => "in";
public static string OUT => "out";
}
public class TrafficACSRequestBody(string agvId, string trafficZoneId, string inOut)
{
[JsonPropertyName("agvid")]
[Required]
public string AgvId { get; set; } = agvId;
[JsonPropertyName("area")]
[Required]
public string TrafficZoneId { get; set; } = trafficZoneId;
[JsonPropertyName("inout")]
[Required]
public string InOut { get; set; } = inOut;
}
public class TrafficACSRequest
{
[JsonPropertyName("header")]
[Required]
public ACSHeader Header { get; set; } = new("TRAFFIC_REQ", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"));
[JsonPropertyName("body")]
[Required]
public TrafficACSRequestBody Body { get; set; }
public TrafficACSRequest(string agvId, string trafficZoneId, string inOut)
{
if (string.IsNullOrWhiteSpace(agvId))
throw new ArgumentException("AGV ID không thể rỗng.", nameof(agvId));
if (string.IsNullOrWhiteSpace(trafficZoneId))
throw new ArgumentException("Traffic Zone ID không thể rỗng.", nameof(trafficZoneId));
if (string.IsNullOrWhiteSpace(inOut))
throw new ArgumentException("In OUT không thể rỗng.", nameof(inOut));
Body = new(agvId, trafficZoneId, inOut);
}
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace RobotNet10.FleetManager.Services.OpenACS;
public class TrafficACSResult
{
public static string GO => "go";
public static string NO => "no";
}
public class TrafficACSResponse
{
[JsonPropertyName("time")]
[Required]
public string? Time { get; set; }
[JsonPropertyName("agv_id")]
[Required]
public string? AgvId { get; set; }
[JsonPropertyName("traffic_zone_id")]
[Required]
public string? TrafficZoneId { get; set; }
[JsonPropertyName("inout")]
[Required]
public string? InOut { get; set; }
[JsonPropertyName("result")]
[Required]
public string? Result { get; set; }
}

View File

@@ -0,0 +1,35 @@
using RobotNet.VDA5050.InstantAction;
using RobotNet.VDA5050.Order;
namespace RobotNet10.FleetManager.Services.RobotConnections;
/// <summary>
/// Service interface for managing MQTT connections to robots via VDA5050 protocol
/// </summary>
public interface IRobotConnectionsService
{
/// <summary>
/// Start MQTT connection and subscribe to topics
/// </summary>
Task StartAsync(CancellationToken? cancellationToken);
/// <summary>
/// Stop MQTT connection
/// </summary>
Task StopAsync();
/// <summary>
/// Check if MQTT client is connected
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Publish order message to a robot
/// </summary>
Task<bool> PublishOrderAsync(string robotId, OrderMsg order, CancellationToken cancellationToken = default);
/// <summary>
/// Publish instant actions message to a robot
/// </summary>
Task<bool> PublishInstantActionsAsync(string robotId, InstantActionsMsg instantActions, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
namespace RobotNet10.FleetManager.Services.RobotConnections.Models;
/// <summary>
/// VDA5050 Protocol configuration
/// </summary>
public class VDA5050ProtocolConfig
{
public string Manufacturer { get; set; } = string.Empty;
public string Version { get; set; } = string.Empty;
public string TopicPrefix { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,329 @@
using MQTTnet;
using MQTTnet.Packets;
using RobotNet.VDA5050;
using RobotNet.VDA5050.Connection;
using RobotNet.VDA5050.Factsheet;
using RobotNet.VDA5050.InstantAction;
using RobotNet.VDA5050.Order;
using RobotNet.VDA5050.State;
using RobotNet.VDA5050.Visualization;
using RobotNet10.FleetManager.Events;
using RobotNet10.FleetManager.Services.ConfigManager;
using RobotNet10.MqttConnection;
using System.Text;
using System.Text.Json;
namespace RobotNet10.FleetManager.Services.RobotConnections;
/// <summary>
/// Service implementation for managing MQTT connections to robots via VDA5050 protocol
/// </summary>
public class RobotConnectionsService(
IConnectionConfig configManager,
IRobotEventBus eventBus,
IServiceProvider serviceProvider,
Logger<RobotConnectionsService> logger,
ILogger<MQTTClient> mqttLogger) : IRobotConnectionsService
{
private readonly IConnectionConfig _configManager = configManager;
private readonly IRobotEventBus _eventBus = eventBus;
private readonly IServiceProvider _serviceProvider = serviceProvider;
private readonly Logger<RobotConnectionsService> _logger = logger;
private readonly ILogger<MQTTClient> _mqttLogger = mqttLogger;
private MQTTClient? _mqttClient;
private readonly SemaphoreSlim _connectionSemaphore = new(1, 1);
public bool IsConnected => _mqttClient is not null && _mqttClient.IsConnected;
public async Task StartAsync(CancellationToken? cancellationToken)
{
try
{
await StopAsync();
if (!_connectionSemaphore.Wait(1000)) return;
var mqttConfig = _configManager.GetMqttConfig();
var vdaConfig = _configManager.GetVDA5050Config();
MqttTopicFilter[] topics = [
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.STATE.ToJsonString()}")
.WithAtMostOnceQoS()
.Build(),
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.VISUALIZATION.ToJsonString()}")
.WithAtMostOnceQoS()
.Build(),
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.FACTSHEET.ToJsonString()}")
.WithAtMostOnceQoS()
.Build(),
new MqttTopicFilterBuilder().WithTopic($"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/+/{VDA5050Topic.CONNECTION.ToJsonString()}")
.WithAtMostOnceQoS()
.Build()
];
_mqttClient = new MQTTClient(mqttConfig, topics, _mqttLogger);
_mqttClient.MessageUpdated += MessageUpdated;
if (_mqttClient is not null) await _mqttClient.ConnectAsync(cancellationToken);
if (_mqttClient is not null) await _mqttClient.SubscribeAsync(cancellationToken);
_logger.Info("RobotConnectionsService started successfully");
}
catch (Exception ex)
{
_logger.Warning($"Connection broker is failed: {ex.Message}");
}
finally
{
_connectionSemaphore.Release();
}
}
public async Task StopAsync()
{
if (_mqttClient is not null)
{
await _mqttClient.DisposeAsync();
_mqttClient = null;
}
_logger.Info("RobotConnectionsService stopped");
}
private async Task MessageUpdated(MqttApplicationMessageReceivedEventArgs e)
{
try
{
var topic = e.ApplicationMessage.Topic;
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
var (robotId, messageType) = ParseVDA5050Topic(topic);
if (!string.IsNullOrEmpty(robotId) && !string.IsNullOrEmpty(messageType))
{
using var scope = _serviceProvider.CreateAsyncScope();
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
if (await _robotService.ExistsAsync(robotId))
{
if (messageType == VDA5050Topic.STATE.ToJsonString())
{
HandleStateMessageAsync(robotId, payload);
}
else if (messageType == VDA5050Topic.VISUALIZATION.ToJsonString())
{
HandleVisualizationMessageAsync(robotId, payload);
}
else if (messageType == VDA5050Topic.FACTSHEET.ToJsonString())
{
HandleFactsheetMessageAsync(robotId, payload);
}
else if (messageType == VDA5050Topic.CONNECTION.ToJsonString())
{
HandleConnectionMessageAsync(robotId, payload);
}
}
}
else
{
_logger.Warning("Failed to parse topic");
}
}
catch (Exception ex)
{
_logger.Warning($"Error processing message: {ex.Message}");
}
}
private (string? robotId, string? messageType) ParseVDA5050Topic(string topic)
{
try
{
if (string.IsNullOrEmpty(topic)) return (null, null);
var vdaConfig = _configManager.GetVDA5050Config();
ReadOnlySpan<char> topicSpan = topic.AsSpan();
var manufacturerSpan = $"/{vdaConfig.Manufacturer}/".AsSpan();
int manufacturerIndex = topicSpan.IndexOf(manufacturerSpan);
if (manufacturerIndex == -1) return (null, null);
var remaining = topicSpan[(manufacturerIndex + manufacturerSpan.Length)..];
int firstSlash = remaining.IndexOf('/');
if (firstSlash == -1) return (null, null);
var robotId = remaining[..firstSlash].ToString();
var messageType = remaining[(firstSlash + 1)..].ToString();
return (robotId, messageType);
}
catch (Exception ex)
{
_logger.Warning($"Parse VDA5050 Topic failed: {ex}");
return (null, null);
}
}
public async Task<bool> PublishOrderAsync(string robotId, OrderMsg order, CancellationToken cancellationToken = default)
{
if (_mqttClient is null)
{
_logger.Warning("Mqtt Client not initialized");
return false;
}
if (string.IsNullOrEmpty(robotId))
{
_logger.Warning("Cannot publish order: robotId is null or empty");
return false;
}
if (order == null)
{
_logger.Warning("Cannot publish order: order message is null");
return false;
}
if (string.IsNullOrEmpty(order.OrderId))
{
_logger.Warning("Cannot publish order: orderId is null or empty");
return false;
}
if (!IsConnected)
{
_logger.Warning($"Cannot publish order to robot {robotId}: MQTT client is not connected");
return false;
}
try
{
var topic = BuildPublishTopic(robotId, VDA5050Topic.ORDER);
var data = JsonSerializer.Serialize(order, JsonOptionExtends.Write);
await _mqttClient.PublishAsync(topic, data);
return true;
}
catch (OperationCanceledException)
{
_logger.Warning($"Publish order to robot {robotId} was cancelled");
return false;
}
catch (Exception ex)
{
_logger.Error($"Error publishing order to robot {robotId}: {ex.Message}");
return false;
}
}
public async Task<bool> PublishInstantActionsAsync(string robotId, InstantActionsMsg instantActions, CancellationToken cancellationToken = default)
{
if (_mqttClient is null)
{
_logger.Warning("Mqtt Client not initialized");
return false;
}
if (string.IsNullOrEmpty(robotId))
{
_logger.Warning("Cannot publish instantActions: robotId is null or empty");
return false;
}
if (instantActions == null)
{
_logger.Warning("Cannot publish instantActions: instantActions message is null");
return false;
}
if (instantActions.Actions == null || instantActions.Actions.Length == 0)
{
_logger.Warning("Cannot publish instantActions: actions array is null or empty");
return false;
}
if (!IsConnected)
{
_logger.Warning($"Cannot publish instantActions to robot {robotId}: MQTT client is not connected");
return false;
}
try
{
var topic = BuildPublishTopic(robotId, VDA5050Topic.INSTANTACTIONS);
var data = JsonSerializer.Serialize(instantActions, JsonOptionExtends.Write);
await _mqttClient.PublishAsync(topic, data);
return true;
}
catch (OperationCanceledException)
{
_logger.Warning($"Publish instantActions to robot {robotId} was cancelled");
return false;
}
catch (Exception ex)
{
_logger.Error($"Error publishing instantActions to robot {robotId}: {ex.Message}");
return false;
}
}
private void HandleStateMessageAsync(string serialNumber, string payload)
{
try
{
var stateMsg = JsonSerializer.Deserialize<StateMsg>(payload, JsonOptionExtends.Read);
if (stateMsg == null || stateMsg.SerialNumber != serialNumber) return;
_eventBus.PublishStateMessageReceived(serialNumber, stateMsg);
}
catch (Exception ex)
{
_logger.Error($"Error handling state message from robot {serialNumber}: {ex.Message}");
}
}
private void HandleConnectionMessageAsync(string serialNumber, string payload)
{
try
{
var connectionMsg = JsonSerializer.Deserialize<ConnectionMsg>(payload, JsonOptionExtends.Read);
if (connectionMsg == null || connectionMsg.SerialNumber != serialNumber) return;
_eventBus.PublishConnectionStateChanged(serialNumber, connectionMsg.ConnectionState);
}
catch (Exception ex)
{
_logger.Error($"Error handling connection message from robot {serialNumber}: {ex.Message}");
}
}
private void HandleVisualizationMessageAsync(string serialNumber, string payload)
{
try
{
var visualizationMsg = JsonSerializer.Deserialize<VisualizationMsg>(payload, JsonOptionExtends.Read);
if (visualizationMsg == null || visualizationMsg.SerialNumber != serialNumber) return;
_eventBus.PublishVisualizationMessageReceived(serialNumber, visualizationMsg);
}
catch (Exception ex)
{
_logger.Error($"Error handling visualization message from robot {serialNumber}: {ex.Message}");
}
}
private void HandleFactsheetMessageAsync(string serialNumber, string payload)
{
try
{
var factsheetMsg = JsonSerializer.Deserialize<FactSheetMsg>(payload, JsonOptionExtends.Read);
if (factsheetMsg == null || factsheetMsg.SerialNumber != serialNumber) return;
_eventBus.PublishFactsheetMessageReceived(serialNumber, factsheetMsg);
}
catch (Exception ex)
{
_logger.Error($"Error handling factsheet message from robot {serialNumber}: {ex.Message}");
}
}
private string BuildPublishTopic(string robotId, VDA5050Topic topic)
{
var vdaConfig = _configManager.GetVDA5050Config();
return $"{vdaConfig.TopicPrefix}/{vdaConfig.Manufacturer}/{robotId}/{topic.ToJsonString()}";
}
}

View File

@@ -0,0 +1,98 @@
using RobotNet.VDA5050.InstantAction;
using RobotNet.VDA5050.Order;
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Services.RobotManager.Models;
using RobotNet10.Shared;
using Action = RobotNet.VDA5050.InstantAction.Action;
namespace RobotNet10.FleetManager.Services.RobotController;
/// <summary>
/// Interface for RobotController - instance per robot
/// </summary>
public interface IRobotController : IDisposable
{
/// <summary>
/// Robot ID (SerialNumber)
/// </summary>
string RobotId { get; }
/// <summary>
/// Robot data containing all robot information
/// </summary>
RobotData Data { get; }
/// <summary>
/// Whether robot is online (derived from ConnectionState)
/// </summary>
bool IsOnline { get; }
/// <summary>
/// Whether the robot is ready for the order
/// </summary>
bool IsReady { get; }
/// <summary>
/// Move robot to a specific node by NodeName
/// </summary>
Task<MessageResult> MoveToNodeAsync(string nodeName, double? angle, CancellationToken cancellationToken = default);
/// <summary>
/// Move robot to a specific node by NodeId (string)
/// </summary>
Task<MessageResult> MoveToNodeByIdAsync(string nodeId, double? angle, CancellationToken cancellationToken = default);
/// <summary>
/// Move robot to a specific node by NodeId (Guid)
/// </summary>
Task<MessageResult> MoveToNodeByGuidAsync(Guid nodeId, double? angle, CancellationToken cancellationToken = default);
/// <summary>
/// Move the robot to a taget action and execute action
/// </summary>
/// <param name="nodeName"></param>
/// <param name="action"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<MessageResult> MoveToStationAsync(string nodeName, StationAction action, CancellationToken cancellationToken = default);
/// <summary>
/// Send a single instant action to robot
/// </summary>
Task<MessageResult> SendInstantActionAsync(Action action, CancellationToken cancellationToken = default);
/// <summary>
/// Send order to robot
/// </summary>
Task<MessageResult> SendOrderAsync(OrderMsg order, CancellationToken cancellationToken = default);
/// <summary>
/// Send instant actions message to robot
/// </summary>
Task<MessageResult> SendInstantActionsAsync(InstantActionsMsg instantActions, CancellationToken cancellationToken = default);
/// <summary>
/// Request factsheet from robot
/// </summary>
Task<MessageResult> RequestFactsheetAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Request state from robot
/// </summary>
Task<MessageResult> RequestStateAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Cancel current order
/// </summary>
Task<MessageResult> CancelOrderAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,612 @@
using RobotNet.VDA5050;
using RobotNet.VDA5050.InstantAction;
using RobotNet.VDA5050.Order;
using RobotNet.VDA5050.Type;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Services.ConfigManager;
using RobotNet10.FleetManager.Services.RobotConnections;
using RobotNet10.FleetManager.Services.RobotManager.Models;
using RobotNet10.FleetManager.Services.TrafficControl;
using RobotNet10.FleetManager.Services.TrafficControl.Models;
using RobotNet10.MapManager.Services;
using RobotNet10.Shared;
using Action = RobotNet.VDA5050.InstantAction.Action;
namespace RobotNet10.FleetManager.Services.RobotController;
/// <summary>
/// RobotController - instance per robot
/// Mô hình hóa thông tin của 1 robot, định danh bằng RobotId (SerialNumber)
/// </summary>
public class RobotController : IRobotController
{
private readonly IRobotConnectionsService _robotConnectionsService;
private readonly IConnectionConfig _configManager;
private readonly ITrafficControlService _trafficControlService;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly Logger<RobotController> _logger;
private readonly SemaphoreSlim _methodLock = new(1, 1);
private readonly SemaphoreSlim _publishLock = new(1, 1);
private uint _headerIdCounter = 0;
private readonly Lock _headerIdLock = new();
private readonly TimeSpan SendTimeOut = TimeSpan.FromSeconds(10);
public string RobotId { get; }
public RobotData Data { get; }
public bool IsOnline => Data.ConnectionState == ConnectionState.ONLINE;
public bool IsReady => IsOnline && !IsRobotBusy();
public RobotController(
string robotId,
IRobotConnectionsService robotConnectionsService,
IConnectionConfig configManager,
ITrafficControlService trafficControlService,
IServiceScopeFactory serviceScopeFactory,
Logger<RobotController> logger)
{
RobotId = robotId;
_robotConnectionsService = robotConnectionsService;
_configManager = configManager;
_trafficControlService = trafficControlService;
_serviceScopeFactory = serviceScopeFactory;
_logger = logger;
Data = new RobotData
{
RobotId = robotId,
ConnectionState = ConnectionState.OFFLINE,
LastUpdated = DateTime.UtcNow
};
}
public async Task<MessageResult> MoveToNodeAsync(string nodeName, double? angle, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(nodeName))
{
_logger.Warning($"Cannot move to node: nodeName is null or empty for robot {RobotId}");
return new(false, $"Cannot move to node: nodeName is null or empty for robot {RobotId}");
}
await _methodLock.WaitAsync(cancellationToken);
try
{
// 1. Check if robot is busy with an order
if (IsRobotBusy())
{
_logger.Warning($"Cannot move to node: robot {RobotId} is busy with an order {Data.State?.OrderId}");
return new(false, $"Robot {RobotId} is busy with an order");
}
// 2. Get robot's levelId (MapId)
var levelId = await GetRobotLevelIdAsync();
if (levelId == null)
{
_logger.Warning($"Cannot move to node: robot {RobotId} has no MapId assigned");
return new(false, $"Robot {RobotId} has no MapId assigned");
}
// 3. Find goal node by NodeName
var goalNode = await FindNodeByNameAsync(levelId.Value, nodeName);
if (goalNode == null)
{
_logger.Warning($"Cannot move to node: node '{nodeName}' not found in level {levelId}");
return new(false, $"Node '{nodeName}' not found");
}
// 4. Plan route and send order
return await PlanRouteAndSendOrderAsync(goalNode.Id, angle, cancellationToken);
}
catch (Exception ex)
{
_logger.Error($"Error in MoveToNodeAsync for robot {RobotId}: {ex.Message}");
return new(false, $"Error in MoveToNodeAsync for robot {RobotId}: {ex.Message}");
}
finally
{
_methodLock.Release();
}
}
/// <summary>
/// Move robot to a node by NodeId (string)
/// </summary>
public async Task<MessageResult> MoveToNodeByIdAsync(string nodeId, double? angle, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(nodeId))
{
_logger.Warning($"Cannot move to node: nodeId is null or empty for robot {RobotId}");
return new(false, $"Cannot move to node: nodeId is null or empty for robot {RobotId}");
}
await _methodLock.WaitAsync(cancellationToken);
try
{
// 1. Check if robot is busy with an order
if (IsRobotBusy())
{
_logger.Warning($"Cannot move to node: robot {RobotId} is busy with an order {Data.State?.OrderId}");
return new(false, $"Robot {RobotId} is busy with an order");
}
// 2. Get robot's levelId (MapId)
var levelId = await GetRobotLevelIdAsync();
if (levelId == null)
{
_logger.Warning($"Cannot move to node: robot {RobotId} has no MapId assigned");
return new(false, $"Robot {RobotId} has no MapId assigned");
}
// 3. Find goal node by NodeId (string)
var goalNode = await FindNodeByNodeIdAsync(levelId.Value, nodeId);
if (goalNode == null)
{
_logger.Warning($"Cannot move to node: node with NodeId '{nodeId}' not found in level {levelId}");
return new(false, $"Node with NodeId '{nodeId}' not found");
}
// 4. Plan route and send order
return await PlanRouteAndSendOrderAsync(goalNode.Id, angle, cancellationToken);
}
catch (Exception ex)
{
_logger.Error($"Error in MoveToNodeByIdAsync for robot {RobotId}: {ex.Message}");
return new(false, $"Error in MoveToNodeByIdAsync for robot {RobotId}: {ex.Message}");
}
finally
{
_methodLock.Release();
}
}
/// <summary>
/// Move robot to a node by NodeId (Guid)
/// </summary>
public async Task<MessageResult> MoveToNodeByGuidAsync(Guid nodeId, double? angle, CancellationToken cancellationToken = default)
{
if (nodeId == Guid.Empty)
{
_logger.Warning($"Cannot move to node: nodeId is empty for robot {RobotId}");
return new(false, $"Cannot move to node: nodeId is empty for robot {RobotId}");
}
await _methodLock.WaitAsync(cancellationToken);
try
{
// 1. Check if robot is busy with an order
if (IsRobotBusy())
{
_logger.Warning($"Cannot move to node: robot {RobotId} is busy with an order {Data.State?.OrderId}");
return new(false, $"Robot {RobotId} is busy with an order");
}
// 2. Verify node exists
using var scope = _serviceScopeFactory.CreateScope();
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
var goalNode = await nodeService.GetByIdAsync(nodeId);
if (goalNode == null)
{
_logger.Warning($"Cannot move to node: node with Id '{nodeId}' not found");
return new(false, $"Node with Id '{nodeId}' not found");
}
// 3. Plan route and send order
return await PlanRouteAndSendOrderAsync(nodeId, angle, cancellationToken);
}
catch (Exception ex)
{
_logger.Error($"Error in MoveToNodeByGuidAsync for robot {RobotId}: {ex.Message}");
return new(false, $"Error in MoveToNodeByGuidAsync for robot {RobotId}: {ex.Message}");
}
finally
{
_methodLock.Release();
}
}
public Task<MessageResult> MoveToStationAsync(string nodeName, StationAction action, CancellationToken cancellationToken = default)
{
throw new NotImplementedException();
}
public async Task<MessageResult> SendInstantActionAsync(Action action, CancellationToken cancellationToken = default)
{
if (action == null)
{
_logger.Warning($"Cannot send instant action: action is null for robot {RobotId}");
return new(false, $"Cannot send instant action: action is null for robot {RobotId}");
}
await _methodLock.WaitAsync(cancellationToken);
try
{
var instantActions = new InstantActionsMsg
{
Actions = [action]
};
FillVDA5050Header(instantActions);
var publish = await _robotConnectionsService.PublishInstantActionsAsync(RobotId, instantActions, cancellationToken);
return new(publish);
}
catch (Exception ex)
{
_logger.Error($"Error sending instant action to robot {RobotId}: {ex.Message}");
return new(false, $"Error sending instant action to robot {RobotId}: {ex.Message}");
}
finally
{
_methodLock.Release();
}
}
public async Task<MessageResult> SendOrderAsync(OrderMsg order, CancellationToken cancellationToken = default)
{
if (order == null)
{
_logger.Warning($"Cannot send order: order message is null for robot {RobotId}");
return new(false, $"Cannot send order: order message is null for robot {RobotId}");
}
await _publishLock.WaitAsync(cancellationToken);
try
{
// Fill header if not already set
if (order.HeaderId == 0)
{
FillVDA5050Header(order);
}
else
{
// Ensure SerialNumber matches
order.SerialNumber = RobotId;
}
var published = await _robotConnectionsService.PublishOrderAsync(RobotId, order, cancellationToken);
// Update order in RobotData when successfully published
if (published)
{
CancellationTokenSource cancelSend = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cancelSend.CancelAfter(SendTimeOut);
while (true)
{
if (cancelSend.IsCancellationRequested) return new(false, "Publish order failed timout");
if (Data.State is not null)
{
if (Data.State.OrderId == order.OrderId && IsRobotBusy() && Data.State.OrderUpdateId == order.OrderUpdateId)
{
Data.LastUpdated = DateTime.UtcNow;
Data.Order = order;
return new(true);
}
}
}
}
return new(false, "Publish order failed");
}
catch (Exception ex)
{
_logger.Error($"Error sending order to robot {RobotId}: {ex.Message}");
return new(false, $"Error sending order to robot {RobotId}");
}
finally
{
_publishLock.Release();
}
}
public async Task<MessageResult> SendInstantActionsAsync(InstantActionsMsg instantActions, CancellationToken cancellationToken = default)
{
if (instantActions == null)
{
_logger.Warning($"Cannot send instant actions: instantActions message is null for robot {RobotId}");
return new(false, $"Cannot send instant actions: instantActions message is null for robot {RobotId}");
}
if (instantActions.Actions == null || instantActions.Actions.Length == 0)
{
_logger.Warning($"Cannot send instant actions: actions array is null or empty for robot {RobotId}");
return new(false, $"Cannot send instant actions: actions array is null or empty for robot {RobotId}");
}
await _publishLock.WaitAsync(cancellationToken);
try
{
// Fill header if not already set
if (instantActions.HeaderId == 0)
{
FillVDA5050Header(instantActions);
}
else
{
// Ensure SerialNumber matches
instantActions.SerialNumber = RobotId;
}
var publish = await _robotConnectionsService.PublishInstantActionsAsync(RobotId, instantActions, cancellationToken);
return new(publish);
}
catch (Exception ex)
{
_logger.Error($"Error sending instant actions to robot {RobotId}: {ex.Message}");
return new(false, $"Error sending instant actions to robot {RobotId}");
}
finally
{
_publishLock.Release();
}
}
public async Task<MessageResult> RequestFactsheetAsync(CancellationToken cancellationToken = default)
{
await _methodLock.WaitAsync(cancellationToken);
try
{
var action = new Action
{
ActionType = ActionType.FACTSHEET_REQUEST.ToJsonString(),
ActionId = Guid.NewGuid().ToString(),
BlockingType = BlockingType.NONE
};
return await SendInstantActionAsync(action, cancellationToken);
}
catch (Exception ex)
{
_logger.Error($"Error requesting factsheet from robot {RobotId}: {ex.Message}");
return new(false, $"Error requesting factsheet from robot {RobotId}");
}
finally
{
_methodLock.Release();
}
}
public async Task<MessageResult> RequestStateAsync(CancellationToken cancellationToken = default)
{
await _methodLock.WaitAsync(cancellationToken);
try
{
var action = new Action
{
ActionType = ActionType.STATE_REQUEST.ToJsonString(),
ActionId = Guid.NewGuid().ToString(),
BlockingType = BlockingType.NONE
};
return await SendInstantActionAsync(action, cancellationToken);
}
catch (Exception ex)
{
_logger.Error($"Error requesting state from robot {RobotId}: {ex.Message}");
return new(false, $"Error requesting state from robot {RobotId}");
}
finally
{
_methodLock.Release();
}
}
public async Task<MessageResult> CancelOrderAsync(CancellationToken cancellationToken = default)
{
try
{
var action = new Action
{
ActionType = ActionType.CANCEL_ORDER.ToJsonString(),
ActionId = Guid.NewGuid().ToString(),
BlockingType = BlockingType.NONE,
ActionDescription = "Cancel current order"
};
var result = await SendInstantActionAsync(action, cancellationToken);
if (result.IsSuccess && Data.State != null)
{
// Clear order state immediately so a new order can be accepted without waiting
// for the robot to report empty NodeStates/EdgeStates (avoids "robot is busy" after cancel).
Data.State.NodeStates = [];
Data.State.EdgeStates = [];
Data.State.OrderId = string.Empty;
Data.State.OrderUpdateId = 0;
Data.OrderClearedByCancelAt = DateTime.UtcNow;
_logger.Info($"Robot {RobotId}: cleared order state after cancel (ready for new order)");
}
return result;
}
catch (Exception ex)
{
_logger.Error($"Error canceling order for robot {RobotId}: {ex.Message}");
return new(false, $"Error canceling order for robot {RobotId}");
}
}
/// <summary>
/// Check if robot is busy with an order
/// </summary>
private bool IsRobotBusy()
{
// Robot is busy if order status is Sent or Accepted
return Data.State?.NodeStates.Length > 0 || Data.State?.EdgeStates.Length > 0;
}
/// <summary>
/// Get robot's levelId (MapId) from database
/// </summary>
private async Task<Guid?> GetRobotLevelIdAsync()
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
var robot = await robotService.GetByRobotIdAsync(RobotId);
return robot?.MapId; // MapId is the levelId
}
catch (Exception ex)
{
_logger.Error($"Error getting levelId for robot {RobotId}: {ex.Message}");
return null;
}
}
/// <summary>
/// Find node by NodeName in a level
/// </summary>
private async Task<RobotNet10.MapManager.Data.Node?> FindNodeByNameAsync(Guid levelId, string nodeName)
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
// Get all nodes in level
var nodes = await nodeService.GetNodesByLevelAsync(levelId);
// Find by NodeName (case-insensitive)
var node = nodes.FirstOrDefault(n =>
!string.IsNullOrEmpty(n.NodeName) &&
n.NodeName.Equals(nodeName, StringComparison.OrdinalIgnoreCase));
return node;
}
catch (Exception ex)
{
_logger.Error($"Error finding node by name '{nodeName}' in level {levelId}: {ex.Message}");
return null;
}
}
/// <summary>
/// Find node by NodeId (string) in a level
/// </summary>
private async Task<RobotNet10.MapManager.Data.Node?> FindNodeByNodeIdAsync(Guid levelId, string nodeId)
{
try
{
using var scope = _serviceScopeFactory.CreateScope();
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
// Get all nodes in level
var nodes = await nodeService.GetNodesByLevelAsync(levelId);
// Find by NodeId (case-insensitive)
var node = nodes.FirstOrDefault(n =>
n.NodeId.Equals(nodeId, StringComparison.OrdinalIgnoreCase));
return node;
}
catch (Exception ex)
{
_logger.Error($"Error finding node by NodeId '{nodeId}' in level {levelId}: {ex.Message}");
return null;
}
}
/// <summary>
/// Plan route and send order to robot
/// </summary>
private async Task<MessageResult> PlanRouteAndSendOrderAsync(Guid goalNodeId, double? angle, CancellationToken cancellationToken)
{
try
{
// Check if robot has state (required for route planning)
if (Data.State == null || Data.State.AgvPosition == null)
{
_logger.Warning($"Cannot plan route: robot {RobotId} has no state or position");
return new(false, $"Robot {RobotId} has no state or position");
}
// Cancel current order if exists
if (IsRobotBusy())
{
return new(false, $"The robot is working on order {Data.State.OrderId}");
}
// Plan route using TrafficControlService from current position
// Get current position from State.AgvPosition
var currentX = Data.State.AgvPosition.X;
var currentY = Data.State.AgvPosition.Y;
var currentThetaRadians = Data.State.AgvPosition.Theta; // radians
var currentThetaDegrees = currentThetaRadians * 180.0 / Math.PI; // convert to degrees
// Plan route from current position to goal node
RobotRoute? route = await _trafficControlService.PlanRouteFromPositionACSTrafficAsync(
RobotId,
currentX,
currentY,
currentThetaDegrees,
goalNodeId,
angle, // goalAngle in degrees
null, // startDirection
null, // finalDirection
cancellationToken);
if (route == null)
{
_logger.Warning($"Cannot plan route to node {goalNodeId} for robot {RobotId}");
return new(false, $"Failed to plan route to node {goalNodeId}");
}
return new(true);
}
catch (Exception ex)
{
_logger.Error($"Error planning route and sending order for robot {RobotId}: {ex.Message}");
return new(false, $"Error planning route: {ex.Message}");
}
}
private uint GetNextHeaderId()
{
lock (_headerIdLock)
{
_headerIdCounter++;
if (_headerIdCounter == 0) // Handle overflow
{
_headerIdCounter = 1;
}
return _headerIdCounter;
}
}
private void FillVDA5050Header(OrderMsg msg)
{
var config = _configManager.GetVDA5050Config();
msg.HeaderId = GetNextHeaderId();
msg.Timestamp = DateTime.UtcNow;
msg.Version = config.Version;
msg.Manufacturer = config.Manufacturer;
msg.SerialNumber = RobotId;
}
private void FillVDA5050Header(InstantActionsMsg msg)
{
var config = _configManager.GetVDA5050Config();
msg.HeaderId = 1;
msg.Timestamp = DateTime.UtcNow;
msg.Version = config.Version;
msg.Manufacturer = config.Manufacturer;
msg.SerialNumber = RobotId;
}
public void Dispose()
{
// Cleanup resources if needed
_methodLock?.Dispose();
// RobotData will be cleaned up by GC
_logger.Debug($"RobotController disposed for robot {RobotId}");
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,46 @@
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Services.RobotController;
using RobotNet10.FleetManager.Services.RobotManager.Models;
namespace RobotNet10.FleetManager.Services.RobotManager;
/// <summary>
/// Service for managing RobotController instances and routing events
/// </summary>
public interface IRobotManagerService
{
/// <summary>
/// Get RobotController instance for a robot
/// </summary>
IRobotController? GetRobotController(string robotId);
/// <summary>
/// Get all RobotController instances
/// </summary>
IReadOnlyDictionary<string, IRobotController> GetAllRobotControllers();
/// <summary>
/// Remove RobotController instance (when robot is deleted from DB)
/// </summary>
void RemoveRobotController(string robotId);
/// <summary>
/// Get RobotData for a robot (backward compatibility)
/// </summary>
RobotData? GetRobotData(string robotId);
/// <summary>
/// Get all RobotData (backward compatibility)
/// </summary>
IReadOnlyDictionary<string, RobotData> GetAllRobotData();
/// <summary>
/// Get list of available (online) robots
/// </summary>
IReadOnlyList<string> GetAvailableRobots();
/// <summary>
/// Get list of available robots with condititions
/// </summary>
Task<IReadOnlyList<string>> GetAvailableRobots(string layout, string version, string level, string model, Func<RobotState, bool> func);
}

View File

@@ -0,0 +1,14 @@
namespace RobotNet10.FleetManager.Services.RobotManager.Models;
/// <summary>
/// Order status enumeration
/// </summary>
//public enum OrderStatus
//{
// Pending, // Order đã tạo nhưng chưa gửi
// Sent, // Order đã gửi qua MQTT
// Accepted, // Robot đã accept order
// Rejected, // Robot reject order
// Completed, // Order hoàn thành
// Failed // Order failed
//}

View File

@@ -0,0 +1,55 @@
using RobotNet.VDA5050.Connection;
using RobotNet.VDA5050.Factsheet;
using RobotNet.VDA5050.Order;
using RobotNet.VDA5050.State;
using RobotNet.VDA5050.Type;
using RobotNet.VDA5050.Visualization;
namespace RobotNet10.FleetManager.Services.RobotManager.Models;
/// <summary>
/// RobotData chứa tất cả thông tin liên quan đến 1 robot
/// </summary>
public class RobotData
{
/// <summary>
/// Robot ID (SerialNumber)
/// </summary>
public string RobotId { get; set; } = string.Empty;
/// <summary>
/// Latest State message
/// </summary>
public StateMsg? State { get; set; }
/// <summary>
/// Latest Connection state
/// </summary>
public ConnectionState ConnectionState { get; set; }
/// <summary>
/// Latest Order message
/// </summary>
public OrderMsg? Order { get; set; }
/// <summary>
/// Latest Factsheet message
/// </summary>
public FactSheetMsg? Factsheet { get; set; }
/// <summary>
/// Latest Visualization message
/// </summary>
public VisualizationMsg? Visualization { get; set; }
/// <summary>
/// Last updated timestamp
/// </summary>
public DateTime LastUpdated { get; set; }
/// <summary>
/// When order state was cleared by cancel (so new order can be accepted before robot reports empty state).
/// Used to avoid overwriting with stale state from robot; cleared when robot sends empty NodeStates/EdgeStates or after timeout.
/// </summary>
public DateTime? OrderClearedByCancelAt { get; set; }
}

View File

@@ -0,0 +1,430 @@
using RobotNet.VDA5050.State;
using RobotNet.VDA5050.Type;
using RobotNet10.Common;
using RobotNet10.FleetManager.Controllers;
using RobotNet10.FleetManager.Events;
using RobotNet10.FleetManager.Events.Events;
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Services.ConfigManager;
using RobotNet10.FleetManager.Services.RobotConnections;
using RobotNet10.FleetManager.Services.RobotController;
using RobotNet10.FleetManager.Services.RobotManager.Models;
using RobotNet10.FleetManager.Services.TrafficControl;
using RobotNet10.MapManager.Services;
using System.Collections.Concurrent;
using System.Data;
using System.Threading.Tasks;
namespace RobotNet10.FleetManager.Services.RobotManager;
/// <summary>
/// Service implementation for managing RobotController instances and routing events
/// </summary>
/// <remarks>
/// This service:
/// - Manages RobotController instances per robot (instance per robot)
/// - Subscribes to Event Bus events and routes to RobotController
/// - Auto-creates RobotController when receiving first Connection/State message
/// - Timeout monitoring: 30s no State/Visualization → OFFLINE
/// </remarks>
public class RobotManagerService : BackgroundService, IRobotManagerService
{
private readonly IRobotEventBus _eventBus;
private readonly IRobotConnectionsService _robotConnectionsService;
private readonly IConnectionConfig _configManager;
private readonly ITrafficControlService _trafficControlService;
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly ILoggerFactory _loggerFactory;
private readonly Logger<RobotManagerService> _logger;
private readonly Logger<RobotController.RobotController> _loggerRobotController;
// RobotController instances - thread-safe dictionary
private readonly ConcurrentDictionary<string, IRobotController> _robotControllers = new();
// Timeout tracking - last update time for State and Visualization per robot
private readonly ConcurrentDictionary<string, (DateTime? lastStateUpdate, DateTime? lastVisualizationUpdate)> _lastUpdateTimes = new();
// Timeout monitoring timer
private WatchTimerAsync<RobotManagerService>? _timeoutTimer;
private const int TimeoutCheckIntervalMs = 15000; // 15 seconds
private const int TimeoutThresholdSeconds = 30; // 30 seconds
public RobotManagerService(
IRobotEventBus eventBus,
IRobotConnectionsService robotConnectionsService,
IConnectionConfig configManager,
ITrafficControlService trafficControlService,
IServiceScopeFactory serviceScopeFactory,
ILoggerFactory loggerFactory,
Logger<RobotManagerService> logger,
Logger<RobotController.RobotController> loggerRobotController)
{
_eventBus = eventBus;
_robotConnectionsService = robotConnectionsService;
_configManager = configManager;
_trafficControlService = trafficControlService;
_serviceScopeFactory = serviceScopeFactory;
_loggerFactory = loggerFactory;
_logger = logger;
_loggerRobotController = loggerRobotController;
// Subscribe to Event Bus events
_eventBus.StateMessageReceived += OnStateMessageReceived;
_eventBus.ConnectionStateChanged += OnConnectionStateChanged;
_eventBus.VisualizationMessageReceived += OnVisualizationMessageReceived;
_eventBus.FactsheetMessageReceived += OnFactsheetMessageReceived;
}
public IRobotController? GetRobotController(string robotId)
{
_robotControllers.TryGetValue(robotId, out var controller);
return controller;
}
public IReadOnlyDictionary<string, IRobotController> GetAllRobotControllers()
{
return _robotControllers;
}
public void RemoveRobotController(string robotId)
{
try
{
if (_robotControllers.TryRemove(robotId, out var controller))
{
controller.Dispose();
_lastUpdateTimes.TryRemove(robotId, out _);
_logger.Info($"Removed RobotController for robot {robotId}");
}
}
catch (Exception ex)
{
_logger.Error($"Error removing RobotController for robot {robotId}: {ex.Message}");
}
}
public RobotData? GetRobotData(string robotId)
{
var controller = GetRobotController(robotId);
return controller?.Data;
}
public IReadOnlyDictionary<string, RobotData> GetAllRobotData()
{
return _robotControllers.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.Data
);
}
public IReadOnlyList<string> GetAvailableRobots()
{
return [.. _robotControllers
.Where(kvp => kvp.Value.IsOnline)
.Select(kvp => kvp.Key)];
}
private static readonly TimeSpan OrderClearedByCancelWindow = TimeSpan.FromSeconds(15);
private void OnStateMessageReceived(object? sender, StateMessageReceivedEvent e)
{
try
{
var robotId = e.RobotId;
var stateMsg = e.StateMessage;
// Get or create RobotController
var controller = GetOrCreateRobotController(robotId);
if (controller == null) return;
// Update State in RobotData
controller.Data.State = stateMsg;
controller.Data.LastUpdated = DateTime.UtcNow;
var hasOrderState = (stateMsg.NodeStates?.Length ?? 0) > 0 || (stateMsg.EdgeStates?.Length ?? 0) > 0;
var cancelOrderTimedOut = stateMsg.ActionStates?.Any(a =>
(string.Equals(a.ActionType, "CANCEL_ORDER", StringComparison.OrdinalIgnoreCase) || a.ActionType?.Contains("cancelOrder", StringComparison.OrdinalIgnoreCase) == true) &&
a.ActionStatus == ActionStatus.FAILED &&
(a.ResultDescription?.Contains("Timeout", StringComparison.OrdinalIgnoreCase) ?? false)) == true;
// Force-clear order state when cancel was requested but timed out on robot (so FleetManager still shows robot as not busy)
if (hasOrderState && cancelOrderTimedOut)
{
controller.Data.State.NodeStates = [];
controller.Data.State.EdgeStates = [];
controller.Data.State.OrderId = string.Empty;
controller.Data.State.OrderUpdateId = 0;
}
// After cancel we clear order state locally; don't let stale robot state overwrite it until robot reports empty or window expires
else
{
var clearedAt = controller.Data.OrderClearedByCancelAt;
if (clearedAt.HasValue)
{
var elapsed = DateTime.UtcNow - clearedAt.Value;
if (elapsed >= OrderClearedByCancelWindow)
{
controller.Data.OrderClearedByCancelAt = null;
}
else if (hasOrderState)
{
// Robot hasn't reported empty yet; keep order state cleared so new order can be accepted
controller.Data.State.NodeStates = [];
controller.Data.State.EdgeStates = [];
controller.Data.State.OrderId = string.Empty;
controller.Data.State.OrderUpdateId = 0;
}
else
{
controller.Data.OrderClearedByCancelAt = null;
}
}
}
// Update last State update time
if(controller.Data.ConnectionState != RobotNet.VDA5050.Type.ConnectionState.ONLINE) controller.Data.ConnectionState = RobotNet.VDA5050.Type.ConnectionState.ONLINE;
UpdateLastStateTime(robotId);
}
catch (Exception ex)
{
_logger.Error($"Error processing state message for robot {e.RobotId}: {ex.Message}");
}
}
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEvent e)
{
try
{
var robotId = e.RobotId;
var connectionState = e.ConnectionState;
// Get or create RobotController
var controller = GetOrCreateRobotController(robotId);
if (controller == null) return;
// Update Connection State in RobotData
controller.Data.ConnectionState = connectionState;
controller.Data.LastUpdated = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.Error($"Error processing connection state change for robot {e.RobotId}: {ex.Message}");
}
}
private void OnVisualizationMessageReceived(object? sender, VisualizationMessageReceivedEvent e)
{
try
{
var robotId = e.RobotId;
var visualizationMsg = e.VisualizationMessage;
// Get or create RobotController
var controller = GetOrCreateRobotController(robotId);
if (controller == null) return;
// Update Visualization in RobotData
controller.Data.Visualization = visualizationMsg;
controller.Data.LastUpdated = DateTime.UtcNow;
// Update last Visualization update time
UpdateLastVisualizationTime(robotId);
}
catch (Exception ex)
{
_logger.Error($"Error processing visualization message for robot {e.RobotId}: {ex.Message}");
}
}
private void OnFactsheetMessageReceived(object? sender, FactsheetMessageReceivedEvent e)
{
try
{
var robotId = e.RobotId;
var factsheetMsg = e.FactsheetMessage;
// Get or create RobotController
var controller = GetOrCreateRobotController(robotId);
if (controller == null) return;
// Update Factsheet in RobotData
controller.Data.Factsheet = factsheetMsg;
controller.Data.LastUpdated = DateTime.UtcNow;
}
catch (Exception ex)
{
_logger.Error($"Error processing factsheet message for robot {e.RobotId}: {ex.Message}");
}
}
private IRobotController? GetOrCreateRobotController(string robotId)
{
// Check if already exists
if (_robotControllers.TryGetValue(robotId, out var existingController))
{
return existingController;
}
// Validate robot exists in database
using var scope = _serviceScopeFactory.CreateScope();
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
if (!_robotService.ExistsAsync(robotId).Result)
{
_logger.Warning($"Robot {robotId} not found in database, skipping RobotController creation");
return null;
}
// Create new RobotController instance
try
{
var controller = new Services.RobotController.RobotController(
robotId,
_robotConnectionsService,
_configManager,
_trafficControlService,
_serviceScopeFactory,
_loggerRobotController
);
if (_robotControllers.TryAdd(robotId, controller))
{
_lastUpdateTimes.TryAdd(robotId, (null, null));
_logger.Info($"Created RobotController for robot {robotId}");
return controller;
}
else
{
// Another thread created it, dispose this one and get the existing
((IDisposable)controller).Dispose();
_robotControllers.TryGetValue(robotId, out var createdController);
return createdController;
}
}
catch (Exception ex)
{
_logger.Error($"Error creating RobotController for robot {robotId}: {ex.Message}");
return null;
}
}
public async Task<IReadOnlyList<string>> GetAvailableRobots(string layout, string version, string level, string model, Func<RobotState, bool> func)
{
using var scope = _serviceScopeFactory.CreateScope();
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
var _layoutService = scope.ServiceProvider.GetRequiredService<ILayoutService>();
var layoutDb = await _layoutService.GetLayoutByNameAsync(layout);
if (layoutDb is null) return [];
var levelDb = layoutDb.Versions.FirstOrDefault(v => v.Version == version)?.Levels.FirstOrDefault(l => l.LayoutLevelId == level);
if (levelDb is null) return [];
var robotDbs = await _robotService.GetByModelNameAsync(model);
if (robotDbs is null) return [];
var robotDbinMap = robotDbs.Where(r => r.MapId == levelDb.Id);
List<IRobotController> robotControllers = [.. _robotControllers.Where(kvp => kvp.Value.IsOnline && robotDbinMap.Any(r => r.RobotId == kvp.Key)).Select(kvp => kvp.Value)];
return [..robotControllers.Where(r => r.IsOnline && r.Data is not null && r.Data.State is not null && r.Data.Visualization is not null && func(ToRobotState(r))).Select(r => r.RobotId)];
}
private static RobotState ToRobotState(IRobotController robotController)
{
return new RobotState(robotController.IsReady,
robotController.Data.State?.BatteryState.BatteryVoltage ?? 0,
robotController.Data.State?.Loads ?? [],
robotController.Data.State?.BatteryState.Charging ?? false,
robotController.Data.Visualization?.AgvPosition.X ?? 0,
robotController.Data.Visualization?.AgvPosition.Y ?? 0,
robotController.Data.Visualization?.AgvPosition.Theta ?? 0);
}
private void UpdateLastStateTime(string robotId)
{
_lastUpdateTimes.AddOrUpdate(robotId,
(DateTime.UtcNow, null),
(key, oldValue) => (DateTime.UtcNow, oldValue.lastVisualizationUpdate));
}
private void UpdateLastVisualizationTime(string robotId)
{
_lastUpdateTimes.AddOrUpdate(robotId,
(null, DateTime.UtcNow),
(key, oldValue) => (oldValue.lastStateUpdate, DateTime.UtcNow));
}
private async Task CheckTimeouts()
{
try
{
var now = DateTime.UtcNow;
var robotsToCheck = _robotControllers.Keys.ToList();
foreach (var robotId in robotsToCheck)
{
if (!_lastUpdateTimes.TryGetValue(robotId, out var updateTimes))
{
continue;
}
var stateTimeout = updateTimes.lastStateUpdate.HasValue &&
(now - updateTimes.lastStateUpdate.Value).TotalSeconds > TimeoutThresholdSeconds;
var visualizationTimeout = updateTimes.lastVisualizationUpdate.HasValue &&
(now - updateTimes.lastVisualizationUpdate.Value).TotalSeconds > TimeoutThresholdSeconds;
// If both State and Visualization timeout, set OFFLINE
if (stateTimeout && visualizationTimeout)
{
if (_robotControllers.TryGetValue(robotId, out var controller))
{
if (controller.Data.ConnectionState != RobotNet.VDA5050.Type.ConnectionState.OFFLINE)
{
controller.Data.ConnectionState = RobotNet.VDA5050.Type.ConnectionState.OFFLINE;
controller.Data.LastUpdated = now;
_logger.Warning($"Robot {robotId} timed out (30s no State/Visualization), set to OFFLINE");
}
}
}
}
}
catch (Exception ex)
{
_logger.Error($"Error in timeout check: {ex.Message}");
}
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Yield();
await _robotConnectionsService.StartAsync(stoppingToken);
_timeoutTimer = new WatchTimerAsync<RobotManagerService>(
TimeoutCheckIntervalMs,
CheckTimeouts,
_loggerFactory.CreateLogger<RobotManagerService>()
);
_timeoutTimer.Start();
_logger.Info("Started timeout monitoring (15s interval, 30s threshold)");
}
public override Task StopAsync(CancellationToken cancellationToken)
{
_timeoutTimer?.Dispose();
// Dispose all RobotController instances
foreach (var controller in _robotControllers.Values)
{
try
{
controller.Dispose();
}
catch (Exception ex)
{
_logger.Error($"Error disposing RobotController: {ex.Message}");
}
}
_robotControllers.Clear();
_lastUpdateTimes.Clear();
return base.StopAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,159 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RobotNet10.StorageManager;
using SixLabors.ImageSharp;
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Service implementation for robot model image storage using StorageManager
/// Stores images with naming: {robotModelId}.png
/// </summary>
public class RobotModelImageStorageService : IRobotModelImageStorageService, IDisposable
{
private readonly ILogger<RobotModelImageStorageService> _logger;
private readonly StorageManager.StorageManager _storageManager;
private const string ImagePath = "robotModelImages";
private const string ContentType = "image/png";
public RobotModelImageStorageService(IOptionsMonitor<StorageConfig> optionsSnapshot, ILogger<RobotModelImageStorageService> logger)
{
_logger = logger;
var config = optionsSnapshot.Get("RobotModelImages");
ArgumentNullException.ThrowIfNull(config);
_storageManager = new StorageManager.StorageManager(config);
if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("RobotModelImageStorageService initialized");
}
}
private static string GetObjectName(Guid robotModelId) => robotModelId.ToString();
public async Task SaveImageAsync(Guid robotModelId, Stream imageStream, CancellationToken cancellationToken = default)
{
var objectName = GetObjectName(robotModelId);
try
{
// Reset stream position if seekable
if (imageStream.CanSeek)
{
imageStream.Position = 0;
}
// Get stream size - handle cases where Length might not be available
long size = imageStream.Length;
// If size is 0 or stream doesn't support Length, copy to MemoryStream
if (size == 0 || !imageStream.CanSeek)
{
using var memoryStream = new MemoryStream();
await imageStream.CopyToAsync(memoryStream, cancellationToken);
size = memoryStream.Length;
memoryStream.Position = 0;
await _storageManager.UploadAsync(ImagePath, objectName, memoryStream, size, ContentType, cancellationToken);
}
else
{
// Stream has valid length and is seekable, use directly
await _storageManager.UploadAsync(ImagePath, objectName, imageStream, size, ContentType, cancellationToken);
}
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to save image for robot model {RobotModelId}", robotModelId);
throw;
}
}
public async Task<Stream?> GetImageAsync(Guid robotModelId, CancellationToken cancellationToken = default)
{
var objectName = GetObjectName(robotModelId);
try
{
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
if (!exists)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for robot model {RobotModelId}", robotModelId);
return null;
}
var stream = await _storageManager.GetFileAsync(ImagePath, objectName, cancellationToken);
return stream;
}
catch (FileNotFoundException)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for robot model {RobotModelId}", robotModelId);
return null;
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to read image for robot model {RobotModelId}", robotModelId);
throw;
}
}
public async Task<bool> DeleteImageAsync(Guid robotModelId, CancellationToken cancellationToken = default)
{
var objectName = GetObjectName(robotModelId);
try
{
var exists = await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
if (!exists)
{
if (_logger.IsEnabled(LogLevel.Warning)) _logger.LogWarning("Image not found for deletion: {RobotModelId}", robotModelId);
return false;
}
await _storageManager.DeleteAsync(ImagePath, objectName, cancellationToken);
return true;
}
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) _logger.LogError(ex, "Failed to delete image for robot model {RobotModelId}", robotModelId);
throw;
}
}
public async Task<bool> ImageExistsAsync(Guid robotModelId, CancellationToken cancellationToken = default)
{
var objectName = GetObjectName(robotModelId);
return await _storageManager.ExistsAsync(ImagePath, objectName, cancellationToken);
}
public async Task<(int width, int height)> GetImageDimensionsAsync(Stream imageStream, CancellationToken cancellationToken = default)
{
try
{
// Reset stream position if seekable
if (imageStream.CanSeek)
{
imageStream.Position = 0;
}
// Load image to get dimensions
using var image = await Image.LoadAsync(imageStream, cancellationToken);
return (image.Width, image.Height);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to extract image dimensions");
throw new InvalidOperationException("Invalid image format or corrupted file", ex);
}
}
public void Dispose()
{
_storageManager?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,370 @@
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
using RobotNet10.MapEditor.Shared.DTOs.Edge;
using RobotNet10.MapEditor.Shared.DTOs.Node;
using RobotNet10.MapManager.Data;
using RobotNet10.MapManager.Services;
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Service implementation for managing robot model map data based on VehicleType filtering
/// </summary>
public class RobotModelMapService(
IRobotModelService robotModelService,
IVehicleTypeService vehicleTypeService,
IMapQueryService mapQueryService,
ILayoutService layoutService,
Logger<RobotModelMapService> logger) : IRobotModelMapService
{
private readonly IRobotModelService _robotModelService = robotModelService;
private readonly IVehicleTypeService _vehicleTypeService = vehicleTypeService;
private readonly IMapQueryService _mapQueryService = mapQueryService;
private readonly ILayoutService _layoutService = layoutService;
private readonly Logger<RobotModelMapService> _logger = logger;
public async Task<List<Node>> GetFilteredNodesAsync(Guid robotModelId)
{
// Get RobotModel
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
// Validate VehicleTypeId is set
if (!robotModel.VehicleTypeId.HasValue)
{
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
}
var vehicleTypeId = robotModel.VehicleTypeId.Value;
// Validate VehicleType exists
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
if (!vehicleType.IsActive)
{
_logger.Warning($"VehicleType '{vehicleType.VehicleTypeName}' is not active.");
}
// Get filtered nodes: nodes that have NodeVehicleProperties for this VehicleType
var filteredNodes = await _mapQueryService.GetNodesByVehicleTypeAsync(vehicleTypeId);
_logger.Info($"Found {filteredNodes.Count} filtered nodes for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}')");
return filteredNodes;
}
public async Task<List<Edge>> GetFilteredEdgesAsync(Guid robotModelId)
{
// Get RobotModel
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
// Validate VehicleTypeId is set
if (!robotModel.VehicleTypeId.HasValue)
{
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
}
var vehicleTypeId = robotModel.VehicleTypeId.Value;
// Validate VehicleType exists
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
// Get filtered edges: edges that have EdgeVehicleProperties for this VehicleType
var filteredEdges = await _mapQueryService.GetEdgesByVehicleTypeAsync(vehicleTypeId);
_logger.Info($"Found {filteredEdges.Count} filtered edges for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}')");
return filteredEdges;
}
public async Task<List<Node>> GetFilteredNodesByLevelAsync(Guid robotModelId, Guid levelId)
{
// Get RobotModel
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
// Validate VehicleTypeId is set
if (!robotModel.VehicleTypeId.HasValue)
{
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
}
var vehicleTypeId = robotModel.VehicleTypeId.Value;
// Validate VehicleType exists
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
if (!vehicleType.IsActive)
{
_logger.Warning($"VehicleType '{vehicleType.VehicleTypeName}' is not active.");
}
// Get filtered nodes: nodes that have NodeVehicleProperties for this VehicleType and belong to the specified level
var filteredNodes = await _mapQueryService.GetNodesByVehicleTypeAndLevelAsync(vehicleTypeId, levelId);
_logger.Info($"Found {filteredNodes.Count} filtered nodes for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}') in LevelId={levelId}");
return filteredNodes;
}
public async Task<List<Edge>> GetFilteredEdgesByLevelAsync(Guid robotModelId, Guid levelId)
{
// Get RobotModel
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
// Validate VehicleTypeId is set
if (!robotModel.VehicleTypeId.HasValue)
{
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
}
var vehicleTypeId = robotModel.VehicleTypeId.Value;
// Validate VehicleType exists
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
// Get filtered edges: edges that have EdgeVehicleProperties for this VehicleType and belong to the specified level
var filteredEdges = await _mapQueryService.GetEdgesByVehicleTypeAndLevelAsync(vehicleTypeId, levelId);
_logger.Info($"Found {filteredEdges.Count} filtered edges for RobotModel '{robotModel.ModelName}' (VehicleType: '{vehicleType.VehicleTypeName}') in LevelId={levelId}");
return filteredEdges;
}
public async Task<RobotModelMapDataDto> GetValidatedMapDataAsync(Guid robotModelId)
{
// Get RobotModel
var robotModel = await _robotModelService.GetByIdAsync(robotModelId)
?? throw new KeyNotFoundException($"RobotModel with ID {robotModelId} not found.");
// Validate VehicleTypeId is set
if (!robotModel.VehicleTypeId.HasValue)
{
throw new InvalidOperationException($"RobotModel '{robotModel.ModelName}' does not have VehicleTypeId set.");
}
var vehicleTypeId = robotModel.VehicleTypeId.Value;
// Get VehicleType
var vehicleType = await _vehicleTypeService.GetByIdAsync(vehicleTypeId)
?? throw new KeyNotFoundException($"VehicleType with ID {vehicleTypeId} not found.");
// Get filtered nodes and edges (from all levels that match VehicleType)
// Note: If RobotModel needs to filter by specific LevelId, MapId should be added to RobotModel
var filteredNodes = await GetFilteredNodesAsync(robotModelId);
var filteredEdges = await GetFilteredEdgesAsync(robotModelId);
// Get total counts across all levels (for reference)
// In the future, if RobotModel has MapId (LevelId), we can filter by specific level
int totalNodesInLevel = await _mapQueryService.GetTotalNodesCountAsync();
int totalEdgesInLevel = await _mapQueryService.GetTotalEdgesCountAsync();
string? levelName = null;
Guid? levelId = null;
// If we have filtered nodes, get the level from first node (for display)
if (filteredNodes.Count > 0)
{
var firstNode = filteredNodes.First();
var level = await _layoutService.GetLevelAsync(firstNode.LevelId);
levelName = level?.LayoutLevelId;
levelId = level?.Id;
}
// Validate: Remove nodes without edges, edges without both nodes
var nodeIds = new HashSet<Guid>(filteredNodes.Select(n => n.Id));
var validNodeIds = new HashSet<Guid>();
var validEdges = new List<Edge>();
// First pass: Find edges that have both start and end nodes in filtered nodes
foreach (var edge in filteredEdges)
{
if (nodeIds.Contains(edge.StartNodeId) && nodeIds.Contains(edge.EndNodeId))
{
validEdges.Add(edge);
validNodeIds.Add(edge.StartNodeId);
validNodeIds.Add(edge.EndNodeId);
}
}
// Second pass: Keep only nodes that are connected by valid edges
var validNodes = filteredNodes.Where(n => validNodeIds.Contains(n.Id)).ToList();
// Count removed items
int nodesRemoved = filteredNodes.Count - validNodes.Count;
int edgesRemoved = filteredEdges.Count - validEdges.Count;
// Build validation result
var validationResult = new MapValidationResultDto
{
IsValid = nodesRemoved == 0 && edgesRemoved == 0,
NodesRemoved = nodesRemoved,
EdgesRemoved = edgesRemoved
};
// Add errors for removed nodes
foreach (var node in filteredNodes.Where(n => !validNodeIds.Contains(n.Id)))
{
validationResult.Errors.Add(new ValidationError
{
Code = "NODE_NO_EDGES",
Message = $"Node '{node.NodeId}' has no connected edges in the filtered map",
EntityId = node.Id.ToString(),
EntityType = "Node"
});
}
// Add errors for removed edges
foreach (var edge in filteredEdges.Where(e => !validEdges.Contains(e)))
{
var missingStart = !nodeIds.Contains(edge.StartNodeId);
var missingEnd = !nodeIds.Contains(edge.EndNodeId);
if (missingStart && missingEnd)
{
validationResult.Errors.Add(new ValidationError
{
Code = "EDGE_MISSING_BOTH_NODES",
Message = $"Edge '{edge.EdgeId}' is missing both start and end nodes",
EntityId = edge.Id.ToString(),
EntityType = "Edge"
});
}
else if (missingStart)
{
validationResult.Errors.Add(new ValidationError
{
Code = "EDGE_MISSING_START_NODE",
Message = $"Edge '{edge.EdgeId}' is missing start node",
EntityId = edge.Id.ToString(),
EntityType = "Edge"
});
}
else if (missingEnd)
{
validationResult.Errors.Add(new ValidationError
{
Code = "EDGE_MISSING_END_NODE",
Message = $"Edge '{edge.EdgeId}' is missing end node",
EntityId = edge.Id.ToString(),
EntityType = "Edge"
});
}
}
// Convert to DTOs
var nodeDtos = validNodes.Select(n => new NodeDto
{
Id = n.Id,
LevelId = n.LevelId,
NodeId = n.NodeId,
NodeName = n.NodeName,
NodeDescription = n.NodeDescription,
MapId = n.MapId,
X = n.X,
Y = n.Y,
VehicleProperties = [.. n.VehicleProperties
.Where(vp => vp.VehicleTypeId == vehicleTypeId)
.Select(vp => new NodeVehiclePropertyDto
{
Id = vp.Id,
NodeId = vp.NodeId,
VehicleTypeId = vp.VehicleTypeId,
Theta = vp.Theta,
Actions = vp.Actions
})]
}).ToList();
var edgeDtos = validEdges.Select(e => new EdgeDto
{
Id = e.Id,
LevelId = e.LevelId,
EdgeId = e.EdgeId,
EdgeName = e.EdgeName,
EdgeDescription = e.EdgeDescription,
StartNodeId = e.StartNodeId,
EndNodeId = e.EndNodeId,
StartNode = nodeDtos.FirstOrDefault(n => n.Id == e.StartNodeId),
EndNode = nodeDtos.FirstOrDefault(n => n.Id == e.EndNodeId),
VehicleProperties = [.. e.VehicleProperties
.Where(vp => vp.VehicleTypeId == vehicleTypeId)
.Select(vp => new EdgeVehiclePropertyDto
{
Id = vp.Id,
EdgeId = vp.EdgeId,
VehicleTypeId = vp.VehicleTypeId,
VehicleOrientation = vp.VehicleOrientation,
OrientationType = vp.OrientationType,
RotationAllowed = vp.RotationAllowed,
RotationAtStartNodeAllowed = vp.RotationAtStartNodeAllowed,
RotationAtEndNodeAllowed = vp.RotationAtEndNodeAllowed,
MaxSpeed = vp.MaxSpeed,
MaxRotationSpeed = vp.MaxRotationSpeed,
MinHeight = vp.MinHeight,
MaxHeight = vp.MaxHeight,
LoadRestriction = null,
Actions = vp.Actions
})]
}).ToList();
var result = new RobotModelMapDataDto
{
RobotModelId = robotModel.Id,
RobotModelName = robotModel.ModelName,
VehicleTypeId = vehicleTypeId,
VehicleTypeName = vehicleType.VehicleTypeName,
LevelId = levelId,
LevelName = levelName,
ValidNodes = nodeDtos,
ValidEdges = edgeDtos,
TotalNodesInLevel = totalNodesInLevel,
TotalEdgesInLevel = totalEdgesInLevel,
FilteredNodesCount = filteredNodes.Count,
FilteredEdgesCount = filteredEdges.Count,
ValidationResult = validationResult
};
_logger.Info($"Validated map data for RobotModel '{robotModel.ModelName}': {validNodes.Count} valid nodes, {validEdges.Count} valid edges (removed {nodesRemoved} nodes, {edgesRemoved} edges)");
return result;
}
public async Task<MapValidationResultDto> ValidateMapForRobotModelAsync(Guid robotModelId)
{
var mapData = await GetValidatedMapDataAsync(robotModelId);
return mapData.ValidationResult;
}
public async Task<bool> HasValidMapConfigurationAsync(Guid robotModelId)
{
try
{
var robotModel = await _robotModelService.GetByIdAsync(robotModelId);
if (robotModel == null)
return false;
if (!robotModel.VehicleTypeId.HasValue)
return false;
// Check if VehicleType exists and is active
var vehicleType = await _vehicleTypeService.GetByIdAsync(robotModel.VehicleTypeId.Value);
if (vehicleType == null || !vehicleType.IsActive)
return false;
// Validate map data
var validationResult = await ValidateMapForRobotModelAsync(robotModelId);
return validationResult.IsValid;
}
catch (Exception ex)
{
_logger.Error($"Error checking valid map configuration for RobotModel {robotModelId}: {ex.Message}");
return false;
}
}
}

View File

@@ -0,0 +1,207 @@
using Microsoft.EntityFrameworkCore;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Events;
using RobotNet10.FleetManager.Events.Events;
using RobotNet10.FleetManager.Shared.DTOs.RobotModel;
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Service implementation for managing robot models.
/// Handles business logic for CRUD operations on robot models.
/// </summary>
/// <remarks>
/// This service validates business rules such as duplicate model names,
/// checks for associated robots before deletion, and provides search functionality.
/// </remarks>
public class RobotModelService(
ApplicationDbContext context,
Logger<RobotModelService> logger,
IRobotEventBus? eventBus = null) : IRobotModelService
{
private readonly ApplicationDbContext _context = context;
private readonly Logger<RobotModelService> _logger = logger;
private readonly IRobotEventBus? _eventBus = eventBus;
public async Task<RobotModel> CreateAsync(CreateRobotModelRequest request)
{
// Check if model name already exists
if (await ExistsAsync(request.ModelName))
{
throw new InvalidOperationException($"Robot model with name '{request.ModelName}' already exists.");
}
var robotModel = new RobotModel
{
Id = Guid.NewGuid(),
ModelName = request.ModelName,
Length = request.Length,
Width = request.Width,
ImageWidth = request.ImageWidth,
ImageHeight = request.ImageHeight,
NavigationPointX = request.NavigationPointX,
NavigationPointY = request.NavigationPointY,
NavigationType = request.NavigationType,
VehicleTypeId = request.VehicleTypeId,
CreatedDate = DateTime.UtcNow
};
_context.RobotModels.Add(robotModel);
await _context.SaveChangesAsync();
_logger.Info($"Created robot model {robotModel.ModelName} with ID {robotModel.Id}");
return robotModel;
}
public async Task<List<RobotModel>> GetAllAsync()
{
return await _context.RobotModels
.AsNoTracking()
.OrderBy(rm => rm.ModelName)
.ToListAsync();
}
public async Task<RobotModel?> GetByIdAsync(Guid id)
{
return await _context.RobotModels
.AsNoTracking()
.Include(rm => rm.Robots)
.FirstOrDefaultAsync(rm => rm.Id == id);
}
public async Task<RobotModel> UpdateAsync(Guid id, UpdateRobotModelRequest request)
{
var robotModel = await _context.RobotModels.FindAsync(id) ?? throw new KeyNotFoundException($"Robot model with ID {id} not found.");
// Check if model name is being changed and if new name already exists
if (request.ModelName != null && request.ModelName != robotModel.ModelName)
{
if (await ExistsAsync(request.ModelName))
{
throw new InvalidOperationException($"Robot model with name '{request.ModelName}' already exists.");
}
robotModel.ModelName = request.ModelName;
}
if (request.Length.HasValue)
robotModel.Length = request.Length.Value;
if (request.Width.HasValue)
robotModel.Width = request.Width.Value;
if (request.ImageWidth.HasValue)
robotModel.ImageWidth = request.ImageWidth.Value;
if (request.ImageHeight.HasValue)
robotModel.ImageHeight = request.ImageHeight.Value;
if (request.NavigationPointX.HasValue)
robotModel.NavigationPointX = request.NavigationPointX.Value;
if (request.NavigationPointY.HasValue)
robotModel.NavigationPointY = request.NavigationPointY.Value;
if (request.NavigationType.HasValue)
robotModel.NavigationType = request.NavigationType.Value;
// VehicleTypeId: nullable, can be set or cleared
// Note: In ASP.NET Core, if VehicleTypeId is in the JSON request, it will be bound
// If not in JSON, it remains the default (null for nullable Guid?)
// For simplicity, we always update VehicleTypeId if the request contains it
// This allows setting to null by sending "VehicleTypeId": null in JSON
// To avoid updating when not provided, we'd need a different approach (e.g., use a wrapper DTO)
// For now, we'll always update if the property exists in the request object
robotModel.VehicleTypeId = request.VehicleTypeId;
robotModel.UpdatedDate = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.Info($"Updated robot model {robotModel.ModelName} with ID {robotModel.Id}");
// Publish event for cache invalidation
if (_eventBus != null)
{
try
{
// Get all robots using this model
var affectedRobots = await _context.Robots
.Where(r => r.ModelId == robotModel.Id)
.Select(r => r.RobotId)
.ToListAsync();
var eventData = new RobotModelUpdatedEvent
{
ModelId = robotModel.Id,
AffectedRobotIds = affectedRobots
};
_eventBus.PublishRobotModelUpdated(eventData);
_logger.Debug($"Published RobotModelUpdated event for ModelId {robotModel.Id} affecting {affectedRobots.Count} robot(s)");
}
catch (Exception ex)
{
_logger.Error($"Error publishing RobotModelUpdated event: {ex.Message}");
// Don't fail the update if event publishing fails
}
}
return robotModel;
}
public async Task<bool> DeleteAsync(Guid id)
{
var robotModel = await _context.RobotModels
.Include(rm => rm.Robots)
.FirstOrDefaultAsync(rm => rm.Id == id);
if (robotModel == null)
{
return false;
}
// Check if there are any robots using this model
if (robotModel.Robots.Count != 0)
{
throw new InvalidOperationException($"Cannot delete robot model '{robotModel.ModelName}' because it is being used by {robotModel.Robots.Count} robot(s).");
}
_context.RobotModels.Remove(robotModel);
await _context.SaveChangesAsync();
_logger.Info($"Deleted robot model {robotModel.ModelName} with ID {robotModel.Id}");
return true;
}
public async Task<bool> ExistsAsync(string modelName)
{
return await _context.RobotModels
.AnyAsync(rm => rm.ModelName == modelName);
}
public async Task<List<RobotModel>> SearchAsync(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return await GetAllAsync();
}
var lowerQuery = query.ToLowerInvariant();
return await _context.RobotModels
.AsNoTracking()
.Where(rm => rm.ModelName.ToLower().Contains(lowerQuery))
.OrderBy(rm => rm.ModelName)
.ToListAsync();
}
public async Task<RobotModelUsageInfoDto> GetUsageInfoAsync(Guid id)
{
var robotModel = await _context.RobotModels
.AsNoTracking()
.Include(rm => rm.Robots)
.FirstOrDefaultAsync(rm => rm.Id == id);
return robotModel == null
? throw new KeyNotFoundException($"Robot model with ID {id} not found.")
: new RobotModelUsageInfoDto
{
Id = robotModel.Id,
ModelName = robotModel.ModelName,
RobotCount = robotModel.Robots.Count
};
}
}

View File

@@ -0,0 +1,222 @@
using Microsoft.EntityFrameworkCore;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Events;
using RobotNet10.FleetManager.Events.Events;
using RobotNet10.FleetManager.Shared.DTOs.Robot;
namespace RobotNet10.FleetManager.Services;
/// <summary>
/// Service implementation for managing robots.
/// Handles business logic for CRUD operations on robots.
/// </summary>
/// <remarks>
/// This service validates business rules such as duplicate robot IDs,
/// ensures referenced robot models exist, and provides filtering and search functionality.
/// </remarks>
public class RobotService(
ApplicationDbContext context,
Logger<RobotService> logger,
IRobotEventBus? eventBus = null) : IRobotService
{
private readonly ApplicationDbContext _context = context;
private readonly Logger<RobotService> _logger = logger;
private readonly IRobotEventBus? _eventBus = eventBus;
public async Task<Robot> CreateAsync(CreateRobotRequest request)
{
// Check if robot ID already exists
if (await ExistsAsync(request.RobotId))
{
throw new InvalidOperationException($"Robot with ID '{request.RobotId}' already exists.");
}
// Verify that the model exists
var modelExists = await _context.RobotModels.AnyAsync(rm => rm.Id == request.ModelId);
if (!modelExists)
{
throw new KeyNotFoundException($"Robot model with ID {request.ModelId} not found.");
}
var robot = new Robot
{
Id = Guid.NewGuid(),
RobotId = request.RobotId,
Name = request.Name,
ModelId = request.ModelId,
MapId = request.MapId,
CreatedDate = DateTime.UtcNow
};
_context.Robots.Add(robot);
await _context.SaveChangesAsync();
_logger.Info($"Created robot {robot.Name} with RobotId {robot.RobotId} and ID {robot.Id}");
return robot;
}
public async Task<List<Robot>> GetAllAsync(Guid? modelId = null, Guid? mapId = null)
{
var query = _context.Robots.AsNoTracking().Include(r => r.Model).AsQueryable();
if (modelId.HasValue)
{
query = query.Where(r => r.ModelId == modelId.Value);
}
if (mapId.HasValue)
{
query = query.Where(r => r.MapId == mapId.Value);
}
return await query
.OrderBy(r => r.Name)
.ToListAsync();
}
public async Task<Robot?> GetByIdAsync(Guid id)
{
return await _context.Robots
.AsNoTracking()
.Include(r => r.Model)
.FirstOrDefaultAsync(r => r.Id == id);
}
public async Task<Robot?> GetByRobotIdAsync(string robotId)
{
return await _context.Robots
.AsNoTracking()
.Include(r => r.Model)
.FirstOrDefaultAsync(r => r.RobotId == robotId);
}
public async Task<Robot> UpdateAsync(Guid id, UpdateRobotRequest request)
{
var robot = await _context.Robots.FindAsync(id) ?? throw new KeyNotFoundException($"Robot with ID {id} not found.");
// Check if robot ID is being changed and if new ID already exists
if (request.RobotId != null && request.RobotId != robot.RobotId)
{
if (await ExistsAsync(request.RobotId))
{
throw new InvalidOperationException($"Robot with ID '{request.RobotId}' already exists.");
}
robot.RobotId = request.RobotId;
}
if (request.Name != null)
robot.Name = request.Name;
if (request.ModelId.HasValue)
{
// Verify that the model exists
var modelExists = await _context.RobotModels.AnyAsync(rm => rm.Id == request.ModelId.Value);
if (!modelExists)
{
throw new KeyNotFoundException($"Robot model with ID {request.ModelId.Value} not found.");
}
// Check if ModelId is actually changing
var previousModelId = robot.ModelId;
if (previousModelId != request.ModelId.Value)
{
robot.ModelId = request.ModelId.Value;
// Publish event for cache invalidation
if (_eventBus != null)
{
try
{
var eventData = new RobotModelIdChangedEvent
{
RobotId = robot.RobotId,
PreviousModelId = previousModelId,
NewModelId = request.ModelId.Value
};
_eventBus.PublishRobotModelIdChanged(eventData);
_logger.Debug($"Published RobotModelIdChanged event for robot {robot.RobotId} (from {previousModelId} to {request.ModelId.Value})");
}
catch (Exception ex)
{
_logger.Error($"Error publishing RobotModelIdChanged event: {ex.Message}");
// Don't fail the update if event publishing fails
}
}
}
}
// MapId: if provided (including null), update it
// Note: In C#, nullable Guid? means: HasValue = true means a value was provided (could be Guid.Empty or a valid Guid)
// To distinguish between "not provided" and "explicitly set to null", we'd need a different approach
// For now, we'll only update MapId if it has a value (non-null)
if (request.MapId.HasValue)
{
robot.MapId = request.MapId.Value;
}
robot.UpdatedDate = DateTime.UtcNow;
await _context.SaveChangesAsync();
_logger.Info($"Updated robot {robot.Name} with RobotId {robot.RobotId} and ID {robot.Id}");
return robot;
}
public async Task<bool> DeleteAsync(Guid id)
{
var robot = await _context.Robots.FindAsync(id);
if (robot == null)
{
return false;
}
_context.Robots.Remove(robot);
await _context.SaveChangesAsync();
_logger.Info($"Deleted robot {robot.Name} with RobotId {robot.RobotId} and ID {robot.Id}");
return true;
}
public async Task<List<Robot>> GetByModelIdAsync(Guid modelId)
{
return await _context.Robots
.AsNoTracking()
.Include(r => r.Model)
.Where(r => r.ModelId == modelId)
.OrderBy(r => r.Name)
.ToListAsync();
}
public async Task<List<Robot>> SearchAsync(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
return await GetAllAsync();
}
var lowerQuery = query.ToLowerInvariant();
return await _context.Robots
.AsNoTracking()
.Include(r => r.Model)
.Where(r => r.RobotId.ToLower().Contains(lowerQuery) || r.Name.ToLower().Contains(lowerQuery))
.OrderBy(r => r.Name)
.ToListAsync();
}
public async Task<bool> ExistsAsync(string robotId)
{
return await _context.Robots
.AnyAsync(r => r.RobotId == robotId);
}
public async Task<List<Robot>> GetByModelNameAsync(string modelName)
{
return await _context.Robots
.AsNoTracking()
.Include(r => r.Model)
.Where(r => r.Name == modelName)
.OrderBy(r => r.Name)
.ToListAsync();
}
}

View File

@@ -0,0 +1,21 @@
using RobotNet10.FleetManager.Script;
namespace RobotNet10.FleetManager.Services.Script;
public class ScriptLayoutManager : ILayoutManager
{
public Task<RobotNet.VDA5050.InstantAction.Action> GetAction(string layout, string version, string level, string name, string robotId)
{
throw new NotImplementedException();
}
public Task<INode> GetNode(string layout, string version, string level, string name)
{
throw new NotImplementedException();
}
public Task<IStation> GetStation(string layout, string version, string level, string name)
{
throw new NotImplementedException();
}
}

View File

@@ -0,0 +1,52 @@
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Services.RobotController;
namespace RobotNet10.FleetManager.Services.Script;
public class ScriptRobot(string robotId, string robotName, Guid modelId, Guid? mapId, IRobotController robotController) : IRobot
{
public string RobotId { get; } = robotId;
public string Name { get; } = robotName;
public Guid ModelId { get; } = modelId;
public Guid? MapId { get; } = mapId;
public RobotState State => new( robotController.IsReady,
robotController.Data.State?.BatteryState.BatteryVoltage ?? 0,
robotController.Data.State?.Loads ?? [],
robotController.Data.State?.BatteryState.Charging ?? false,
robotController.Data.Visualization?.AgvPosition.X ?? 0,
robotController.Data.Visualization?.AgvPosition.Y ?? 0,
robotController.Data.Visualization?.AgvPosition.Theta ?? 0);
public Task AbortMovement()
{
throw new NotImplementedException();
}
public async Task<RobotResult> Execute(RobotNet.VDA5050.InstantAction.Action action, CancellationToken cancellationToken)
{
var result = await robotController.SendInstantActionAsync(action, cancellationToken);
return new(result.IsSuccess, result.Message);
}
public async Task<RobotResult> MoveToNode(string nodeName, CancellationToken cancellationToken)
{
var result = await robotController.MoveToNodeAsync(nodeName, null, cancellationToken);
return new(result.IsSuccess, result.Message);
}
public async Task<RobotResult> MoveToNode(string nodeName, double lastAngle, CancellationToken cancellationToken)
{
var result = await robotController.MoveToNodeAsync(nodeName, lastAngle, cancellationToken);
return new(result.IsSuccess, result.Message);
}
public async Task<RobotResult> MoveToStation(string stationName, StationAction action, CancellationToken cancellationToken)
{
var result = await robotController.MoveToStationAsync(stationName, action, cancellationToken);
return new(result.IsSuccess, result.Message);
}
}

View File

@@ -0,0 +1,53 @@
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Services.RobotManager;
using RobotNet10.FleetManager.Services.TrafficControl;
using System.Linq.Expressions;
namespace RobotNet10.FleetManager.Services.Script;
public class ScriptRobotmanager(IRobotManagerService RobotManager, IServiceScopeFactory ScopeFactory, IOrderControlService OrderControlService) : IRobotManager
{
public async Task<IRobot?> GetRobotById(string robotId)
{
using var scope = ScopeFactory.CreateScope();
var _robotService = scope.ServiceProvider.GetRequiredService<IRobotService>();
var robotDb = await _robotService.GetByRobotIdAsync(robotId);
if (robotDb is null) return null;
var robotController = RobotManager.GetRobotController(robotId);
if (robotController is null) return null;
return new ScriptRobot(robotDb.RobotId, robotDb.Name, robotDb.ModelId, robotDb.MapId, robotController);
}
public Task<RobotOrderStatus> GetRobotOrderStatus(string robotId)
{
var orderStatus = OrderControlService.GetRobotOrderStatus(robotId);
return orderStatus switch
{
OrderStatus.IsError => Task.FromResult(RobotOrderStatus.IsError),
OrderStatus.IsCompleted => Task.FromResult(RobotOrderStatus.IsCompleted),
OrderStatus.IsProccessing => Task.FromResult(RobotOrderStatus.IsProccessing),
OrderStatus.IsCanceled => Task.FromResult(RobotOrderStatus.IsCanceled),
_ => Task.FromResult(RobotOrderStatus.Empty),
};
}
public Task<RobotState?> GetRobotState(string robotId)
{
var robotController = RobotManager.GetRobotController(robotId);
if (robotController is null || robotController.Data is null || robotController.Data.State is null || robotController.Data.Visualization is null) return Task.FromResult<RobotState?>(null);
return Task.FromResult<RobotState?>(new RobotState(robotController.IsReady,
robotController.Data.State.BatteryState.BatteryVoltage ?? 0,
robotController.Data.State.Loads,
robotController.Data.State.BatteryState.Charging,
robotController.Data.Visualization.AgvPosition.X,
robotController.Data.Visualization.AgvPosition.Y,
robotController.Data.Visualization.AgvPosition.Theta));
}
public async Task<IEnumerable<string>> SearchRobots(string layout, string version, string level, string model)
=> await RobotManager.GetAvailableRobots(layout, version, level, model, state => true);
public async Task<IEnumerable<string>> SearchRobots(string layout, string version, string level, string model, Expression<Func<RobotState, bool>> expr)
=> await RobotManager.GetAvailableRobots(layout, version, level, model, expr.Compile());
}

View File

@@ -0,0 +1,31 @@
using RobotNet10.FleetManager.Models;
using RobotNet10.FleetManager.Script;
using RobotNet10.FleetManager.Script.Shared;
using RobotNet10.ScriptEngine.Helpers;
using RobotNet10.ScriptEngine.Shared;
using System.Collections.Immutable;
namespace RobotNet10.FleetManager.Services;
public class ScriptEngineResource(IRobotManager robotManager, ILayoutManager layoutManager) : IScriptEngineResource
{
public Type AppGlobalType => FleetManagerScriptEngineResource.GlobalType;
public ImmutableArray<string> UsingNamespaces => FleetManagerScriptEngineResource.UsingNamespaces;
public ImmutableArray<string> Modules => FleetManagerScriptEngineResource.Modules;
public ImmutableArray<string> DocModules => FleetManagerScriptEngineResource.DocModules;
public IDictionary<string, object?> GetMissionGlobals(Guid id, CancellationToken cancellationToken)
{
var globals = new FleetManagerScriptGlobals(robotManager, layoutManager);
return ScriptHelper.ConvertGlobalsToDictionary(globals, typeof(IFleetManagerScriptGlobals));
}
public IDictionary<string, object?> GetTaskGlobals()
{
var globals = new FleetManagerScriptGlobals(robotManager, layoutManager);
return ScriptHelper.ConvertGlobalsToDictionary(globals, typeof(IFleetManagerScriptGlobals));
}
}

View File

@@ -0,0 +1,923 @@
using RobotNet.VDA5050.Order;
using RobotNet.VDA5050.State;
using RobotNet10.Common;
using RobotNet10.FleetManager.Data;
using RobotNet10.FleetManager.Services.ConfigManager;
using RobotNet10.FleetManager.Services.OpenACS;
using RobotNet10.FleetManager.Services.RobotController;
using RobotNet10.FleetManager.Services.RobotManager;
using RobotNet10.FleetManager.Services.TrafficControl.Models;
using RobotNet10.MapManager.Services;
using System.Collections.Concurrent;
namespace RobotNet10.FleetManager.Services.TrafficControl.ACS;
public class OrderACSControl : IOrderControlService, IDisposable
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IACSTrafficConfig _acsTrafficConfig;
private readonly TrafficACS _trafficACS;
private readonly Logger<OrderACSControl> _logger;
private readonly ILogger<OrderACSControl> _loggerTimer;
// Store order state per robot
private readonly ConcurrentDictionary<string, OrderACSState> _orderStates = new();
private WatchTimerAsync<OrderACSControl>? _processingTimer;
private readonly Lock _timerLock = new();
private bool _disposed = false;
public OrderACSControl(
IServiceScopeFactory serviceScopeFactory,
IACSTrafficConfig acsTrafficConfig,
TrafficACS trafficACS,
Logger<OrderACSControl> logger,
ILoggerFactory loggerFactory)
{
_serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
_acsTrafficConfig = acsTrafficConfig ?? throw new ArgumentNullException(nameof(acsTrafficConfig));
_trafficACS = trafficACS ?? throw new ArgumentNullException(nameof(trafficACS));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_loggerTimer = loggerFactory?.CreateLogger<OrderACSControl>() ?? throw new ArgumentNullException(nameof(loggerFactory));
// Subscribe to config changes
_acsTrafficConfig.ConfigChanged += OnConfigChanged;
_logger.Info($"Started OrderACSControl processing timer with interval {_acsTrafficConfig.TrafficInterval}ms");
}
private void OnConfigChanged(object? sender, EventArgs e)
{
try
{
var newInterval = _acsTrafficConfig.TrafficInterval;
if (newInterval == _processingTimer?.Interval) return;
_logger.Info($"ACSTrafficConfig changed, updating timer interval to {newInterval}ms");
lock (_timerLock)
{
// Stop old timer
_processingTimer?.Stop();
_processingTimer?.Dispose();
// Start new timer with new interval
StartProcessingTimer();
}
_logger.Info($"OrderACSControl processing timer updated to interval {newInterval}ms");
}
catch (Exception ex)
{
_logger.Error($"Error updating timer interval: {ex.Message}");
}
}
private void StartProcessingTimer()
{
var interval = _acsTrafficConfig.TrafficInterval;
_processingTimer = new WatchTimerAsync<OrderACSControl>(
interval,
ProcessAllOrdersAsync,
_loggerTimer
);
_processingTimer.Start();
}
public void Dispose()
{
if (_disposed) return;
// Unsubscribe from config changes
_acsTrafficConfig.ConfigChanged -= OnConfigChanged;
lock (_timerLock)
{
_processingTimer?.Stop();
_processingTimer?.Dispose();
}
_disposed = true;
_logger.Info("Stopped OrderACSControl processing timer");
GC.SuppressFinalize(this);
}
private async Task ProcessAllOrdersAsync()
{
try
{
// Process all active orders
foreach (var kvp in _orderStates)
{
var robotId = kvp.Key;
var orderState = kvp.Value;
if (orderState.Status != OrderStatus.IsProccessing)
{
// Skip non-processing orders
continue;
}
// Get robot controller and data using service scope to avoid circular dependency
IRobotController? robotController = null;
try
{
using var scope = _serviceScopeFactory.CreateScope();
var robotManagerService = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
robotController = robotManagerService.GetRobotController(robotId);
}
catch (Exception ex)
{
_logger.Error($"ProcessAllOrdersAsync: Error getting robot controller for {robotId}: {ex.Message}");
continue;
}
if (robotController == null)
{
continue;
}
// Check if robot is online
if (!robotController.IsOnline)
{
// Check if robot has been offline for more than 1 minute
var offlineDuration = DateTime.UtcNow - orderState.LastUpdated;
if (offlineDuration.TotalMinutes > 1)
{
orderState.Status = OrderStatus.IsError;
orderState.Error = $"Robot {robotId} has been offline for more than 1 minute";
orderState.LastUpdated = DateTime.UtcNow;
_logger.Warning($"CheckRobotOnline: Order for robot {robotId} marked as Error due to offline timeout ({offlineDuration.TotalMinutes:F1} minutes)");
continue;
}
// Robot is offline but less than 1 minute, skip processing
continue;
}
var robotData = robotController.Data;
var stateMsg = robotData?.State;
if (stateMsg == null)
{
continue;
}
// Update last updated time
orderState.LastUpdated = DateTime.UtcNow;
// Check if robot has reached a mapped node
var currentLastNodeId = stateMsg.LastNodeId;
await CheckAndProcessMappedNodesAsync(robotId, currentLastNodeId, orderState, stateMsg);
}
}
catch (Exception ex)
{
_logger.Error($"ProcessAllOrdersAsync: Error processing orders: {ex.Message}");
}
}
public OrderStatus GetRobotOrderStatus(string robotId)
{
if (_orderStates.TryGetValue(robotId, out var state))
{
return state.Status;
}
return OrderStatus.Empty; // No order found
}
public async Task<bool> CreateRobotOrderAsync(string robotId, RobotRoute route)
{
try
{
if (string.IsNullOrEmpty(robotId))
{
_logger.Warning("CreateRobotOrderAsync: robotId is null or empty");
return false;
}
if (route == null || route.FullRoute == null || route.FullRoute.Count == 0)
{
_logger.Warning($"CreateRobotOrderAsync: Invalid route for robot {robotId}");
return false;
}
// Find all nodes that are mapped to ACS zones (IN and OUT separately)
var (inMappedNodes, outMappedNodes) = await FindMappedNodesAsync(route);
// Find first IN mapped node index (only IN nodes affect Base/Horizon calculation)
var firstMappedNodeIndex = FindFirstMappedNodeIndex(route, inMappedNodes);
int baseSegmentCount;
if (inMappedNodes.Count == 0 || firstMappedNodeIndex < 0)
{
// No IN mapped nodes, base = full route
baseSegmentCount = route.FullRoute.Count;
_logger.Info($"CreateRobotOrderAsync: No IN mapped nodes found for robot {robotId}, base = full route");
}
else
{
// Calculate Base: segments from start to first mapped node + 1 segment
baseSegmentCount = firstMappedNodeIndex + 3;
if (baseSegmentCount > route.FullRoute.Count)
{
baseSegmentCount = route.FullRoute.Count;
}
}
// Split route into Base and Horizon
route.Base = [.. route.FullRoute.Take(baseSegmentCount)];
route.Horizon = [.. route.FullRoute.Skip(baseSegmentCount)];
// Mark base segments as released
foreach (var segment in route.Base)
{
segment.Released = true;
}
_logger.Info($"CreateRobotOrderAsync: Order created for robot {robotId}, Base segments: {route.Base.Count}, Horizon segments: {route.Horizon.Count}, IN mapped nodes: {inMappedNodes.Count}, OUT mapped nodes: {outMappedNodes.Count}");
// Send order to robot
var send = await SendOrderToRobotAsync(robotId, route, isInitial: true);
if (send)
{
// Create order state
var orderState = new OrderACSState
{
RobotId = robotId,
Status = OrderStatus.IsProccessing,
Route = route,
InMappedNodes = inMappedNodes,
OutMappedNodes = outMappedNodes,
CurrentInMappedNodeIndex = 0,
CreatedAt = DateTime.UtcNow,
LastUpdated = DateTime.UtcNow,
ZoneRequestInCompleting = [],
ZoneRequestOutCompleting = [],
};
_orderStates.AddOrUpdate(robotId, orderState, (key, old) => orderState);
}
return send;
}
catch (Exception ex)
{
_logger.Error($"CreateRobotOrderAsync: Error creating order for robot {robotId}: {ex.Message}");
return false;
}
}
private async Task<(List<(string NodeIdString, string ZoneId)> InMappedNodes, List<(string NodeIdString, string ZoneId)> OutMappedNodes)> FindMappedNodesAsync(RobotRoute route)
{
var inMappedNodes = new List<(string NodeIdString, string ZoneId)>();
var outMappedNodes = new List<(string NodeIdString, string ZoneId)>();
var acsZoneMapping = _acsTrafficConfig.ACSZoneMaping;
var acsOutMapping = _acsTrafficConfig.ACSOutMaping;
// Get all unique NodeIds (Guid) from route segments
var nodeIds = route.FullRoute
.Where(s => s.VdaNode != null)
.Select(s => s.NodeId)
.Distinct()
.ToList();
if (nodeIds.Count == 0)
{
return (inMappedNodes, outMappedNodes);
}
// Create mapping: NodeId (Guid) -> NodeName
var nodeIdToNodeName = new Dictionary<Guid, string?>();
try
{
using var scope = _serviceScopeFactory.CreateScope();
var nodeService = scope.ServiceProvider.GetRequiredService<INodeService>();
foreach (var nodeId in nodeIds)
{
var node = await nodeService.GetByIdAsync(nodeId, includeVehicleProperties: false);
if (node != null && !string.IsNullOrEmpty(node.NodeName))
{
nodeIdToNodeName[nodeId] = node.NodeName;
}
}
}
catch (Exception ex)
{
_logger.Error($"FindMappedNodesAsync: Error getting NodeName from database: {ex.Message}");
// Continue with empty mapping - will skip nodes without NodeName
}
foreach (var segment in route.FullRoute)
{
if (segment.VdaNode != null)
{
var nodeIdString = segment.VdaNode.NodeId;
// Get NodeName from mapping (using NodeId Guid)
if (!nodeIdToNodeName.TryGetValue(segment.NodeId, out var nodeName) || string.IsNullOrEmpty(nodeName))
{
// Skip if NodeName not found
continue;
}
// Check if node is in ACSZoneMapping (RequestIn) using NodeName
if (acsZoneMapping.TryGetValue(nodeName, out var zoneId))
{
inMappedNodes.Add((nodeIdString, zoneId));
}
// Check if node is in ACSOutMapping (RequestOut) using NodeName
if (acsOutMapping.TryGetValue(nodeName, out var outZoneId))
{
outMappedNodes.Add((nodeIdString, outZoneId));
}
}
}
return (inMappedNodes, outMappedNodes);
}
private static int FindFirstMappedNodeIndex(RobotRoute route, List<(string NodeIdString, string ZoneId)> mappedNodes)
{
if (mappedNodes.Count == 0) return -1;
(string NodeIdString, _) = mappedNodes[0];
for (int i = 0; i < route.FullRoute.Count; i++)
{
var segment = route.FullRoute[i];
if (segment.VdaNode != null && segment.VdaNode.NodeId == NodeIdString)
{
return i;
}
}
return -1;
}
private async Task CheckAndProcessMappedNodesAsync(string robotId, string lastNodeId, OrderACSState orderState, StateMsg stateMsg)
{
try
{
foreach (var (NodeIdString, ZoneId) in orderState.OutMappedNodes)
{
if (lastNodeId == NodeIdString && !string.IsNullOrEmpty(ZoneId) && !orderState.ZoneRequestOutCompleting.Contains(ZoneId) && !orderState.ZoneRequestOutCompleted.Contains(ZoneId))
{
orderState.ZoneRequestOutCompleting.Add(ZoneId);
}
}
var requestOut = ProcessRequestOutAsync(robotId, orderState);
foreach (var (NodeIdString, ZoneId) in orderState.InMappedNodes)
{
if (lastNodeId == NodeIdString && !string.IsNullOrEmpty(ZoneId) && !orderState.ZoneRequestInCompleting.Contains(ZoneId) && !orderState.ZoneRequestInCompleted.Contains(ZoneId))
{
orderState.ZoneRequestInCompleting.Add(ZoneId);
}
}
await ProcessRequestInAsync(robotId, orderState);
await requestOut.WaitAsync(CancellationToken.None);
// Check if order is completed
CheckOrderCompletion(robotId, orderState, stateMsg);
}
catch (Exception ex)
{
_logger.Error($"CheckAndProcessMappedNodes: Error for robot {robotId}: {ex.Message}");
orderState.Status = OrderStatus.IsError;
orderState.Error = ex.Message;
}
}
private async Task ProcessRequestInAsync(string robotId, OrderACSState orderState)
{
string[] zoneIdsIn = [.. orderState.ZoneRequestInCompleting];
if (zoneIdsIn.Length == 0) return;
foreach (var zoneId in zoneIdsIn)
{
try
{
if (orderState.ZoneRequestInCompleted.Contains(zoneId)) continue;
_logger.Info($"ProcessRequestInAsync: Robot {robotId} requesting into zone {zoneId}");
var result = await _trafficACS.RequestIn(robotId, zoneId);
if (result.IsSuccess && result.Data)
{
// RequestIn successful - save zone to cache for future RequestOut validation
orderState.ZoneRequestInCompleted.Add(zoneId);
orderState.ZoneRequestInCompleting.Remove(zoneId);
orderState.ZoneRequestOutCompleted.Remove(zoneId);
orderState.LastUpdated = DateTime.UtcNow;
_logger.Info($"ProcessRequestInAsync: Robot {robotId} successfully requested into zone {zoneId}");
}
else
{
// RequestIn failed, just log warning - will retry on next timer cycle
_logger.Warning($"ProcessRequestInAsync: Robot {robotId} failed to request into zone {zoneId}: {result.Message}. Will retry on next cycle.");
}
}
catch (Exception ex)
{
_logger.Error($"ProcessRequestInAsync: Error for robot {robotId}, zone {zoneId}: {ex.Message}. Will retry on next cycle.");
}
}
if (orderState.ZoneRequestInCompleting.Count == 0)
{
orderState.CurrentInMappedNodeIndex++;
await TryReleaseNextHorizonSegmentAsync(robotId, orderState);
}
}
private async Task ProcessRequestOutAsync(string robotId, OrderACSState orderState)
{
string[] zoneIdsOut = [.. orderState.ZoneRequestOutCompleting];
if (zoneIdsOut.Length == 0) return;
foreach (var zoneId in zoneIdsOut)
{
try
{
if (orderState.ZoneRequestOutCompleted.Contains(zoneId)) continue;
_logger.Info($"ProcessRequestOutAsync: Robot {robotId} requesting out of zone {zoneId}");
var result = await _trafficACS.RequestOut(robotId, zoneId);
if (result.IsSuccess && result.Data)
{
orderState.LastUpdated = DateTime.UtcNow;
orderState.ZoneRequestOutCompleted.Add(zoneId);
orderState.ZoneRequestOutCompleting.Remove(zoneId);
orderState.ZoneRequestInCompleted.Remove(zoneId);
_logger.Info($"ProcessRequestOutAsync: Robot {robotId} successfully requested out of zone {zoneId}");
// Note: OUT does NOT affect Base/Horizon, so we don't call TryReleaseNextHorizonSegmentAsync
}
else
{
// RequestOut failed - will retry on next timer cycle until successful
// OUT must retry until successful (unlike IN which can be skipped)
_logger.Warning($"ProcessRequestOutAsync: Robot {robotId} failed to request out of zone {zoneId}: {result.Message}. Will retry on next cycle until successful.");
}
}
catch (Exception ex)
{
// RequestOut error - will retry on next timer cycle until successful
_logger.Error($"ProcessRequestOutAsync: Error for robot {robotId}, zone {zoneId}: {ex.Message}. Will retry on next cycle until successful.");
}
}
}
private async Task TryReleaseNextHorizonSegmentAsync(string robotId, OrderACSState orderState)
{
try
{
bool horizonUpdated = false;
int baseCountBeforeRelease = orderState.Route.Base.Count; // Store base count before release
// Check if there are more IN mapped nodes to process (only IN affects Base/Horizon)
if (orderState.CurrentInMappedNodeIndex >= orderState.InMappedNodes.Count)
{
// All mapped nodes processed, check if we can release all remaining horizon
if (orderState.Route.Horizon.Count > 0)
{
// Move all remaining horizon to base
var segmentsToMove = orderState.Route.Horizon.ToList();
orderState.Route.Base.AddRange(segmentsToMove);
orderState.Route.Horizon.Clear();
// Only set Released = true for newly released segments
foreach (var segment in segmentsToMove)
{
segment.Released = true;
}
orderState.LastUpdated = DateTime.UtcNow;
horizonUpdated = true;
_logger.Info($"TryReleaseNextHorizonSegmentAsync: Released all remaining horizon segments for robot {robotId}");
}
}
else
{
// Find next IN mapped node
var (NodeIdString, _) = orderState.InMappedNodes[orderState.CurrentInMappedNodeIndex];
// Find index of next mapped node in full route
int nextMappedNodeIndex = -1;
for (int i = 0; i < orderState.Route.FullRoute.Count; i++)
{
var segment = orderState.Route.FullRoute[i];
if (segment.VdaNode != null && segment.VdaNode.NodeId == NodeIdString)
{
nextMappedNodeIndex = i;
break;
}
}
if (nextMappedNodeIndex >= 0)
{
// Calculate how many segments to release: from current base end to next mapped node + 1
var currentBaseEndIndex = orderState.Route.Base.Count;
var segmentsToRelease = nextMappedNodeIndex - currentBaseEndIndex + 3;
if (segmentsToRelease > 0 && segmentsToRelease <= orderState.Route.Horizon.Count)
{
// Release segments to base
var segmentsToMove = orderState.Route.Horizon.Take(segmentsToRelease).ToList();
orderState.Route.Base.AddRange(segmentsToMove);
orderState.Route.Horizon.RemoveRange(0, segmentsToRelease);
foreach (var segment in segmentsToMove)
{
segment.Released = true;
}
orderState.LastUpdated = DateTime.UtcNow;
horizonUpdated = true;
_logger.Info($"TryReleaseNextHorizonSegmentAsync: Released {segmentsToRelease} segments to base for robot {robotId}");
}
}
}
// If horizon was updated, send OrderUpdate to robot
if (horizonUpdated)
{
await SendOrderToRobotAsync(robotId, orderState.Route, isInitial: false, baseCountBeforeRelease);
}
}
catch (Exception ex)
{
_logger.Error($"TryReleaseNextHorizonSegmentAsync: Error for robot {robotId}: {ex.Message}");
}
}
private async Task<bool> SendOrderToRobotAsync(string robotId, RobotRoute route, bool isInitial, int baseCountBeforeRelease = -1)
{
try
{
// Get robot controller using service scope to avoid circular dependency
IRobotController? robotController = null;
try
{
using var scope = _serviceScopeFactory.CreateScope();
var robotManagerService = scope.ServiceProvider.GetRequiredService<IRobotManagerService>();
robotController = robotManagerService.GetRobotController(robotId);
}
catch (Exception ex)
{
_logger.Error($"SendOrderToRobotAsync: Error getting robot controller for {robotId}: {ex.Message}");
return false;
}
if (robotController == null)
{
_logger.Warning($"SendOrderToRobotAsync: Robot controller not found for robot {robotId}");
return false;
}
// Build nodes and edges
var nodes = new List<Node>();
var edges = new List<Edge>();
if (isInitial)
{
// Initial order: send ALL Base + Horizon segments
// Add Base segments (released = true)
foreach (var segment in route.Base)
{
if (segment.VdaNode != null)
{
var node = CloneNode(segment.VdaNode);
node.Released = true; // Base segments are always released
nodes.Add(node);
}
if (segment.VdaEdge != null)
{
var edge = CloneEdge(segment.VdaEdge);
edge.Released = true; // Base segments are always released
edges.Add(edge);
}
}
// Add Horizon segments (released = false)
foreach (var segment in route.Horizon)
{
if (segment.VdaNode != null)
{
var node = CloneNode(segment.VdaNode);
node.Released = false; // Horizon segments are not released
nodes.Add(node);
}
if (segment.VdaEdge != null)
{
var edge = CloneEdge(segment.VdaEdge);
edge.Released = false; // Horizon segments are not released
edges.Add(edge);
}
}
// Create initial Order
var order = new OrderMsg
{
OrderId = route.OrderId,
OrderUpdateId = 0,
SerialNumber = robotId,
Timestamp = DateTime.UtcNow,
Nodes = [.. nodes],
Edges = [.. edges]
};
var result = await robotController.SendOrderAsync(order);
if (result.IsSuccess)
{
route.OrderUpdateId = order.OrderUpdateId;
_logger.Info($"SendOrderToRobotAsync: Successfully sent initial Order (ID: {route.OrderId}, UpdateID: {order.OrderUpdateId}) to robot {robotId}");
return true;
}
else
{
_logger.Warning($"SendOrderToRobotAsync: Failed to send initial order to robot {robotId}: {result.Message}");
return false;
}
}
else
{
// OrderUpdate: Send stitching node + new Base segments + new Horizon segments
// 1. Stitching node: last node of old Base (before release)
// 2. New Base: segments that were just released from Horizon
// 3. New Horizon: remaining segments in Horizon
if (baseCountBeforeRelease < 0)
{
// Fallback: use current Base.Count - 1 (assume only 1 segment was released)
// This shouldn't happen if called from TryReleaseNextHorizonSegmentAsync
baseCountBeforeRelease = Math.Max(0, route.Base.Count - 1);
}
// Get stitching node: last node of Base before release
var oldBaseSegments = route.Base.Take(baseCountBeforeRelease).ToList();
var lastOldBaseNode = oldBaseSegments.LastOrDefault(s => s.VdaNode != null);
if (lastOldBaseNode?.VdaNode == null)
{
_logger.Warning($"SendOrderToRobotAsync: Cannot create OrderUpdate for robot {robotId}: no old base node found");
return false;
}
// 1. Add stitching node (last node of old Base, released = true)
var stitchingNode = CloneNode(lastOldBaseNode.VdaNode);
stitchingNode.Released = true;
nodes.Add(stitchingNode);
// 2. Add new Base segments (segments that were just released, released = true)
var newBaseSegments = route.Base.Skip(baseCountBeforeRelease).ToList();
foreach (var segment in newBaseSegments)
{
if (segment.VdaNode != null)
{
var node = CloneNode(segment.VdaNode);
node.Released = true; // New Base segments are released
nodes.Add(node);
}
if (segment.VdaEdge != null)
{
var edge = CloneEdge(segment.VdaEdge);
edge.Released = true; // New Base segments are released
edges.Add(edge);
}
}
// 3. Add new Horizon segments (remaining segments in Horizon, released = false)
foreach (var segment in route.Horizon)
{
if (segment.VdaNode != null)
{
var node = CloneNode(segment.VdaNode);
node.Released = false; // Horizon segments are not released
nodes.Add(node);
}
if (segment.VdaEdge != null)
{
var edge = CloneEdge(segment.VdaEdge);
edge.Released = false; // Horizon segments are not released
edges.Add(edge);
}
}
// Get current order to increment OrderUpdateId
var currentOrder = robotController.Data.Order;
var orderUpdateId = currentOrder?.OrderUpdateId ?? 0;
var orderUpdate = new OrderMsg
{
OrderId = route.OrderId,
OrderUpdateId = orderUpdateId + 1,
SerialNumber = robotId,
Timestamp = DateTime.UtcNow,
Nodes = [.. nodes],
Edges = [.. edges]
};
var result = await robotController.SendOrderAsync(orderUpdate);
if (result.IsSuccess)
{
route.OrderUpdateId = orderUpdate.OrderUpdateId;
_logger.Info($"SendOrderToRobotAsync: Successfully sent OrderUpdate (ID: {route.OrderId}, UpdateID: {orderUpdate.OrderUpdateId}) to robot {robotId}");
return true;
}
else
{
_logger.Warning($"SendOrderToRobotAsync: Failed to send OrderUpdate to robot {robotId}: {result.Message}");
return false;
}
}
}
catch (Exception ex)
{
_logger.Error($"SendOrderToRobotAsync: Error sending order to robot {robotId}: {ex.Message}");
return false;
}
}
private static Node CloneNode(Node source)
{
return new Node
{
NodeId = source.NodeId,
SequenceId = source.SequenceId,
Released = source.Released,
NodeDescription = source.NodeDescription,
NodePosition = source.NodePosition is null ? null : new NodePosition
{
X = source.NodePosition.X,
Y = source.NodePosition.Y,
Theta = source.NodePosition.Theta,
AllowedDeviationXY = source.NodePosition.AllowedDeviationXY,
AllowedDeviationTheta = source.NodePosition.AllowedDeviationTheta,
MapId = source.NodePosition.MapId,
MapDescription = source.NodePosition.MapDescription
},
Actions = source.Actions?.Select(a => CloneAction(a)).ToArray() ?? [] // Clone each action
};
}
private static RobotNet.VDA5050.InstantAction.Action CloneAction(RobotNet.VDA5050.InstantAction.Action source)
{
return new RobotNet.VDA5050.InstantAction.Action
{
ActionType = source.ActionType,
ActionId = source.ActionId,
ActionDescription = source.ActionDescription,
BlockingType = source.BlockingType,
ActionParameters = source.ActionParameters?.Select(p => new RobotNet.VDA5050.InstantAction.ActionParameter
{
Key = p.Key,
Value = p.Value
}).ToArray() ?? null
};
}
private static Edge CloneEdge(Edge source)
{
return new Edge
{
EdgeId = source.EdgeId,
SequenceId = source.SequenceId,
Released = source.Released,
EdgeDescription = source.EdgeDescription,
StartNodeId = source.StartNodeId,
EndNodeId = source.EndNodeId,
MaxSpeed = source.MaxSpeed,
MaxHeight = source.MaxHeight,
MinHeight = source.MinHeight,
Orientation = source.Orientation,
OrientationType = source.OrientationType,
Direction = source.Direction,
RotationAllowed = source.RotationAllowed,
MaxRotationSpeed = source.MaxRotationSpeed,
Length = source.Length,
Trajectory = source.Trajectory == null ? null : new Trajectory
{
Degree = source.Trajectory.Degree,
KnotVector = [.. source.Trajectory.KnotVector], // Clone array
ControlPoints = [.. source.Trajectory.ControlPoints.Select(cp => new ControlPoint
{
X = cp.X,
Y = cp.Y,
Weight = cp.Weight
})]
},
Corridor = source.Corridor == null ? null : new Corridor
{
LeftWidth = source.Corridor.LeftWidth,
RightWidth = source.Corridor.RightWidth,
CorridorRefPoint = source.Corridor.CorridorRefPoint
},
Actions = source.Actions?.Select(a => CloneAction(a)).ToArray() ?? [] // Clone each action
};
}
private void CheckOrderCompletion(string robotId, OrderACSState orderState, StateMsg stateMsg)
{
try
{
// Check if NodeStates and EdgeStates are empty
var nodeStatesEmpty = stateMsg.NodeStates == null || stateMsg.NodeStates.Length == 0;
var edgeStatesEmpty = stateMsg.EdgeStates == null || stateMsg.EdgeStates.Length == 0;
if (!nodeStatesEmpty || !edgeStatesEmpty)
{
// Still processing, not completed yet
return;
}
// NodeStates and EdgeStates are empty - check completion conditions
var lastNodeInRoute = orderState.Route.FullRoute.LastOrDefault()?.VdaNode?.NodeId;
var lastNodeId = stateMsg.LastNodeId;
// Check if LastNodeId matches the last node in route
bool isAtLastNode = lastNodeId == lastNodeInRoute;
// Check actions on last node - get last node's actions from route
bool allActionsFinished = true;
bool hasActionFailed = false;
if (stateMsg.ActionStates != null && stateMsg.ActionStates.Length > 0)
{
// Get actions from last node in route
var lastNodeSegment = orderState.Route.FullRoute.LastOrDefault(s => s.VdaNode != null);
if (lastNodeSegment?.VdaNode?.Actions != null && lastNodeSegment.VdaNode.Actions.Length > 0)
{
// Check if all actions from last node are finished
var lastNodeActionIds = lastNodeSegment.VdaNode.Actions.Select(a => a.ActionId).ToHashSet();
var lastNodeActionStates = stateMsg.ActionStates
.Where(a => lastNodeActionIds.Contains(a.ActionId))
.ToList();
if (lastNodeActionStates.Count > 0)
{
foreach (var actionState in lastNodeActionStates)
{
if (actionState.ActionStatus == RobotNet.VDA5050.Type.ActionStatus.FAILED)
{
hasActionFailed = true;
allActionsFinished = false;
break;
}
else if (actionState.ActionStatus != RobotNet.VDA5050.Type.ActionStatus.FINISHED)
{
allActionsFinished = false;
}
}
}
}
}
// Determine order status
if (isAtLastNode && allActionsFinished)
{
if (orderState.Status != OrderStatus.IsCompleted) _logger.Info($"CheckOrderCompletion: Order completed successfully for robot {robotId}");
// Order completed successfully
orderState.Status = OrderStatus.IsCompleted;
orderState.LastUpdated = DateTime.UtcNow;
}
else if (!isAtLastNode || hasActionFailed)
{
// Order error: not at last node or action failed
orderState.Status = OrderStatus.IsError;
if (!isAtLastNode)
{
orderState.Error = $"Robot {robotId} finished at node {lastNodeId} but expected last node {lastNodeInRoute}";
}
else if (hasActionFailed)
{
orderState.Error = $"Robot {robotId} has failed actions on last node {lastNodeId}";
}
orderState.LastUpdated = DateTime.UtcNow;
_logger.Warning($"CheckOrderCompletion: Order error for robot {robotId}: {orderState.Error}");
}
}
catch (Exception ex)
{
_logger.Error($"CheckOrderCompletion: Error for robot {robotId}: {ex.Message}");
orderState.Status = OrderStatus.IsError;
orderState.Error = ex.Message;
}
}
public RobotRoute? GetRobotRoute(string robotId)
{
if (_orderStates.TryGetValue(robotId, out var state))
{
return state.Route;
}
return null;
}
}

View File

@@ -0,0 +1,25 @@
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
/// <summary>
/// Severity levels for conflicts
/// </summary>
public enum ConflictSeverity
{
/// <summary>
/// Low severity - can be resolved by waiting
/// </summary>
Low,
/// <summary>
/// Medium severity - needs route adjustment
/// </summary>
Medium,
/// <summary>
/// High severity - needs complete reroute
/// </summary>
High
}

View File

@@ -0,0 +1,62 @@
namespace RobotNet10.FleetManager.Services.TrafficControl.Models;
/// <summary>
/// Types of conflicts between robots
/// </summary>
public enum ConflictType
{
/// <summary>
/// Xảy ra khi hai robot sử dụng cùng một cạnh (edge) trong biểu đồ đường đi (graph)
/// trong khoảng thời gian trùng lặp, đồng thời lộ trình tiếp theo của robot chồng lên nhau.
/// </summary>
Confrontation,
/// <summary>
/// Xảy ra khi hai robot sử dụng cùng một cạnh (edge) trong biểu đồ đường đi (graph)
/// trong khoảng thời gian trùng lặp nhưng lộ trình tiếp theo của 2 robot không chồng lấn lên nhau.
/// </summary>
Edge,
/// <summary>
/// Xảy ra khi hai robot chiếm cùng một nút (vertex/node) trong biểu đồ đường đi
/// tại cùng một thời điểm hoặc trong khoảng thời gian trùng lặp.
/// </summary>
Vertex,
/// <summary>
/// Xảy ra khi hai robot ở quá gần nhau (dựa trên khoảng cách Euclidean) trong không gian liên tục,
/// vi phạm khoảng cách an toàn (minDistance).
/// </summary>
Proximity,
/// <summary>
/// Xảy ra khi hai robot di chuyển qua một hành lang hẹp (thường được biểu diễn bằng một chuỗi cạnh hoặc node)
/// theo hướng ngược nhau, dẫn đến tình trạng không thể vượt qua nhau.
/// </summary>
Corridor,
/// <summary>
/// Xảy ra khi hai robot có lộ trình giao nhau về mặt thời gian, nhưng không nhất thiết ở cùng một cạnh hoặc nút,
/// mà ở các vị trí khiến chúng không thể di chuyển tiếp mà không va chạm.
/// </summary>
Temporal,
/// <summary>
/// Xảy ra khi hai robot cần xoay tại một điểm (thường là node) và không gian xoay bị chồng lấn,
/// dẫn đến va chạm hoặc cản trở.
/// </summary>
Rotation,
/// <summary>
/// Xảy ra khi các robot cạnh tranh cho một tài nguyên chung (ví dụ: một khu vực làm việc, điểm sạc, hoặc thiết bị nâng)
/// </summary>
Resource,
/// <summary>
/// Xảy ra lỗi khi kiểm tra xung đột
/// </summary>
None
}

Some files were not shown because too many files have changed in this diff Show More