Initial commit

This commit is contained in:
2026-07-03 16:31:37 +07:00
commit 899c7c637d
1939 changed files with 641750 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using RobotNet10.RobotApp.Data;
using System.Security.Claims;
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.RobotApp.Data;
namespace RobotNet10.RobotApp.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.RobotApp.Data;
namespace RobotNet10.RobotApp.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 System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
using RobotNet10.RobotApp.Data;
namespace RobotNet10.RobotApp.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,123 @@
@page "/Account/Login"
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Identity
@using RobotNet10.RobotApp.Data
@inject SignInManager<ApplicationUser> SignInManager
@inject ILogger<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)
{
RedirectManager.RedirectTo(ReturnUrl);
}
else if (result.RequiresTwoFactor)
{
RedirectManager.RedirectTo(
"Account/LoginWith2fa",
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
}
else if (result.IsLockedOut)
{
Logger.LogWarning("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.RobotApp.Components.Account
{
public class PasskeyInputModel
{
public string? CredentialJson { get; set; }
public string? Error { get; set; }
}
}

View File

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

View File

@@ -0,0 +1,34 @@
<!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.RobotApp.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.ScriptEditor/scripts.js"]"></script>
<script src="@Assets["_content/RobotNet10.CustomConfigurationEditor/js/downloadFile.js"]"></script>
<script src="js/lidar.js"></script>
<script src="js/xloc-map-renderer.js?v=20260323a"></script>
<script src="js/velocity-chart-renderer.js?v=20260128i"></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,13 @@
@page "/"
@rendermode InteractiveServer
@attribute [Authorize]
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<MudThemeProvider />
<MudPopoverProvider />

View File

@@ -0,0 +1,215 @@
@page "/lidar"
@using RobotNet10.RobotApp.Devices
@using RobotNet10.Shared
@using RobotNet10.RobotApp.Client.Shared.Devices
@rendermode InteractiveServer
@attribute [Authorize]
@inject IDeviceProvider DeviceProvider
<PageTitle>Tim781S LiDAR Visualization</PageTitle>
<style>
.lidar-container {
display: flex;
flex-direction: column;
height: 100vh;
background: #1a1a1a;
color: #ffffff;
}
.lidar-header {
padding: 20px;
background: #2a2a2a;
border-bottom: 2px solid #3a3a3a;
}
.lidar-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-top: 15px;
}
.stat-card {
background: #3a3a3a;
padding: 15px;
border-radius: 8px;
border-left: 4px solid #00ff88;
}
.stat-label {
font-size: 12px;
color: #888;
margin-bottom: 5px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #00ff88;
}
.canvas-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
position: relative;
}
#lidarCanvas {
border: 2px solid #3a3a3a;
background: #0a0a0a;
}
.status-indicator {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
}
.status-connected {
background: #00ff88;
box-shadow: 0 0 10px #00ff88;
}
.status-disconnected {
background: #ff4444;
}
</style>
<div class="lidar-container">
<div class="lidar-header">
<h1>
<span class="status-indicator @(isConnected ? "status-connected" : "status-disconnected")"></span>
Tim781S LiDAR Visualization
</h1>
<div class="lidar-stats">
<div class="stat-card">
<div class="stat-label">Scan Frequency</div>
<div class="stat-value">@scanFrequency Hz</div>
</div>
<div class="stat-card">
<div class="stat-label">Points Per Scan</div>
<div class="stat-value">@pointCount</div>
</div>
<div class="stat-card">
<div class="stat-label">Min Range</div>
<div class="stat-value">@minRange.ToString("F2") m</div>
</div>
<div class="stat-card">
<div class="stat-label">Max Range</div>
<div class="stat-value">@maxRange.ToString("F2") m</div>
</div>
<div class="stat-card">
<div class="stat-label">Valid Points</div>
<div class="stat-value">@validPoints</div>
</div>
</div>
</div>
<div class="canvas-container">
<canvas id="lidarCanvas" width="800" height="800"></canvas>
</div>
</div>
@code {
private IJSRuntime? JS { get; set; }
private bool isConnected = false;
private int scanFrequency = 0;
private int pointCount = 0;
private float minRange = 0.0f;
private float maxRange = 0.0f;
private int validPoints = 0;
private ILidar? lidar;
private System.Threading.Timer? updateTimer;
[Inject]
public required IJSRuntime JSRuntime { get; set; }
protected override async Task OnInitializedAsync()
{
JS = JSRuntime;
// Find Tim781S LiDAR device
var devices = DeviceProvider.GetDevicesByType(DeviceType.Lidar).OfType<ILidar>();
lidar = devices.FirstOrDefault(d => (d as DeviceBase)?.DeviceId.Contains("tim781s", StringComparison.OrdinalIgnoreCase) == true);
if (lidar != null)
{
lidar.ScanDataReceived += OnScanDataReceived;
var deviceBase = lidar as DeviceBase;
isConnected = deviceBase?.Status == DeviceStatus.Connected;
}
// Start update timer for stats
updateTimer = new System.Threading.Timer(async _ =>
{
await InvokeAsync(StateHasChanged);
}, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(10));
}
private async void OnScanDataReceived(object? sender, LidarScanDataEventArgs e)
{
if (JS == null) return;
var scan = e.MeasurementData;
// Update stats
pointCount = scan.Ranges.Length;
minRange = (float)scan.RangeMin;
maxRange = (float)scan.RangeMax;
validPoints = scan.Ranges.Count(r => r >= scan.RangeMin && r <= scan.RangeMax);
// Calculate scan frequency (simple moving average)
if (lidar != null && lidar.ScanFrequencyHz.HasValue)
{
scanFrequency = (int)lidar.ScanFrequencyHz.Value;
}
// Prepare data for JavaScript visualization
var points = new List<object>();
for (int i = 0; i < scan.Ranges.Length; i++)
{
float angle = (float)(scan.AngleMin + (i * scan.AngleIncrement));
float range = (float)scan.Ranges[i];
// Only add valid points
if (range >= scan.RangeMin && range <= scan.RangeMax)
{
points.Add(new { angle, range });
}
}
// Call JavaScript to draw
try
{
await JS.InvokeVoidAsync("drawLidarScan", points);
}
catch
{
// Silently ignore if JS not ready
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && JS != null)
{
// Initialize canvas
await JS.InvokeVoidAsync("initLidarCanvas");
}
}
public void Dispose()
{
updateTimer?.Dispose();
if (lidar != null)
{
lidar.ScanDataReceived -= OnScanDataReceived;
}
}
}

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>

File diff suppressed because it is too large Load Diff

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,15 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@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.RobotApp
@using RobotNet10.RobotApp.Client
@using RobotNet10.RobotApp.Components
@using RobotNet10.RobotApp.Components.Layout

View File

@@ -0,0 +1,187 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RobotNet10.RobotApp.Data;
using RobotNet10.RobotApp.Shared.DockStation;
using RobotNet10.Shared.Enum;
using RobotNet10.Shared.Numbers;
using System.Text.Json;
namespace RobotNet10.RobotApp.Controllers;
[Route("api/dock-station-config")]
[ApiController]
[Authorize]
public class DockStationConfigController(ApplicationDbContext dbContext) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<List<DockStationConfigSummaryDto>>> GetAll()
{
var configs = await dbContext.DockStationConfigs
.Include(d => d.MarkerEntries)
.OrderByDescending(d => d.UpdatedAt)
.ToListAsync();
return Ok(configs.Select(MapToSummaryDto).ToList());
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<DockStationConfigDto>> GetById(Guid id)
{
var config = await dbContext.DockStationConfigs
.Include(d => d.MarkerEntries)
.FirstOrDefaultAsync(d => d.Id == id);
if (config is null) return NotFound();
return Ok(MapToDto(config));
}
[HttpGet("station/{stationId}")]
public async Task<ActionResult<DockStationConfigDto>> GetByStationId(string stationId)
{
var config = await dbContext.DockStationConfigs
.Include(d => d.MarkerEntries)
.FirstOrDefaultAsync(d => d.StationId == stationId);
if (config is null) return NotFound();
return Ok(MapToDto(config));
}
[HttpPost]
public async Task<ActionResult<DockStationConfigDto>> Create(
[FromBody] CreateDockStationConfigRequest request)
{
if (await dbContext.DockStationConfigs.AnyAsync(d => d.StationId == request.StationId))
return BadRequest($"StationId '{request.StationId}' already exists.");
var entity = new DockStationConfig
{
StationId = request.StationId,
ConfigName = request.ConfigName ?? string.Empty,
Description = request.Description ?? string.Empty,
X = request.X,
Y = request.Y,
Yaw = request.Yaw,
Width = request.Width,
Length = request.Length,
IsActive = request.IsActive,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
MarkerEntries = request.MarkerEntries.Select(MapFromEntryDto).ToList()
};
dbContext.DockStationConfigs.Add(entity);
await dbContext.SaveChangesAsync();
return CreatedAtAction(nameof(GetById), new { id = entity.Id }, MapToDto(entity));
}
[HttpPut("{id:guid}")]
public async Task<ActionResult<DockStationConfigDto>> Update(
Guid id, [FromBody] UpdateDockStationConfigRequest request)
{
var entity = await dbContext.DockStationConfigs
.Include(d => d.MarkerEntries)
.FirstOrDefaultAsync(d => d.Id == id);
if (entity is null) return NotFound();
if (request.StationId is not null)
{
if (request.StationId != entity.StationId &&
await dbContext.DockStationConfigs.AnyAsync(d => d.StationId == request.StationId))
return BadRequest($"StationId '{request.StationId}' already exists.");
entity.StationId = request.StationId;
}
if (request.ConfigName is not null) entity.ConfigName = request.ConfigName;
if (request.Description is not null) entity.Description = request.Description;
if (request.X.HasValue) entity.X = request.X.Value;
if (request.Y.HasValue) entity.Y = request.Y.Value;
if (request.Yaw.HasValue) entity.Yaw = request.Yaw.Value;
if (request.Width.HasValue) entity.Width = request.Width.Value;
if (request.Length.HasValue) entity.Length = request.Length.Value;
if (request.IsActive.HasValue) entity.IsActive = request.IsActive.Value;
entity.UpdatedAt = DateTime.UtcNow;
if (request.MarkerEntries is not null)
{
dbContext.DockStationMarkerEntries.RemoveRange(entity.MarkerEntries);
entity.MarkerEntries = request.MarkerEntries.Select(MapFromEntryDto).ToList();
}
await dbContext.SaveChangesAsync();
return Ok(MapToDto(entity));
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
var entity = await dbContext.DockStationConfigs.FindAsync(id);
if (entity is null) return NotFound();
dbContext.DockStationConfigs.Remove(entity);
await dbContext.SaveChangesAsync();
return NoContent();
}
#region Mapping Helpers
private static DockStationConfigDto MapToDto(DockStationConfig entity) => new()
{
Id = entity.Id,
StationId = entity.StationId,
ConfigName = entity.ConfigName,
Description = entity.Description,
X = entity.X,
Y = entity.Y,
Yaw = entity.Yaw,
Width = entity.Width,
Length = entity.Length,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
IsActive = entity.IsActive,
MarkerEntries = entity.MarkerEntries?.Select(MapToEntryDto).ToList() ?? []
};
private static DockStationConfigSummaryDto MapToSummaryDto(DockStationConfig entity) => new()
{
Id = entity.Id,
StationId = entity.StationId,
ConfigName = entity.ConfigName,
Description = entity.Description,
IsActive = entity.IsActive,
MarkerEntryCount = entity.MarkerEntries?.Count ?? 0,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
private static DockStationMarkerEntryDto MapToEntryDto(DockStationMarkerEntry entity) => new()
{
Id = entity.Id,
MarkerId = entity.MarkerId,
Type = (MarkerType)entity.Type,
Priority = entity.Priority,
DeviceId = entity.DeviceId,
Code = entity.Code,
ReferencePoints = DeserializeReferencePoints(entity.ReferencePointsJson)
};
private static DockStationMarkerEntry MapFromEntryDto(DockStationMarkerEntryDto dto) => new()
{
MarkerId = dto.MarkerId,
Type = (int)dto.Type,
Priority = dto.Priority,
DeviceId = dto.DeviceId ?? string.Empty,
Code = dto.Code ?? string.Empty,
ReferencePointsJson = JsonSerializer.Serialize(dto.ReferencePoints ?? [])
};
private static List<Vector2> DeserializeReferencePoints(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try { return JsonSerializer.Deserialize<List<Vector2>>(json) ?? []; }
catch { return []; }
}
#endregion
}

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace RobotNet10.RobotApp.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,88 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using RobotNet10.RobotApp.Client.Shared.SLAM;
using RobotNet10.RobotApp.Shared;
using RobotNet10.RobotApp.SLAM;
namespace RobotNet10.RobotApp.Controllers;
/// <summary>
/// API Controller for map management operations
/// </summary>
[Route("api/[controller]")]
[ApiController]
[Authorize]
public class MapsController(ISLAMService slamService, ILogger<MapsController> logger) : ControllerBase
{
/// <summary>
/// Get map info by name
/// </summary>
/// <param name="mapName">Name of the map</param>
/// <returns>MapInfoDto if found, NotFound otherwise</returns>
[HttpGet("{mapName}")]
public ActionResult<MapInfoDto> GetMapInfo(string mapName)
{
try
{
var mapInfo = slamService.GetMapInfo(mapName);
if (mapInfo == null)
{
logger.LogWarning("MapsController: Map not found: {MapName}", mapName);
return NotFound($"Map '{mapName}' not found");
}
return Ok(MapMapInfoToDto(mapInfo));
}
catch (Exception ex)
{
logger.LogError(ex, "MapsController: Failed to get map info: {MapName}", mapName);
return StatusCode(500, "Failed to get map info");
}
}
/// <summary>
/// Get map image by name
/// </summary>
/// <param name="mapName">Name of the map</param>
/// <returns>PNG image file</returns>
[HttpGet("{mapName}/image")]
public IActionResult GetMapImage(string mapName)
{
try
{
var imagePath = slamService.GetMapImagePath(mapName);
if (imagePath == null)
{
logger.LogWarning("MapsController: Map image not found: {MapName}", mapName);
return NotFound($"Map image for '{mapName}' not found");
}
var contentType = imagePath.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
? "image/png"
: "image/jpeg";
var fileStream = new FileStream(imagePath, FileMode.Open, FileAccess.Read, FileShare.Read);
return File(fileStream, contentType);
}
catch (Exception ex)
{
logger.LogError(ex, "MapsController: Failed to get map image: {MapName}", mapName);
return StatusCode(500, "Failed to get map image");
}
}
private static MapInfoDto MapMapInfoToDto(MapInfo mapInfo)
{
return new MapInfoDto
{
Name = mapInfo.Name,
CreatedDate = mapInfo.CreatedDate,
Resolution = mapInfo.Resolution,
Width = mapInfo.Size.Width,
Height = mapInfo.Size.Height,
TrajectoryNodeCount = mapInfo.TrajectoryNodeCount,
OriginX = mapInfo.Origin.Position.X,
OriginY = mapInfo.Origin.Position.Y,
};
}
}

View File

@@ -0,0 +1,31 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace RobotNet10.RobotApp.Data
{
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ApplicationUser>(options)
{
public DbSet<RobotConfig> RobotConfigs { get; private set; }
public DbSet<RobotSimulationConfig> RobotSimulationConfigs { get; private set; }
public DbSet<RobotPlcConfig> RobotPlcConfigs { get; private set; }
public DbSet<RobotVDA5050Config> RobotVDA5050Configs { get; private set; }
public DbSet<RobotSafetyConfig> RobotSafetyConfigs { get; private set; }
public DbSet<DockStationConfig> DockStationConfigs { get; private set; }
public DbSet<DockStationMarkerEntry> DockStationMarkerEntries { get; private set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<DockStationConfig>()
.HasIndex(d => d.StationId)
.IsUnique();
modelBuilder.Entity<DockStationConfig>()
.HasMany(d => d.MarkerEntries)
.WithOne(m => m.DockStationConfig)
.HasForeignKey(m => m.DockStationConfigId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

View File

@@ -0,0 +1,106 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using RobotNet10.RobotApp.Data;
namespace RobotNet10.RobotApp.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.RobotApp.Data
{
// Add profile data for application users by adding properties to the ApplicationUser class
public class ApplicationUser : IdentityUser
{
}
}

View File

@@ -0,0 +1,55 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.RobotApp.Data;
#nullable disable
[Table("DockStationConfig")]
public class DockStationConfig
{
[Column("Id", TypeName = "uniqueidentifier")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("StationId", TypeName = "nvarchar(128)")]
[Required]
[MaxLength(128)]
public string StationId { get; set; }
[Column("ConfigName", TypeName = "nvarchar(64)")]
[MaxLength(100)]
public string ConfigName { get; set; }
[Column("Description", TypeName = "ntext")]
[MaxLength(500)]
public string Description { get; set; }
[Column("X", TypeName = "double")]
public double X { get; set; }
[Column("Y", TypeName = "double")]
public double Y { get; set; }
[Column("Yaw", TypeName = "double")]
public double Yaw { get; set; }
[Column("Width", TypeName = "double")]
public double Width { get; set; }
[Column("Length", TypeName = "double")]
public double Length { get; set; }
[Column("CreatedAt", TypeName = "datetime2")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt", TypeName = "datetime2")]
public DateTime UpdatedAt { get; set; }
[Column("IsActive", TypeName = "bit")]
public bool IsActive { get; set; }
public ICollection<DockStationMarkerEntry> MarkerEntries { get; set; }
}

View File

@@ -0,0 +1,45 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.RobotApp.Data;
#nullable disable
[Table("DockStationMarkerEntry")]
public class DockStationMarkerEntry
{
[Column("Id", TypeName = "uniqueidentifier")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("DockStationConfigId", TypeName = "uniqueidentifier")]
[Required]
public Guid DockStationConfigId { get; set; }
[ForeignKey(nameof(DockStationConfigId))]
public DockStationConfig DockStationConfig { get; set; }
[Column("MarkerId", TypeName = "nvarchar(128)")]
[Required]
[MaxLength(128)]
public string MarkerId { get; set; }
[Column("Type", TypeName = "int")]
public int Type { get; set; }
[Column("Priority", TypeName = "int")]
public int Priority { get; set; }
[Column("DeviceId", TypeName = "nvarchar(128)")]
[MaxLength(128)]
public string DeviceId { get; set; }
[Column("Code", TypeName = "nvarchar(256)")]
[MaxLength(256)]
public string Code { get; set; }
[Column("ReferencePointsJson", TypeName = "ntext")]
public string ReferencePointsJson { get; set; }
}

View File

@@ -0,0 +1,586 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.RobotApp.Data;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.AppDb
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260129034352_AddAppDb")]
partial class AddAppDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
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("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<double>("Height")
.HasColumnType("double")
.HasColumnName("Height");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("Length")
.HasColumnType("double")
.HasColumnName("Length");
b.Property<int>("NavigationType")
.HasColumnType("int")
.HasColumnName("NavigationType");
b.Property<double>("RadiusWheel")
.HasColumnType("double")
.HasColumnName("RadiusWheel");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<double>("Width")
.HasColumnType("double")
.HasColumnName("Width");
b.HasKey("Id");
b.ToTable("RobotConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotPlcConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<string>("PLCAddress")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("PLCAddress");
b.Property<int>("PLCPort")
.HasColumnType("int")
.HasColumnName("PLCPort");
b.Property<byte>("PLCUnitId")
.HasColumnType("tinyint")
.HasColumnName("PLCUnitId");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotPlcConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotSafetyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("SafetySpeedFast")
.HasColumnType("double")
.HasColumnName("SafetySpeedFast");
b.Property<double>("SafetySpeedMedium")
.HasColumnType("double")
.HasColumnName("SafetySpeedMedium");
b.Property<double>("SafetySpeedNormal")
.HasColumnType("double")
.HasColumnName("SafetySpeedNormal");
b.Property<double>("SafetySpeedOptimal")
.HasColumnType("double")
.HasColumnName("SafetySpeedOptimal");
b.Property<double>("SafetySpeedSlow")
.HasColumnType("double")
.HasColumnName("SafetySpeedSlow");
b.Property<double>("SafetySpeedVeryFast")
.HasColumnType("double")
.HasColumnName("SafetySpeedVeryFast");
b.Property<double>("SafetySpeedVerySlow")
.HasColumnType("double")
.HasColumnName("SafetySpeedVerySlow");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotSafetyConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotSimulationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("EnableSimulation")
.HasColumnType("bit")
.HasColumnName("EnableSimulation");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("SimulationAcceleration")
.HasColumnType("double")
.HasColumnName("SimulationAcceleration");
b.Property<double>("SimulationDeceleration")
.HasColumnType("double")
.HasColumnName("SimulationDeceleration");
b.Property<double>("SimulationMaxAngularVelocity")
.HasColumnType("double")
.HasColumnName("SimulationMaxAngularVelocity");
b.Property<double>("SimulationMaxVelocity")
.HasColumnType("double")
.HasColumnName("SimulationMaxVelocity");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotSimulationConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotVDA5050Config", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<string>("SerialNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("SerialNumber");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<string>("VDA5050CA")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_CA");
b.Property<string>("VDA5050Cer")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Cer");
b.Property<bool>("VDA5050EnablePassword")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnablePassword");
b.Property<bool>("VDA5050EnableSSLSecure")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnableSSLSecure");
b.Property<bool>("VDA5050EnableTls")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnableTls");
b.Property<string>("VDA5050HostServer")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_HostServer");
b.Property<string>("VDA5050Key")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Key");
b.Property<string>("VDA5050Manufacturer")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Manufacturer");
b.Property<string>("VDA5050Password")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Password");
b.Property<int>("VDA5050Port")
.HasColumnType("int")
.HasColumnName("VDA5050_Port");
b.Property<int>("VDA5050PublishRepeat")
.HasColumnType("int")
.HasColumnName("VDA5050_PublishRepeat");
b.Property<string>("VDA5050TopicPrefix")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_TopicPrefix");
b.Property<string>("VDA5050UserName")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_UserName");
b.Property<string>("VDA5050Version")
.HasMaxLength(20)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Version");
b.HasKey("Id");
b.ToTable("RobotVDA5050Config");
});
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.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("RobotNet10.RobotApp.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.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("RobotNet10.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,352 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RobotNet10.RobotApp.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: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RobotConfig",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
NavigationType = table.Column<int>(type: "int", nullable: false),
RadiusWheel = table.Column<double>(type: "double", nullable: false),
Width = table.Column<double>(type: "double", nullable: false),
Length = table.Column<double>(type: "double", nullable: false),
Height = table.Column<double>(type: "double", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ConfigName = table.Column<string>(type: "nvarchar(64)", maxLength: 100, nullable: true),
Description = table.Column<string>(type: "ntext", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RobotConfig", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RobotPlcConfig",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
PLCAddress = table.Column<string>(type: "nvarchar(64)", maxLength: 50, nullable: true),
PLCPort = table.Column<int>(type: "int", nullable: false),
PLCUnitId = table.Column<byte>(type: "tinyint", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ConfigName = table.Column<string>(type: "nvarchar(64)", maxLength: 100, nullable: true),
Description = table.Column<string>(type: "ntext", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RobotPlcConfig", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RobotSafetyConfig",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SafetySpeedVerySlow = table.Column<double>(type: "double", nullable: false),
SafetySpeedSlow = table.Column<double>(type: "double", nullable: false),
SafetySpeedNormal = table.Column<double>(type: "double", nullable: false),
SafetySpeedMedium = table.Column<double>(type: "double", nullable: false),
SafetySpeedOptimal = table.Column<double>(type: "double", nullable: false),
SafetySpeedFast = table.Column<double>(type: "double", nullable: false),
SafetySpeedVeryFast = table.Column<double>(type: "double", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ConfigName = table.Column<string>(type: "nvarchar(64)", maxLength: 100, nullable: true),
Description = table.Column<string>(type: "ntext", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RobotSafetyConfig", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RobotSimulationConfig",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
EnableSimulation = table.Column<bool>(type: "bit", nullable: false),
SimulationMaxVelocity = table.Column<double>(type: "double", nullable: false),
SimulationMaxAngularVelocity = table.Column<double>(type: "double", nullable: false),
SimulationAcceleration = table.Column<double>(type: "double", nullable: false),
SimulationDeceleration = table.Column<double>(type: "double", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ConfigName = table.Column<string>(type: "nvarchar(64)", maxLength: 100, nullable: true),
Description = table.Column<string>(type: "ntext", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RobotSimulationConfig", x => x.Id);
});
migrationBuilder.CreateTable(
name: "RobotVDA5050Config",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SerialNumber = table.Column<string>(type: "nvarchar(64)", maxLength: 50, nullable: true),
VDA5050_TopicPrefix = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
VDA5050_Manufacturer = table.Column<string>(type: "nvarchar(64)", maxLength: 50, nullable: true),
VDA5050_Version = table.Column<string>(type: "nvarchar(64)", maxLength: 20, nullable: true),
VDA5050_HostServer = table.Column<string>(type: "nvarchar(64)", maxLength: 100, nullable: true),
VDA5050_Port = table.Column<int>(type: "int", nullable: false),
VDA5050_UserName = table.Column<string>(type: "nvarchar(64)", maxLength: 50, nullable: true),
VDA5050_Password = table.Column<string>(type: "nvarchar(64)", maxLength: 50, nullable: true),
VDA5050_PublishRepeat = table.Column<int>(type: "int", nullable: false),
VDA5050_EnablePassword = table.Column<bool>(type: "bit", nullable: false),
VDA5050_EnableTls = table.Column<bool>(type: "bit", nullable: false),
VDA5050_EnableSSLSecure = table.Column<bool>(type: "bit", nullable: false),
VDA5050_CA = table.Column<string>(type: "nvarchar(64)", nullable: true),
VDA5050_Cer = table.Column<string>(type: "nvarchar(64)", nullable: true),
VDA5050_Key = table.Column<string>(type: "nvarchar(64)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
ConfigName = table.Column<string>(type: "nvarchar(64)", maxLength: 100, nullable: true),
Description = table.Column<string>(type: "ntext", maxLength: 500, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_RobotVDA5050Config", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
RoleId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", 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: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
UserId = table.Column<string>(type: "TEXT", nullable: false),
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
ClaimValue = table.Column<string>(type: "TEXT", 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: "TEXT", nullable: false),
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
UserId = table.Column<string>(type: "TEXT", 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: "TEXT", nullable: false),
RoleId = table.Column<string>(type: "TEXT", 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: "TEXT", nullable: false),
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", 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.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true);
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);
}
/// <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: "RobotConfig");
migrationBuilder.DropTable(
name: "RobotPlcConfig");
migrationBuilder.DropTable(
name: "RobotSafetyConfig");
migrationBuilder.DropTable(
name: "RobotSimulationConfig");
migrationBuilder.DropTable(
name: "RobotVDA5050Config");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@@ -0,0 +1,711 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.RobotApp.Data;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.AppDb
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260226030442_AddDockStationConfig")]
partial class AddDockStationConfig
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
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("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("Length")
.HasColumnType("double")
.HasColumnName("Length");
b.Property<string>("StationId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("StationId");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<double>("Width")
.HasColumnType("double")
.HasColumnName("Width");
b.Property<double>("X")
.HasColumnType("double")
.HasColumnName("X");
b.Property<double>("Y")
.HasColumnType("double")
.HasColumnName("Y");
b.Property<double>("Yaw")
.HasColumnType("double")
.HasColumnName("Yaw");
b.HasKey("Id");
b.HasIndex("StationId")
.IsUnique();
b.ToTable("DockStationConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationMarkerEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("Code")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasColumnName("Code");
b.Property<string>("DeviceId")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("DeviceId");
b.Property<Guid>("DockStationConfigId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DockStationConfigId");
b.Property<string>("MarkerId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("MarkerId");
b.Property<int>("Priority")
.HasColumnType("int")
.HasColumnName("Priority");
b.Property<string>("ReferencePointsJson")
.HasColumnType("ntext")
.HasColumnName("ReferencePointsJson");
b.Property<int>("Type")
.HasColumnType("int")
.HasColumnName("Type");
b.HasKey("Id");
b.HasIndex("DockStationConfigId");
b.ToTable("DockStationMarkerEntry");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<double>("Height")
.HasColumnType("double")
.HasColumnName("Height");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("Length")
.HasColumnType("double")
.HasColumnName("Length");
b.Property<int>("NavigationType")
.HasColumnType("int")
.HasColumnName("NavigationType");
b.Property<double>("RadiusWheel")
.HasColumnType("double")
.HasColumnName("RadiusWheel");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<double>("Width")
.HasColumnType("double")
.HasColumnName("Width");
b.HasKey("Id");
b.ToTable("RobotConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotPlcConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<string>("PLCAddress")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("PLCAddress");
b.Property<int>("PLCPort")
.HasColumnType("int")
.HasColumnName("PLCPort");
b.Property<byte>("PLCUnitId")
.HasColumnType("tinyint")
.HasColumnName("PLCUnitId");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotPlcConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotSafetyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("SafetySpeedFast")
.HasColumnType("double")
.HasColumnName("SafetySpeedFast");
b.Property<double>("SafetySpeedMedium")
.HasColumnType("double")
.HasColumnName("SafetySpeedMedium");
b.Property<double>("SafetySpeedNormal")
.HasColumnType("double")
.HasColumnName("SafetySpeedNormal");
b.Property<double>("SafetySpeedOptimal")
.HasColumnType("double")
.HasColumnName("SafetySpeedOptimal");
b.Property<double>("SafetySpeedSlow")
.HasColumnType("double")
.HasColumnName("SafetySpeedSlow");
b.Property<double>("SafetySpeedVeryFast")
.HasColumnType("double")
.HasColumnName("SafetySpeedVeryFast");
b.Property<double>("SafetySpeedVerySlow")
.HasColumnType("double")
.HasColumnName("SafetySpeedVerySlow");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotSafetyConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotSimulationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("EnableSimulation")
.HasColumnType("bit")
.HasColumnName("EnableSimulation");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("SimulationAcceleration")
.HasColumnType("double")
.HasColumnName("SimulationAcceleration");
b.Property<double>("SimulationDeceleration")
.HasColumnType("double")
.HasColumnName("SimulationDeceleration");
b.Property<double>("SimulationMaxAngularVelocity")
.HasColumnType("double")
.HasColumnName("SimulationMaxAngularVelocity");
b.Property<double>("SimulationMaxVelocity")
.HasColumnType("double")
.HasColumnName("SimulationMaxVelocity");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotSimulationConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotVDA5050Config", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<string>("SerialNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("SerialNumber");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<string>("VDA5050CA")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_CA");
b.Property<string>("VDA5050Cer")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Cer");
b.Property<bool>("VDA5050EnablePassword")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnablePassword");
b.Property<bool>("VDA5050EnableSSLSecure")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnableSSLSecure");
b.Property<bool>("VDA5050EnableTls")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnableTls");
b.Property<string>("VDA5050HostServer")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_HostServer");
b.Property<string>("VDA5050Key")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Key");
b.Property<string>("VDA5050Manufacturer")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Manufacturer");
b.Property<string>("VDA5050Password")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Password");
b.Property<int>("VDA5050Port")
.HasColumnType("int")
.HasColumnName("VDA5050_Port");
b.Property<int>("VDA5050PublishRepeat")
.HasColumnType("int")
.HasColumnName("VDA5050_PublishRepeat");
b.Property<string>("VDA5050TopicPrefix")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_TopicPrefix");
b.Property<string>("VDA5050UserName")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_UserName");
b.Property<string>("VDA5050Version")
.HasMaxLength(20)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Version");
b.HasKey("Id");
b.ToTable("RobotVDA5050Config");
});
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.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("RobotNet10.RobotApp.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.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("RobotNet10.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationMarkerEntry", b =>
{
b.HasOne("RobotNet10.RobotApp.Data.DockStationConfig", "DockStationConfig")
.WithMany("MarkerEntries")
.HasForeignKey("DockStationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DockStationConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationConfig", b =>
{
b.Navigation("MarkerEntries");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.AppDb
{
/// <inheritdoc />
public partial class AddDockStationConfig : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DockStationConfig",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
StationId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
ConfigName = table.Column<string>(type: "nvarchar(64)", maxLength: 100, nullable: true),
Description = table.Column<string>(type: "ntext", maxLength: 500, nullable: true),
X = table.Column<double>(type: "double", nullable: false),
Y = table.Column<double>(type: "double", nullable: false),
Yaw = table.Column<double>(type: "double", nullable: false),
Width = table.Column<double>(type: "double", nullable: false),
Length = table.Column<double>(type: "double", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DockStationConfig", x => x.Id);
});
migrationBuilder.CreateTable(
name: "DockStationMarkerEntry",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
DockStationConfigId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
MarkerId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Type = table.Column<int>(type: "int", nullable: false),
Priority = table.Column<int>(type: "int", nullable: false),
DeviceId = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
Code = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ReferencePointsJson = table.Column<string>(type: "ntext", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DockStationMarkerEntry", x => x.Id);
table.ForeignKey(
name: "FK_DockStationMarkerEntry_DockStationConfig_DockStationConfigId",
column: x => x.DockStationConfigId,
principalTable: "DockStationConfig",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_DockStationConfig_StationId",
table: "DockStationConfig",
column: "StationId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_DockStationMarkerEntry_DockStationConfigId",
table: "DockStationMarkerEntry",
column: "DockStationConfigId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DockStationMarkerEntry");
migrationBuilder.DropTable(
name: "DockStationConfig");
}
}
}

View File

@@ -0,0 +1,708 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.RobotApp.Data;
#nullable disable
namespace RobotNet10.RobotApp.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");
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
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("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.ApplicationUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("Length")
.HasColumnType("double")
.HasColumnName("Length");
b.Property<string>("StationId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("StationId");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<double>("Width")
.HasColumnType("double")
.HasColumnName("Width");
b.Property<double>("X")
.HasColumnType("double")
.HasColumnName("X");
b.Property<double>("Y")
.HasColumnType("double")
.HasColumnName("Y");
b.Property<double>("Yaw")
.HasColumnType("double")
.HasColumnName("Yaw");
b.HasKey("Id");
b.HasIndex("StationId")
.IsUnique();
b.ToTable("DockStationConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationMarkerEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("Code")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasColumnName("Code");
b.Property<string>("DeviceId")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("DeviceId");
b.Property<Guid>("DockStationConfigId")
.HasColumnType("uniqueidentifier")
.HasColumnName("DockStationConfigId");
b.Property<string>("MarkerId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)")
.HasColumnName("MarkerId");
b.Property<int>("Priority")
.HasColumnType("int")
.HasColumnName("Priority");
b.Property<string>("ReferencePointsJson")
.HasColumnType("ntext")
.HasColumnName("ReferencePointsJson");
b.Property<int>("Type")
.HasColumnType("int")
.HasColumnName("Type");
b.HasKey("Id");
b.HasIndex("DockStationConfigId");
b.ToTable("DockStationMarkerEntry");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<double>("Height")
.HasColumnType("double")
.HasColumnName("Height");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("Length")
.HasColumnType("double")
.HasColumnName("Length");
b.Property<int>("NavigationType")
.HasColumnType("int")
.HasColumnName("NavigationType");
b.Property<double>("RadiusWheel")
.HasColumnType("double")
.HasColumnName("RadiusWheel");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<double>("Width")
.HasColumnType("double")
.HasColumnName("Width");
b.HasKey("Id");
b.ToTable("RobotConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotPlcConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<string>("PLCAddress")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("PLCAddress");
b.Property<int>("PLCPort")
.HasColumnType("int")
.HasColumnName("PLCPort");
b.Property<byte>("PLCUnitId")
.HasColumnType("tinyint")
.HasColumnName("PLCUnitId");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotPlcConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotSafetyConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("SafetySpeedFast")
.HasColumnType("double")
.HasColumnName("SafetySpeedFast");
b.Property<double>("SafetySpeedMedium")
.HasColumnType("double")
.HasColumnName("SafetySpeedMedium");
b.Property<double>("SafetySpeedNormal")
.HasColumnType("double")
.HasColumnName("SafetySpeedNormal");
b.Property<double>("SafetySpeedOptimal")
.HasColumnType("double")
.HasColumnName("SafetySpeedOptimal");
b.Property<double>("SafetySpeedSlow")
.HasColumnType("double")
.HasColumnName("SafetySpeedSlow");
b.Property<double>("SafetySpeedVeryFast")
.HasColumnType("double")
.HasColumnName("SafetySpeedVeryFast");
b.Property<double>("SafetySpeedVerySlow")
.HasColumnType("double")
.HasColumnName("SafetySpeedVerySlow");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotSafetyConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotSimulationConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("EnableSimulation")
.HasColumnType("bit")
.HasColumnName("EnableSimulation");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<double>("SimulationAcceleration")
.HasColumnType("double")
.HasColumnName("SimulationAcceleration");
b.Property<double>("SimulationDeceleration")
.HasColumnType("double")
.HasColumnName("SimulationDeceleration");
b.Property<double>("SimulationMaxAngularVelocity")
.HasColumnType("double")
.HasColumnName("SimulationMaxAngularVelocity");
b.Property<double>("SimulationMaxVelocity")
.HasColumnType("double")
.HasColumnName("SimulationMaxVelocity");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.HasKey("Id");
b.ToTable("RobotSimulationConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.RobotVDA5050Config", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier")
.HasColumnName("Id");
b.Property<string>("ConfigName")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("ConfigName");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2")
.HasColumnName("CreatedAt");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("ntext")
.HasColumnName("Description");
b.Property<bool>("IsActive")
.HasColumnType("bit")
.HasColumnName("IsActive");
b.Property<string>("SerialNumber")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("SerialNumber");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2")
.HasColumnName("UpdatedAt");
b.Property<string>("VDA5050CA")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_CA");
b.Property<string>("VDA5050Cer")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Cer");
b.Property<bool>("VDA5050EnablePassword")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnablePassword");
b.Property<bool>("VDA5050EnableSSLSecure")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnableSSLSecure");
b.Property<bool>("VDA5050EnableTls")
.HasColumnType("bit")
.HasColumnName("VDA5050_EnableTls");
b.Property<string>("VDA5050HostServer")
.HasMaxLength(100)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_HostServer");
b.Property<string>("VDA5050Key")
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Key");
b.Property<string>("VDA5050Manufacturer")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Manufacturer");
b.Property<string>("VDA5050Password")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Password");
b.Property<int>("VDA5050Port")
.HasColumnType("int")
.HasColumnName("VDA5050_Port");
b.Property<int>("VDA5050PublishRepeat")
.HasColumnType("int")
.HasColumnName("VDA5050_PublishRepeat");
b.Property<string>("VDA5050TopicPrefix")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_TopicPrefix");
b.Property<string>("VDA5050UserName")
.HasMaxLength(50)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_UserName");
b.Property<string>("VDA5050Version")
.HasMaxLength(20)
.HasColumnType("nvarchar(64)")
.HasColumnName("VDA5050_Version");
b.HasKey("Id");
b.ToTable("RobotVDA5050Config");
});
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.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("RobotNet10.RobotApp.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.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("RobotNet10.RobotApp.Data.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationMarkerEntry", b =>
{
b.HasOne("RobotNet10.RobotApp.Data.DockStationConfig", "DockStationConfig")
.WithMany("MarkerEntries")
.HasForeignKey("DockStationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("DockStationConfig");
});
modelBuilder.Entity("RobotNet10.RobotApp.Data.DockStationConfig", b =>
{
b.Navigation("MarkerEntries");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,705 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.MapManager.Data;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.MapDb
{
[DbContext(typeof(MapDbContext))]
[Migration("20260305070456_AddMapDb")]
partial class AddMapDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("EdgeDescription")
.HasColumnType("TEXT");
b.Property<string>("EdgeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("EdgeName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("EndNodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<Guid>("StartNodeId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("Actions")
.HasColumnType("TEXT");
b.Property<double?>("CorridorLeftWidth")
.HasColumnType("REAL");
b.Property<int?>("CorridorRefPoint")
.HasColumnType("INTEGER");
b.Property<double?>("CorridorRightWidth")
.HasColumnType("REAL");
b.Property<Guid>("EdgeId")
.HasColumnType("TEXT");
b.Property<string>("LoadRestriction_LoadSetNames")
.HasColumnType("TEXT");
b.Property<bool?>("LoadRestriction_Loaded")
.HasColumnType("INTEGER");
b.Property<bool?>("LoadRestriction_Unloaded")
.HasColumnType("INTEGER");
b.Property<double?>("MaxHeight")
.HasColumnType("REAL");
b.Property<double?>("MaxRotationSpeed")
.HasColumnType("REAL");
b.Property<double?>("MaxSpeed")
.HasColumnType("REAL");
b.Property<double?>("MinHeight")
.HasColumnType("REAL");
b.Property<int?>("OrientationType")
.HasColumnType("INTEGER");
b.Property<bool?>("RotationAllowed")
.HasColumnType("INTEGER");
b.Property<int?>("RotationAtEndNodeAllowed")
.HasColumnType("INTEGER");
b.Property<int?>("RotationAtStartNodeAllowed")
.HasColumnType("INTEGER");
b.Property<double?>("TrajectoryControlPoint1X")
.HasColumnType("REAL");
b.Property<double?>("TrajectoryControlPoint1Y")
.HasColumnType("REAL");
b.Property<double?>("TrajectoryControlPoint2X")
.HasColumnType("REAL");
b.Property<double?>("TrajectoryControlPoint2Y")
.HasColumnType("REAL");
b.Property<int?>("TrajectoryDegree")
.HasColumnType("INTEGER");
b.Property<double?>("VehicleOrientation")
.HasColumnType("REAL");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("LayoutId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("LayoutName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("ModifiedBy")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("LayoutLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LevelOrder")
.HasColumnType("INTEGER");
b.Property<Guid>("VersionId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<double?>("BoundsMaxX")
.HasColumnType("REAL");
b.Property<double?>("BoundsMaxY")
.HasColumnType("REAL");
b.Property<double?>("BoundsMinX")
.HasColumnType("REAL");
b.Property<double?>("BoundsMinY")
.HasColumnType("REAL");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<double>("EdgeMinLengthCreate")
.HasColumnType("REAL");
b.Property<bool>("EdgeNameAutoGenerate")
.HasColumnType("INTEGER");
b.Property<double?>("ImageHeight")
.HasColumnType("REAL");
b.Property<double?>("ImageWidth")
.HasColumnType("REAL");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("TEXT");
b.Property<bool>("NodeNameAutoGenerate")
.HasColumnType("INTEGER");
b.Property<double>("NodeProximityRadius")
.HasColumnType("REAL");
b.Property<double>("OriginX")
.HasColumnType("REAL");
b.Property<double>("OriginY")
.HasColumnType("REAL");
b.Property<double>("Resolution")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("LevelId")
.IsUnique();
b.ToTable("LayoutLevelEditorSettings");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("LayoutDescription")
.HasColumnType("TEXT");
b.Property<Guid>("LayoutId")
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
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("TEXT");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<string>("MapId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NodeDescription")
.HasColumnType("TEXT");
b.Property<string>("NodeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NodeName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<double>("X")
.HasColumnType("REAL");
b.Property<double>("Y")
.HasColumnType("REAL");
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("TEXT");
b.Property<string>("Actions")
.HasColumnType("TEXT");
b.Property<double?>("AllowedDeviationTheta")
.HasColumnType("REAL");
b.Property<double?>("AllowedDeviationXY")
.HasColumnType("REAL");
b.Property<Guid>("NodeId")
.HasColumnType("TEXT");
b.Property<double?>("Theta")
.HasColumnType("REAL");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<string>("StationDescription")
.HasColumnType("TEXT");
b.Property<double?>("StationHeight")
.HasColumnType("REAL");
b.Property<string>("StationId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("StationName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<double?>("Theta")
.HasColumnType("REAL");
b.Property<double>("X")
.HasColumnType("REAL");
b.Property<double>("Y")
.HasColumnType("REAL");
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("TEXT");
b.Property<Guid>("NodeId")
.HasColumnType("TEXT");
b.Property<Guid>("StationId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("Actions")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Specifications")
.HasColumnType("TEXT");
b.Property<string>("VehicleTypeId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("VehicleTypeName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
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.RobotApp.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: "TEXT", nullable: false),
LayoutId = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
LayoutName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: true),
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
CreatedDate = table.Column<DateTime>(type: "TEXT", nullable: false),
ModifiedDate = table.Column<DateTime>(type: "TEXT", nullable: false),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
ModifiedBy = table.Column<string>(type: "TEXT", 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: "TEXT", nullable: false),
VehicleTypeId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
VehicleTypeName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: true),
Specifications = table.Column<string>(type: "TEXT", nullable: true),
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
Actions = table.Column<string>(type: "TEXT", nullable: true),
CreatedDate = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VehicleTypes", x => x.Id);
});
migrationBuilder.CreateTable(
name: "LayoutVersions",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
LayoutId = table.Column<Guid>(type: "TEXT", nullable: false),
Version = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
LayoutDescription = table.Column<string>(type: "TEXT", nullable: true),
CreatedBy = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
CreatedDate = table.Column<DateTime>(type: "TEXT", nullable: false),
IsActive = table.Column<bool>(type: "INTEGER", 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: "TEXT", nullable: false),
VersionId = table.Column<Guid>(type: "TEXT", nullable: false),
LayoutLevelId = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
LevelOrder = table.Column<int>(type: "INTEGER", 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: "TEXT", nullable: false),
LevelId = table.Column<Guid>(type: "TEXT", nullable: false),
EdgeMinLengthCreate = table.Column<double>(type: "REAL", nullable: false),
EdgeNameAutoGenerate = table.Column<bool>(type: "INTEGER", nullable: false),
NodeNameAutoGenerate = table.Column<bool>(type: "INTEGER", nullable: false),
NodeProximityRadius = table.Column<double>(type: "REAL", nullable: false),
OriginX = table.Column<double>(type: "REAL", nullable: false),
OriginY = table.Column<double>(type: "REAL", nullable: false),
Resolution = table.Column<double>(type: "REAL", nullable: false),
BoundsMinX = table.Column<double>(type: "REAL", nullable: true),
BoundsMaxX = table.Column<double>(type: "REAL", nullable: true),
BoundsMinY = table.Column<double>(type: "REAL", nullable: true),
BoundsMaxY = table.Column<double>(type: "REAL", nullable: true),
ImageWidth = table.Column<double>(type: "REAL", nullable: true),
ImageHeight = table.Column<double>(type: "REAL", nullable: true),
CreatedDate = table.Column<DateTime>(type: "TEXT", nullable: false),
ModifiedDate = table.Column<DateTime>(type: "TEXT", 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: "TEXT", nullable: false),
LevelId = table.Column<Guid>(type: "TEXT", nullable: false),
NodeId = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
NodeName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
NodeDescription = table.Column<string>(type: "TEXT", nullable: true),
MapId = table.Column<string>(type: "TEXT", maxLength: 128, nullable: true),
X = table.Column<double>(type: "REAL", nullable: false),
Y = table.Column<double>(type: "REAL", 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: "TEXT", nullable: false),
LevelId = table.Column<Guid>(type: "TEXT", nullable: false),
StationId = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
StationName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
StationDescription = table.Column<string>(type: "TEXT", nullable: true),
StationHeight = table.Column<double>(type: "REAL", nullable: true),
X = table.Column<double>(type: "REAL", nullable: false),
Y = table.Column<double>(type: "REAL", nullable: false),
Theta = table.Column<double>(type: "REAL", 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: "TEXT", nullable: false),
LevelId = table.Column<Guid>(type: "TEXT", nullable: false),
EdgeId = table.Column<string>(type: "TEXT", maxLength: 128, nullable: false),
StartNodeId = table.Column<Guid>(type: "TEXT", nullable: false),
EndNodeId = table.Column<Guid>(type: "TEXT", nullable: false),
EdgeName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
EdgeDescription = table.Column<string>(type: "TEXT", 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: "TEXT", nullable: false),
NodeId = table.Column<Guid>(type: "TEXT", nullable: false),
VehicleTypeId = table.Column<Guid>(type: "TEXT", nullable: false),
Theta = table.Column<double>(type: "REAL", nullable: true),
Actions = table.Column<string>(type: "TEXT", nullable: true),
AllowedDeviationXY = table.Column<double>(type: "REAL", nullable: true),
AllowedDeviationTheta = table.Column<double>(type: "REAL", 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: "TEXT", nullable: false),
StationId = table.Column<Guid>(type: "TEXT", nullable: false),
NodeId = table.Column<Guid>(type: "TEXT", 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: "TEXT", nullable: false),
EdgeId = table.Column<Guid>(type: "TEXT", nullable: false),
VehicleTypeId = table.Column<Guid>(type: "TEXT", nullable: false),
VehicleOrientation = table.Column<double>(type: "REAL", nullable: true),
OrientationType = table.Column<int>(type: "INTEGER", nullable: true),
RotationAllowed = table.Column<bool>(type: "INTEGER", nullable: true),
RotationAtStartNodeAllowed = table.Column<int>(type: "INTEGER", nullable: true),
RotationAtEndNodeAllowed = table.Column<int>(type: "INTEGER", nullable: true),
MaxSpeed = table.Column<double>(type: "REAL", nullable: true),
MaxRotationSpeed = table.Column<double>(type: "REAL", nullable: true),
MinHeight = table.Column<double>(type: "REAL", nullable: true),
MaxHeight = table.Column<double>(type: "REAL", nullable: true),
LoadRestriction_Unloaded = table.Column<bool>(type: "INTEGER", nullable: true),
LoadRestriction_Loaded = table.Column<bool>(type: "INTEGER", nullable: true),
LoadRestriction_LoadSetNames = table.Column<string>(type: "TEXT", nullable: true),
TrajectoryDegree = table.Column<int>(type: "INTEGER", nullable: true),
TrajectoryControlPoint1X = table.Column<double>(type: "REAL", nullable: true),
TrajectoryControlPoint1Y = table.Column<double>(type: "REAL", nullable: true),
TrajectoryControlPoint2X = table.Column<double>(type: "REAL", nullable: true),
TrajectoryControlPoint2Y = table.Column<double>(type: "REAL", nullable: true),
Actions = table.Column<string>(type: "TEXT", nullable: true),
CorridorLeftWidth = table.Column<double>(type: "REAL", nullable: true),
CorridorRightWidth = table.Column<double>(type: "REAL", nullable: true),
CorridorRefPoint = table.Column<int>(type: "INTEGER", 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,702 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.MapManager.Data;
#nullable disable
namespace RobotNet10.RobotApp.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");
modelBuilder.Entity("RobotNet10.MapManager.Data.Edge", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("EdgeDescription")
.HasColumnType("TEXT");
b.Property<string>("EdgeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("EdgeName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("EndNodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<Guid>("StartNodeId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("Actions")
.HasColumnType("TEXT");
b.Property<double?>("CorridorLeftWidth")
.HasColumnType("REAL");
b.Property<int?>("CorridorRefPoint")
.HasColumnType("INTEGER");
b.Property<double?>("CorridorRightWidth")
.HasColumnType("REAL");
b.Property<Guid>("EdgeId")
.HasColumnType("TEXT");
b.Property<string>("LoadRestriction_LoadSetNames")
.HasColumnType("TEXT");
b.Property<bool?>("LoadRestriction_Loaded")
.HasColumnType("INTEGER");
b.Property<bool?>("LoadRestriction_Unloaded")
.HasColumnType("INTEGER");
b.Property<double?>("MaxHeight")
.HasColumnType("REAL");
b.Property<double?>("MaxRotationSpeed")
.HasColumnType("REAL");
b.Property<double?>("MaxSpeed")
.HasColumnType("REAL");
b.Property<double?>("MinHeight")
.HasColumnType("REAL");
b.Property<int?>("OrientationType")
.HasColumnType("INTEGER");
b.Property<bool?>("RotationAllowed")
.HasColumnType("INTEGER");
b.Property<int?>("RotationAtEndNodeAllowed")
.HasColumnType("INTEGER");
b.Property<int?>("RotationAtStartNodeAllowed")
.HasColumnType("INTEGER");
b.Property<double?>("TrajectoryControlPoint1X")
.HasColumnType("REAL");
b.Property<double?>("TrajectoryControlPoint1Y")
.HasColumnType("REAL");
b.Property<double?>("TrajectoryControlPoint2X")
.HasColumnType("REAL");
b.Property<double?>("TrajectoryControlPoint2Y")
.HasColumnType("REAL");
b.Property<int?>("TrajectoryDegree")
.HasColumnType("INTEGER");
b.Property<double?>("VehicleOrientation")
.HasColumnType("REAL");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("LayoutId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("LayoutName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("ModifiedBy")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("LayoutLevelId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("LevelOrder")
.HasColumnType("INTEGER");
b.Property<Guid>("VersionId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<double?>("BoundsMaxX")
.HasColumnType("REAL");
b.Property<double?>("BoundsMaxY")
.HasColumnType("REAL");
b.Property<double?>("BoundsMinX")
.HasColumnType("REAL");
b.Property<double?>("BoundsMinY")
.HasColumnType("REAL");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<double>("EdgeMinLengthCreate")
.HasColumnType("REAL");
b.Property<bool>("EdgeNameAutoGenerate")
.HasColumnType("INTEGER");
b.Property<double?>("ImageHeight")
.HasColumnType("REAL");
b.Property<double?>("ImageWidth")
.HasColumnType("REAL");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<DateTime>("ModifiedDate")
.HasColumnType("TEXT");
b.Property<bool>("NodeNameAutoGenerate")
.HasColumnType("INTEGER");
b.Property<double>("NodeProximityRadius")
.HasColumnType("REAL");
b.Property<double>("OriginX")
.HasColumnType("REAL");
b.Property<double>("OriginY")
.HasColumnType("REAL");
b.Property<double>("Resolution")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("LevelId")
.IsUnique();
b.ToTable("LayoutLevelEditorSettings");
});
modelBuilder.Entity("RobotNet10.MapManager.Data.LayoutVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("LayoutDescription")
.HasColumnType("TEXT");
b.Property<Guid>("LayoutId")
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
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("TEXT");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<string>("MapId")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NodeDescription")
.HasColumnType("TEXT");
b.Property<string>("NodeId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("NodeName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<double>("X")
.HasColumnType("REAL");
b.Property<double>("Y")
.HasColumnType("REAL");
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("TEXT");
b.Property<string>("Actions")
.HasColumnType("TEXT");
b.Property<double?>("AllowedDeviationTheta")
.HasColumnType("REAL");
b.Property<double?>("AllowedDeviationXY")
.HasColumnType("REAL");
b.Property<Guid>("NodeId")
.HasColumnType("TEXT");
b.Property<double?>("Theta")
.HasColumnType("REAL");
b.Property<Guid>("VehicleTypeId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<Guid>("LevelId")
.HasColumnType("TEXT");
b.Property<string>("StationDescription")
.HasColumnType("TEXT");
b.Property<double?>("StationHeight")
.HasColumnType("REAL");
b.Property<string>("StationId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("StationName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<double?>("Theta")
.HasColumnType("REAL");
b.Property<double>("X")
.HasColumnType("REAL");
b.Property<double>("Y")
.HasColumnType("REAL");
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("TEXT");
b.Property<Guid>("NodeId")
.HasColumnType("TEXT");
b.Property<Guid>("StationId")
.HasColumnType("TEXT");
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("TEXT");
b.Property<string>("Actions")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedDate")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<string>("Specifications")
.HasColumnType("TEXT");
b.Property<string>("VehicleTypeId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("VehicleTypeName")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
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,72 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.ScriptEngine.Data;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.ScriptDb
{
[DbContext(typeof(ScriptEngineDbContext))]
[Migration("20260129034736_AddScriptDb")]
partial class AddScriptDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RobotNet10.ScriptEngine.Data.InstanceMission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("Id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("CreatedAt");
b.Property<string>("Log")
.HasColumnType("TEXT")
.HasColumnName("Log");
b.Property<string>("MissionName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("MissionName");
b.Property<string>("Parameters")
.HasColumnType("TEXT")
.HasColumnName("Parameters");
b.Property<int>("Score")
.HasColumnType("INTEGER")
.HasColumnName("Score");
b.Property<int>("State")
.HasColumnType("INTEGER")
.HasColumnName("State");
b.Property<DateTime>("StoppedAt")
.HasColumnType("TEXT")
.HasColumnName("StoppedAt");
b.Property<int>("TotalScore")
.HasColumnType("INTEGER")
.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.RobotApp.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: "TEXT", nullable: false),
MissionName = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
Parameters = table.Column<string>(type: "TEXT", nullable: true),
TotalScore = table.Column<int>(type: "INTEGER", nullable: false),
State = table.Column<int>(type: "INTEGER", nullable: false),
Score = table.Column<int>(type: "INTEGER", nullable: false),
StoppedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
Log = table.Column<string>(type: "TEXT", 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,69 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.ScriptEngine.Data;
#nullable disable
namespace RobotNet10.RobotApp.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");
modelBuilder.Entity("RobotNet10.ScriptEngine.Data.InstanceMission", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("Id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("CreatedAt");
b.Property<string>("Log")
.HasColumnType("TEXT")
.HasColumnName("Log");
b.Property<string>("MissionName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("MissionName");
b.Property<string>("Parameters")
.HasColumnType("TEXT")
.HasColumnName("Parameters");
b.Property<int>("Score")
.HasColumnType("INTEGER")
.HasColumnName("Score");
b.Property<int>("State")
.HasColumnType("INTEGER")
.HasColumnName("State");
b.Property<DateTime>("StoppedAt")
.HasColumnType("TEXT")
.HasColumnName("StoppedAt");
b.Property<int>("TotalScore")
.HasColumnType("INTEGER")
.HasColumnName("TotalScore");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.ToTable("InstanceMissions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,325 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.NavigationTune.Data;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.TuneDb
{
[DbContext(typeof(TuningDbContext))]
[Migration("20260227022756_AddTunDb")]
partial class AddTunDb
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RobotNet10.NavigationTune.Data.TestScenarioEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ConfigJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Name");
b.HasIndex("Type");
b.ToTable("test_scenarios", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.NavigationParameterSet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ControllerType")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EstimatorConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<string>("MotorDynamicsConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MovePidConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("NavigationConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PurePursuitConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RotatePidConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SignalConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StanleyConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("IsDefault");
b.HasIndex("Name");
b.ToTable("parameter_sets", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.SafetyViolation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<int>("Severity")
.HasColumnType("INTEGER");
b.Property<Guid>("TestRunId")
.HasColumnType("TEXT");
b.Property<double>("Threshold")
.HasColumnType("REAL");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<double>("Value")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("TestRunId");
b.HasIndex("Timestamp");
b.HasIndex("TestRunId", "Timestamp");
b.ToTable("safety_violations", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestMetrics", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<double>("AccelerationStdDev")
.HasColumnType("REAL");
b.Property<double>("AverageSpeed")
.HasColumnType("REAL");
b.Property<double>("CompletionTime")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorMean")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorPeak")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorRMS")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorStdDev")
.HasColumnType("REAL");
b.Property<double>("EfficiencyScore")
.HasColumnType("REAL");
b.Property<double>("GoalHeadingError")
.HasColumnType("REAL");
b.Property<double>("GoalPositionError")
.HasColumnType("REAL");
b.Property<double>("HeadingErrorPeak")
.HasColumnType("REAL");
b.Property<double>("HeadingErrorRMS")
.HasColumnType("REAL");
b.Property<double>("MaxSpeed")
.HasColumnType("REAL");
b.Property<double>("OverallScore")
.HasColumnType("REAL");
b.Property<bool>("PassedCriteria")
.HasColumnType("INTEGER");
b.Property<double>("PathLengthRatio")
.HasColumnType("REAL");
b.Property<double>("SmoothnessScore")
.HasColumnType("REAL");
b.Property<Guid>("TestRunId")
.HasColumnType("TEXT");
b.Property<double>("TrackingScore")
.HasColumnType("REAL");
b.Property<double>("VelocityStdDev")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("TestRunId")
.IsUnique();
b.ToTable("test_metrics", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<double>("Duration")
.HasColumnType("REAL");
b.Property<DateTime?>("EndTime")
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<Guid>("ParameterSetId")
.HasColumnType("TEXT");
b.Property<Guid>("ScenarioId")
.HasColumnType("TEXT");
b.Property<DateTime>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ParameterSetId");
b.HasIndex("ScenarioId");
b.HasIndex("StartTime");
b.HasIndex("Status");
b.HasIndex("ScenarioId", "ParameterSetId");
b.ToTable("test_runs", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.SafetyViolation", b =>
{
b.HasOne("RobotNet10.NavigationTune.Shared.Models.TestRun", null)
.WithMany("SafetyViolations")
.HasForeignKey("TestRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestMetrics", b =>
{
b.HasOne("RobotNet10.NavigationTune.Shared.Models.TestRun", null)
.WithOne("Metrics")
.HasForeignKey("RobotNet10.NavigationTune.Shared.Models.TestMetrics", "TestRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestRun", b =>
{
b.Navigation("Metrics");
b.Navigation("SafetyViolations");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,233 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.TuneDb
{
/// <inheritdoc />
public partial class AddTunDb : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "parameter_sets",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
Version = table.Column<int>(type: "INTEGER", nullable: false),
ControllerType = table.Column<int>(type: "INTEGER", nullable: false),
MovePidConfig = table.Column<string>(type: "TEXT", nullable: false),
RotatePidConfig = table.Column<string>(type: "TEXT", nullable: false),
PurePursuitConfig = table.Column<string>(type: "TEXT", nullable: false),
StanleyConfig = table.Column<string>(type: "TEXT", nullable: false),
EstimatorConfig = table.Column<string>(type: "TEXT", nullable: false),
SignalConfig = table.Column<string>(type: "TEXT", nullable: false),
MotorDynamicsConfig = table.Column<string>(type: "TEXT", nullable: false),
NavigationConfig = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_parameter_sets", x => x.Id);
});
migrationBuilder.CreateTable(
name: "test_runs",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ScenarioId = table.Column<Guid>(type: "TEXT", nullable: false),
ParameterSetId = table.Column<Guid>(type: "TEXT", nullable: false),
StartTime = table.Column<DateTime>(type: "TEXT", nullable: false),
EndTime = table.Column<DateTime>(type: "TEXT", nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
Duration = table.Column<double>(type: "REAL", nullable: false),
Notes = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true),
ErrorMessage = table.Column<string>(type: "TEXT", maxLength: 1000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_test_runs", x => x.Id);
});
migrationBuilder.CreateTable(
name: "test_scenarios",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Description = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
ConfigJson = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_test_scenarios", x => x.Id);
});
migrationBuilder.CreateTable(
name: "safety_violations",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
TestRunId = table.Column<Guid>(type: "TEXT", nullable: false),
Timestamp = table.Column<DateTime>(type: "TEXT", nullable: false),
Type = table.Column<int>(type: "INTEGER", nullable: false),
Severity = table.Column<int>(type: "INTEGER", nullable: false),
Value = table.Column<double>(type: "REAL", nullable: false),
Threshold = table.Column<double>(type: "REAL", nullable: false),
Message = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_safety_violations", x => x.Id);
table.ForeignKey(
name: "FK_safety_violations_test_runs_TestRunId",
column: x => x.TestRunId,
principalTable: "test_runs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "test_metrics",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
TestRunId = table.Column<Guid>(type: "TEXT", nullable: false),
CrossTrackErrorRMS = table.Column<double>(type: "REAL", nullable: false),
CrossTrackErrorPeak = table.Column<double>(type: "REAL", nullable: false),
CrossTrackErrorMean = table.Column<double>(type: "REAL", nullable: false),
CrossTrackErrorStdDev = table.Column<double>(type: "REAL", nullable: false),
HeadingErrorRMS = table.Column<double>(type: "REAL", nullable: false),
HeadingErrorPeak = table.Column<double>(type: "REAL", nullable: false),
GoalPositionError = table.Column<double>(type: "REAL", nullable: false),
GoalHeadingError = table.Column<double>(type: "REAL", nullable: false),
VelocityStdDev = table.Column<double>(type: "REAL", nullable: false),
AccelerationStdDev = table.Column<double>(type: "REAL", nullable: false),
PathLengthRatio = table.Column<double>(type: "REAL", nullable: false),
CompletionTime = table.Column<double>(type: "REAL", nullable: false),
AverageSpeed = table.Column<double>(type: "REAL", nullable: false),
MaxSpeed = table.Column<double>(type: "REAL", nullable: false),
OverallScore = table.Column<double>(type: "REAL", nullable: false),
TrackingScore = table.Column<double>(type: "REAL", nullable: false),
SmoothnessScore = table.Column<double>(type: "REAL", nullable: false),
EfficiencyScore = table.Column<double>(type: "REAL", nullable: false),
PassedCriteria = table.Column<bool>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_test_metrics", x => x.Id);
table.ForeignKey(
name: "FK_test_metrics_test_runs_TestRunId",
column: x => x.TestRunId,
principalTable: "test_runs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_parameter_sets_CreatedAt",
table: "parameter_sets",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_parameter_sets_IsDefault",
table: "parameter_sets",
column: "IsDefault");
migrationBuilder.CreateIndex(
name: "IX_parameter_sets_Name",
table: "parameter_sets",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_safety_violations_TestRunId",
table: "safety_violations",
column: "TestRunId");
migrationBuilder.CreateIndex(
name: "IX_safety_violations_TestRunId_Timestamp",
table: "safety_violations",
columns: new[] { "TestRunId", "Timestamp" });
migrationBuilder.CreateIndex(
name: "IX_safety_violations_Timestamp",
table: "safety_violations",
column: "Timestamp");
migrationBuilder.CreateIndex(
name: "IX_test_metrics_TestRunId",
table: "test_metrics",
column: "TestRunId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_test_runs_ParameterSetId",
table: "test_runs",
column: "ParameterSetId");
migrationBuilder.CreateIndex(
name: "IX_test_runs_ScenarioId",
table: "test_runs",
column: "ScenarioId");
migrationBuilder.CreateIndex(
name: "IX_test_runs_ScenarioId_ParameterSetId",
table: "test_runs",
columns: new[] { "ScenarioId", "ParameterSetId" });
migrationBuilder.CreateIndex(
name: "IX_test_runs_StartTime",
table: "test_runs",
column: "StartTime");
migrationBuilder.CreateIndex(
name: "IX_test_runs_Status",
table: "test_runs",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_test_scenarios_CreatedAt",
table: "test_scenarios",
column: "CreatedAt");
migrationBuilder.CreateIndex(
name: "IX_test_scenarios_Name",
table: "test_scenarios",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_test_scenarios_Type",
table: "test_scenarios",
column: "Type");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "parameter_sets");
migrationBuilder.DropTable(
name: "safety_violations");
migrationBuilder.DropTable(
name: "test_metrics");
migrationBuilder.DropTable(
name: "test_scenarios");
migrationBuilder.DropTable(
name: "test_runs");
}
}
}

View File

@@ -0,0 +1,322 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RobotNet10.NavigationTune.Data;
#nullable disable
namespace RobotNet10.RobotApp.Data.Migrations.TuneDb
{
[DbContext(typeof(TuningDbContext))]
partial class TuningDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RobotNet10.NavigationTune.Data.TestScenarioEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("ConfigJson")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Name");
b.HasIndex("Type");
b.ToTable("test_scenarios", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.NavigationParameterSet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("ControllerType")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("EstimatorConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<string>("MotorDynamicsConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("MovePidConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("NavigationConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PurePursuitConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("RotatePidConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SignalConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("StanleyConfig")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<int>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("IsDefault");
b.HasIndex("Name");
b.ToTable("parameter_sets", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.SafetyViolation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<int>("Severity")
.HasColumnType("INTEGER");
b.Property<Guid>("TestRunId")
.HasColumnType("TEXT");
b.Property<double>("Threshold")
.HasColumnType("REAL");
b.Property<DateTime>("Timestamp")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<double>("Value")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("TestRunId");
b.HasIndex("Timestamp");
b.HasIndex("TestRunId", "Timestamp");
b.ToTable("safety_violations", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestMetrics", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<double>("AccelerationStdDev")
.HasColumnType("REAL");
b.Property<double>("AverageSpeed")
.HasColumnType("REAL");
b.Property<double>("CompletionTime")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorMean")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorPeak")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorRMS")
.HasColumnType("REAL");
b.Property<double>("CrossTrackErrorStdDev")
.HasColumnType("REAL");
b.Property<double>("EfficiencyScore")
.HasColumnType("REAL");
b.Property<double>("GoalHeadingError")
.HasColumnType("REAL");
b.Property<double>("GoalPositionError")
.HasColumnType("REAL");
b.Property<double>("HeadingErrorPeak")
.HasColumnType("REAL");
b.Property<double>("HeadingErrorRMS")
.HasColumnType("REAL");
b.Property<double>("MaxSpeed")
.HasColumnType("REAL");
b.Property<double>("OverallScore")
.HasColumnType("REAL");
b.Property<bool>("PassedCriteria")
.HasColumnType("INTEGER");
b.Property<double>("PathLengthRatio")
.HasColumnType("REAL");
b.Property<double>("SmoothnessScore")
.HasColumnType("REAL");
b.Property<Guid>("TestRunId")
.HasColumnType("TEXT");
b.Property<double>("TrackingScore")
.HasColumnType("REAL");
b.Property<double>("VelocityStdDev")
.HasColumnType("REAL");
b.HasKey("Id");
b.HasIndex("TestRunId")
.IsUnique();
b.ToTable("test_metrics", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestRun", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<double>("Duration")
.HasColumnType("REAL");
b.Property<DateTime?>("EndTime")
.HasColumnType("TEXT");
b.Property<string>("ErrorMessage")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<Guid>("ParameterSetId")
.HasColumnType("TEXT");
b.Property<Guid>("ScenarioId")
.HasColumnType("TEXT");
b.Property<DateTime>("StartTime")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ParameterSetId");
b.HasIndex("ScenarioId");
b.HasIndex("StartTime");
b.HasIndex("Status");
b.HasIndex("ScenarioId", "ParameterSetId");
b.ToTable("test_runs", (string)null);
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.SafetyViolation", b =>
{
b.HasOne("RobotNet10.NavigationTune.Shared.Models.TestRun", null)
.WithMany("SafetyViolations")
.HasForeignKey("TestRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestMetrics", b =>
{
b.HasOne("RobotNet10.NavigationTune.Shared.Models.TestRun", null)
.WithOne("Metrics")
.HasForeignKey("RobotNet10.NavigationTune.Shared.Models.TestMetrics", "TestRunId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RobotNet10.NavigationTune.Shared.Models.TestRun", b =>
{
b.Navigation("Metrics");
b.Navigation("SafetyViolations");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,49 @@
using RobotNet10.RobotApp.Shared.Enums;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.RobotApp.Data;
#nullable disable
[Table("RobotConfig")]
public class RobotConfig
{
[Column("Id", TypeName = "uniqueidentifier")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("NavigationType", TypeName = "int")]
public NavigationType NavigationType { get; set; }
[Column("RadiusWheel", TypeName = "double")]
public double RadiusWheel { get; set; }
[Column("Width", TypeName = "double")]
public double Width { get; set; }
[Column("Length", TypeName = "double")]
public double Length { get; set; }
[Column("Height", TypeName = "double")]
public double Height { get; set; }
[Column("CreatedAt", TypeName = "datetime2")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt", TypeName = "datetime2")]
public DateTime UpdatedAt { get; set; }
[Column("IsActive", TypeName = "bit")]
public bool IsActive { get; set; }
[Column("ConfigName", TypeName = "nvarchar(64)")]
[MaxLength(100)]
public string ConfigName { get; set; }
[Column("Description", TypeName = "ntext")]
[MaxLength(500)]
public string Description { get; set; }
}

View File

@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.RobotApp.Data;
#nullable disable
[Table("RobotPlcConfig")]
public class RobotPlcConfig
{
[Column("Id", TypeName = "uniqueidentifier")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("PLCAddress", TypeName = "nvarchar(64)")]
[MaxLength(50)]
public string PLCAddress { get; set; }
[Column("PLCPort", TypeName = "int")]
public int PLCPort { get; set; }
[Column("PLCUnitId", TypeName = "tinyint")]
public byte PLCUnitId { get; set; }
[Column("CreatedAt", TypeName = "datetime2")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt", TypeName = "datetime2")]
public DateTime UpdatedAt { get; set; }
[Column("IsActive", TypeName = "bit")]
public bool IsActive { get; set; }
[Column("ConfigName", TypeName = "nvarchar(64)")]
[MaxLength(100)]
public string ConfigName { get; set; }
[Column("Description", TypeName = "ntext")]
[MaxLength(500)]
public string Description { get; set; }
}

View File

@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.RobotApp.Data;
#nullable disable
[Table("RobotSafetyConfig")]
public class RobotSafetyConfig
{
[Column("Id", TypeName = "uniqueidentifier")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("SafetySpeedVerySlow", TypeName = "double")]
public double SafetySpeedVerySlow { get; set; }
[Column("SafetySpeedSlow", TypeName = "double")]
public double SafetySpeedSlow { get; set; }
[Column("SafetySpeedNormal", TypeName = "double")]
public double SafetySpeedNormal { get; set; }
[Column("SafetySpeedMedium", TypeName = "double")]
public double SafetySpeedMedium { get; set; }
[Column("SafetySpeedOptimal", TypeName = "double")]
public double SafetySpeedOptimal { get; set; }
[Column("SafetySpeedFast", TypeName = "double")]
public double SafetySpeedFast { get; set; }
[Column("SafetySpeedVeryFast", TypeName = "double")]
public double SafetySpeedVeryFast { get; set; }
[Column("CreatedAt", TypeName = "datetime2")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt", TypeName = "datetime2")]
public DateTime UpdatedAt { get; set; }
[Column("IsActive", TypeName = "bit")]
public bool IsActive { get; set; }
[Column("ConfigName", TypeName = "nvarchar(64)")]
[MaxLength(100)]
public string ConfigName { get; set; }
[Column("Description", TypeName = "ntext")]
[MaxLength(500)]
public string Description { get; set; }
}

View File

@@ -0,0 +1,48 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.RobotApp.Data;
#nullable disable
[Table("RobotSimulationConfig")]
public class RobotSimulationConfig
{
[Column("Id", TypeName = "uniqueidentifier")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("EnableSimulation", TypeName = "bit")]
public bool EnableSimulation { get; set; }
[Column("SimulationMaxVelocity", TypeName = "double")]
public double SimulationMaxVelocity { get; set; }
[Column("SimulationMaxAngularVelocity", TypeName = "double")]
public double SimulationMaxAngularVelocity { get; set; }
[Column("SimulationAcceleration", TypeName = "double")]
public double SimulationAcceleration { get; set; }
[Column("SimulationDeceleration", TypeName = "double")]
public double SimulationDeceleration { get; set; }
[Column("CreatedAt", TypeName = "datetime2")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt", TypeName = "datetime2")]
public DateTime UpdatedAt { get; set; }
[Column("IsActive", TypeName = "bit")]
public bool IsActive { get; set; }
[Column("ConfigName", TypeName = "nvarchar(64)")]
[MaxLength(100)]
public string ConfigName { get; set; }
[Column("Description", TypeName = "ntext")]
[MaxLength(500)]
public string Description { get; set; }
}

View File

@@ -0,0 +1,85 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RobotNet10.RobotApp.Data;
#nullable disable
[Table("RobotVDA5050Config")]
public class RobotVDA5050Config
{
[Column("Id", TypeName = "uniqueidentifier")]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
[Key]
[Required]
public Guid Id { get; set; }
[Column("SerialNumber", TypeName = "nvarchar(64)")]
[MaxLength(50)]
public string SerialNumber { get; set; }
[Column("VDA5050_TopicPrefix", TypeName = "nvarchar(64)")]
[MaxLength(64)]
public string VDA5050TopicPrefix { get; set; }
[Column("VDA5050_Manufacturer", TypeName = "nvarchar(64)")]
[MaxLength(50)]
public string VDA5050Manufacturer { get; set; }
[Column("VDA5050_Version", TypeName = "nvarchar(64)")]
[MaxLength(20)]
public string VDA5050Version { get; set; }
[Column("VDA5050_HostServer", TypeName = "nvarchar(64)")]
[MaxLength(100)]
public string VDA5050HostServer { get; set; }
[Column("VDA5050_Port", TypeName = "int")]
public int VDA5050Port { get; set; }
[Column("VDA5050_UserName", TypeName = "nvarchar(64)")]
[MaxLength(50)]
public string VDA5050UserName { get; set; }
[Column("VDA5050_Password", TypeName = "nvarchar(64)")]
[MaxLength(50)]
public string VDA5050Password { get; set; }
[Column("VDA5050_PublishRepeat", TypeName = "int")]
public int VDA5050PublishRepeat { get; set; }
[Column("VDA5050_EnablePassword", TypeName = "bit")]
public bool VDA5050EnablePassword { get; set; }
[Column("VDA5050_EnableTls", TypeName = "bit")]
public bool VDA5050EnableTls { get; set; }
[Column("VDA5050_EnableSSLSecure", TypeName = "bit")]
public bool VDA5050EnableSSLSecure { get; set; }
[Column("VDA5050_CA", TypeName = "nvarchar(64)")]
public string VDA5050CA { get; set; }
[Column("VDA5050_Cer", TypeName = "nvarchar(64)")]
public string VDA5050Cer { get; set; }
[Column("VDA5050_Key", TypeName = "nvarchar(64)")]
public string VDA5050Key { get; set; }
[Column("CreatedAt", TypeName = "datetime2")]
public DateTime CreatedAt { get; set; }
[Column("UpdatedAt", TypeName = "datetime2")]
public DateTime UpdatedAt { get; set; }
[Column("IsActive", TypeName = "bit")]
public bool IsActive { get; set; }
[Column("ConfigName", TypeName = "nvarchar(64)")]
[MaxLength(100)]
public string ConfigName { get; set; }
[Column("Description", TypeName = "ntext")]
[MaxLength(500)]
public string Description { get; set; }
}

View File

@@ -0,0 +1,338 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Odometry Comparison Test</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/8.0.0/signalr.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
color: #333;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: white;
text-align: center;
margin-bottom: 30px;
font-size: 2.5em;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.controls {
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
margin-bottom: 20px;
display: flex;
gap: 15px;
align-items: center;
}
button {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
transition: all 0.3s ease;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0,0,0,0.3);
}
button:active {
transform: translateY(0);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
#status {
flex: 1;
font-weight: 600;
padding: 10px;
border-radius: 6px;
text-align: center;
}
.status-disconnected { background: #fee; color: #c33; }
.status-connecting { background: #ffeaa7; color: #d63031; }
.status-connected { background: #d5f4e6; color: #00b894; }
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.odom-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
}
.odom-card h2 {
margin-bottom: 20px;
color: #667eea;
border-bottom: 3px solid #667eea;
padding-bottom: 10px;
}
.raw h2 { color: #e74c3c; border-bottom-color: #e74c3c; }
.filtered h2 { color: #27ae60; border-bottom-color: #27ae60; }
.data-row {
display: flex;
justify-content: space-between;
padding: 12px;
margin: 8px 0;
background: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #667eea;
}
.raw .data-row { border-left-color: #e74c3c; }
.filtered .data-row { border-left-color: #27ae60; }
.label {
font-weight: 600;
color: #666;
}
.value {
font-family: 'Courier New', monospace;
font-weight: 700;
color: #333;
}
.diff-card {
background: white;
padding: 25px;
border-radius: 12px;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
}
.diff-card h2 {
margin-bottom: 20px;
color: #f39c12;
border-bottom: 3px solid #f39c12;
padding-bottom: 10px;
}
.diff-row {
padding: 12px;
margin: 8px 0;
background: #fff3cd;
border-radius: 6px;
border-left: 4px solid #f39c12;
display: flex;
justify-content: space-between;
align-items: center;
}
.warning {
background: #fee;
border-left-color: #e74c3c;
color: #c33;
}
.success {
background: #d5f4e6;
border-left-color: #27ae60;
color: #00b894;
}
</style>
</head>
<body>
<div class="container">
<h1>🤖 Odometry Comparison: Raw vs Filtered</h1>
<div class="controls">
<button id="connectBtn" onclick="connect()">Connect</button>
<button id="refreshBtn" onclick="refresh()" disabled>🔄 Refresh Data</button>
<div id="status" class="status-disconnected">Disconnected</div>
</div>
<div class="comparison-grid">
<div class="odom-card raw">
<h2>📊 RAW Odometry (Encoder Only)</h2>
<div id="rawData">
<div class="data-row"><span class="label">Position X:</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Position Y:</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Theta (yaw):</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Linear X:</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Angular Z:</span><span class="value">-</span></div>
</div>
</div>
<div class="odom-card filtered">
<h2>✨ FILTERED Odometry (EKF + Slip Detection)</h2>
<div id="filteredData">
<div class="data-row"><span class="label">Position X:</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Position Y:</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Theta (yaw):</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Linear X:</span><span class="value">-</span></div>
<div class="data-row"><span class="label">Angular Z:</span><span class="value">-</span></div>
</div>
</div>
</div>
<div class="diff-card">
<h2>⚠️ Differences (Slip Impact)</h2>
<div id="diffData">
<div class="diff-row"><span class="label">ΔX:</span><span class="value">-</span></div>
<div class="diff-row"><span class="label">ΔY:</span><span class="value">-</span></div>
<div class="diff-row"><span class="label">Δθ:</span><span class="value">-</span></div>
</div>
</div>
</div>
<script>
let connection = null;
let updateInterval = null;
async function connect() {
const statusEl = document.getElementById('status');
const connectBtn = document.getElementById('connectBtn');
const refreshBtn = document.getElementById('refreshBtn');
try {
statusEl.textContent = 'Connecting...';
statusEl.className = 'status-connecting';
connectBtn.disabled = true;
connection = new signalR.HubConnectionBuilder()
.withUrl("https://0.0.0.0:7002/hubs/odometry", {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets
})
.withAutomaticReconnect()
.build();
await connection.start();
statusEl.textContent = 'Connected ✓';
statusEl.className = 'status-connected';
refreshBtn.disabled = false;
// Auto refresh every 500ms
updateInterval = setInterval(refresh, 500);
refresh();
} catch (err) {
console.error('Connection error:', err);
statusEl.textContent = 'Connection failed: ' + err.message;
statusEl.className = 'status-disconnected';
connectBtn.disabled = false;
}
}
async function refresh() {
if (!connection) return;
try {
const comparison = await connection.invoke("GetOdometryComparison");
updateDisplay(comparison);
} catch (err) {
console.error('Refresh error:', err);
}
}
function updateDisplay(comparison) {
const raw = comparison.raw;
const filtered = comparison.filtered;
if (!raw || !filtered) return;
// Raw data
const rawPos = raw.pose?.pose?.position || {};
const rawOri = raw.pose?.pose?.orientation || {};
const rawTwist = raw.twist?.twist || {};
document.querySelector('#rawData .data-row:nth-child(1) .value').textContent =
(rawPos.x || 0).toFixed(3) + ' m';
document.querySelector('#rawData .data-row:nth-child(2) .value').textContent =
(rawPos.y || 0).toFixed(3) + ' m';
document.querySelector('#rawData .data-row:nth-child(3) .value').textContent =
quaternionToYaw(rawOri).toFixed(3) + ' rad';
document.querySelector('#rawData .data-row:nth-child(4) .value').textContent =
(rawTwist.linear?.x || 0).toFixed(3) + ' m/s';
document.querySelector('#rawData .data-row:nth-child(5) .value').textContent =
(rawTwist.angular?.z || 0).toFixed(3) + ' rad/s';
// Filtered data
const filtPos = filtered.pose?.pose?.position || {};
const filtOri = filtered.pose?.pose?.orientation || {};
const filtTwist = filtered.twist?.twist || {};
document.querySelector('#filteredData .data-row:nth-child(1) .value').textContent =
(filtPos.x || 0).toFixed(3) + ' m';
document.querySelector('#filteredData .data-row:nth-child(2) .value').textContent =
(filtPos.y || 0).toFixed(3) + ' m';
document.querySelector('#filteredData .data-row:nth-child(3) .value').textContent =
quaternionToYaw(filtOri).toFixed(3) + ' rad';
document.querySelector('#filteredData .data-row:nth-child(4) .value').textContent =
(filtTwist.linear?.x || 0).toFixed(3) + ' m/s';
document.querySelector('#filteredData .data-row:nth-child(5) .value').textContent =
(filtTwist.angular?.z || 0).toFixed(3) + ' rad/s';
// Differences
const diffX = Math.abs((rawPos.x || 0) - (filtPos.x || 0));
const diffY = Math.abs((rawPos.y || 0) - (filtPos.y || 0));
const diffTheta = Math.abs(quaternionToYaw(rawOri) - quaternionToYaw(filtOri));
const diffRows = document.querySelectorAll('#diffData .diff-row');
updateDiffRow(diffRows[0], 'ΔX:', diffX, 'm');
updateDiffRow(diffRows[1], 'ΔY:', diffY, 'm');
updateDiffRow(diffRows[2], 'Δθ:', diffTheta, 'rad');
}
function updateDiffRow(row, label, value, unit) {
row.querySelector('.label').textContent = label;
row.querySelector('.value').textContent = value.toFixed(3) + ' ' + unit;
// Color code based on magnitude
if (value > 0.5) {
row.className = 'diff-row warning';
} else if (value < 0.05) {
row.className = 'diff-row success';
} else {
row.className = 'diff-row';
}
}
function quaternionToYaw(q) {
if (!q) return 0;
const sinYaw = 2.0 * (q.w * q.z + q.x * q.y);
const cosYaw = 1.0 - 2.0 * (q.y * q.y + q.z * q.z);
return Math.atan2(sinYaw, cosYaw);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,204 @@
using RobotNet10.RobotApp.Devices;
using RobotNet10.RobotApp.SLAM;
using RobotNet10.Shared.Geometry;
namespace RobotNet10.RobotApp.Detection;
public class DetectSession(Guid id, ISLAMService sLAMService) : IDetectSession
{
private static uint SequenceDetection = 1;
private readonly List<DetectorInfo> _detectors = [];
private readonly Lock _lockGoal = new();
private Thread? _updateThread;
private bool _isRunning = false;
private const int UPDATE_FREQUENCY_HZ = 30;
private const double DETECTION_TIMEOUT_SECONDS = 2.0;
public Guid SessionId { get; } = id;
public PoseStamped? Goal
{
get
{
lock (_lockGoal)
{
return field;
}
}
private set
{
lock (_lockGoal)
{
field = value;
}
}
}
public void AddShapeReflectiveDetector(string markerId, int priority, Point2D[] markerReferencePoints, RectangleRegion searchRegion, ILidar lidar, Pose lidarPose, double intensityThreshold = 1000)
{
// Create detector with configurable intensity threshold
var detector = new ShapeReflectiveDetector(
markerReferencePoints,
searchRegion,
sLAMService,
lidar,
lidarPose,
intensityThreshold);
// Add to list with priority
_detectors.Add(new DetectorInfo(markerId, priority, detector));
// Sort by priority (higher priority first)
_detectors.Sort((a, b) => b.Priority.CompareTo(a.Priority));
}
public void AddQRDetector(string markerId, int priority, string qrCode, ICameraQr camera, Pose cameraPose)
{
// Create QR detector
var detector = new QRDetector(
qrCode,
sLAMService,
camera,
cameraPose);
// Add to list with priority
_detectors.Add(new DetectorInfo(markerId, priority, detector));
// Sort by priority (higher priority first)
_detectors.Sort((a, b) => b.Priority.CompareTo(a.Priority));
}
public void Start()
{
if (_isRunning)
return;
_isRunning = true;
// Activate all detectors
foreach (var detectorInfo in _detectors)
{
detectorInfo.Detector.Active();
}
// Create and start goal update thread (30Hz)
_updateThread = new Thread(UpdateGoalLoop)
{
Name = "DetectSession_UpdateGoal",
IsBackground = true
};
_updateThread.Start();
}
public void Dispose()
{
if (!_isRunning)
return;
_isRunning = false;
// Disable all detectors
foreach (var detectorInfo in _detectors)
{
detectorInfo.Detector.Disable();
detectorInfo.Detector.Dispose();
}
// Wait for thread to finish
_updateThread?.Join();
_updateThread = null;
// Clear detectors list
_detectors.Clear();
GC.SuppressFinalize(this);
}
/// <summary>
/// Thread loop to update Goal based on detector priorities at 30Hz
/// Filters detectors by detection time, pose validity, and priority
/// </summary>
private void UpdateGoalLoop()
{
int delayMs = 1000 / UPDATE_FREQUENCY_HZ; // ~33ms for 30Hz
while (_isRunning)
{
try
{
var now = DateTime.UtcNow;
PoseStamped? bestGoal = null;
int bestPriority = int.MinValue;
DateTime bestDetectionTime = DateTime.MinValue;
// Find best detector based on priority and detection freshness
foreach (var detectorInfo in _detectors)
{
var markerPose = detectorInfo.Detector.MarkerPose;
var detectionTime = detectorInfo.Detector.DetectionTime;
// Skip if pose is invalid (default value)
if (IsDefaultPose(markerPose))
continue;
// Skip if detection is too old (timeout)
var timeSinceDetection = (now - detectionTime).TotalSeconds;
if (timeSinceDetection > DETECTION_TIMEOUT_SECONDS)
continue;
// Select detector with highest priority
// If same priority, prefer more recent detection
if (detectorInfo.Priority > bestPriority ||
(detectorInfo.Priority == bestPriority && detectionTime > bestDetectionTime))
{
bestGoal = new PoseStamped()
{
Header = new RobotNet10.Shared.Header()
{
FrameId = detectorInfo.MakerId,
Stamp = detectionTime,
Seq = ++SequenceDetection,
},
Pose = markerPose,
};
bestPriority = detectorInfo.Priority;
bestDetectionTime = detectionTime;
}
}
// Update goal if we found a valid one
if (bestGoal.HasValue)
{
Goal = bestGoal.Value;
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [DetectSession] Updated Goal from detector '{bestGoal.Value.Header.FrameId}' with priority {bestPriority} at pose: [{bestGoal.Value.Pose.Position.X}, {bestGoal.Value.Pose.Position.Y}, {bestGoal.Value.Pose.Orientation.ToYawDegrees()}deg]");
}
}
catch
{
// Ignore errors in update loop
}
// Sleep to maintain 30Hz update rate
Thread.Sleep(delayMs);
}
}
/// <summary>
/// Check if pose is default/invalid
/// </summary>
private static bool IsDefaultPose(Pose pose)
{
return pose.Position.X == 0 &&
pose.Position.Y == 0 &&
pose.Position.Z == 0 &&
pose.Orientation.X == 0 &&
pose.Orientation.Y == 0 &&
pose.Orientation.Z == 0 &&
pose.Orientation.W == 0;
}
/// <summary>
/// Internal class to store detector with priority
/// </summary>
private record DetectorInfo(string MakerId, int Priority, IDetector Detector);
}

View File

@@ -0,0 +1,13 @@
using RobotNet10.Shared.Geometry;
namespace RobotNet10.RobotApp.Detection;
/// <summary>
/// Interface for a marker detection session
/// Manages detection of multiple markers with priority-based aggregation
/// </summary>
public interface IDetectSession : IDisposable
{
Guid SessionId { get; }
PoseStamped? Goal { get; }
}

View File

@@ -0,0 +1,11 @@
using RobotNet10.Shared.Geometry;
namespace RobotNet10.RobotApp.Detection;
public interface IDetector : IDisposable
{
Pose MarkerPose { get; }
DateTime DetectionTime { get; }
void Active();
void Disable();
}

View File

@@ -0,0 +1,36 @@
using RobotNet10.Shared.Detection;
namespace RobotNet10.RobotApp.Detection;
/// <summary>
/// Main service interface for marker and landmark detection
/// Manages detection sessions and device transforms
/// </summary>
public interface IMarkerDetector : IDisposable
{
/// <summary>
/// Create a new detection session
/// </summary>
/// <param name="config">Session configuration with search area and marker list</param>
/// <returns>Created detection session instance</returns>
/// <exception cref="ArgumentException">
/// Thrown if:
/// - Marker IDs don't exist in database
/// - Device IDs don't exist in device provider
/// - Invalid configuration parameters
/// </exception>
Task<IDetectSession> CreateSessionAsync(MarkersSearchRequest request);
/// <summary>
/// Get all active sessions
/// </summary>
/// <returns>Read-only list of active sessions</returns>
IReadOnlyList<IDetectSession> GetActiveSessions();
/// <summary>
/// Get session by ID
/// </summary>
/// <param name="sessionId">Session ID to find</param>
/// <returns>Session if found, null otherwise</returns>
IDetectSession? GetSession(Guid sessionId);
}

View File

@@ -0,0 +1,178 @@
using Microsoft.Extensions.Configuration;
using RobotNet10.RobotApp.Devices;
using RobotNet10.RobotApp.SLAM;
using RobotNet10.Shared.Detection;
using RobotNet10.Shared.Enum;
using RobotNet10.Shared.Geometry;
using SkiaSharp;
namespace RobotNet10.RobotApp.Detection;
public class MarkerDetector(
IConfiguration configuration,
IDeviceProvider deviceProvider,
ISLAMService slamService) : IMarkerDetector
{
private readonly MarkerDetectorConfiguration _config = BindConfig(configuration);
private readonly List<IDetectSession> _sessions = [];
private readonly Lock _lock = new();
private static MarkerDetectorConfiguration BindConfig(IConfiguration configuration)
{
var section = configuration.GetSection("Detection:MarkerDetector");
if (!section.Exists())
throw new InvalidOperationException("Configuration section 'Detection:MarkerDetector' not found.");
var config = new MarkerDetectorConfiguration();
section.Bind(config);
return config;
}
public Task<IDetectSession> CreateSessionAsync(MarkersSearchRequest request)
{
var session = new DetectSession(Guid.NewGuid(), slamService);
// Create search region from request in global frame
// (X, Y as center, Yaw as rotation - all in global/world coordinates)
var searchRegion = new RectangleRegion(
request.X,
request.Y,
request.Width,
request.Length,
request.Yaw);
foreach (var entry in request.MarkerSearchRequests)
{
switch (entry.Type)
{
case MarkerType.ShapeReflective:
AddShapeReflectiveDetector(session, entry, searchRegion);
break;
case MarkerType.QRCode:
AddQRDetector(session, entry);
break;
case MarkerType.ArUco:
// TODO: Implement ArUco detector
break;
default:
// TODO: Handle other marker types
break;
}
}
session.Start();
lock (_lock)
{
_sessions.Add(session);
}
return Task.FromResult<IDetectSession>(session);
}
public IReadOnlyList<IDetectSession> GetActiveSessions()
{
lock (_lock)
{
return [.. _sessions];
}
}
public IDetectSession? GetSession(Guid sessionId)
{
lock (_lock)
{
return _sessions.FirstOrDefault(s => s.SessionId == sessionId);
}
}
public void Dispose()
{
lock (_lock)
{
foreach (var session in _sessions)
{
session.Dispose();
}
_sessions.Clear();
}
GC.SuppressFinalize(this);
}
private void AddShapeReflectiveDetector(DetectSession session, MarkerEntry entry, RectangleRegion searchRegion)
{
// Get device from provider and cast to ILidar
var device = deviceProvider.GetDevice(entry.DeviceId)
?? throw new ArgumentException($"Device '{entry.DeviceId}' not found.");
if (device is not ILidar lidar)
throw new ArgumentException($"Device '{entry.DeviceId}' is not an ILidar.");
// Get lidar transform from configuration (includes pose and intensity threshold)
var lidarTransform = GetDeviceTransform(_config.LidarDevices, entry.DeviceId);
var lidarPose = new Pose(
lidarTransform.Position,
new RobotNet10.Shared.Geometry.Quaternion(
lidarTransform.Orientation.X,
lidarTransform.Orientation.Y,
lidarTransform.Orientation.Z,
lidarTransform.Orientation.W));
// Parse reference points from Parameters [x1, y1, x2, y2, ...]
var referencePoints = entry.ReferencePoints.Select(p => new Point2D(p.X, p.Y)).ToArray();
session.AddShapeReflectiveDetector(entry.MarkerId,
entry.Priority,
referencePoints,
searchRegion,
lidar,
lidarPose,
lidarTransform.IntensityThreshold);
}
private void AddQRDetector(DetectSession session, MarkerEntry entry)
{
// Get device from provider and cast to ICameraQr
var device = deviceProvider.GetDevice(entry.DeviceId)
?? throw new ArgumentException($"Device '{entry.DeviceId}' not found.");
if (device is not ICameraQr camera)
throw new ArgumentException($"Device '{entry.DeviceId}' is not an ICameraQr.");
// Get camera pose from configuration
var cameraPose = GetDevicePose(_config.QrDevices, entry.DeviceId);
// Use MarkerId as QR code string to detect
var qrCode = entry.Code;
session.AddQRDetector(entry.MarkerId,
entry.Priority,
qrCode,
camera,
cameraPose);
}
private static Pose GetDevicePose(DeviceTransform[] devices, string deviceId)
{
var transform = GetDeviceTransform(devices, deviceId);
return new Pose(
transform.Position,
new RobotNet10.Shared.Geometry.Quaternion(
transform.Orientation.X,
transform.Orientation.Y,
transform.Orientation.Z,
transform.Orientation.W));
}
private static DeviceTransform GetDeviceTransform(DeviceTransform[] devices, string deviceId)
{
var transform = Array.Find(devices, d => d.DeviceId == deviceId);
if (string.IsNullOrEmpty(transform.DeviceId))
throw new ArgumentException($"Device transform for '{deviceId}' not found in configuration.");
return transform;
}
}

View File

@@ -0,0 +1,43 @@
using RobotNet10.Shared.Numbers;
namespace RobotNet10.RobotApp.Detection;
#region Service Configuration
/// <summary>
/// Configuration for MarkerLandmarkDetector service
/// Loaded from RobotConfig JSON (e.g., ModelConfigs/test.json)
/// </summary>
public class MarkerDetectorConfiguration
{
/// <summary>
/// Device transforms: deviceId -> Pose3D in base_link frame
/// </summary>
public DeviceTransform[] LidarDevices { get; set; } = [];
public DeviceTransform[] QrDevices { get; set; } = [];
}
/// <summary>
/// Transform configuration for a device (camera/LiDAR) relative to base_link
/// </summary>
public struct DeviceTransform
{
public string DeviceId { get; set; }
/// <summary>
/// Position (X, Y, Z in meters) in base_link frame
/// </summary>
public Vector3 Position { get; set; }
/// <summary>
/// Orientation (quaternion) in base_link frame
/// </summary>
public Quaternion Orientation { get; set; }
/// <summary>
/// Intensity threshold for reflective marker detection (LiDAR devices only)
/// </summary>
public double IntensityThreshold { get; set; }
}
#endregion

View File

@@ -0,0 +1,416 @@
namespace RobotNet10.RobotApp.Detection;
/// <summary>
/// OPTICS (Ordering Points To Identify the Clustering Structure) clustering algorithm
/// Implements density-based clustering with spatial indexing optimizations
/// </summary>
public class OpticsClusteringAlgorithm(double eps, int minPts)
{
private readonly List<Point> _points = [];
private readonly List<int> _orderedList = [];
private readonly Dictionary<long, List<int>> _grid = [];
private double _gridCellSize = 0;
private KDTree? _kdTree;
/// <summary>
/// KD-tree implementation for efficient radius searches
/// </summary>
private class KDTree
{
private class Node
{
public int Idx { get; set; }
public int Left { get; set; } = -1;
public int Right { get; set; } = -1;
}
private readonly List<Point> _pts;
private readonly List<Node> _nodes;
private readonly int _root;
public KDTree(List<Point> points)
{
_pts = points;
_nodes = [];
_root = -1;
if (points.Count > 0)
{
var idxs = Enumerable.Range(0, points.Count).ToList();
_root = BuildRec(idxs, 0, idxs.Count - 1, 0);
}
}
private int BuildRec(List<int> idxs, int l, int r, int depth)
{
if (l > r) return -1;
int axis = depth % 2;
int m = (l + r) / 2;
// Partition based on axis
idxs.Sort(l, r - l + 1, Comparer<int>.Create((a, b) =>
{
if (axis == 0)
return _pts[a].X.CompareTo(_pts[b].X);
return _pts[a].Y.CompareTo(_pts[b].Y);
}));
int nodeIdx = _nodes.Count;
_nodes.Add(new Node { Idx = idxs[m] });
_nodes[nodeIdx].Left = BuildRec(idxs, l, m - 1, depth + 1);
_nodes[nodeIdx].Right = BuildRec(idxs, m + 1, r, depth + 1);
return nodeIdx;
}
public List<int> RadiusSearch(Point q, double radius)
{
var result = new List<int>();
double r2 = radius * radius;
SearchRec(_root, q, r2, 0, result);
return result;
}
private void SearchRec(int nodeIdx, Point q, double r2, int depth, List<int> output)
{
if (nodeIdx < 0) return;
var node = _nodes[nodeIdx];
var p = _pts[node.Idx];
double dx = q.X - p.X;
double dy = q.Y - p.Y;
double dist2 = dx * dx + dy * dy;
if (dist2 <= r2)
output.Add(node.Idx);
int axis = depth % 2;
double diff = axis == 0 ? dx : dy;
if (diff <= 0)
{
SearchRec(node.Left, q, r2, depth + 1, output);
if (diff * diff <= r2)
SearchRec(node.Right, q, r2, depth + 1, output);
}
else
{
SearchRec(node.Right, q, r2, depth + 1, output);
if (diff * diff <= r2)
SearchRec(node.Left, q, r2, depth + 1, output);
}
}
}
/// <summary>
/// Add a single point to the dataset
/// </summary>
public void AddPoint(double x, double y, double range, double alpha)
{
_points.Add(new Point(x, y, range, alpha));
// If grid is enabled, insert into the grid
if (_gridCellSize > 0)
{
int ix = (int)Math.Floor(x / _gridCellSize);
int iy = (int)Math.Floor(y / _gridCellSize);
long key = CellKey(ix, iy);
if (!_grid.ContainsKey(key))
_grid[key] = [];
_grid[key].Add(_points.Count - 1);
}
}
/// <summary>
/// Add multiple points to the dataset
/// </summary>
public void AddPoints(List<Point> newPoints)
{
_points.Clear();
_points.AddRange(newPoints);
// Build spatial index using eps as cell size
_grid.Clear();
_gridCellSize = eps;
BuildSpatialIndex();
BuildKDTree();
}
/// <summary>
/// Clear all points and reset the algorithm state
/// </summary>
public void ClearPoints()
{
_points.Clear();
_orderedList.Clear();
_grid.Clear();
_gridCellSize = 0;
_kdTree = null;
}
/// <summary>
/// Run the OPTICS clustering algorithm
/// </summary>
public void Run()
{
_orderedList.Clear();
_orderedList.Capacity = _points.Count;
for (int i = 0; i < _points.Count; i++)
{
if (!_points[i].Processed)
{
ExpandClusterOrder(i);
}
}
}
/// <summary>
/// Get the ordered list of point indices after running OPTICS
/// </summary>
public IReadOnlyList<int> GetClusterOrder() => _orderedList.AsReadOnly();
/// <summary>
/// Extract clusters using a reachability distance threshold
/// </summary>
public List<List<int>> ExtractClusters(double clusterThreshold)
{
var clusters = new List<List<int>>();
var currentCluster = new List<int>();
double thrSq = clusterThreshold * clusterThreshold;
for (int i = 0; i < _orderedList.Count; i++)
{
int pointIdx = _orderedList[i];
if (_points[pointIdx].ReachabilityDistance > thrSq)
{
if (currentCluster.Count > 0)
{
clusters.Add(currentCluster);
currentCluster = [];
}
}
currentCluster.Add(pointIdx);
}
if (currentCluster.Count > 0)
{
clusters.Add(currentCluster);
}
return clusters;
}
/// <summary>
/// Get clustered points as Point2D structures
/// </summary>
public List<List<Point2D>> GetClusters(double clusterThreshold)
{
var result = new List<List<Point2D>>();
var clusters = ExtractClusters(clusterThreshold);
foreach (var cluster in clusters)
{
var clusteredPoints = new List<Point2D>();
foreach (int pointIdx in cluster)
{
var pt = _points[pointIdx];
clusteredPoints.Add(new Point2D(pt.X, pt.Y));
}
result.Add(clusteredPoints);
}
return result;
}
private void ExpandClusterOrder(int pointIdx)
{
var neighbors = GetNeighbors(pointIdx);
_points[pointIdx].Processed = true;
_orderedList.Add(pointIdx);
if (neighbors.Count >= minPts)
{
// Compute core distance for point_idx
double coreDistPoint = double.MaxValue;
if (neighbors.Count >= minPts)
{
var tmp = new List<(double, int)>(neighbors);
tmp.Sort((a, b) => a.Item1.CompareTo(b.Item1));
coreDistPoint = tmp[minPts - 1].Item1;
}
// Priority queue ordered by reachability distance
var seeds = new SortedSet<(double, int)>(Comparer<(double, int)>.Create((a, b) =>
{
int cmp = a.Item1.CompareTo(b.Item1);
return cmp != 0 ? cmp : a.Item2.CompareTo(b.Item2);
}));
foreach (var (dist, neighborIdx) in neighbors)
{
if (!_points[neighborIdx].Processed)
{
double newReachDist = Math.Max(dist, coreDistPoint);
if (_points[neighborIdx].ReachabilityDistance == double.MaxValue)
{
_points[neighborIdx].ReachabilityDistance = newReachDist;
seeds.Add((newReachDist, neighborIdx));
}
else if (newReachDist < _points[neighborIdx].ReachabilityDistance)
{
_points[neighborIdx].ReachabilityDistance = newReachDist;
seeds.Add((newReachDist, neighborIdx));
}
}
}
while (seeds.Count > 0)
{
var (_, current) = seeds.Min;
seeds.Remove(seeds.Min);
var currentNeighbors = GetNeighbors(current);
_points[current].Processed = true;
_orderedList.Add(current);
if (currentNeighbors.Count >= minPts)
{
// Compute core distance for current
double coreDistCurrent = double.MaxValue;
if (currentNeighbors.Count >= minPts)
{
var tmp2 = new List<(double, int)>(currentNeighbors);
tmp2.Sort((a, b) => a.Item1.CompareTo(b.Item1));
coreDistCurrent = tmp2[minPts - 1].Item1;
}
foreach (var (dist, neighborIdx) in currentNeighbors)
{
if (!_points[neighborIdx].Processed)
{
double newReachDist = Math.Max(dist, coreDistCurrent);
if (_points[neighborIdx].ReachabilityDistance == double.MaxValue)
{
_points[neighborIdx].ReachabilityDistance = newReachDist;
seeds.Add((newReachDist, neighborIdx));
}
else if (newReachDist < _points[neighborIdx].ReachabilityDistance)
{
_points[neighborIdx].ReachabilityDistance = newReachDist;
seeds.Add((newReachDist, neighborIdx));
}
}
}
}
}
}
}
private List<(double, int)> GetNeighbors(int pointIdx)
{
var neighbors = new List<(double, int)>(32);
// If we have a KD-tree, prefer it for radius queries
if (_kdTree != null)
{
var ids = _kdTree.RadiusSearch(_points[pointIdx], eps);
foreach (int idx in ids)
{
if (idx == pointIdx) continue;
double distanceSq = EuclideanDistance(_points[pointIdx], _points[idx]);
neighbors.Add((distanceSq, idx));
}
return neighbors;
}
if (_gridCellSize <= 0 || _grid.Count == 0)
{
// Fallback to brute-force
for (int i = 0; i < _points.Count; i++)
{
if (i == pointIdx) continue;
double distanceSq = EuclideanDistance(_points[pointIdx], _points[i]);
if (distanceSq <= eps * eps)
{
neighbors.Add((distanceSq, i));
}
}
return neighbors;
}
var p = _points[pointIdx];
int cx = (int)Math.Floor(p.X / _gridCellSize);
int cy = (int)Math.Floor(p.Y / _gridCellSize);
// Search neighbor cells around (cx, cy)
for (int dx = -1; dx <= 1; dx++)
{
for (int dy = -1; dy <= 1; dy++)
{
long key = CellKey(cx + dx, cy + dy);
if (_grid.TryGetValue(key, out var cellPoints))
{
foreach (int idx in cellPoints)
{
if (idx == pointIdx) continue;
double distanceSq = EuclideanDistance(p, _points[idx]);
if (distanceSq <= eps * eps)
{
neighbors.Add((distanceSq, idx));
}
}
}
}
}
return neighbors;
}
private static double EuclideanDistance(Point p1, Point p2)
{
double dx = p1.X - p2.X;
double dy = p1.Y - p2.Y;
return dx * dx + dy * dy; // Returns squared distance
}
private void BuildSpatialIndex()
{
if (_gridCellSize <= 0) return;
_grid.Clear();
for (int i = 0; i < _points.Count; i++)
{
int ix = (int)Math.Floor(_points[i].X / _gridCellSize);
int iy = (int)Math.Floor(_points[i].Y / _gridCellSize);
long key = CellKey(ix, iy);
if (!_grid.ContainsKey(key))
_grid[key] = [];
_grid[key].Add(i);
}
}
private void BuildKDTree()
{
if (_points.Count == 0)
{
_kdTree = null;
return;
}
_kdTree = new KDTree(_points);
}
private static long CellKey(int ix, int iy)
{
// Pack two 32-bit ints into one 64-bit key
return ((long)ix << 32) ^ (uint)iy;
}
}

View File

@@ -0,0 +1,90 @@
namespace RobotNet10.RobotApp.Detection;
/// <summary>
/// Represents a 2D point with additional laser scan metadata
/// </summary>
public class Point(double x, double y, double range, double alpha)
{
/// <summary>
/// X coordinate in meters
/// </summary>
public double X { get; set; } = x;
/// <summary>
/// Y coordinate in meters
/// </summary>
public double Y { get; set; } = y;
/// <summary>
/// Range of point from Lidar sensor in meters
/// </summary>
public double Range { get; set; } = range;
/// <summary>
/// Angle of laser beam to the point in radians
/// </summary>
public double Alpha { get; set; } = alpha;
/// <summary>
/// Cluster ID assigned by clustering algorithm (-1 if unassigned)
/// </summary>
public int Cluster { get; set; } = -1;
/// <summary>
/// Reachability distance for OPTICS algorithm
/// </summary>
public double ReachabilityDistance { get; set; } = double.MaxValue;
/// <summary>
/// Whether this point has been processed by the algorithm
/// </summary>
public bool Processed { get; set; } = false;
}
/// <summary>
/// Simple 2D point structure for cluster results
/// </summary>
public struct Point2D(double x, double y) : IEquatable<Point2D>, IComparable<Point2D>
{
/// <summary>
/// X coordinate in meters
/// </summary>
public double X { get; set; } = x;
/// <summary>
/// Y coordinate in meters
/// </summary>
public double Y { get; set; } = y;
public readonly bool Equals(Point2D other)
{
return Math.Abs(X - other.X) < 3e-3 && Math.Abs(Y - other.Y) < 3e-3;
}
public override readonly bool Equals(object? obj)
{
return obj is Point2D other && Equals(other);
}
public override readonly int GetHashCode()
{
return HashCode.Combine(X, Y);
}
public readonly int CompareTo(Point2D other)
{
if (!X.Equals(other.X))
return X.CompareTo(other.X);
return Y.CompareTo(other.Y);
}
public static bool operator ==(Point2D left, Point2D right)
{
return left.Equals(right);
}
public static bool operator !=(Point2D left, Point2D right)
{
return !left.Equals(right);
}
}

View File

@@ -0,0 +1,213 @@
using RobotNet10.RobotApp.Devices;
using RobotNet10.RobotApp.SLAM;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Numbers;
namespace RobotNet10.RobotApp.Detection;
/// <summary>
/// Detector for QR code markers using camera
/// Polls QR camera for specific QR code and transforms pose to global frame
/// </summary>
/// <remarks>
/// Create a new QR code detection session
/// </remarks>
/// <param name="qrCode">QR code string to detect</param>
/// <param name="slamService">SLAM service for current robot pose</param>
/// <param name="camera">QR camera device</param>
/// <param name="cameraPose">Camera pose relative to robot base</param>
public class QRDetector(
string qrCode,
ISLAMService slamService,
ICameraQr camera,
Pose cameraPose) : IDetector
{
private readonly Lock _lockPose = new();
private bool _isActive = false;
private Thread? _pollingThread;
private const int POLLING_FREQUENCY_HZ = 30; // Poll at 30Hz
/// <summary>
/// Detected marker pose in global frame
/// Initialized with default pose, updated when QR code is detected
/// </summary>
public Pose MarkerPose
{
get
{
lock (_lockPose)
{
return field;
}
}
private set
{
lock (_lockPose)
{
field = value;
}
}
}
/// <summary>
/// Timestamp of the last successful QR detection
/// </summary>
public DateTime DetectionTime { get; private set; } = DateTime.MinValue;
/// <summary>
/// Activate the QR detection
/// Starts polling QR camera in background thread
/// </summary>
public void Active()
{
if (_isActive)
return;
_isActive = true;
// Create and start polling thread
_pollingThread = new Thread(PollingThreadLoop)
{
Name = $"QRDetection_{qrCode}",
IsBackground = true
};
_pollingThread.Start();
}
/// <summary>
/// Disable the QR detection
/// Stops polling thread
/// </summary>
public void Disable()
{
if (!_isActive)
return;
_isActive = false;
// Wait for thread to finish
_pollingThread?.Join();
_pollingThread = null;
}
/// <summary>
/// Polling thread loop that checks QR camera periodically
/// Runs at 30Hz to check for QR code detection
/// </summary>
private void PollingThreadLoop()
{
int delayMs = 1000 / POLLING_FREQUENCY_HZ; // ~33ms for 30Hz
while (_isActive)
{
try
{
// Check if camera is connected
if (!camera.IsConnected)
{
Thread.Sleep(delayMs);
continue;
}
// Get QR code pose from camera (in camera frame)
var poseStampedInCamera = camera[qrCode];
if (poseStampedInCamera.HasValue)
{
// Get current robot pose in global frame
var currentRobotPose = slamService.CurrentPose;
// Transform: camera frame -> robot frame -> global frame
var poseInRobot = TransformPose(poseStampedInCamera.Value.Pose, cameraPose);
var poseInGlobal = TransformPose(poseInRobot, currentRobotPose);
// Update marker pose and detection time
MarkerPose = poseInGlobal;
DetectionTime = poseStampedInCamera.Value.Header.Stamp;
// Console.WriteLine($"{DetectionTime:HH:mm:ss.ffffff} [QRDetector] Detected found QR code '{qrCode}' at global pose: [{MarkerPose.Position.X}, {MarkerPose.Position.Y}, {MarkerPose.Orientation.ToYawDegrees()}deg]");
}
}
catch
{
// Ignore errors in polling loop
}
// Sleep to maintain polling rate
Thread.Sleep(delayMs);
}
}
/// <summary>
/// Transform a pose by another pose (pose composition)
/// result = parentPose * childPose
/// </summary>
/// <param name="childPose">Pose in child frame</param>
/// <param name="parentPose">Parent frame pose</param>
/// <returns>Transformed pose in parent's parent frame</returns>
private static Pose TransformPose(Pose childPose, Pose parentPose)
{
// Get yaw angles
double parentYaw = parentPose.Orientation.ToYawRadian();
double childYaw = childPose.Orientation.ToYawRadian();
// Rotate child position by parent orientation
double cosParent = Math.Cos(parentYaw);
double sinParent = Math.Sin(parentYaw);
double globalX = parentPose.Position.X + childPose.Position.X * cosParent - childPose.Position.Y * sinParent;
double globalY = parentPose.Position.Y + childPose.Position.X * sinParent + childPose.Position.Y * cosParent;
// Combine orientations
double globalYaw = NormalizeAngle(parentYaw + childYaw);
return new Pose
{
Position = new Vector3
{
X = globalX,
Y = globalY,
Z = parentPose.Position.Z + childPose.Position.Z
},
Orientation = CreateQuaternionFromYaw(globalYaw)
};
}
/// <summary>
/// Create a quaternion from yaw angle (rotation around Z axis)
/// </summary>
/// <param name="yaw">Yaw angle in radians</param>
/// <returns>Quaternion representing rotation around Z axis</returns>
private static RobotNet10.Shared.Geometry.Quaternion CreateQuaternionFromYaw(double yaw)
{
double halfYaw = yaw / 2.0;
return new RobotNet10.Shared.Geometry.Quaternion
{
X = 0,
Y = 0,
Z = Math.Sin(halfYaw),
W = Math.Cos(halfYaw)
};
}
/// <summary>
/// Normalize angle to [-π, π]
/// </summary>
/// <param name="angle">Angle in radians</param>
/// <returns>Normalized angle in [-π, π]</returns>
private static double NormalizeAngle(double angle)
{
while (angle > Math.PI) angle -= 2 * Math.PI;
while (angle < -Math.PI) angle += 2 * Math.PI;
return angle;
}
public void Dispose()
{
// Disable detector (stops thread)
Disable();
// Suppress finalization since we've cleaned up
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,932 @@
using RobotNet10.RobotApp.Devices;
using RobotNet10.RobotApp.SLAM;
using RobotNet10.Shared.Geometry;
// using RobotNet10.Shared.Numbers;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Detection;
/// <summary>
/// Session for detecting reflective markers based on shape matching with laser scan data
/// Supports 2, 3, or 4 reference points
/// IMPORTANT: Marker origin (0,0) in marker frame MUST be at the centroid of reference points
/// </summary>
/// <remarks>
/// Create a new shape reflective marker detection session
/// </remarks>
/// <param name="markerReferencePoints">Reference points in marker frame (2, 3, or 4 points). Centroid must be at marker origin (0,0).</param>
/// <param name="searchRegion">Search region for marker center (in global frame)</param>
/// <param name="intensityThreshold">Intensity threshold for filtering reflective markers</param>
/// <param name="clusteringEps">OPTICS epsilon parameter for clustering</param>
/// <param name="clusteringMinPts">OPTICS minimum points parameter</param>
/// <param name="clusterThreshold">Reachability threshold for cluster extraction</param>
/// <param name="maxFitError">Maximum allowed fitting error (meters)</param>
/// <exception cref="ArgumentException">Thrown when number of reference points is not 2, 3, or 4</exception>
public class ShapeReflectiveDetector(
Point2D[] markerReferencePoints,
RectangleRegion searchRegion,
ISLAMService sLAMService,
ILidar lidar,
Pose lidarPose,
double intensityThreshold = 2500,
double clusteringEps = 0.1,
int clusteringMinPts = 3,
double clusterThreshold = 0.5,
double maxFitError = 0.05) : IDetector
{
// Validate number of reference points (must be 2, 3, or 4)
private readonly Point2D[] _validatedMarkerPoints = markerReferencePoints.Length is >= 1 and <= 4
? markerReferencePoints
: throw new ArgumentException(
$"Number of reference points must be between 2 and 4, but got {markerReferencePoints.Length}",
nameof(markerReferencePoints));
private readonly OpticsClusteringAlgorithm _optics = new(clusteringEps, clusteringMinPts);
private readonly Lock _lockPose = new();
private readonly Lock _lockScan = new();
private bool _isActive = false;
private bool _isProcessing = false;
private Thread? _processingThread;
private AutoResetEvent? _scanReceivedEvent;
private LaserScan? _latestScan;
private DateTime _lastScanTime = DateTime.MinValue;
/// <summary>
/// Detected marker pose in global frame
/// Initialized with search region center, updated when marker is detected
/// </summary>
public Pose MarkerPose
{
get
{
lock (_lockPose)
{
return field;
}
}
private set
{
lock (_lockPose)
{
field = value;
}
}
} = new Pose(new Vector3(searchRegion.Center.X, searchRegion.Center.Y, 0), CreateQuaternionFromYaw(searchRegion.RotationAngle));
/// <summary>
/// Timestamp of the last successful marker detection
/// </summary>
public DateTime DetectionTime { get; private set; }
/// <summary>
/// Activate the marker detection session
/// Starts listening to laser scan data and processes it in background
/// </summary>
public void Active()
{
if (_isActive)
return;
_isActive = true;
_scanReceivedEvent = new AutoResetEvent(false);
// Create and start processing thread
_processingThread = new Thread(ProcessingThreadLoop)
{
Name = "ShapeReflectiveDetection",
IsBackground = true
};
_processingThread.Start();
// Subscribe to laser scan data
lidar.ScanDataReceived += OnScanDataReceived;
}
/// <summary>
/// Disable the marker detection session
/// Stops processing laser scan data and unsubscribes from events
/// </summary>
public void Disable()
{
if (!_isActive)
return;
_isActive = false;
// Unsubscribe from laser scan data
lidar.ScanDataReceived -= OnScanDataReceived;
// Signal the thread to wake up and exit
_scanReceivedEvent?.Set();
// Wait for thread to finish
_processingThread?.Join();
_processingThread = null;
// Dispose wait handle
_scanReceivedEvent?.Dispose();
_scanReceivedEvent = null;
// Clear latest scan
lock (_lockScan)
{
_latestScan = null;
}
}
/// <summary>
/// Event handler for laser scan data received
/// Stores the latest scan and signals the processing thread
/// If already processing, skip this scan to avoid overload
/// </summary>
private void OnScanDataReceived(object? _, LidarScanDataEventArgs e)
{
// Check if already processing, skip if busy
lock (_lockScan)
{
if (_isProcessing)
return;
// Store latest scan data
_latestScan = e.MeasurementData;
_lastScanTime = e.Timestamp;
}
// Signal the processing thread that new data is available
_scanReceivedEvent?.Set();
}
/// <summary>
/// Processing thread loop that waits for new scan data and processes it
/// </summary>
private void ProcessingThreadLoop()
{
while (_isActive)
{
// Wait for signal that new scan data is available
if (_scanReceivedEvent?.WaitOne() == true)
{
// Check if still active (might have been disabled)
if (!_isActive)
break;
// Get the latest scan data
LaserScan? scanToProcess;
lock (_lockScan)
{
scanToProcess = _latestScan;
_latestScan = null; // Clear after reading
// Set processing flag
if (scanToProcess != null)
_isProcessing = true;
}
// Process the scan if available
if (scanToProcess is LaserScan scan)
{
try
{
var currentPose = sLAMService.CurrentPose;
var markerReferenceSearchRegionsInGlobal = CalculateMarkerReferenceSearchRegionsInGlobal(searchRegion);
var markerReferenceSearchRegionsInRobot = TransformRegionsFromGlobalToRobot(markerReferenceSearchRegionsInGlobal, currentPose);
var markerReferenceSearchRegions = TransformRegionsFromRobotToLidar(markerReferenceSearchRegionsInRobot, lidarPose);
var (angleStart, angleEnd) = CalculateAngleRangeFromRegions(markerReferenceSearchRegions);
var points = ConvertLaserScanToPoints(scan, intensityThreshold, angleStart, angleEnd);
_optics.ClearPoints();
_optics.AddPoints(points);
_optics.Run();
// Extract cluster indices for this region
var clusters = _optics.GetClusters(clusterThreshold);
// Extract centroids from clusters in each region
var centroids = clusters.Select(cluster => CalculateCentroid([.. cluster])).ToList();
// Match centroids to their corresponding search regions
var regionCentroids = new List<List<Point2D>>();
for (int i = 0; i < markerReferenceSearchRegions.Count; i++)
{
var region = markerReferenceSearchRegions[i];
var matchingCentroids = new List<Point2D>();
foreach (var centroid in centroids)
{
if (region.ContainsPoint(centroid.X, centroid.Y))
{
matchingCentroids.Add(centroid);
}
}
regionCentroids.Add(matchingCentroids);
}
// Find best matching pose in lidar frame
var poseInLidar = FindMatchingPoses(regionCentroids);
if (poseInLidar.HasValue)
{
// Transform pose from lidar frame -> robot frame -> global frame
var poseInRobot = TransformPose(poseInLidar.Value, lidarPose);
var poseInGlobal = TransformPose(poseInRobot, currentPose);
// Update marker pose and detection time
MarkerPose = poseInGlobal;
DetectionTime = _lastScanTime;
}
}
finally
{
// Clear processing flag
lock (_lockScan)
{
_isProcessing = false;
}
}
}
}
}
}
/// <summary>
/// Transform search region from global frame to robot frame (inverse transform)
/// </summary>
/// <param name="searchRegion">Search region in global frame</param>
/// <param name="robotPose">Robot pose in global frame</param>
/// <returns>Transformed search region in robot frame</returns>
private static RectangleRegion TransformSearchRegionFromGlobalToRobot(RectangleRegion searchRegion, Pose robotPose)
{
// Get yaw angle from robot pose
double robotYaw = robotPose.Orientation.ToYawRadian();
// Inverse transform: global frame → robot frame
// Translate center from global to robot origin (inverse)
double dx = searchRegion.Center.X - robotPose.Position.X;
double dy = searchRegion.Center.Y - robotPose.Position.Y;
// Rotate by negative robot yaw (inverse rotation)
double cosInv = Math.Cos(-robotYaw);
double sinInv = Math.Sin(-robotYaw);
double newCenterX = dx * cosInv - dy * sinInv;
double newCenterY = dx * sinInv + dy * cosInv;
// Transform rotation angle (subtract robot yaw)
double newRotation = searchRegion.RotationAngle - robotYaw;
// Normalize angle to [-π, π]
newRotation = NormalizeAngle(newRotation);
return new RectangleRegion(
new Point2D(newCenterX, newCenterY),
searchRegion.Width,
searchRegion.Height,
newRotation);
}
/// <summary>
/// Transform search region from robot frame to lidar frame (inverse transform)
/// </summary>
/// <param name="searchRegion">Search region in robot frame</param>
/// <param name="lidarPose">Lidar pose relative to robot base</param>
/// <returns>Transformed search region in lidar frame</returns>
private static RectangleRegion TransformSearchRegionInverse(RectangleRegion searchRegion, Pose lidarPose)
{
// Get yaw angle from lidar pose
double lidarYaw = lidarPose.Orientation.ToYawRadian();
// Inverse transform: robot frame → lidar frame
// Translate center from robot origin to lidar origin (inverse)
double dx = searchRegion.Center.X - lidarPose.Position.X;
double dy = searchRegion.Center.Y - lidarPose.Position.Y;
// Rotate by negative lidar yaw (inverse rotation)
double cosInv = Math.Cos(-lidarYaw);
double sinInv = Math.Sin(-lidarYaw);
double newCenterX = dx * cosInv - dy * sinInv;
double newCenterY = dx * sinInv + dy * cosInv;
// Transform rotation angle (subtract lidar yaw)
double newRotation = searchRegion.RotationAngle - lidarYaw;
// Normalize angle to [-π, π]
newRotation = NormalizeAngle(newRotation);
return new RectangleRegion(
new Point2D(newCenterX, newCenterY),
searchRegion.Width,
searchRegion.Height,
newRotation);
}
/// <summary>
/// Calculate search regions for each marker reference point in global frame
/// Transforms each reference point from marker frame to global frame
/// based on predicted marker pose, then creates search region around it
/// Each search region inherits size from the main search region to account for pose uncertainty
/// </summary>
/// <param name="searchRegion">Predicted marker pose (center + rotation) in global frame</param>
/// <returns>List of search regions for each reference point in global frame</returns>
private List<RectangleRegion> CalculateMarkerReferenceSearchRegionsInGlobal(RectangleRegion searchRegion)
{
var regions = new List<RectangleRegion>();
// Use search region center and rotation as predicted marker pose in global frame
double markerX = searchRegion.Center.X;
double markerY = searchRegion.Center.Y;
double markerRotation = searchRegion.RotationAngle;
double cosTheta = Math.Cos(markerRotation);
double sinTheta = Math.Sin(markerRotation);
// Transform each reference point from marker frame to global frame
for (int i = 0; i < _validatedMarkerPoints.Length; i++)
{
var refPoint = _validatedMarkerPoints[i];
// Apply rotation and translation to transform from marker frame to global frame
double pointX = markerX + refPoint.X * cosTheta - refPoint.Y * sinTheta;
double pointY = markerY + refPoint.X * sinTheta + refPoint.Y * cosTheta;
// Create search region centered at this predicted point
// Use same size as main search region to account for pose uncertainty
regions.Add(new RectangleRegion(
new Point2D(pointX, pointY),
searchRegion.Width,
searchRegion.Height,
0)); // Axis-aligned for simplicity
}
return regions;
}
/// <summary>
/// Transform a list of rectangle regions from global frame to robot frame
/// </summary>
/// <param name="regionsInGlobal">List of regions in global frame</param>
/// <param name="robotPose">Robot pose in global frame</param>
/// <returns>List of regions in robot frame</returns>
private static List<RectangleRegion> TransformRegionsFromGlobalToRobot(List<RectangleRegion> regionsInGlobal, Pose robotPose)
{
var regionsInRobot = new List<RectangleRegion>();
foreach (var region in regionsInGlobal)
{
var transformedRegion = TransformSearchRegionFromGlobalToRobot(region, robotPose);
regionsInRobot.Add(transformedRegion);
}
return regionsInRobot;
}
/// <summary>
/// Transform a list of rectangle regions from robot frame to lidar frame
/// </summary>
/// <param name="regionsInRobot">List of regions in robot frame</param>
/// <param name="lidarPose">Lidar pose relative to robot base</param>
/// <returns>List of regions in lidar frame</returns>
private static List<RectangleRegion> TransformRegionsFromRobotToLidar(List<RectangleRegion> regionsInRobot, Pose lidarPose)
{
var regionsInLidar = new List<RectangleRegion>();
foreach (var region in regionsInRobot)
{
var transformedRegion = TransformSearchRegionInverse(region, lidarPose);
regionsInLidar.Add(transformedRegion);
}
return regionsInLidar;
}
/// <summary>
/// Calculate the angle range needed to cover all search regions
/// This optimizes laser scan processing by only considering relevant angles
/// </summary>
/// <param name="regions">Search regions to analyze</param>
/// <returns>Tuple of (angleStart, angleEnd) in radians</returns>
private static (double angleStart, double angleEnd) CalculateAngleRangeFromRegions(List<RectangleRegion> regions)
{
double minAngle = double.MaxValue;
double maxAngle = double.MinValue;
foreach (var region in regions)
{
// Get the 4 corners of the rectangle
var corners = GetRectangleCorners(region);
// Calculate angle to each corner from origin (lidar position at 0,0)
foreach (var corner in corners)
{
double angle = Math.Atan2(corner.Y, corner.X);
if (angle < minAngle) minAngle = angle;
if (angle > maxAngle) maxAngle = angle;
}
}
// Add small margin (5 degrees) to ensure we don't miss any points at the boundaries
const double margin = 5.0 * Math.PI / 180.0; // 5 degrees in radians
minAngle -= margin;
maxAngle += margin;
// Clamp to valid angle range [-π, π]
minAngle = Math.Max(minAngle, -Math.PI);
maxAngle = Math.Min(maxAngle, Math.PI);
return (minAngle, maxAngle);
}
/// <summary>
/// Get the 4 corners of a rectangle region
/// </summary>
/// <param name="region">Rectangle region</param>
/// <returns>List of 4 corner points in the same frame as the input region</returns>
private static List<Point2D> GetRectangleCorners(RectangleRegion region)
{
double halfWidth = region.Width / 2.0;
double halfHeight = region.Height / 2.0;
// Define 4 corners in local frame (before rotation)
var localCorners = new List<(double x, double y)>
{
(-halfWidth, -halfHeight),
(halfWidth, -halfHeight),
(halfWidth, halfHeight),
(-halfWidth, halfHeight)
};
// Transform to region frame (apply rotation and translation)
var corners = new List<Point2D>();
double cosTheta = Math.Cos(region.RotationAngle);
double sinTheta = Math.Sin(region.RotationAngle);
foreach (var (x, y) in localCorners)
{
double worldX = region.Center.X + x * cosTheta - y * sinTheta;
double worldY = region.Center.Y + x * sinTheta + y * cosTheta;
corners.Add(new Point2D(worldX, worldY));
}
return corners;
}
/// <summary>
/// Find best matching pose from region centroids
/// Returns the pose with the lowest fitting error
/// </summary>
/// <param name="regionCentroids">Centroids from each search region</param>
/// <returns>Best detected pose, or null if no valid match found</returns>
private Pose? FindMatchingPoses(List<List<Point2D>> regionCentroids)
{
// Generate all combinations of centroids (one from each region)
// GenerateCombinations already validates that all regions have clusters
var combinations = GenerateCombinations(regionCentroids);
if (combinations.Count == 0)
{
return null;
}
Pose? bestPose = null;
double bestError = double.MaxValue;
int validPoseCount = 0;
int acceptableErrorCount = 0;
foreach (var combination in combinations)
{
// Try to estimate pose from this combination
var pose = EstimatePoseFromPoints(_validatedMarkerPoints, combination);
if (pose.HasValue)
{
validPoseCount++;
// Calculate fitting error
double error = CalculateFitError(_validatedMarkerPoints, combination, pose.Value);
// Check if error is acceptable and better than previous best
if (error <= maxFitError && error < bestError)
{
acceptableErrorCount++;
bestPose = pose.Value;
bestError = error;
}
}
}
return bestPose;
}
/// <summary>
/// Generate all valid combinations of centroids from different regions
/// IMPORTANT: Each combination[i] must come from regionCentroids[i] to maintain correct correspondence
/// with _validatedMarkerPoints[i]. Skipping regions is NOT allowed.
/// </summary>
/// <param name="regionCentroids">Centroids from each region</param>
/// <returns>List of point combinations</returns>
private List<List<Point2D>> GenerateCombinations(List<List<Point2D>> regionCentroids)
{
var combinations = new List<List<Point2D>>();
// Ensure we have exactly N regions (one per reference point)
int numReferencePoints = _validatedMarkerPoints.Length;
if (regionCentroids.Count != numReferencePoints)
{
return combinations; // Return empty if mismatch
}
// Check if ALL required regions have at least one cluster
// If ANY region is empty, we cannot form valid combinations
for (int i = 0; i < numReferencePoints; i++)
{
if (regionCentroids[i].Count == 0)
{
return combinations; // Return empty - missing required points
}
}
// Generate combinations where combination[i] comes from regionCentroids[i]
void GenerateRecursive(int regionIndex, List<Point2D> current)
{
// Base case: processed all regions
if (regionIndex == numReferencePoints)
{
combinations.Add([.. current]);
return;
}
// Try each centroid from the CURRENT region only (no skipping!)
foreach (var centroid in regionCentroids[regionIndex])
{
current.Add(centroid);
GenerateRecursive(regionIndex + 1, current);
current.RemoveAt(current.Count - 1);
}
}
GenerateRecursive(0, []);
return combinations;
}
/// <summary>
/// Estimate marker pose from reference points and measured points
/// Uses a simplified point set registration algorithm
/// </summary>
/// <param name="referencePoints">Reference points in marker frame</param>
/// <param name="measuredPoints">Measured points in lidar frame</param>
/// <returns>Estimated pose in lidar frame, or null if estimation fails</returns>
private static Pose? EstimatePoseFromPoints(Point2D[] referencePoints, List<Point2D> measuredPoints)
{
if (referencePoints.Length != measuredPoints.Count)
{
return null;
}
// Handle single-point detection
if (referencePoints.Length == 1)
{
// For single point: marker position = measured position - reference point offset
// Since we don't know rotation, assume rotation = 0
// Marker position = measured point - reference point (with rotation 0)
double marker_X = measuredPoints[0].X - referencePoints[0].X;
double marker_Y = measuredPoints[0].Y - referencePoints[0].Y;
return new Pose
{
Position = new Vector3
{
X = marker_X,
Y = marker_Y,
Z = 0
},
Orientation = CreateQuaternionFromYaw(0) // Cannot determine rotation from single point
};
}
if (referencePoints.Length < 2)
{
return null;
}
// Calculate centroids
var refCentroid = CalculateCentroid(referencePoints);
var measCentroid = CalculateCentroid([.. measuredPoints]);
// Center the point sets
var refCentered = referencePoints.Select(p => new Point2D(p.X - refCentroid.X, p.Y - refCentroid.Y)).ToList();
var measCentered = measuredPoints.Select(p => new Point2D(p.X - measCentroid.X, p.Y - measCentroid.Y)).ToList();
// Calculate rotation using SVD-like approach (simplified for 2D)
double theta = EstimateRotation(refCentered, measCentered);
// Calculate marker origin position in lidar frame
// Formula: t = M_centroid - R(theta) * R_centroid
// This accounts for cases where reference centroid is not at marker origin (0,0)
double cosTheta = Math.Cos(theta);
double sinTheta = Math.Sin(theta);
// R(theta) * refCentroid
double rotatedRefX = refCentroid.X * cosTheta - refCentroid.Y * sinTheta;
double rotatedRefY = refCentroid.X * sinTheta + refCentroid.Y * cosTheta;
// t = measCentroid - R(theta) * refCentroid
double markerX = measCentroid.X - rotatedRefX;
double markerY = measCentroid.Y - rotatedRefY;
var pose = new Pose
{
Position = new Vector3
{
X = markerX,
Y = markerY,
Z = 0
},
Orientation = CreateQuaternionFromYaw(theta)
};
return pose;
}
/// <summary>
/// Estimate rotation angle between two centered point sets
/// </summary>
private static double EstimateRotation(List<Point2D> refCentered, List<Point2D> measCentered)
{
// Use cross-covariance method
double sxx = 0, sxy = 0, syx = 0, syy = 0;
for (int i = 0; i < refCentered.Count; i++)
{
sxx += measCentered[i].X * refCentered[i].X;
sxy += measCentered[i].X * refCentered[i].Y;
syx += measCentered[i].Y * refCentered[i].X;
syy += measCentered[i].Y * refCentered[i].Y;
}
// Calculate rotation angle using atan2
double theta = Math.Atan2(syx - sxy, sxx + syy);
return theta;
}
/// <summary>
/// Calculate fitting error between reference and measured points given a pose
/// Error represents the average Euclidean distance between:
/// - Predicted positions: where reference points SHOULD be (based on estimated pose)
/// - Measured positions: where reflective markers were ACTUALLY detected by lidar
///
/// Lower error = better match between model and reality
/// </summary>
/// <param name="referencePoints">Reference points in marker frame (model)</param>
/// <param name="measuredPoints">Measured points in lidar frame (reality)</param>
/// <param name="pose">Estimated marker pose to validate</param>
/// <returns>Average distance error in meters</returns>
private static double CalculateFitError(Point2D[] referencePoints, List<Point2D> measuredPoints, Pose pose)
{
if (referencePoints.Length != measuredPoints.Count)
return double.MaxValue;
double totalError = 0;
double theta = pose.Orientation.ToYawRadian();
// Pre-calculate cos and sin (optimization - computed once instead of per iteration)
double cosTheta = Math.Cos(theta);
double sinTheta = Math.Sin(theta);
for (int i = 0; i < referencePoints.Length; i++)
{
// Transform reference point from marker frame to lidar frame using the estimated pose
// Formula: P_predicted = t + R(theta) * P_reference
double transformedX = pose.Position.X + referencePoints[i].X * cosTheta - referencePoints[i].Y * sinTheta;
double transformedY = pose.Position.Y + referencePoints[i].X * sinTheta + referencePoints[i].Y * cosTheta;
// Calculate Euclidean distance between predicted and measured positions
double dx = transformedX - measuredPoints[i].X;
double dy = transformedY - measuredPoints[i].Y;
double distance = Math.Sqrt(dx * dx + dy * dy);
totalError += distance;
}
double averageError = totalError / referencePoints.Length;
return averageError;
}
/// <summary>
/// Create a quaternion from yaw angle (rotation around Z axis)
/// </summary>
private static RobotNet10.Shared.Geometry.Quaternion CreateQuaternionFromYaw(double yaw)
{
double halfYaw = yaw / 2.0;
return new RobotNet10.Shared.Geometry.Quaternion
{
X = 0,
Y = 0,
Z = Math.Sin(halfYaw),
W = Math.Cos(halfYaw)
};
}
/// <summary>
/// Normalize angle to [-π, π]
/// </summary>
private static double NormalizeAngle(double angle)
{
while (angle > Math.PI) angle -= 2 * Math.PI;
while (angle < -Math.PI) angle += 2 * Math.PI;
return angle;
}
/// <summary>
/// Transform a pose by another pose (pose composition)
/// result = parentPose * childPose
/// </summary>
private static Pose TransformPose(Pose childPose, Pose parentPose)
{
// Get yaw angles
double parentYaw = parentPose.Orientation.ToYawRadian();
double childYaw = childPose.Orientation.ToYawRadian();
// Rotate child position by parent orientation
double cosParent = Math.Cos(parentYaw);
double sinParent = Math.Sin(parentYaw);
double globalX = parentPose.Position.X + childPose.Position.X * cosParent - childPose.Position.Y * sinParent;
double globalY = parentPose.Position.Y + childPose.Position.X * sinParent + childPose.Position.Y * cosParent;
// Combine orientations
double globalYaw = NormalizeAngle(parentYaw + childYaw);
return new Pose
{
Position = new Vector3
{
X = globalX,
Y = globalY,
Z = parentPose.Position.Z + childPose.Position.Z
},
Orientation = CreateQuaternionFromYaw(globalYaw)
};
}
private static List<Point> ConvertLaserScanToPoints(LaserScan scan, double intensityThreshold, double angleStart, double angleEnd)
{
var points = new List<Point>();
// Skip invalid scans
if (scan.Ranges.Length == 0)
{
return points;
}
int invalidRanges = 0;
int filteredByAngle = 0;
int filteredByIntensity = 0;
double normalizedStart = NormalizeAngle(angleStart);
double normalizedEnd = NormalizeAngle(angleEnd);
for (int i = 0; i < scan.Ranges.Length; i++)
{
double range = scan.Ranges[i];
// Skip invalid ranges (out of bounds or NaN/Infinity)
if (double.IsNaN(range) || double.IsInfinity(range) ||
range < scan.RangeMin || range > scan.RangeMax)
{
invalidRanges++;
continue;
}
// Calculate angle for this measurement
double alpha = scan.AngleMin + (i * scan.AngleIncrement);
// Normalize alpha to [-π, π] for proper comparison
double normalizedAlpha = NormalizeAngle(alpha);
// Handle angle wrapping around (e.g., from -π to π)
if (normalizedStart <= normalizedEnd)
{
// Normal case: angleStart < angleEnd
if (normalizedAlpha < normalizedStart || normalizedAlpha > normalizedEnd)
{
filteredByAngle++;
continue;
}
}
else
{
// Wrapped case: angleEnd < angleStart (e.g., 3π/4 to -3π/4)
if (normalizedAlpha < normalizedStart && normalizedAlpha > normalizedEnd)
{
filteredByAngle++;
continue;
}
}
// Filter by intensity if threshold is set and intensities are available
if (scan.Intensities.Length > i)
{
if (scan.Intensities[i] < intensityThreshold)
{
filteredByIntensity++;
continue;
}
}
points.Add(new Point(range * Math.Cos(alpha), range * Math.Sin(alpha), range, alpha));
}
return points;
}
public static Point2D CalculateCentroid(Point2D[] cluster)
{
if (cluster.Length == 0)
return new Point2D(0, 0);
double sumX = 0;
double sumY = 0;
foreach (var point in cluster)
{
sumX += point.X;
sumY += point.Y;
}
return new Point2D(sumX / cluster.Length, sumY / cluster.Length);
}
public void Dispose()
{
// Disable detector (stops thread, unsubscribes events, disposes resources)
Disable();
// Suppress finalization since we've cleaned up
GC.SuppressFinalize(this);
}
}
public readonly struct RectangleRegion(Point2D center, double width, double height, double rotationAngle)
{
/// <summary>
/// Center point of the rectangle
/// </summary>
public Point2D Center { get; init; } = center;
/// <summary>
/// Width of the rectangle (meters)
/// </summary>
public double Width { get; init; } = width;
/// <summary>
/// Height of the rectangle (meters)
/// </summary>
public double Height { get; init; } = height;
/// <summary>
/// Rotation angle in radians (counterclockwise from positive X-axis)
/// </summary>
public double RotationAngle { get; init; } = rotationAngle;
public RectangleRegion(double centerX, double centerY, double width, double height, double rotationAngle)
: this(new Point2D(centerX, centerY), width, height, rotationAngle)
{
}
/// <summary>
/// Check if a point is contained within this rectangle
/// Uses coordinate transformation to handle rotation efficiently
/// </summary>
/// <param name="point">Point to check</param>
/// <returns>True if point is inside the rectangle</returns>
public bool ContainsPoint(double x, double y)
{
// Translate point to rectangle's local coordinate system (center at origin)
double dx = x - Center.X;
double dy = y - Center.Y;
// Rotate point by negative rotation angle to align with rectangle axes
double cosTheta = Math.Cos(-RotationAngle);
double sinTheta = Math.Sin(-RotationAngle);
double localX = dx * cosTheta - dy * sinTheta;
double localY = dx * sinTheta + dy * cosTheta;
// Check if point is within rectangle bounds
double halfWidth = Width / 2.0;
double halfHeight = Height / 2.0;
return Math.Abs(localX) <= halfWidth && Math.Abs(localY) <= halfHeight;
}
}

View File

@@ -0,0 +1,29 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Event args khi trạng thái kết nối thay đổi
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
public bool IsConnected { get; }
public DeviceStatus PreviousStatus { get; }
public DeviceStatus CurrentStatus { get; }
public string? Message { get; }
public DateTime Timestamp { get; }
public ConnectionStateChangedEventArgs(
bool isConnected,
DeviceStatus previousStatus,
DeviceStatus currentStatus,
string? message = null)
{
IsConnected = isConnected;
PreviousStatus = previousStatus;
CurrentStatus = currentStatus;
Message = message;
Timestamp = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,61 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Attribute để đánh dấu và cung cấp metadata cho các class kế thừa từ DeviceBase
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class DeviceAttribute : Attribute
{
/// <summary>
/// Loại thiết bị
/// </summary>
public DeviceType DeviceType { get; }
/// <summary>
/// Thương hiệu/nhà sản xuất của thiết bị (ví dụ: "PhenikaaX", "SICK", "Hokuyo", etc.)
/// </summary>
public string Brand { get; }
/// <summary>
/// Tên driver/implementation của thiết bị (ví dụ: "SickLMS100", "HokuyoUST10", "ModbusTCPClient", etc.)
/// </summary>
public string DriverName { get; }
/// <summary>
/// Mô tả ngắn về thiết bị (optional)
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Version của driver (optional)
/// </summary>
public string Version { get; }
/// <summary>
/// Initializes a new instance of the DeviceAttribute class
/// </summary>
/// <param name="deviceType">Loại thiết bị</param>
/// <param name="brand">Thương hiệu/nhà sản xuất</param>
/// <param name="driverName">Tên driver/implementation</param>
/// <param name="version">Tên version/implementation</param>
/// <exception cref="ArgumentNullException">Thrown when brand or driverName is null or empty</exception>
public DeviceAttribute(DeviceType deviceType, string brand, string driverName, string version)
{
if (string.IsNullOrWhiteSpace(brand))
throw new ArgumentNullException(nameof(brand), "Brand cannot be null or empty");
if (string.IsNullOrWhiteSpace(driverName))
throw new ArgumentNullException(nameof(driverName), "DriverName cannot be null or empty");
if (string.IsNullOrWhiteSpace(version))
throw new ArgumentNullException(nameof(version), "Version cannot be null or empty");
DeviceType = deviceType;
Brand = brand;
DriverName = driverName;
Version = version;
}
}

View File

@@ -0,0 +1,799 @@
using Appccelerate.StateMachine;
using Appccelerate.StateMachine.Machine;
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Base class cho tất cả các device implementations trong hệ thống AMR
/// Cung cấp state machine management và auto-reconnect logic
/// Tất cả các thiết bị nên kế thừa từ class này
/// </summary>
public abstract class DeviceBase : IDisposable
{
private readonly PassiveStateMachine<DeviceStatus, DeviceTrigger> _stateMachine;
private readonly Lock _lock = new();
private DeviceStatus _currentStatus = DeviceStatus.Uninitialized;
private DateTime _lastUpdateStateTime = DateTime.UtcNow;
private DateTime? _lastConnectedTime;
private DateTime? _lastDisconnectedTime;
private Exception? _lastError;
private int _reconnectAttemptCount;
private CancellationTokenSource? _reconnectCts;
private Task? _reconnectTask;
private bool _disposed;
private readonly Dictionary<string, string> _properties;
protected DeviceBase(string deviceId, string deviceName, DeviceType type, string? description = null)
{
DeviceId = deviceId ?? throw new ArgumentNullException(nameof(deviceId));
DeviceName = deviceName ?? throw new ArgumentNullException(nameof(deviceName));
Type = type;
Description = description;
// Tạo PropertyDescriptions từ derived class
var propertyDescriptions = CreatePropertyDescriptions().ToList();
// Validate PropertyDescriptions
ValidatePropertyDescriptions(propertyDescriptions);
PropertyDescriptions = propertyDescriptions;
// Khởi tạo Properties dictionary với keys từ PropertyDescriptions
_properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var propDesc in PropertyDescriptions)
{
_properties[propDesc.Key] = propDesc.DefaultValue ?? "0";
}
AutoReconnectEnabled = true;
ReconnectDelayMs = 3000; // 3 seconds default
MaxReconnectAttempts = 0; // Unlimited by default
// Configure and create state machine
var builder = new StateMachineDefinitionBuilder<DeviceStatus, DeviceTrigger>();
ConfigureStateMachine(builder);
_stateMachine = builder
.WithInitialState(DeviceStatus.Uninitialized)
.Build()
.CreatePassiveStateMachine();
_currentStatus = DeviceStatus.Uninitialized;
_stateMachine.Start();
}
private void ConfigureStateMachine(StateMachineDefinitionBuilder<DeviceStatus, DeviceTrigger> builder)
{
// Uninitialized state
builder.In(DeviceStatus.Uninitialized)
.ExecuteOnEntry(() => { _currentStatus = DeviceStatus.Uninitialized; UpdateLastUpdateStateTime(); })
.On(DeviceTrigger.Initialize)
.Goto(DeviceStatus.Initializing)
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Initializing state
builder.In(DeviceStatus.Initializing)
.ExecuteOnEntry(() => { _currentStatus = DeviceStatus.Initializing; UpdateLastUpdateStateTime(); })
.On(DeviceTrigger.InitializationCompleted)
.Goto(DeviceStatus.Disconnected)
.On(DeviceTrigger.ErrorOccurred)
.Goto(DeviceStatus.Error)
.Execute(() => { _currentStatus = DeviceStatus.Error; })
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Disconnected state
builder.In(DeviceStatus.Disconnected)
.ExecuteOnEntry(() =>
{
_currentStatus = DeviceStatus.Disconnected;
UpdateLastUpdateStateTime();
_lastDisconnectedTime = DateTime.UtcNow;
})
.On(DeviceTrigger.Connect)
.Goto(DeviceStatus.Connecting)
.On(DeviceTrigger.StartReconnect)
.Goto(DeviceStatus.Reconnecting)
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Connecting state
builder.In(DeviceStatus.Connecting)
.ExecuteOnEntry(() => { _currentStatus = DeviceStatus.Connecting; UpdateLastUpdateStateTime(); })
.On(DeviceTrigger.ConnectionCompleted)
.Goto(DeviceStatus.Connected)
.On(DeviceTrigger.ErrorOccurred)
.Goto(DeviceStatus.Error)
.Execute(() => { _currentStatus = DeviceStatus.Error; })
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Connected state
builder.In(DeviceStatus.Connected)
.ExecuteOnEntry(() =>
{
_currentStatus = DeviceStatus.Connected;
UpdateLastUpdateStateTime();
_lastConnectedTime = DateTime.UtcNow;
_reconnectAttemptCount = 0; // Reset counter on successful connection
})
.On(DeviceTrigger.Disconnect)
.Goto(DeviceStatus.Disconnecting)
.On(DeviceTrigger.ErrorOccurred)
.Goto(DeviceStatus.Error)
.Execute(() => { _currentStatus = DeviceStatus.Error; })
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Disconnecting state
builder.In(DeviceStatus.Disconnecting)
.ExecuteOnEntry(() => { _currentStatus = DeviceStatus.Disconnecting; UpdateLastUpdateStateTime(); })
.On(DeviceTrigger.DisconnectionCompleted)
.Goto(DeviceStatus.Disconnected)
.On(DeviceTrigger.ErrorOccurred)
.Goto(DeviceStatus.Error)
.Execute(() => { _currentStatus = DeviceStatus.Error; })
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Reconnecting state
builder.In(DeviceStatus.Reconnecting)
.ExecuteOnEntry(() => { _currentStatus = DeviceStatus.Reconnecting; UpdateLastUpdateStateTime(); })
.On(DeviceTrigger.ReconnectionCompleted)
.Goto(DeviceStatus.Connected)
.On(DeviceTrigger.ErrorOccurred)
.Goto(DeviceStatus.Error)
.Execute(() => { _currentStatus = DeviceStatus.Error; })
.On(DeviceTrigger.StopReconnect)
.Goto(DeviceStatus.Disconnected)
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Error state
builder.In(DeviceStatus.Error)
.ExecuteOnEntry(() => { _currentStatus = DeviceStatus.Error; UpdateLastUpdateStateTime(); })
.On(DeviceTrigger.StartReconnect)
.Goto(DeviceStatus.Reconnecting)
.On(DeviceTrigger.Disconnect)
.Goto(DeviceStatus.Disconnecting)
.On(DeviceTrigger.Dispose)
.Goto(DeviceStatus.Disposed);
// Disposed state (terminal)
builder.In(DeviceStatus.Disposed)
.ExecuteOnEntry(() => { _currentStatus = DeviceStatus.Disposed; UpdateLastUpdateStateTime(); });
}
// Device Properties
public string DeviceId { get; }
public string DeviceName { get; }
public DeviceType Type { get; }
/// <summary>
/// Mô tả thiết bị (dùng để hiển thị trên web UI)
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Danh sách mô tả các properties của thiết bị (dùng để hiển thị trên web UI)
/// Được tạo cố định từ constructor, không thay đổi trong runtime
/// </summary>
public IReadOnlyList<PropertyDescription> PropertyDescriptions { get; }
/// <summary>
/// Dictionary chứa các giá trị properties của thiết bị (dùng để hiển thị trên web UI)
/// Key không phân biệt hoa thường, Value là string
/// Chỉ đọc từ bên ngoài, chỉ có thể cập nhật giá trị thông qua SetProperty method
/// </summary>
public IReadOnlyDictionary<string, string> Properties => _properties;
public DeviceStatus Status
{
get
{
lock (_lock)
{
return _currentStatus;
}
}
}
public bool IsConnected => Status == DeviceStatus.Connected;
public DateTime LastUpdateStateTime
{
get
{
lock (_lock)
{
return _lastUpdateStateTime;
}
}
}
public DateTime? LastConnectedTime
{
get
{
lock (_lock)
{
return _lastConnectedTime;
}
}
}
public DateTime? LastDisconnectedTime
{
get
{
lock (_lock)
{
return _lastDisconnectedTime;
}
}
}
public Exception? LastError
{
get
{
lock (_lock)
{
return _lastError;
}
}
protected set
{
lock (_lock)
{
_lastError = value;
}
}
}
public bool AutoReconnectEnabled { get; set; }
public int ReconnectDelayMs { get; set; }
public int ReconnectAttemptCount
{
get
{
lock (_lock)
{
return _reconnectAttemptCount;
}
}
}
public int MaxReconnectAttempts { get; set; }
// Events
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
public event EventHandler<DeviceErrorEventArgs>? ErrorOccurred;
public event EventHandler<DeviceStatusChangedEventArgs>? StatusChanged;
// State Machine Helper Methods
private static void ValidatePropertyDescriptions(List<PropertyDescription> propertyDescriptions)
{
var seenKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var propDesc in propertyDescriptions)
{
if (string.IsNullOrWhiteSpace(propDesc.Key))
{
throw new ArgumentException("PropertyDescription.Key cannot be null or empty", nameof(propertyDescriptions));
}
if (string.IsNullOrWhiteSpace(propDesc.DisplayName))
{
throw new ArgumentException($"PropertyDescription.DisplayName cannot be null or empty for key '{propDesc.Key}'", nameof(propertyDescriptions));
}
if (!seenKeys.Add(propDesc.Key))
{
throw new ArgumentException($"Duplicate PropertyDescription.Key found: '{propDesc.Key}'", nameof(propertyDescriptions));
}
}
}
private void UpdateLastUpdateStateTime()
{
lock (_lock)
{
_lastUpdateStateTime = DateTime.UtcNow;
}
}
private void FireTrigger(DeviceTrigger trigger)
{
lock (_lock)
{
ObjectDisposedException.ThrowIf(_disposed && trigger != DeviceTrigger.Dispose, this);
try
{
_stateMachine.Fire(trigger);
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, $"Failed to fire trigger {trigger}");
throw;
}
}
}
// Abstract methods to be implemented by derived classes
/// <summary>
/// Tạo danh sách PropertyDescriptions cho thiết bị
/// Method này được gọi một lần trong constructor, kết quả được cache
/// </summary>
protected virtual IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
return [];
}
/// <summary>
/// Khởi tạo thiết bị (implementation specific)
/// </summary>
protected abstract Task OnInitializeAsync(CancellationToken cancellationToken);
/// <summary>
/// Kết nối với thiết bị (implementation specific)
/// </summary>
protected abstract Task OnConnectAsync(CancellationToken cancellationToken);
/// <summary>
/// Ngắt kết nối với thiết bị (implementation specific)
/// </summary>
protected abstract Task OnDisconnectAsync(CancellationToken cancellationToken);
/// <summary>
/// Reset thiết bị (implementation specific)
/// </summary>
protected abstract Task OnResetAsync(CancellationToken cancellationToken);
/// <summary>
/// Kiểm tra kết nối (implementation specific)
/// </summary>
protected abstract Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken);
// Public methods with state machine management
public virtual async Task InitializeAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var previousStatus = Status;
if (previousStatus != DeviceStatus.Uninitialized)
return; // Already initialized
FireTrigger(DeviceTrigger.Initialize);
try
{
await OnInitializeAsync(cancellationToken);
FireTrigger(DeviceTrigger.InitializationCompleted);
OnStatusChanged(previousStatus, DeviceStatus.Disconnected);
}
catch (Exception ex)
{
LastError = ex;
FireTrigger(DeviceTrigger.ErrorOccurred);
OnErrorOccurred(ex, "Initialize failed");
throw;
}
}
public virtual async Task ConnectAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
var currentStatus = Status;
if (currentStatus == DeviceStatus.Connected)
return; // Already connected
// Auto-initialize if needed
if (currentStatus == DeviceStatus.Uninitialized)
{
await InitializeAsync(cancellationToken);
}
var previousStatus = Status;
FireTrigger(DeviceTrigger.Connect);
try
{
await OnConnectAsync(cancellationToken);
// Verify connection
var isConnected = await OnCheckConnectionAsync(cancellationToken);
if (isConnected)
{
// Clear last error when connection succeeds
lock (_lock)
{
_lastError = null;
}
FireTrigger(DeviceTrigger.ConnectionCompleted);
OnConnectionStateChanged(true, previousStatus, DeviceStatus.Connected, "Connected successfully");
}
else
{
FireTrigger(DeviceTrigger.ErrorOccurred);
throw new InvalidOperationException("Connection check failed after connect");
}
}
catch (Exception ex)
{
LastError = ex;
FireTrigger(DeviceTrigger.ErrorOccurred);
OnErrorOccurred(ex, "Connect failed");
// Start auto-reconnect if enabled
if (AutoReconnectEnabled && !_disposed)
{
StartAutoReconnect();
}
throw;
}
}
public virtual async Task DisconnectAsync(CancellationToken cancellationToken = default)
{
if (_disposed)
return;
var currentStatus = Status;
if (currentStatus == DeviceStatus.Disconnected || currentStatus == DeviceStatus.Disposed)
return;
// Stop auto-reconnect
StopAutoReconnect();
var previousStatus = currentStatus;
FireTrigger(DeviceTrigger.Disconnect);
try
{
await OnDisconnectAsync(cancellationToken);
FireTrigger(DeviceTrigger.DisconnectionCompleted);
OnConnectionStateChanged(false, previousStatus, DeviceStatus.Disconnected, "Disconnected");
}
catch (Exception ex)
{
LastError = ex;
FireTrigger(DeviceTrigger.ErrorOccurred);
OnErrorOccurred(ex, "Disconnect failed");
throw;
}
}
public virtual async Task ResetAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
// Stop auto-reconnect
StopAutoReconnect();
try
{
// Disconnect first if connected
if (Status == DeviceStatus.Connected || Status == DeviceStatus.Connecting)
{
await DisconnectAsync(cancellationToken);
}
// Reset implementation
await OnResetAsync(cancellationToken);
// Reset state
lock (_lock)
{
_reconnectAttemptCount = 0;
_lastError = null;
}
// If not already disconnected, transition to disconnected
if (Status != DeviceStatus.Disconnected)
{
FireTrigger(DeviceTrigger.Disconnect);
FireTrigger(DeviceTrigger.DisconnectionCompleted);
}
}
catch (Exception ex)
{
LastError = ex;
FireTrigger(DeviceTrigger.ErrorOccurred);
OnErrorOccurred(ex, "Reset failed");
throw;
}
}
public virtual async Task<bool> CheckConnectionAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
if (Status != DeviceStatus.Connected)
return false;
try
{
// Capture current status before checking (may change during check)
var currentStatusBeforeCheck = Status;
var isConnected = await OnCheckConnectionAsync(cancellationToken);
// Check if status changed or connection lost
var currentStatusAfterCheck = Status;
if (!isConnected && currentStatusBeforeCheck == DeviceStatus.Connected && currentStatusAfterCheck == DeviceStatus.Connected)
{
// Connection lost but status hasn't changed yet
FireTrigger(DeviceTrigger.ErrorOccurred);
OnConnectionStateChanged(false, currentStatusBeforeCheck, DeviceStatus.Error, "Connection lost");
// Start auto-reconnect if enabled
if (AutoReconnectEnabled)
{
StartAutoReconnect();
}
}
return isConnected;
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, "Check connection failed");
return false;
}
}
// Auto-Reconnect Logic
private void StartAutoReconnect()
{
lock (_lock)
{
if (_reconnectTask != null && !_reconnectTask.IsCompleted)
return; // Already reconnecting
if (!AutoReconnectEnabled || _disposed)
return;
_reconnectCts?.Cancel();
_reconnectCts = new CancellationTokenSource();
_reconnectTask = Task.Run(() => AutoReconnectLoopAsync(_reconnectCts.Token));
}
}
private void StopAutoReconnect()
{
lock (_lock)
{
_reconnectCts?.Cancel();
_reconnectCts?.Dispose();
_reconnectCts = null;
_reconnectTask = null;
}
}
private async Task AutoReconnectLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested && !_disposed)
{
try
{
// Check if we should stop (read status in lock to avoid race condition)
DeviceStatus currentStatus;
lock (_lock)
{
if (_disposed)
break;
currentStatus = _currentStatus;
}
if (currentStatus == DeviceStatus.Connected || currentStatus == DeviceStatus.Disposed)
break;
// Check max attempts
lock (_lock)
{
if (MaxReconnectAttempts > 0 && _reconnectAttemptCount >= MaxReconnectAttempts)
{
FireTrigger(DeviceTrigger.ErrorOccurred);
OnErrorOccurred(
new InvalidOperationException($"Max reconnect attempts ({MaxReconnectAttempts}) reached"),
"Auto-reconnect stopped");
break;
}
_reconnectAttemptCount++;
}
// Wait before reconnect
await Task.Delay(ReconnectDelayMs, cancellationToken);
// Try to reconnect (read status in lock to avoid race condition)
lock (_lock)
{
if (_disposed)
break;
currentStatus = _currentStatus;
}
if (currentStatus == DeviceStatus.Error || currentStatus == DeviceStatus.Disconnected)
{
try
{
FireTrigger(DeviceTrigger.StartReconnect);
// Call implementation directly (not ConnectAsync to avoid state machine conflict)
await OnConnectAsync(cancellationToken);
// Verify connection
var isConnected = await OnCheckConnectionAsync(cancellationToken);
if (isConnected)
{
// Clear last error when reconnection succeeds
lock (_lock)
{
_lastError = null;
}
FireTrigger(DeviceTrigger.ReconnectionCompleted);
OnConnectionStateChanged(true, DeviceStatus.Reconnecting, DeviceStatus.Connected,
$"Auto-reconnected (attempt {ReconnectAttemptCount})");
break; // Success
}
else
{
FireTrigger(DeviceTrigger.ErrorOccurred);
}
}
catch (Exception ex)
{
LastError = ex;
FireTrigger(DeviceTrigger.ErrorOccurred);
OnErrorOccurred(ex, $"Auto-reconnect attempt {ReconnectAttemptCount} failed");
// Continue loop to retry
}
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, "Auto-reconnect loop error");
// Wait before retrying
await Task.Delay(ReconnectDelayMs, cancellationToken);
}
}
}
// Event Handlers
protected virtual void OnConnectionStateChanged(bool isConnected, DeviceStatus previousStatus, DeviceStatus currentStatus, string? message = null)
{
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(isConnected, previousStatus, currentStatus, message));
}
protected virtual void OnErrorOccurred(Exception exception, string? context = null)
{
ErrorOccurred?.Invoke(this, new DeviceErrorEventArgs(exception, Status, context));
}
protected virtual void OnStatusChanged(DeviceStatus previousStatus, DeviceStatus currentStatus)
{
StatusChanged?.Invoke(this, new DeviceStatusChangedEventArgs(previousStatus, currentStatus));
}
// Property Management
/// <summary>
/// Cập nhật giá trị của một property
/// Chỉ có thể cập nhật property đã được định nghĩa trong PropertyDescriptions
/// </summary>
protected void SetProperty(string key, string value)
{
if (string.IsNullOrWhiteSpace(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
lock (_lock)
{
if (!_properties.ContainsKey(key))
{
throw new ArgumentException($"Property '{key}' is not defined in PropertyDescriptions", nameof(key));
}
_properties[key] = value ?? string.Empty;
}
}
/// <summary>
/// Lấy giá trị của một property
/// </summary>
protected string? GetProperty(string key)
{
if (string.IsNullOrWhiteSpace(key))
return null;
lock (_lock)
{
return _properties.TryGetValue(key, out var value) ? value : null;
}
}
// IDisposable
protected virtual void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
// Stop auto-reconnect
StopAutoReconnect();
// Disconnect if connected
var currentStatus = Status;
if (currentStatus == DeviceStatus.Connected || currentStatus == DeviceStatus.Connecting)
{
try
{
// Use Task.Run to avoid deadlock in async contexts
Task.Run(async () => await DisconnectAsync().ConfigureAwait(false)).GetAwaiter().GetResult();
}
catch
{
// Ignore errors during dispose
}
}
// Transition to disposed state
try
{
FireTrigger(DeviceTrigger.Dispose);
}
catch
{
// Ignore errors during dispose
}
// Stop state machine
try
{
_stateMachine.Stop();
}
catch
{
// Ignore errors
}
}
_disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,23 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Event args khi có lỗi xảy ra
/// </summary>
public class DeviceErrorEventArgs : EventArgs
{
public Exception Exception { get; }
public DeviceStatus Status { get; }
public string? Context { get; }
public DateTime Timestamp { get; }
public DeviceErrorEventArgs(Exception exception, DeviceStatus status, string? context = null)
{
Exception = exception;
Status = status;
Context = context;
Timestamp = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,842 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
using System.Reflection;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Service quản lý và cung cấp truy xuất devices
/// Tự động tạo devices từ configuration khi start
/// Thread-safe implementation
/// </summary>
public class DeviceProvider(
IConfiguration _configuration,
IServiceProvider _serviceProvider,
ILogger<DeviceProvider> _logger) : IDeviceProvider, IHostedService
{
private readonly Dictionary<string, DeviceBase> _devicesById = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<DeviceType, List<DeviceBase>> _devicesByType = [];
private readonly Lock _lock = new();
private bool _devicesLoaded = false;
private bool _devicesConnected = false;
private readonly ManualResetEventSlim _devicesLoadedEvent = new(false);
private readonly ManualResetEventSlim _devicesConnectedEvent = new(false);
private Task? _connectMonitorTask;
private CancellationTokenSource? _connectCts;
public async Task StartAsync(CancellationToken cancellationToken)
{
try
{
// Discover all device types that implement DeviceBase and have DeviceAttribute
var deviceTypes = DiscoverDeviceTypes();
// Log discovered device types
foreach (var kvp in deviceTypes)
{
var attribute = kvp.Value.GetCustomAttribute<DeviceAttribute>();
}
// Lấy collection các IConfigurationSection từ section "Devices"
var sections = _configuration.GetSection("Devices").GetChildren();
// Validate duplicate DeviceIds before creating devices
var deviceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var section in sections)
{
var enabled = section["Enabled"];
if (enabled == null || !bool.Parse(enabled))
{
continue;
}
var deviceId = section["DeviceId"];
if (!string.IsNullOrWhiteSpace(deviceId))
{
if (!deviceIds.Add(deviceId))
{
_logger.LogWarning("Duplicate DeviceId '{DeviceId}' found in configuration section {SectionKey}. Skipping duplicate.",
deviceId, section.Key);
}
}
}
// Tạo devices từ configuration sections
var createdDevices = new List<DeviceBase>();
foreach (var section in sections)
{
try
{
var enabled = section["Enabled"];
if (enabled == null || !bool.Parse(enabled))
{
continue;
}
var device = CreateDeviceFromConfigurationSection(section, deviceTypes);
if (device != null)
{
RegisterDeviceInternal(device);
createdDevices.Add(device);
// Subscribe to device status changes to update _devicesConnected
device.StatusChanged += OnDeviceStatusChanged;
}
}
catch (Exception ex)
{
var errorMessage = $"Failed to create device from configuration section '{section.Key}'. " +
"This is a critical error and the application will stop.";
_logger.LogError(ex, "{}", errorMessage);
throw new InvalidOperationException(errorMessage, ex);
}
}
// Mark devices as loaded
lock (_lock)
{
_devicesLoaded = true;
}
_devicesLoadedEvent.Set();
DevicesLoaded?.Invoke(this, EventArgs.Empty);
// Initialize all devices first (synchronous, should be fast)
if (createdDevices.Count > 0)
{
var initTasks = new List<Task>();
foreach (var device in createdDevices)
{
initTasks.Add(InitializeDeviceAsync(device, cancellationToken));
}
// Wait for all devices to initialize (with timeout)
try
{
await Task.WhenAll(initTasks).WaitAsync(TimeSpan.FromSeconds(60), cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning("Timeout waiting for all devices to initialize");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during device initialization");
}
// Connect all devices in background threads (non-blocking)
var connectTasks = new List<Task>();
foreach (var device in createdDevices)
{
// Fire and forget - connect in background thread
var task = Task.Run(async () =>
{
try
{
await ConnectDeviceAsync(device, cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error connecting device {DeviceId} in background", device.DeviceId);
}
}, cancellationToken);
connectTasks.Add(task);
}
// Wait for all devices to connect in background (tracked for proper shutdown)
_connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_connectMonitorTask = Task.Run(async () =>
{
try
{
await Task.WhenAll(connectTasks);
// Check if all devices are connected
bool allConnected = true;
lock (_lock)
{
foreach (var device in createdDevices)
{
if (device.Status != DeviceStatus.Connected)
{
allConnected = false;
break;
}
}
}
if (allConnected)
{
lock (_lock)
{
_devicesConnected = true;
}
_devicesConnectedEvent.Set();
DevicesConnected?.Invoke(this, EventArgs.Empty);
}
}
catch (OperationCanceledException)
{
_logger.LogWarning("Device connection monitoring cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error waiting for devices to connect");
}
}, _connectCts.Token);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting DeviceProvider");
throw;
}
await Task.CompletedTask;
}
/// <summary>
/// Discover all device types that implement DeviceBase and have DeviceAttribute
/// Key format: "DriverName:Version" or "DriverName" (if version is null)
/// Scans all loaded assemblies, not just executing assembly
/// </summary>
private Dictionary<string, Type> DiscoverDeviceTypes()
{
var deviceTypes = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase);
// Scan all loaded assemblies, not just executing assembly
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
foreach (var assembly in assemblies)
{
try
{
foreach (var type in assembly.GetTypes())
{
// Check if type is a class, not abstract, and implements DeviceBase
if (!type.IsClass || type.IsAbstract || !typeof(DeviceBase).IsAssignableFrom(type))
continue;
// Check if type has DeviceAttribute
var attribute = type.GetCustomAttribute<DeviceAttribute>();
if (attribute == null)
continue;
// Check if DriverName is provided
if (string.IsNullOrWhiteSpace(attribute.DriverName))
{
_logger.LogWarning("Device type {TypeName} has DeviceAttribute but DriverName is empty, skipping", type.Name);
continue;
}
// Create key with version if available
var key = string.IsNullOrWhiteSpace(attribute.Version)
? attribute.DriverName
: $"{attribute.DriverName}:{attribute.Version}";
// Check for duplicate key
if (deviceTypes.TryGetValue(key, out Type? value))
{
_logger.LogWarning("Duplicate device key '{Key}' found: {ExistingType} and {NewType}, using {ExistingType}",
key, value.Name, type.Name, value.Name);
continue;
}
deviceTypes[key] = type;
}
}
catch (ReflectionTypeLoadException ex)
{
// Some assemblies may fail to load types (e.g., native dependencies)
_logger.LogWarning("Failed to load types from assembly {AssemblyName}: {Message}",
assembly.FullName, ex.Message);
}
catch (Exception ex)
{
// Ignore other exceptions during type discovery
_logger.LogError("Error scanning assembly {AssemblyName}: {Message}",
assembly.FullName, ex.Message);
}
}
return deviceTypes;
}
/// <summary>
/// Tìm device type theo driverName và version
/// </summary>
private static Type? FindDeviceType(Dictionary<string, Type> deviceTypes, string driverName, string? version)
{
if (string.IsNullOrWhiteSpace(driverName))
return null;
// Try to find with version first
if (!string.IsNullOrWhiteSpace(version))
{
var keyWithVersion = $"{driverName}:{version}";
if (deviceTypes.TryGetValue(keyWithVersion, out var typeWithVersion))
{
return typeWithVersion;
}
}
// Fallback to driverName only (without version)
if (deviceTypes.TryGetValue(driverName, out var type))
{
return type;
}
return null;
}
/// <summary>
/// Tìm constructor phù hợp với tham số: deviceId (string), deviceName (string), IConfigurationSection, và optional IServiceProvider
/// </summary>
private static ConstructorInfo? FindMatchingConstructor(Type deviceType)
{
var constructors = deviceType.GetConstructors(BindingFlags.Public | BindingFlags.Instance);
foreach (var constructor in constructors)
{
var parameters = constructor.GetParameters();
// Check if constructor has 3 parameters: string, string, IConfigurationSection
if (parameters.Length == 3)
{
var param1 = parameters[0];
var param2 = parameters[1];
var param3 = parameters[2];
if (param1.ParameterType == typeof(string) &&
param2.ParameterType == typeof(string) &&
param3.ParameterType == typeof(IConfigurationSection))
{
return constructor;
}
}
// Check if constructor has 4 parameters: string, string, IConfigurationSection, IServiceProvider
if (parameters.Length == 4)
{
var param1 = parameters[0];
var param2 = parameters[1];
var param3 = parameters[2];
var param4 = parameters[3];
if (param1.ParameterType == typeof(string) &&
param2.ParameterType == typeof(string) &&
param3.ParameterType == typeof(IConfigurationSection) &&
param4.ParameterType == typeof(IServiceProvider))
{
return constructor;
}
}
}
return null;
}
/// <summary>
/// Tạo device từ configuration section
/// </summary>
private DeviceBase? CreateDeviceFromConfigurationSection(IConfigurationSection section, Dictionary<string, Type> deviceTypes)
{
// Đọc các thông tin từ section
var deviceId = section["DeviceId"];
var deviceName = section["DeviceName"];
var driverName = section["DriverName"];
var driverVersion = section["DriverVersion"];
var connectionSection = section.GetSection("Connection");
// Validate required fields
if (string.IsNullOrWhiteSpace(deviceId))
{
_logger.LogWarning("Configuration section {SectionKey} missing DeviceId, skipping", section.Key);
return null;
}
if (string.IsNullOrWhiteSpace(deviceName))
{
_logger.LogWarning("Configuration section {SectionKey} missing DeviceName, skipping", section.Key);
return null;
}
if (string.IsNullOrWhiteSpace(driverName))
{
_logger.LogWarning("Configuration section {SectionKey} missing DriverName, skipping", section.Key);
return null;
}
// Tìm device type theo driverName và version
var deviceType = FindDeviceType(deviceTypes, driverName, driverVersion);
if (deviceType == null)
{
var errorMessage = $"Device type not found for DriverName: '{driverName}' (Version: '{driverVersion ?? "null"}', DeviceId: '{deviceId}'). " +
$"Please check that the driver class exists and has [Device] attribute with matching DriverName.";
_logger.LogError("{}", errorMessage);
throw new InvalidOperationException(errorMessage);
}
// Kiểm tra DeviceType từ configuration có khớp với DeviceAttribute.DeviceType không
var configDeviceTypeStr = section["DeviceType"];
if (!string.IsNullOrWhiteSpace(configDeviceTypeStr))
{
if (Enum.TryParse<DeviceType>(configDeviceTypeStr, ignoreCase: true, out var configDeviceType))
{
var attribute = deviceType.GetCustomAttribute<DeviceAttribute>();
if (attribute != null && attribute.DeviceType != configDeviceType)
{
_logger.LogWarning("DeviceType mismatch for device {DeviceId}: " +
"Configuration specifies {ConfigDeviceType} but DeviceAttribute has {AttributeDeviceType}. Skipping.",
deviceId, configDeviceType, attribute.DeviceType);
return null;
}
}
else
{
_logger.LogWarning("Invalid DeviceType value '{DeviceType}' in configuration section {SectionKey} for device {DeviceId}. Skipping.",
configDeviceTypeStr, section.Key, deviceId);
return null;
}
}
// Tìm constructor phù hợp
var constructor = FindMatchingConstructor(deviceType);
if (constructor == null)
{
var errorMessage = $"No matching constructor found for device type '{deviceType.Name}' (DeviceId: '{deviceId}'). " +
"Expected constructor with parameters: (string deviceId, string deviceName, IConfigurationSection connection) " +
"or (string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider). " +
"Please check the driver class constructor signature.";
_logger.LogError("{}", errorMessage);
throw new InvalidOperationException(errorMessage);
}
// Tạo device instance
try
{
var parameters = constructor.GetParameters();
object[] constructorArgs;
if (parameters.Length == 3)
{
// Constructor without IServiceProvider
constructorArgs =
[
deviceId,
deviceName,
connectionSection
];
}
else
{
// Constructor with IServiceProvider
constructorArgs =
[
deviceId,
deviceName,
connectionSection,
_serviceProvider
];
}
var device = (DeviceBase)constructor.Invoke(constructorArgs);
return device;
}
catch (Exception ex)
{
var errorMessage = $"Failed to create device instance: DeviceId='{deviceId}', TypeName='{deviceType.Name}'. " +
"Please check the driver class constructor and configuration.";
_logger.LogError(ex, "{}", errorMessage);
throw new InvalidOperationException(errorMessage, ex);
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
try
{
// Cancel and wait for connection monitor task to finish
if (_connectCts != null)
{
try
{
_connectCts.Cancel();
}
catch (ObjectDisposedException)
{
_logger.LogError("DeviceProvider: Connection CTS already disposed");
// Already disposed, ignore
}
}
if (_connectMonitorTask != null)
{
try
{
await _connectMonitorTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken);
}
catch (TimeoutException)
{
_logger.LogWarning("DeviceProvider: Timeout waiting for connection monitor task to finish");
}
catch (Exception ex)
{
_logger.LogError(ex, "DeviceProvider: Error waiting for connection monitor task");
}
}
// Dispose connection CTS
_connectCts?.Dispose();
_connectCts = null;
_connectMonitorTask = null;
// Get all devices and disconnect them first
List<DeviceBase> devicesToStop;
lock (_lock)
{
devicesToStop = [.. _devicesById.Values];
}
if (devicesToStop.Count > 0)
{
// Disconnect all devices in parallel
var disconnectTasks = new List<Task>();
foreach (var device in devicesToStop)
{
disconnectTasks.Add(DisconnectDeviceAsync(device, cancellationToken));
}
// Wait for all devices to disconnect (with timeout)
try
{
await Task.WhenAll(disconnectTasks);
}
catch (TimeoutException)
{
_logger.LogWarning("DeviceProvider: Timeout waiting for all devices to disconnect");
}
catch (Exception ex)
{
_logger.LogError(ex, "DeviceProvider: Error during device disconnection");
}
}
// Dispose all devices
lock (_lock)
{
foreach (var device in devicesToStop)
{
try
{
device.Dispose();
}
catch (Exception ex)
{
_logger.LogError(ex, "DeviceProvider: Error disposing device: {DeviceId}", device.DeviceId);
}
}
_devicesById.Clear();
_devicesByType.Clear();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "DeviceProvider: Error during StopAsync()");
throw;
}
}
/// <summary>
/// Initialize a device
/// </summary>
private async Task InitializeDeviceAsync(DeviceBase device, CancellationToken cancellationToken)
{
try
{
await device.InitializeAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to initialize device: {DeviceId}", device.DeviceId);
throw; // Re-throw để caller biết có lỗi
}
}
/// <summary>
/// Connect a device (runs in background thread)
/// </summary>
private async Task ConnectDeviceAsync(DeviceBase device, CancellationToken cancellationToken)
{
try
{
await device.ConnectAsync(cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to connect device: {DeviceId}", device.DeviceId);
// Don't throw - allow other devices to continue
}
}
/// <summary>
/// Disconnect a device
/// </summary>
private async Task DisconnectDeviceAsync(DeviceBase device, CancellationToken cancellationToken)
{
try
{
if (device.Status == DeviceStatus.Connected || device.Status == DeviceStatus.Connecting)
{
await device.DisconnectAsync(cancellationToken);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to disconnect device: {DeviceId}", device.DeviceId);
// Don't throw - allow other devices to continue
}
}
private void RegisterDeviceInternal(DeviceBase device)
{
ArgumentNullException.ThrowIfNull(device);
if (string.IsNullOrWhiteSpace(device.DeviceId))
throw new ArgumentException("Device.DeviceId cannot be null or empty", nameof(device));
lock (_lock)
{
// Kiểm tra deviceId đã tồn tại chưa
if (_devicesById.ContainsKey(device.DeviceId))
{
_logger.LogWarning("Device with ID {DeviceId} already exists, skipping registration", device.DeviceId);
return;
}
// Đăng ký vào dictionary theo ID
_devicesById[device.DeviceId] = device;
// Đăng ký vào dictionary theo Type
if (!_devicesByType.TryGetValue(device.Type, out var devicesByType))
{
devicesByType = [];
_devicesByType[device.Type] = devicesByType;
}
devicesByType.Add(device);
}
}
/// <summary>
/// Handler for device status changes - updates _devicesConnected when devices disconnect/reconnect
/// </summary>
private void OnDeviceStatusChanged(object? sender, DeviceStatusChangedEventArgs e)
{
if (sender is not DeviceBase device)
return;
lock (_lock)
{
// If a device disconnected and we previously had all devices connected, reset the flag
if (_devicesConnected && e.CurrentStatus != DeviceStatus.Connected)
{
_devicesConnected = false;
_devicesConnectedEvent.Reset();
}
// If a device connected, check if all devices are now connected
else if (!_devicesConnected && e.CurrentStatus == DeviceStatus.Connected)
{
// Check if all devices are now connected
bool allConnected = true;
foreach (var d in _devicesById.Values)
{
if (d.Status != DeviceStatus.Connected)
{
allConnected = false;
break;
}
}
if (allConnected)
{
_devicesConnected = true;
_devicesConnectedEvent.Set();
DevicesConnected?.Invoke(this, EventArgs.Empty);
}
}
}
}
public DeviceBase? GetDevice(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
return null;
lock (_lock)
{
return _devicesById.TryGetValue(deviceId, out var device) ? device : null;
}
}
public Task<DeviceBase?> GetDeviceAsync(string deviceId, CancellationToken cancellationToken = default)
{
return Task.FromResult(GetDevice(deviceId));
}
public DeviceBase? GetDeviceByType(DeviceType deviceType)
{
lock (_lock)
{
if (_devicesByType.TryGetValue(deviceType, out var devices) && devices.Count > 0)
{
return devices[0];
}
return null;
}
}
public Task<DeviceBase?> GetDeviceByTypeAsync(DeviceType deviceType, CancellationToken cancellationToken = default)
{
return Task.FromResult(GetDeviceByType(deviceType));
}
public IReadOnlyList<DeviceBase> GetDevicesByType(DeviceType deviceType)
{
lock (_lock)
{
if (_devicesByType.TryGetValue(deviceType, out var devices))
{
return devices.ToList().AsReadOnly();
}
return Array.Empty<DeviceBase>().ToList().AsReadOnly();
}
}
public Task<IReadOnlyList<DeviceBase>> GetDevicesByTypeAsync(DeviceType deviceType, CancellationToken cancellationToken = default)
{
return Task.FromResult(GetDevicesByType(deviceType));
}
public IReadOnlyList<DeviceBase> GetAllDevices()
{
lock (_lock)
{
return _devicesById.Values.ToList().AsReadOnly();
}
}
public Task<IReadOnlyList<DeviceBase>> GetAllDevicesAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(GetAllDevices());
}
public bool ContainsDevice(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId))
return false;
lock (_lock)
{
return _devicesById.ContainsKey(deviceId);
}
}
public int GetDeviceCount()
{
lock (_lock)
{
return _devicesById.Count;
}
}
public int GetDeviceCountByType(DeviceType deviceType)
{
lock (_lock)
{
if (_devicesByType.TryGetValue(deviceType, out var devices))
{
return devices.Count;
}
return 0;
}
}
public bool AreDevicesLoaded
{
get
{
lock (_lock)
{
return _devicesLoaded;
}
}
}
public bool AreDevicesConnected
{
get
{
lock (_lock)
{
if (!_devicesLoaded)
return false;
// Use cached _devicesConnected value, but verify if it's true
// If _devicesConnected is false, we know for sure not all are connected
if (!_devicesConnected)
return false;
// If _devicesConnected is true, verify all devices are still connected
// (in case a device disconnected after initial connection)
foreach (var device in _devicesById.Values)
{
if (device.Status != DeviceStatus.Connected)
{
// Update cached value if we find a disconnected device
_devicesConnected = false;
return false;
}
}
return true;
}
}
}
public async Task<bool> WaitForDevicesLoadedAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
{
if (AreDevicesLoaded)
return true;
try
{
return await Task.Run(() => _devicesLoadedEvent.Wait(timeout, cancellationToken), cancellationToken);
}
catch (OperationCanceledException)
{
return false;
}
}
public async Task<bool> WaitForDevicesConnectedAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
{
if (AreDevicesConnected)
return true;
// First wait for devices to be loaded
if (!await WaitForDevicesLoadedAsync(timeout, cancellationToken))
return false;
try
{
// Then wait for devices to be connected
return await Task.Run(() => _devicesConnectedEvent.Wait(timeout, cancellationToken), cancellationToken);
}
catch (OperationCanceledException)
{
return false;
}
}
public event EventHandler? DevicesLoaded;
public event EventHandler? DevicesConnected;
}

View File

@@ -0,0 +1,26 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Event args khi trạng thái device thay đổi
/// </summary>
public class DeviceStatusChangedEventArgs : EventArgs
{
public DeviceStatus PreviousStatus { get; }
public DeviceStatus CurrentStatus { get; }
public string? Message { get; }
public DateTime Timestamp { get; }
public DeviceStatusChangedEventArgs(
DeviceStatus previousStatus,
DeviceStatus currentStatus,
string? message = null)
{
PreviousStatus = previousStatus;
CurrentStatus = currentStatus;
Message = message;
Timestamp = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,57 @@
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Triggers cho state machine của Device
/// </summary>
public enum DeviceTrigger
{
/// <summary>
/// Khởi tạo thiết bị
/// </summary>
Initialize,
/// <summary>
/// Kết nối với thiết bị
/// </summary>
Connect,
/// <summary>
/// Ngắt kết nối với thiết bị
/// </summary>
Disconnect,
/// <summary>
/// Reset thiết bị
/// </summary>
Reset,
/// <summary>
/// Bắt đầu auto-reconnect
/// </summary>
StartReconnect,
/// <summary>
/// Dừng auto-reconnect
/// </summary>
StopReconnect,
/// <summary>
/// Internal triggers - tự động fire khi operation hoàn thành
/// </summary>
InitializationCompleted,
ConnectionCompleted,
DisconnectionCompleted,
ReconnectionCompleted,
ResetCompleted,
/// <summary>
/// Xảy ra lỗi
/// </summary>
ErrorOccurred,
/// <summary>
/// Dispose thiết bị
/// </summary>
Dispose
}

View File

@@ -0,0 +1,20 @@
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Interface cho thiết bị Battery (Battery Management System) trong robot AGV
/// Cung cấp thông tin battery theo chuẩn ROS sensor_msgs/BatteryState
/// </summary>
public interface IBattery
{
/// <summary>
/// Battery state hiện tại được cache (thread-safe)
/// </summary>
BatteryState? CurrentBatteryState { get; }
/// <summary>
/// Đọc battery state từ thiết bị
/// </summary>
Task<BatteryState> ReadBatteryStateAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using RobotNet10.Shared.Geometry;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Interface cho thiết bị Camera QR Code Detection
/// </summary>
public interface ICameraQr
{
bool IsConnected { get; }
PoseStamped? this[string code] { get; }
Dictionary<string, PoseStamped> Codes { get; }
}

View File

@@ -0,0 +1,329 @@
using RobotNet10.CANOpen.CiA402;
using RobotNet10.CANOpen.CiA402.Enums;
using RobotNet10.CANOpen.CiA402.Models;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Interface cho CiA402 Servo - cung cấp tất cả chức năng điều khiển động cơ servo theo chuẩn CiA402
/// Tập trung vào phần điều khiển động cơ, không bao gồm các chức năng liên quan đến CAN/PDO
/// </summary>
public interface ICiA402Servo
{
// Cached values (thread-safe, read-only)
/// <summary>
/// Statusword hiện tại (real-time, thread-safe)
/// </summary>
Statusword CachedStatusword { get; }
/// <summary>
/// Actual position hiện tại (thread-safe)
/// </summary>
int CachedPosition { get; }
/// <summary>
/// Actual velocity hiện tại (thread-safe)
/// </summary>
int CachedVelocity { get; }
/// <summary>
/// Actual torque hiện tại (thread-safe)
/// </summary>
short CachedTorque { get; }
// Events
/// <summary>
/// Event được kích hoạt khi Statusword thay đổi
/// </summary>
event EventHandler<StatuswordChangedEventArgs>? StatuswordChanged;
/// <summary>
/// Event được kích hoạt khi Position thay đổi
/// </summary>
event EventHandler<PositionChangedEventArgs>? PositionChanged;
/// <summary>
/// Event được kích hoạt khi Velocity thay đổi
/// </summary>
event EventHandler<VelocityChangedEventArgs>? VelocityChanged;
// Statusword & Controlword
/// <summary>
/// Get statusword hiện tại
/// </summary>
Task<Statusword> GetStatuswordAsync(CancellationToken ct = default);
/// <summary>
/// Set controlword để điều khiển motor
/// </summary>
Task SetControlwordAsync(Controlword controlword, CancellationToken ct = default);
/// <summary>
/// Get drive state từ statusword
/// </summary>
Task<DriveState> GetStateAsync(CancellationToken ct = default);
// Operation Mode
/// <summary>
/// Set operation mode (Profile Position, Profile Velocity, Profile Torque, etc.)
/// </summary>
Task SetOperationModeAsync(OperationMode mode, CancellationToken ct = default);
/// <summary>
/// Get operation mode hiện tại
/// </summary>
Task<OperationMode> GetOperationModeAsync(CancellationToken ct = default);
// State Machine Control
/// <summary>
/// Reset fault trên motor
/// </summary>
Task FaultResetAsync(CancellationToken ct = default);
/// <summary>
/// Shutdown command
/// </summary>
Task ShutdownAsync(CancellationToken ct = default);
/// <summary>
/// Switch on command
/// </summary>
Task SwitchOnAsync(CancellationToken ct = default);
/// <summary>
/// Enable operation command
/// </summary>
Task EnableOperationAsync(CancellationToken ct = default);
/// <summary>
/// Disable operation command
/// </summary>
Task DisableOperationAsync(CancellationToken ct = default);
/// <summary>
/// Quick stop command
/// </summary>
Task QuickStopAsync(CancellationToken ct = default);
/// <summary>
/// Enable motor với automatic state transitions
/// </summary>
Task EnableAsync(CancellationToken ct = default);
/// <summary>
/// Disable motor
/// </summary>
Task DisableAsync(CancellationToken ct = default);
// Position Control
/// <summary>
/// Get actual position hiện tại
/// </summary>
Task<int> GetActualPositionAsync(CancellationToken ct = default);
/// <summary>
/// Set target position
/// </summary>
Task SetTargetPositionAsync(int position, CancellationToken ct = default);
/// <summary>
/// Set profile speed
/// </summary>
Task SetProfileSpeedAsync(uint velocity, CancellationToken ct = default);
/// <summary>
/// Set profile velocity
/// </summary>
Task SetProfileVelocityAsync(uint velocity, CancellationToken ct = default);
/// <summary>
/// Set profile acceleration
/// </summary>
Task SetProfileAccelerationAsync(uint acceleration, CancellationToken ct = default);
/// <summary>
/// Set profile deceleration
/// </summary>
Task SetProfileDecelerationAsync(uint deceleration, CancellationToken ct = default);
/// <summary>
/// Get profile speed (0x6081) từ drive
/// </summary>
Task<uint> GetProfileSpeedAsync(CancellationToken ct = default);
/// <summary>
/// Get profile acceleration (0x6083) từ drive
/// </summary>
Task<uint> GetProfileAccelerationAsync(CancellationToken ct = default);
/// <summary>
/// Get profile deceleration (0x6084) từ drive
/// </summary>
Task<uint> GetProfileDecelerationAsync(CancellationToken ct = default);
/// <summary>
/// Start position move (set new setpoint bit)
/// </summary>
Task StartPositionMoveAsync(CancellationToken ct = default);
/// <summary>
/// Move to position với Profile Position mode
/// </summary>
Task MoveToPositionAsync(int position, uint velocity = 1000, uint acceleration = 5000, uint deceleration = 5000, CancellationToken ct = default);
// Velocity Control
/// <summary>
/// Get actual velocity hiện tại
/// </summary>
Task<int> GetActualVelocityAsync(CancellationToken ct = default);
/// <summary>
/// Set target velocity
/// </summary>
Task SetTargetVelocityAsync(int velocity, CancellationToken ct = default);
/// <summary>
/// Target velocity với Profile Velocity mode
/// </summary>
Task TargetVelocityAsync(int targetVelocity, uint acceleration = 5000, uint deceleration = 5000, CancellationToken ct = default);
/// <summary>
/// Profile velocity với Profile Velocity mode
/// </summary>
Task ProfileVelocityAsync(int targetVelocity, uint acceleration = 5000, uint deceleration = 5000, CancellationToken ct = default);
// Torque Control
/// <summary>
/// Get actual torque hiện tại
/// </summary>
Task<short> GetActualTorqueAsync(CancellationToken ct = default);
/// <summary>
/// Set target torque
/// </summary>
Task SetTargetTorqueAsync(short torque, CancellationToken ct = default);
/// <summary>
/// Run torque với Profile Torque mode
/// </summary>
Task RunTorqueAsync(short torque, CancellationToken ct = default);
// Homing
/// <summary>
/// Set homing method
/// </summary>
Task SetHomingMethodAsync(byte method, CancellationToken ct = default);
/// <summary>
/// Set homing speed (speed during search for switch, sub-index 1 of 0x6099).
/// </summary>
Task SetHomingSpeedAsync(int speed, CancellationToken ct = default);
/// <summary>
/// Set homing offset (0x607C). Offset applied after homing is complete.
/// </summary>
Task SetHomingOffsetAsync(int offset, CancellationToken ct = default);
/// <summary>
/// Đọc lại homing method từ drive (0x6098) để kiểm tra đã ghi xuống chưa.
/// </summary>
Task<byte> GetHomingMethodAsync(CancellationToken ct = default);
/// <summary>
/// Đọc lại homing speed từ drive (0x6099 sub-index 1) để kiểm tra đã ghi xuống chưa.
/// </summary>
Task<int> GetHomingSpeedAsync(CancellationToken ct = default);
/// <summary>
/// Đọc lại homing offset từ drive (0x607C) để kiểm tra đã ghi xuống chưa.
/// </summary>
Task<int> GetHomingOffsetAsync(CancellationToken ct = default);
/// <summary>
/// Start homing procedure
/// </summary>
Task StartHomingAsync(byte method, int speed, CancellationToken ct = default);
// Target Position Checking
/// <summary>
/// Kiểm tra xem motor đã đến vị trí target chưa
/// </summary>
/// <param name="tolerance">Tolerance cho position comparison (encoder counts). Chỉ dùng nếu không có TargetReached bit.</param>
/// <param name="useStatusword">True để dùng Statusword.TargetReached bit (recommended), False để so sánh position</param>
bool IsAtTarget(int tolerance = 100, bool useStatusword = true);
/// <summary>
/// Đợi cho đến khi motor đến vị trí target
/// </summary>
Task WaitUntilAtTargetAsync(int tolerance = 100, bool useStatusword = true, int checkIntervalMs = 10, CancellationToken ct = default);
// Error and Status Information
/// <summary>
/// Kiểm tra xem motor có đang ở trạng thái Fault không
/// </summary>
Task<bool> IsInFaultStateAsync(CancellationToken ct = default);
/// <summary>
/// Đọc Error Register từ device
/// </summary>
Task<byte> GetErrorRegisterAsync(CancellationToken ct = default);
/// <summary>
/// Đọc Pre-defined Error Field từ device (danh sách các error codes gần đây)
/// </summary>
Task<ushort[]> GetErrorHistoryAsync(CancellationToken ct = default);
/// <summary>
/// Đọc error code mới nhất từ Error History
/// </summary>
Task<ushort> GetLatestErrorCodeAsync(CancellationToken ct = default);
/// <summary>
/// Reset fault trên motor (nếu motor đang ở trạng thái Fault)
/// </summary>
Task<bool> TryFaultResetAsync(CancellationToken ct = default);
/// <summary>
/// Kiểm tra xem motor có đang enabled (Operation Enabled state) không
/// </summary>
Task<bool> IsEnabledAsync(CancellationToken ct = default);
/// <summary>
/// Kiểm tra xem motor có đang ready (Ready to Switch On hoặc Switched On) không
/// </summary>
Task<bool> IsReadyAsync(CancellationToken ct = default);
}
#region Event Args
/// <summary>
/// Event args cho StatuswordChanged event
/// </summary>
public class StatuswordChangedEventArgs(Statusword statusword, DriveState oldState, DriveState newState) : EventArgs
{
public Statusword Statusword { get; } = statusword;
public DriveState OldState { get; } = oldState;
public DriveState NewState { get; } = newState;
}
/// <summary>
/// Event args cho PositionChanged event
/// </summary>
public class PositionChangedEventArgs(int position, int oldPosition) : EventArgs
{
public int Position { get; } = position;
public int OldPosition { get; } = oldPosition;
public int Delta => Position - OldPosition;
}
/// <summary>
/// Event args cho VelocityChanged event
/// </summary>
public class VelocityChangedEventArgs(int velocity, int oldVelocity) : EventArgs
{
public int Velocity { get; } = velocity;
public int OldVelocity { get; } = oldVelocity;
}
#endregion

View File

@@ -0,0 +1,124 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Interface cung cấp các chức năng để quản lý và truy xuất devices
/// </summary>
public interface IDeviceProvider
{
/// <summary>
/// Lấy device theo deviceId
/// </summary>
/// <param name="deviceId">Định danh duy nhất của device</param>
/// <returns>Device nếu tìm thấy, null nếu không tìm thấy</returns>
DeviceBase? GetDevice(string deviceId);
/// <summary>
/// Lấy device theo deviceId (async)
/// </summary>
/// <param name="deviceId">Định danh duy nhất của device</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Device nếu tìm thấy, null nếu không tìm thấy</returns>
Task<DeviceBase?> GetDeviceAsync(string deviceId, CancellationToken cancellationToken = default);
/// <summary>
/// Lấy device theo deviceType (lấy device đầu tiên tìm thấy)
/// </summary>
/// <param name="deviceType">Loại thiết bị</param>
/// <returns>Device nếu tìm thấy, null nếu không tìm thấy</returns>
DeviceBase? GetDeviceByType(DeviceType deviceType);
/// <summary>
/// Lấy device theo deviceType (async)
/// </summary>
/// <param name="deviceType">Loại thiết bị</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Device nếu tìm thấy, null nếu không tìm thấy</returns>
Task<DeviceBase?> GetDeviceByTypeAsync(DeviceType deviceType, CancellationToken cancellationToken = default);
/// <summary>
/// Lấy tất cả devices theo deviceType
/// </summary>
/// <param name="deviceType">Loại thiết bị</param>
/// <returns>Danh sách devices của loại được chỉ định</returns>
IReadOnlyList<DeviceBase> GetDevicesByType(DeviceType deviceType);
/// <summary>
/// Lấy tất cả devices theo deviceType (async)
/// </summary>
/// <param name="deviceType">Loại thiết bị</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Danh sách devices của loại được chỉ định</returns>
Task<IReadOnlyList<DeviceBase>> GetDevicesByTypeAsync(DeviceType deviceType, CancellationToken cancellationToken = default);
/// <summary>
/// Lấy tất cả devices
/// </summary>
/// <returns>Danh sách tất cả devices</returns>
IReadOnlyList<DeviceBase> GetAllDevices();
/// <summary>
/// Lấy tất cả devices (async)
/// </summary>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Danh sách tất cả devices</returns>
Task<IReadOnlyList<DeviceBase>> GetAllDevicesAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Kiểm tra device có tồn tại không
/// </summary>
/// <param name="deviceId">Định danh của device</param>
/// <returns>True nếu device tồn tại, false nếu không</returns>
bool ContainsDevice(string deviceId);
/// <summary>
/// Lấy số lượng devices
/// </summary>
/// <returns>Số lượng devices đã đăng ký</returns>
int GetDeviceCount();
/// <summary>
/// Lấy số lượng devices theo deviceType
/// </summary>
/// <param name="deviceType">Loại thiết bị</param>
/// <returns>Số lượng devices của loại được chỉ định</returns>
int GetDeviceCountByType(DeviceType deviceType);
/// <summary>
/// Kiểm tra xem tất cả devices đã được load chưa
/// </summary>
bool AreDevicesLoaded { get; }
/// <summary>
/// Kiểm tra xem tất cả devices đã kết nối chưa
/// </summary>
bool AreDevicesConnected { get; }
/// <summary>
/// Đợi cho đến khi tất cả devices đã được load
/// </summary>
/// <param name="timeout">Timeout để đợi</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True nếu devices đã load, false nếu timeout</returns>
Task<bool> WaitForDevicesLoadedAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
/// <summary>
/// Đợi cho đến khi tất cả devices đã kết nối
/// </summary>
/// <param name="timeout">Timeout để đợi</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>True nếu devices đã connect, false nếu timeout</returns>
Task<bool> WaitForDevicesConnectedAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
/// <summary>
/// Event được kích hoạt khi tất cả devices đã được load
/// </summary>
event EventHandler? DevicesLoaded;
/// <summary>
/// Event được kích hoạt khi tất cả devices đã kết nối
/// </summary>
event EventHandler? DevicesConnected;
}

View File

@@ -0,0 +1,223 @@
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Devices;
// Type alias để giữ tương thích với code hiện tại
using AccelerationData = AccelStamped;
/// <summary>
/// Interface cho thiết bị IMU (Inertial Measurement Unit) trong robot AGV
/// </summary>
public interface IInertialMeasurementUnit
{
/// <summary>
/// Trạng thái kết nối với thiết bị IMU
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Trạng thái đã được calibrate hay chưa
/// </summary>
bool IsCalibrated { get; }
/// <summary>
/// Tần số lấy mẫu hiện tại (Hz)
/// </summary>
double SampleRate { get; }
/// <summary>
/// Dữ liệu gia tốc được cache (m/s²)
/// </summary>
AccelStamped CachedAcceleration { get; }
/// <summary>
/// Dữ liệu vận tốc góc được cache (rad/s)
/// Roll, Pitch, Yaw được map sang X, Y, Z của Vector3
/// </summary>
Vector3Stamped CachedAngularVelocity { get; }
/// <summary>
/// Dữ liệu từ trường (magnetometer) được cache (µT hoặc Gauss)
/// </summary>
Vector3Stamped? CachedMagnetometer { get; }
/// <summary>
/// Dữ liệu hướng (Euler angles) được cache (rad)
/// Roll, Pitch, Yaw được map sang X, Y, Z của Vector3
/// </summary>
Vector3Stamped CachedOrientation { get; }
/// <summary>
/// Dữ liệu quaternion được cache
/// </summary>
QuaternionStamped? CachedQuaternion { get; }
/// <summary>
/// Nhiệt độ cảm biến được cache (°C)
/// </summary>
double? CachedTemperature { get; }
/// <summary>
/// Timestamp của lần đọc dữ liệu gần nhất
/// </summary>
DateTime LastUpdateTime { get; }
// Events
/// <summary>
/// Event được kích hoạt khi dữ liệu gia tốc thay đổi
/// </summary>
event EventHandler<AccelerationChangedEventArgs>? AccelerationChanged;
/// <summary>
/// Event được kích hoạt khi dữ liệu vận tốc góc thay đổi
/// </summary>
event EventHandler<AngularVelocityChangedEventArgs>? AngularVelocityChanged;
/// <summary>
/// Event được kích hoạt khi dữ liệu từ trường thay đổi
/// </summary>
event EventHandler<MagnetometerChangedEventArgs>? MagnetometerChanged;
/// <summary>
/// Event được kích hoạt khi hướng (orientation) thay đổi
/// </summary>
event EventHandler<OrientationChangedEventArgs>? OrientationChanged;
// Connection Methods
/// <summary>
/// Kết nối với thiết bị IMU
/// </summary>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Ngắt kết nối với thiết bị IMU
/// </summary>
Task DisconnectAsync(CancellationToken cancellationToken = default);
// Data Reading Methods
/// <summary>
/// Đọc dữ liệu gia tốc (m/s²)
/// </summary>
Task<AccelStamped> ReadAccelerationAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Đọc dữ liệu vận tốc góc (rad/s)
/// Roll, Pitch, Yaw được map sang X, Y, Z của Vector3
/// </summary>
Task<Vector3Stamped> ReadAngularVelocityAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Đọc dữ liệu từ trường (magnetometer) nếu có (µT hoặc Gauss)
/// </summary>
Task<Vector3Stamped?> ReadMagnetometerAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Đọc dữ liệu hướng (Euler angles) (rad)
/// Roll, Pitch, Yaw được map sang X, Y, Z của Vector3
/// </summary>
Task<Vector3Stamped> ReadOrientationAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Đọc dữ liệu quaternion nếu có
/// </summary>
Task<QuaternionStamped?> ReadQuaternionAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Đọc tất cả dữ liệu IMU cùng lúc
/// </summary>
Task<Imu> ReadAllDataAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Đọc nhiệt độ cảm biến nếu có (°C)
/// </summary>
Task<double?> ReadTemperatureAsync(CancellationToken cancellationToken = default);
// Calibration Methods
/// <summary>
/// Calibrate IMU (gyroscope và accelerometer)
/// </summary>
Task CalibrateAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Calibrate magnetometer (nếu có)
/// </summary>
Task CalibrateMagnetometerAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Reset calibration về mặc định
/// </summary>
Task ResetCalibrationAsync(CancellationToken cancellationToken = default);
// Configuration Methods
/// <summary>
/// Thiết lập tần số lấy mẫu (Hz)
/// </summary>
Task SetSampleRateAsync(double sampleRate, CancellationToken cancellationToken = default);
/// <summary>
/// Thiết lập dải đo gia tốc (g)
/// </summary>
Task SetAccelerometerRangeAsync(double range, CancellationToken cancellationToken = default);
/// <summary>
/// Thiết lập dải đo vận tốc góc (rad/s)
/// </summary>
Task SetGyroscopeRangeAsync(double range, CancellationToken cancellationToken = default);
}
// Event Args
/// <summary>
/// Event args khi dữ liệu gia tốc thay đổi
/// </summary>
public class AccelerationChangedEventArgs(AccelStamped data) : EventArgs
{
public AccelStamped Data { get; } = data;
}
/// <summary>
/// Event args khi dữ liệu vận tốc góc thay đổi
/// </summary>
public class AngularVelocityChangedEventArgs(Vector3Stamped data) : EventArgs
{
public Vector3Stamped Data { get; } = data;
}
/// <summary>
/// Event args khi dữ liệu từ trường thay đổi
/// </summary>
public class MagnetometerChangedEventArgs(Vector3Stamped data) : EventArgs
{
public Vector3Stamped Data { get; } = data;
}
/// <summary>
/// Event args khi hướng (orientation) thay đổi
/// </summary>
public class OrientationChangedEventArgs(Vector3Stamped data) : EventArgs
{
public Vector3Stamped Data { get; } = data;
}
/// <summary>
/// Event args chứa toàn bộ dữ liệu IMU trong 1 sample (dùng cho SensorPipeline)
/// </summary>
public class ImuDataChangedEventArgs(
AccelStamped acceleration,
Vector3Stamped angularVelocity,
Vector3Stamped magnetometer,
Vector3Stamped orientation,
DateTime timestamp) : EventArgs
{
public AccelStamped Acceleration { get; } = acceleration;
public Vector3Stamped AngularVelocity { get; } = angularVelocity;
public Vector3Stamped Magnetometer { get; } = magnetometer;
public Vector3Stamped Orientation { get; } = orientation;
public DateTime Timestamp { get; } = timestamp;
}

View File

@@ -0,0 +1,93 @@
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Interface cho thiết bị LiDAR trong robot AGV (common interface for all Lidar brands)
/// Không bao gồm các chức năng kết nối vì đã được xử lý trong DeviceBase
/// </summary>
public interface ILidar
{
// Scan Data Properties (common for all Lidar brands)
/// <summary>
/// Measurement data hiện tại (scan points) - sử dụng LaserScan từ sensor_msgs
/// </summary>
LaserScan? CurrentMeasurementData { get; }
/// <summary>
/// Timestamp của scan data gần nhất
/// </summary>
DateTime? LastScanDataTimestamp { get; }
// Device Specifications (common for all Lidar brands)
/// <summary>
/// Góc quét tối thiểu (radian) - góc bắt đầu của phạm vi quét
/// </summary>
double MinAngleRad { get; }
/// <summary>
/// Góc quét tối đa (radian) - góc kết thúc của phạm vi quét
/// </summary>
double MaxAngleRad { get; }
/// <summary>
/// Tầm quét tối thiểu (mét) - khoảng cách gần nhất có thể đo được
/// </summary>
double MinRangeM { get; }
/// <summary>
/// Tầm quét tối đa (mét) - khoảng cách xa nhất có thể đo được
/// </summary>
double MaxRangeM { get; }
/// <summary>
/// Độ phân giải góc (radian) - góc giữa hai điểm scan liên tiếp
/// </summary>
double? AngularResolutionRad { get; }
/// <summary>
/// Tần số quét (Hz) - số lần quét trong một giây
/// </summary>
double? ScanFrequencyHz { get; }
/// <summary>
/// Góc quét tổng (Field of View - FOV) (radian) - tổng góc quét của thiết bị
/// </summary>
double FieldOfViewRad { get; }
/// <summary>
/// Hỗ trợ đo intensity (cường độ phản xạ) hay không
/// </summary>
bool SupportsIntensity { get; }
/// <summary>
/// Độ chính xác đo khoảng cách (mét) - sai số đo khoảng cách
/// </summary>
double? AccuracyM { get; }
// Events
/// <summary>
/// Event được kích hoạt khi có dữ liệu scan mới
/// </summary>
event EventHandler<LidarScanDataEventArgs>? ScanDataReceived;
}
/// <summary>
/// EventArgs cho event ScanDataReceived - common structure for all Lidar brands
/// </summary>
public class LidarScanDataEventArgs(DateTime timestamp, LaserScan measurementData) : EventArgs
{
/// <summary>
/// Timestamp khi scan data được nhận
/// </summary>
public DateTime Timestamp { get; init; } = timestamp;
/// <summary>
/// Measurement data từ scan (sử dụng LaserScan từ sensor_msgs)
/// </summary>
public LaserScan MeasurementData { get; init; } = measurementData;
}

View File

@@ -0,0 +1,178 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
namespace RobotNet10.RobotApp.Devices;
public enum ModbusRegisterType
{
Holding,
DiscreteInput,
Input,
Coil,
}
/// <summary>
/// Interface cho thiết bị ModbusTCP
/// </summary>
public interface IModbusTcpDevice
{
/// <summary>
/// IP Address của ModbusTCP server
/// </summary>
string IpAddress { get; }
/// <summary>
/// Port của ModbusTCP server (mặc định: 502)
/// </summary>
int Port { get; }
/// <summary>
/// Slave ID (Unit Identifier)
/// </summary>
byte SlaveId { get; }
/// <summary>
/// Trạng thái kết nối
/// </summary>
bool IsConnected { get; }
// Configuration Properties
event Action<ModbusRegisterType> DataRegisterChanged;
/// <summary>
/// Danh sách các vùng Holding Registers đã được cấu hình
/// </summary>
IReadOnlyList<ModbusRangeData> HoldingRegisterRanges { get; }
/// <summary>
/// Danh sách các vùng Input Registers đã được cấu hình
/// </summary>
IReadOnlyList<ModbusRangeData> InputRegisterRanges { get; }
/// <summary>
/// Danh sách các vùng Coils đã được cấu hình
/// </summary>
IReadOnlyList<ModbusRangeData> CoilRanges { get; }
/// <summary>
/// Danh sách các vùng Discrete Inputs đã được cấu hình
/// </summary>
IReadOnlyList<ModbusRangeData> DiscreteInputRanges { get; }
// Holding Registers Methods
/// <summary>
/// Đọc một holding register từ cache
/// </summary>
/// <param name="address">Địa chỉ register</param>
/// <returns>Giá trị register (16-bit unsigned)</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
ushort ReadHoldingRegister(ushort address);
/// <summary>
/// Đọc nhiều holding registers từ cache
/// </summary>
/// <param name="startAddress">Địa chỉ bắt đầu</param>
/// <param name="quantity">Số lượng registers cần đọc</param>
/// <returns>Mảng giá trị registers</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
ushort[] ReadHoldingRegisters(ushort startAddress, ushort quantity);
/// <summary>
/// Ghi một holding register vào cache (sẽ được đồng bộ bởi vòng lặp)
/// </summary>
/// <param name="address">Địa chỉ register</param>
/// <param name="value">Giá trị cần ghi</param>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
Task WriteHoldingRegisterAsync(ushort address, ushort value, CancellationToken cancellationToken = default);
/// <summary>
/// Ghi nhiều holding registers vào cache (sẽ được đồng bộ bởi vòng lặp)
/// </summary>
/// <param name="startAddress">Địa chỉ bắt đầu</param>
/// <param name="values">Mảng giá trị cần ghi</param>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
Task WriteHoldingRegistersAsync(ushort startAddress, ushort[] values, CancellationToken cancellationToken = default);
// Input Registers Methods
/// <summary>
/// Đọc một input register từ cache
/// </summary>
/// <param name="address">Địa chỉ register</param>
/// <returns>Giá trị register (16-bit unsigned)</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
ushort ReadInputRegister(ushort address);
/// <summary>
/// Đọc nhiều input registers từ cache
/// </summary>
/// <param name="startAddress">Địa chỉ bắt đầu</param>
/// <param name="quantity">Số lượng registers cần đọc</param>
/// <returns>Mảng giá trị registers</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
ushort[] ReadInputRegisters(ushort startAddress, ushort quantity);
// Coils Methods
/// <summary>
/// Đọc một coil từ cache
/// </summary>
/// <param name="address">Địa chỉ coil</param>
/// <returns>Giá trị coil (true/false)</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
bool ReadCoil(ushort address);
/// <summary>
/// Đọc nhiều coils từ cache
/// </summary>
/// <param name="startAddress">Địa chỉ bắt đầu</param>
/// <param name="quantity">Số lượng coils cần đọc</param>
/// <returns>Mảng giá trị coils</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
bool[] ReadCoils(ushort startAddress, ushort quantity);
/// <summary>
/// Ghi một coil vào cache (sẽ được đồng bộ bởi vòng lặp)
/// </summary>
/// <param name="address">Địa chỉ coil</param>
/// <param name="value">Giá trị cần ghi</param>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
Task WriteCoilAsync(ushort address, bool value, CancellationToken cancellationToken = default);
/// <summary>
/// Ghi nhiều coils vào cache (sẽ được đồng bộ bởi vòng lặp)
/// </summary>
/// <param name="startAddress">Địa chỉ bắt đầu</param>
/// <param name="values">Mảng giá trị cần ghi</param>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
Task WriteCoilsAsync(ushort startAddress, bool[] values, CancellationToken cancellationToken = default);
/// <summary>
/// Ghi một coil xuống PLC ngay lập tức (không qua queue). Dùng cho pulse (M815 alarm reset).
/// </summary>
/// <param name="address">Địa chỉ coil</param>
/// <param name="value">Giá trị cần ghi</param>
/// <param name="cancellationToken">Cancellation token</param>
Task WriteCoilImmediateAsync(ushort address, bool value, CancellationToken cancellationToken = default);
// Discrete Inputs Methods
/// <summary>
/// Đọc một discrete input từ cache
/// </summary>
/// <param name="address">Địa chỉ input</param>
/// <returns>Giá trị input (true/false)</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
bool ReadDiscreteInput(ushort address);
/// <summary>
/// Đọc nhiều discrete inputs từ cache
/// </summary>
/// <param name="startAddress">Địa chỉ bắt đầu</param>
/// <param name="quantity">Số lượng inputs cần đọc</param>
/// <returns>Mảng giá trị inputs</returns>
/// <exception cref="ArgumentException">Nếu địa chỉ không nằm trong vùng đã khai báo</exception>
bool[] ReadDiscreteInputs(ushort startAddress, ushort quantity);
}

View File

@@ -0,0 +1,51 @@
using RobotNet10.RobotApp.Interfaces;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Devices;
/// <summary>
/// Interface cho thiết bị RF Handle (Remote Control Handle) - tay điều khiển RF với joystick
/// </summary>
public interface IRfHandle
{
/// <summary>
/// Event được trigger khi dữ liệu được cập nhật
/// </summary>
event Action? Updated;
// ====================== STATES ===========================
DateTime LastUpdateTime { get; }
int Heartbeat { get; }
bool RemoteReady { get; }
bool EStop { get; }
bool LiftUp { get; }
bool LiftDown { get; }
bool RotateLeft { get; }
bool RotateRight { get; }
bool ModeSelect { get; }
bool Enable { get; }
int Speed { get; }
double Linear { get; }
double Angular { get; }
/// <summary>
/// Current RF Handle mode (Default, Maintenance, Override, None)
/// </summary>
RFMode Mode { get; }
/// <summary>
/// Joy state hiện tại được cache (thread-safe)
/// </summary>
Joy? CurrentJoyState { get; }
/// <summary>
/// Đọc joy state từ thiết bị
/// </summary>
Task<Joy> ReadJoyStateAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,442 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared.Geometry;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace RobotNet10.RobotApp.Drivers.Hik;
/// <summary>
/// Driver Hik cho Camera QR
/// </summary>
[Device(DeviceType.CameraQr, "Hik", "HikVisionQr", "1.0.0", Description = "Camera QR Code Detection Hik Driver")]
public class HikVisionQr : DeviceBase, ICameraQr
{
private readonly ILogger _logger;
private readonly Lock _dataLock = new();
private Thread? _receiveThread;
private CancellationTokenSource? _receiveCts;
private UdpClient? _udpClient;
private IPEndPoint? _localEndPoint;
private IPEndPoint? _remoteEndPoint;
// Camera parameters
private readonly int _cameraWidth;
private readonly int _cameraHeight;
private readonly double _distanceToQr;
private readonly double _fovRangeWidthMm;
private readonly double _fovRangeHeightMm;
private readonly double _fovRangeDistanceMm;
private readonly double _focalLengthPixels;
private readonly string _localIp;
private readonly int _localPort;
private readonly double _qrDataTimeoutSeconds;
// QR data dictionary (PoseStamped already contains timestamp in Header.Stamp)
private readonly Dictionary<string, PoseStamped> _qrDictionary = [];
// Data rate tracking
private int _packetCount = 0;
private double _currentDataRate = 0.0;
public Dictionary<string, PoseStamped> Codes
{
get
{
lock (_dataLock)
{
var validCodes = new Dictionary<string, PoseStamped>();
var now = DateTime.UtcNow;
var expiredKeys = new List<string>();
foreach (var (code, poseStamped) in _qrDictionary)
{
var elapsed = (now - poseStamped.Header.Stamp).TotalSeconds;
if (elapsed <= _qrDataTimeoutSeconds)
{
// Data is still valid
validCodes[code] = poseStamped;
}
else
{
// Data expired, mark for removal
expiredKeys.Add(code);
}
}
// Clean up expired entries
foreach (var key in expiredKeys)
{
_qrDictionary.Remove(key);
}
return validCodes;
}
}
}
public HikVisionQr(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider)
: base(deviceId, deviceName, DeviceType.CameraQr)
{
_logger = serviceProvider.GetRequiredService<ILoggerFactory>().CreateLogger<HikVisionQr>();
// Read configuration
var autoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled");
var reconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs");
var maxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts");
// Camera parameters
_cameraWidth = connection.GetValue<int?>("CameraWidth") ?? 1920;
_cameraHeight = connection.GetValue<int?>("CameraHeight") ?? 1080;
_distanceToQr = connection.GetValue<double?>("DistanceToQr") ?? 0.1;
_fovRangeWidthMm = connection.GetValue<double?>("FovRangeWidthMm") ?? 170.0;
_fovRangeHeightMm = connection.GetValue<double?>("FovRangeHeightMm") ?? 130.0;
_fovRangeDistanceMm = connection.GetValue<double?>("FovRangeDistanceMm") ?? 100.0;
_qrDataTimeoutSeconds = connection.GetValue<double?>("QrDataTimeoutSeconds") ?? 3.0;
// Compute focal length in pixels from FOV range (pinhole camera model)
// f = CameraWidth * FovRangeDistance / FovRangeWidth
_focalLengthPixels = _cameraWidth * _fovRangeDistanceMm / _fovRangeWidthMm;
// UDP parameters
_localIp = connection.GetValue<string>("LocalIP") ?? "192.168.254.100";
_localPort = connection.GetValue<int?>("LocalPort") ?? 1024;
if (autoReconnectEnabled.HasValue)
AutoReconnectEnabled = autoReconnectEnabled.Value;
if (reconnectDelayMs.HasValue)
ReconnectDelayMs = reconnectDelayMs.Value;
if (maxReconnectAttempts.HasValue)
MaxReconnectAttempts = maxReconnectAttempts.Value;
// Initialize properties
UpdateProperties();
}
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
yield return new PropertyDescription("DataRate", "Data Rate", "Tần số nhận dữ liệu (packets/s)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 1,
Category = "Trạng thái",
DefaultValue = "0"
};
}
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
{
await Task.CompletedTask;
}
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
{
return await Task.FromResult(_udpClient != null && _receiveThread != null && _receiveThread.IsAlive);
}
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
{
// Create local endpoint and UDP client
_localEndPoint = new IPEndPoint(IPAddress.Parse(_localIp), _localPort);
_udpClient = new UdpClient(_localEndPoint);
// Create cancellation token source for receive thread
_receiveCts = new CancellationTokenSource();
// Create and start high-priority thread for receiving data
_receiveThread = new Thread(() => ReceiveDataLoop(_receiveCts.Token))
{
IsBackground = true,
Priority = ThreadPriority.Highest,
Name = $"HikQr-{DeviceId}-ReceiveThread"
};
_receiveThread.Start();
await Task.CompletedTask;
}
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
{
// Stop receive thread
_receiveCts?.Cancel();
// Wait for thread to finish (with timeout)
if (_receiveThread != null && _receiveThread.IsAlive)
{
var joined = _receiveThread.Join(TimeSpan.FromSeconds(5));
if (!joined)
{
// Thread didn't finish gracefully
System.Diagnostics.Debug.WriteLine($"[HikQr] Receive thread did not finish in time");
}
}
_receiveCts?.Dispose();
_receiveCts = null;
_receiveThread = null;
// Close and dispose UDP client
_udpClient?.Close();
_udpClient?.Dispose();
_udpClient = null;
_localEndPoint = null;
_remoteEndPoint = null;
await Task.CompletedTask;
}
protected override async Task OnResetAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
// Clear QR dictionary
_qrDictionary.Clear();
_packetCount = 0;
_currentDataRate = 0.0;
UpdateProperties();
}
await Task.CompletedTask;
}
private void ReceiveDataLoop(CancellationToken cancellationToken)
{
Thread.BeginThreadAffinity();
try
{
var stopwatch = Stopwatch.StartNew();
var packetCountInSecond = 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
// Check if 1 second has elapsed
if (stopwatch.Elapsed.TotalSeconds >= 1.0)
{
lock (_dataLock)
{
_currentDataRate = packetCountInSecond / stopwatch.Elapsed.TotalSeconds;
UpdateProperties();
}
// Reset counter and stopwatch
packetCountInSecond = 0;
stopwatch.Restart();
}
// Check if UDP client is available
if (_udpClient == null)
break;
// Set receive timeout to allow checking cancellation token
_udpClient.Client.ReceiveTimeout = 100;
// Receive UDP packet
var receivedData = _udpClient.Receive(ref _remoteEndPoint);
if (receivedData.Length > 0)
{
packetCountInSecond++;
lock (_dataLock)
{
_packetCount++;
}
// Process received data
UpdateData(receivedData);
}
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut)
{
// Timeout is expected, continue loop
continue;
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
// Log error and continue
_logger.LogError(ex, "[HikVisionQr] ReceiveDataLoop error");
}
}
stopwatch.Stop();
}
finally
{
Thread.EndThreadAffinity();
}
}
private void UpdateData(byte[] receivedData)
{
lock (_dataLock)
{
if (receivedData.Length == 0)
{
// No data, clear dictionary
_qrDictionary.Clear();
}
else
{
try
{
// Extract message (40 bytes from position 37)
if (receivedData.Length < 77)
{
// Invalid packet size
return;
}
byte[] messageBytes = new byte[40];
Array.Copy(receivedData, 37, messageBytes, 0, 40);
string message = Encoding.UTF8.GetString(messageBytes);
// Extract QR data (9 bytes)
byte[] dataBytes = new byte[9];
Array.Copy(messageBytes, 0, dataBytes, 0, 9);
string qrCode = Encoding.UTF8.GetString(dataBytes);
// Extract X position (3 bytes from position 11)
byte[] xBytes = new byte[3];
Array.Copy(messageBytes, 11, xBytes, 0, 3);
int qrPositionX = int.Parse(Encoding.ASCII.GetString(xBytes));
// Extract Y position (3 bytes from position 19)
byte[] yBytes = new byte[3];
Array.Copy(messageBytes, 19, yBytes, 0, 3);
int qrPositionY = int.Parse(Encoding.ASCII.GetString(yBytes));
// Extract angle (between ')' and '@')
double qrAngle = 0;
int start = message.IndexOf(')') + 1;
int end = message.IndexOf('@', start);
if (start > 0 && end > start)
{
string numberAngle = message[start..end];
qrAngle = double.Parse(numberAngle);
}
// Calculate pose immediately
var pose = CreatePoseFromPixels(
qrPositionX,
qrPositionY,
qrAngle,
_cameraWidth,
_cameraHeight,
_distanceToQr,
_focalLengthPixels);
if (pose.HasValue)
{
// Console.WriteLine($"[HikQr] Detected QR Code: {qrCode}, X: {pose.Value.Pose.Position.X}, Y: {pose.Value.Pose.Position.Y}, Angle: {pose.Value.Pose.Orientation.ToYawDegrees()} degrees");
// Add or update dictionary (timestamp is in pose.Header.Stamp)
_qrDictionary[qrCode] = pose.Value;
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"[HikQr] UpdateData parsing error: {ex.Message}");
}
}
UpdateProperties();
}
}
private void UpdateProperties()
{
lock (_dataLock)
{
SetProperty("DataRate", _currentDataRate.ToString("F2"));
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_receiveCts?.Cancel();
_receiveCts?.Dispose();
_udpClient?.Close();
_udpClient?.Dispose();
}
base.Dispose(disposing);
}
#region ICameraQr Implementation
public PoseStamped? this[string code]
{
get
{
lock (_dataLock)
{
if (!_qrDictionary.TryGetValue(code, out var poseStamped))
return null;
// Check if data is still valid (within timeout)
var elapsed = (DateTime.UtcNow - poseStamped.Header.Stamp).TotalSeconds;
if (elapsed > _qrDataTimeoutSeconds)
{
// Data expired, remove from dictionary
_qrDictionary.Remove(code);
return null;
}
// Data is valid, return pose
return poseStamped;
}
}
}
private static PoseStamped? CreatePoseFromPixels(
int? px,
int? py,
double? angleDeg,
int cameraWidth,
int cameraHeight,
double distanceMeters,
double focalLengthPixels)
{
if (!px.HasValue || !py.HasValue || !angleDeg.HasValue)
return null;
// Convert pixel coordinates to meters using pinhole camera model
// Same focal length for both axes (square pixels: 4.8μm × 4.8μm)
double xMeters = (px.Value - cameraWidth / 2.0) * distanceMeters / focalLengthPixels;
double yMeters = -(py.Value - cameraHeight / 2.0) * distanceMeters / focalLengthPixels; // Invert Y axis
double zMeters = distanceMeters;
// Convert angle to quaternion
double angleRad = angleDeg.Value * Math.PI / 180.0;
var quat = RobotNet10.Shared.Numbers.Quaternion.FromYawRadian(angleRad);
// Create header
var header = new RobotNet10.Shared.Header
{
Seq = 0,
Stamp = DateTime.UtcNow,
FrameId = "camera"
};
// Create pose
var pose = new Pose
{
Position = new Point { X = xMeters, Y = yMeters, Z = zMeters },
Orientation = new Quaternion(quat.X, quat.Y, quat.Z, quat.W)
};
return new PoseStamped(header, pose);
}
#endregion
}

View File

@@ -0,0 +1,562 @@
using System.Diagnostics;
using Olei.LidarSensor;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Drivers.Olei;
/// <summary>
/// Configuration for Olei 2D LiDAR Driver
/// </summary>
public class Olei2dLidarDriverConfig
{
/// <summary>
/// UDP port to listen for LiDAR data
/// Default: 2368 (typical LiDAR port)
/// </summary>
public int UdpPort { get; set; } = 2368;
/// <summary>
/// Frame ID for the LiDAR sensor
/// Used in ROS-style message headers
/// </summary>
public string FrameId { get; set; } = "laser";
/// <summary>
/// Whether the LiDAR is mounted inverted (upside down).
/// When true, scan angles are mirrored (180° - angle) to correct orientation.
/// </summary>
public bool Inverted { get; set; } = false;
}
/// <summary>
/// Driver for Olei 2D LiDAR Sensor (LR-1F / LR-1BS)
/// Implements DeviceBase and ILidar interface
/// Protocol: UDP/IP v2.1
/// </summary>
[Device(DeviceType.Lidar, "Olei", "Olei2dLidarDriver", "1.0.0",
Description = "2D LiDAR Sensor Communication Data Protocol v2.1")]
public class Olei2dLidarDriver : DeviceBase, ILidar
{
private readonly Olei2dLidarDriverConfig _config = new();
private OleiLidarServer? _lidarServer;
// Cached measurements
private LaserScan? _currentMeasurementData;
private DateTime? _lastScanDataTimestamp;
// Frequency update tracking (for UI properties)
private readonly Stopwatch _frequencyUpdateStopwatch = Stopwatch.StartNew();
// Scan frequency calculation (actual LaserScan generation rate)
private readonly Stopwatch _scanFrequencyStopwatch = Stopwatch.StartNew();
private long _scansGeneratedInCurrentSecond = 0;
private readonly Lock _scanFrequencyLock = new();
// Calculated LiDAR specifications from actual data
private const double _minAngleRad = 0.0;
private const double _maxAngleRad = 2.0 * Math.PI;
private double _angularResolutionRad = 0.0; // Calculated from actual packet data
private const double DEFAULT_ACCURACY_M = 0.02; // 2 cm accuracy (fixed by hardware)
// Hardware range specifications (no filtering applied)
private const double HARDWARE_MIN_RANGE_M = 0.0;
private const double HARDWARE_MAX_RANGE_M = 30.0; // 30m max range for Olei LiDAR
// Packet accumulation for full scan (0° ~ 360°)
private readonly List<LidarDataBlock> _accumulatedBlocks = [];
private double _lastPacketStartAngle = -1.0;
private readonly Lock _accumulationLock = new();
private uint _scanSequenceNumber = 0;
private DateTime _currentScanStartTime = DateTime.UtcNow;
/// <summary>
/// Constructor with configuration
/// </summary>
public Olei2dLidarDriver(
string deviceId,
string deviceName, IConfigurationSection configuration)
: base(deviceId, deviceName, DeviceType.Lidar)
{
configuration.Bind(_config);
Description = "Olei 2D LiDAR Sensor (LR-1F/LR-1BS) - UDP Protocol v2.1";
}
#region ILidar Implementation
/// <summary>
/// Current measurement data (scan points)
/// </summary>
public LaserScan? CurrentMeasurementData => _currentMeasurementData;
/// <summary>
/// Timestamp of the most recent scan data
/// </summary>
public DateTime? LastScanDataTimestamp => _lastScanDataTimestamp;
/// <summary>
/// Minimum scan angle (radians)
/// Calculated from actual LiDAR data
/// </summary>
public double MinAngleRad => _minAngleRad;
/// <summary>
/// Maximum scan angle (radians)
/// Calculated from actual LiDAR data
/// </summary>
public double MaxAngleRad => _maxAngleRad;
/// <summary>
/// Minimum measurement range (meters)
/// Hardware specification (no filtering)
/// </summary>
public double MinRangeM => HARDWARE_MIN_RANGE_M;
/// <summary>
/// Maximum measurement range (meters)
/// Hardware specification (no filtering)
/// </summary>
public double MaxRangeM => HARDWARE_MAX_RANGE_M;
/// <summary>
/// Angular resolution (radians)
/// Calculated from actual LiDAR data
/// </summary>
public double? AngularResolutionRad => _angularResolutionRad;
/// <summary>
/// Scan frequency (Hz)
/// Typically 10-20 Hz for Olei LiDAR
/// </summary>
public double? ScanFrequencyHz { get; private set; }
/// <summary>
/// Field of View (radians)
/// </summary>
public double FieldOfViewRad => MaxAngleRad - MinAngleRad;
/// <summary>
/// Supports intensity measurements
/// </summary>
public bool SupportsIntensity => true;
/// <summary>
/// Measurement accuracy (meters)
/// </summary>
public double? AccuracyM => DEFAULT_ACCURACY_M;
/// <summary>
/// Event raised when new scan data is received
/// </summary>
public event EventHandler<LidarScanDataEventArgs>? ScanDataReceived;
#endregion
#region DeviceBase Implementation
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
{
await Task.Run(() =>
{
// Initialize LiDAR server
_lidarServer = new OleiLidarServer(_config.UdpPort);
// Subscribe to events
_lidarServer.DataReceived += OnLidarDataReceived;
_lidarServer.ErrorOccurred += OnLidarErrorOccurred;
SetProperty("UdpPort", _config.UdpPort.ToString());
SetProperty("FrameId", _config.FrameId);
SetProperty("MinRange", $"{HARDWARE_MIN_RANGE_M:F2} m");
SetProperty("MaxRange", $"{HARDWARE_MAX_RANGE_M:F2} m");
SetProperty("FOV", "N/A"); // Will be calculated from actual data
SetProperty("AngularResolution", "N/A"); // Will be calculated from actual data
}, cancellationToken);
}
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
{
if (_lidarServer == null)
throw new InvalidOperationException("LiDAR server not initialized. Call InitializeAsync first.");
// Start LiDAR server
_lidarServer.Start();
SetProperty("ServerStatus", "Running");
SetProperty("Statistics", _lidarServer.GetStatistics());
}
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
{
_lidarServer?.Stop();
SetProperty("ServerStatus", "Stopped");
SetProperty("Statistics", _lidarServer?.GetStatistics() ?? "N/A");
}
protected override async Task OnResetAsync(CancellationToken cancellationToken)
{
// Reset statistics
_lidarServer?.ResetStatistics();
// Reset scan frequency tracking
lock (_scanFrequencyLock)
{
Interlocked.Exchange(ref _scansGeneratedInCurrentSecond, 0);
_scanFrequencyStopwatch.Restart();
ScanFrequencyHz = null;
}
SetProperty("Statistics", _lidarServer?.GetStatistics() ?? "N/A");
SetProperty("ScanFrequency", "N/A");
}
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
{
await Task.Delay(500, cancellationToken);
if (_lidarServer == null || !_lidarServer.IsRunning)
return false;
lock (_accumulationLock)
{
return _lastScanDataTimestamp.HasValue;
}
}
protected override List<PropertyDescription> CreatePropertyDescriptions()
{
return
[
new PropertyDescription("UdpPort", "UDP Port", "UDP port for receiving LiDAR data"),
new PropertyDescription("FrameId", "Frame ID", "ROS-style frame identifier"),
new PropertyDescription("MinRange", "Min Range", "Minimum measurement range"),
new PropertyDescription("MaxRange", "Max Range", "Maximum measurement range"),
new PropertyDescription("FOV", "Field of View", "Total scanning angle"),
new PropertyDescription("AngularResolution", "Angular Resolution", "Angle between scan points"),
new PropertyDescription("ServerStatus", "Server Status", "UDP server status"),
new PropertyDescription("Statistics", "Statistics", "Server statistics"),
new PropertyDescription("LastScanTime", "Last Scan Time", "Timestamp of last scan"),
new PropertyDescription("ScanFrequency", "Scan Frequency", "Actual scan rate (Hz)"),
];
}
protected override void Dispose(bool disposing)
{
if (disposing && _lidarServer != null)
{
_lidarServer.DataReceived -= OnLidarDataReceived;
_lidarServer.ErrorOccurred -= OnLidarErrorOccurred;
_lidarServer.Dispose();
_lidarServer = null;
}
base.Dispose(disposing);
}
#endregion
#region Event Handlers
/// <summary>
/// Handle data received from LiDAR server
/// Accumulates packets until full 360° scan is complete
/// </summary>
private void OnLidarDataReceived(object? sender, LidarDataPacket e)
{
try
{
var packet = e;
// Validate packet
if (!packet.Header.IsValidFrame)
return;
// Get first valid block's angle to detect scan wrap
double packetStartAngle = -1.0;
for (int i = 0; i < LidarDataPacket.DATA_BLOCK_COUNT; i++)
{
if (packet.DataBlocks[i].IsValid)
{
packetStartAngle = packet.DataBlocks[i].GetAngleDegrees();
break;
}
}
if (packetStartAngle < 0)
return; // No valid blocks in packet
LaserScan? completedScan = null;
bool updateProperties = false;
lock (_accumulationLock)
{
// Detect scan completion: angle wrapped back to near 0° after being > 300°
if (_lastPacketStartAngle > 300.0 && packetStartAngle < 50.0 && _accumulatedBlocks.Count > 0)
{
var scan = CreateLaserScanFromAccumulated(packet.Header, _currentScanStartTime);
completedScan = scan;
_currentMeasurementData = scan;
_lastScanDataTimestamp = scan.Header.Stamp;
Interlocked.Increment(ref _scansGeneratedInCurrentSecond);
UpdateScanFrequency();
if (_frequencyUpdateStopwatch.ElapsedMilliseconds > 1000)
{
updateProperties = true;
_frequencyUpdateStopwatch.Restart();
}
_accumulatedBlocks.Clear();
_currentScanStartTime = DateTime.UtcNow;
}
else if (_accumulatedBlocks.Count == 0)
{
_currentScanStartTime = DateTime.UtcNow;
}
// Add current packet's blocks to accumulation buffer
for (int i = 0; i < LidarDataPacket.DATA_BLOCK_COUNT; i++)
{
var block = packet.DataBlocks[i];
if (block.IsValid)
{
double angleDeg = block.GetAngleDegrees();
if (angleDeg >= 360.0)
angleDeg %= 360.0;
_accumulatedBlocks.Add(block);
}
}
_lastPacketStartAngle = packetStartAngle;
}
// Fire event and update properties outside the lock to avoid blocking UDP reception
if (completedScan is { } publishedScan)
{
if (updateProperties)
{
SetProperty("LastScanTime", publishedScan.Header.Stamp.ToString("HH:mm:ss.fff"));
SetProperty("ScanFrequency", ScanFrequencyHz.HasValue ? $"{ScanFrequencyHz.Value:F2} Hz" : "N/A");
SetProperty("FOV", $"{FieldOfViewRad * 180 / Math.PI:F1}°");
SetProperty("AngularResolution", $"{_angularResolutionRad * 180 / Math.PI:F3}°");
}
ScanDataReceived?.Invoke(this, new LidarScanDataEventArgs(
publishedScan.Header.Stamp,
publishedScan
));
}
}
catch (Exception ex)
{
OnErrorOccurred(new Exception($"Error processing LiDAR data: {ex.Message}", ex));
}
}
/// <summary>
/// Handle errors from LiDAR server
/// </summary>
private void OnLidarErrorOccurred(object? sender, LidarErrorEventArgs e)
{
OnErrorOccurred(e.Exception ?? new Exception(e.Message));
}
#endregion
#region Helper Methods
/// <summary>
/// Update scan frequency based on LaserScan generation rate
/// Calculates how many scans are generated per second
/// </summary>
private void UpdateScanFrequency()
{
// Check if 1 second has elapsed
if (_scanFrequencyStopwatch.ElapsedMilliseconds >= 1000)
{
lock (_scanFrequencyLock)
{
// Double-check inside lock to prevent race condition
if (_scanFrequencyStopwatch.ElapsedMilliseconds >= 1000)
{
// Calculate frequency (scans per second)
long scanCount = Interlocked.Read(ref _scansGeneratedInCurrentSecond);
double elapsedSeconds = _scanFrequencyStopwatch.ElapsedMilliseconds / 1000.0;
ScanFrequencyHz = scanCount / elapsedSeconds;
// Reset for next measurement period
Interlocked.Exchange(ref _scansGeneratedInCurrentSecond, 0);
_scanFrequencyStopwatch.Restart();
}
}
}
}
/// <summary>
/// Create LaserScan from accumulated blocks (full 360° scan)
/// </summary>
private LaserScan CreateLaserScanFromAccumulated(LidarHeader lastHeader, DateTime scanStartTime)
{
var header = new Header(
seq: _scanSequenceNumber++,
stamp: scanStartTime,
frameId: _config.FrameId
);
// Get distance scale from last header
byte distanceScale = lastHeader.DistanceScale;
double distanceScaleToMeters = distanceScale / 1000.0;
// Calculate actual angle and range from accumulated data
double minAngleDeg = double.MaxValue;
double maxAngleDeg = double.MinValue;
double minDistanceM = double.MaxValue;
double maxDistanceM = double.MinValue;
// Create sorted list of angles to calculate angular resolution
var sortedAngles = new List<double>(_accumulatedBlocks.Count);
// First pass: Find actual min/max values from data and collect angles
foreach (var block in _accumulatedBlocks)
{
double angleDeg = block.GetAngleDegrees();
// Normalize angle to 0-360° range
if (angleDeg >= 360.0)
angleDeg %= 360.0;
minAngleDeg = Math.Min(minAngleDeg, angleDeg);
maxAngleDeg = Math.Max(maxAngleDeg, angleDeg);
sortedAngles.Add(angleDeg);
double distanceM = block.DistanceRaw * distanceScaleToMeters;
if (distanceM > 0) // Only consider valid distances
{
minDistanceM = Math.Min(minDistanceM, distanceM);
maxDistanceM = Math.Max(maxDistanceM, distanceM);
}
}
// Fallback to defaults if no valid data
if (minAngleDeg == double.MaxValue || maxAngleDeg == double.MinValue)
{
minAngleDeg = 0.0;
maxAngleDeg = 360.0;
}
if (minDistanceM == double.MaxValue || maxDistanceM == double.MinValue)
{
minDistanceM = HARDWARE_MIN_RANGE_M;
maxDistanceM = HARDWARE_MAX_RANGE_M;
}
// Calculate angular resolution from actual data
double angularResolutionDeg = 0.225; // Default fallback
if (sortedAngles.Count > 1)
{
sortedAngles.Sort();
// Find minimum difference between consecutive angles
double minAngleDiff = double.MaxValue;
for (int i = 1; i < sortedAngles.Count; i++)
{
double diff = sortedAngles[i] - sortedAngles[i - 1];
if (diff > 0.01) // Ignore very small differences (noise/duplicates)
{
minAngleDiff = Math.Min(minAngleDiff, diff);
}
}
if (minAngleDiff < double.MaxValue)
{
angularResolutionDeg = minAngleDiff;
}
}
// Update cached angular resolution for property reporting
_angularResolutionRad = angularResolutionDeg * Math.PI / 180.0;
// Convert angles to radians
double angleMinRad = minAngleDeg * Math.PI / 180.0;
double angleMaxRad = maxAngleDeg * Math.PI / 180.0;
// Calculate expected number of points based on calculated angular resolution
double angleSpanDeg = maxAngleDeg - minAngleDeg;
int expectedPoints = (int)Math.Ceiling(angleSpanDeg / angularResolutionDeg) + 1;
// Initialize arrays with expected size
double[] ranges = new double[expectedPoints];
double[] intensities = new double[expectedPoints];
// Initialize all to -1 (no data, JSON-safe)
Array.Fill(ranges, -1.0);
Array.Fill(intensities, 0.0);
// Second pass: Map accumulated blocks to array indices based on angle
int validCount = 0;
foreach (var block in _accumulatedBlocks)
{
double angleDeg = _config.Inverted
? (block.GetAngleDegrees() + 180.0) % 360.0
: block.GetAngleDegrees();
// Normalize angle to 0-360° range
if (angleDeg < 0)
angleDeg += 360.0;
else if (angleDeg >= 360.0)
angleDeg %= 360.0;
// Calculate array index from relative angle position using calculated angular resolution
double relativeAngle = angleDeg - minAngleDeg;
int index = (int)Math.Round(relativeAngle / angularResolutionDeg);
// Clamp index to valid range
if (index >= 0 && index < expectedPoints)
{
double distanceM = block.DistanceRaw * distanceScaleToMeters;
if (distanceM <= 0 || distanceM >= HARDWARE_MAX_RANGE_M)
{
ranges[index] = -1.0; // No detection / max-range return
intensities[index] = 0.0;
continue;
}
// Store all distance values (no range filtering)
ranges[index] = distanceM;
intensities[index] = block.SignalStrength;
validCount++;
}
}
// Calculate angle increment from actual data
double angleIncrementRad = expectedPoints > 1
? (angleMaxRad - angleMinRad) / (expectedPoints - 1)
: angularResolutionDeg * Math.PI / 180.0; // Use calculated resolution
// Calculate scan timing (estimate based on rotation rate if available)
double scanTime = ScanFrequencyHz.HasValue && ScanFrequencyHz.Value > 0
? 1.0 / ScanFrequencyHz.Value
: 0.1; // Default 10 Hz
double timeIncrement = scanTime / expectedPoints;
return new LaserScan
{
Header = header,
AngleMin = angleMinRad, // From actual data
AngleMax = angleMaxRad, // From actual data
AngleIncrement = angleIncrementRad, // Calculated from data
TimeIncrement = timeIncrement,
ScanTime = scanTime,
RangeMin = HARDWARE_MIN_RANGE_M,
RangeMax = HARDWARE_MAX_RANGE_M,
Ranges = ranges,
Intensities = intensities
};
}
#endregion
}

View File

@@ -0,0 +1,919 @@
using System.Buffers.Binary;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Drivers.Lidar;
/// <summary>
/// UDP connection for Olei GS1-5 (V3 protocol). Packet size 1136 bytes, not 1240.
/// </summary>
internal sealed class OleiGS15UdpConnection
{
public const int ExpectedPacketSize = 1136;
private readonly Queue<Gs15Packet> _packetQueue = new();
private readonly object _packetLock = new();
public string device_IP { get; set; } = "192.168.110.22";
public int device_port { get; set; } = 2468;
public string local_ip { get; set; } = "192.168.110.114";
public UdpClient? udp;
public IPEndPoint check_ip_device = new(IPAddress.Any, 0);
public bool openPort()
{
try
{
if (!IpExists(local_ip))
{
Console.WriteLine($"[ERROR] OleiGS15: local_ip {local_ip} does NOT exist on any NIC.");
return false;
}
if (IsPortBusy(device_port))
{
Console.WriteLine($"[ERROR] OleiGS15: Port {device_port} is already in use!");
return false;
}
udp = new UdpClient(new IPEndPoint(IPAddress.Parse(local_ip), device_port));
check_ip_device = new IPEndPoint(IPAddress.Parse(local_ip), device_port);
udp.Client.ReceiveTimeout = 1000;
Console.WriteLine("[INFO] OleiGS15 UDP port opened.");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] OleiGS15: Cannot open UDP {local_ip}:{device_port} - {ex.Message}");
return false;
}
}
public void readRawdata()
{
if (udp == null) return;
try
{
byte[] data = udp.Receive(ref check_ip_device);
if (check_ip_device.Address == null || check_ip_device.Address.Equals(IPAddress.Any) || check_ip_device.Address.Equals(IPAddress.None) || check_ip_device.Port == 0)
return;
if (check_ip_device.Address.ToString() != device_IP)
return;
if (data.Length != ExpectedPacketSize)
return;
var packet = new Gs15Packet { stamp = DateTime.UtcNow, data = data };
lock (_packetLock)
{
_packetQueue.Enqueue(packet);
}
}
catch (SocketException ex)
{
if (ex.SocketErrorCode != SocketError.TimedOut)
Console.WriteLine($"[ERROR] OleiGS15 socket: {ex.Message}");
}
catch (Exception) { }
}
public Gs15Packet? TryDequeuePacket()
{
lock (_packetLock)
{
if (_packetQueue.Count == 0) return null;
return _packetQueue.Dequeue();
}
}
private static bool IpExists(string ip)
{
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
{
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
{
if (ua.Address.ToString() == ip) return true;
}
}
return false;
}
private static bool IsPortBusy(int port)
{
var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners();
return listeners.Any(ep => ep.Port == port);
}
}
internal sealed class Gs15Packet
{
public DateTime stamp;
public byte[] data = Array.Empty<byte>();
}
public sealed class OleiGS15DriverConfig
{
public int Version { get; set; } = 3;
public string ScannerIp { get; set; } = "192.168.110.22";
public string LocalIp { get; set; } = "192.168.110.114";
public int Port { get; set; } = 2468;
public string Transport { get; set; } = "udp";
public string FrameId { get; set; } = "olelidar";
public string ScanTopic { get; set; } = "scan";
public bool Inverted { get; set; } = false;
public bool TimeFromLidar { get; set; } = true;
public bool Ntp { get; set; } = false;
public string PacketType { get; set; } = "B";
public double RangeMin { get; set; } = 0.08;
public double RangeMax { get; set; } = 50.0;
public bool AutoReconnectEnabled { get; set; } = true;
public int ReconnectDelayMs { get; set; } = 3000;
public int MaxReconnectAttempts { get; set; } = 0;
public static OleiGS15DriverConfig Parse(IConfigurationSection connection)
{
var cfg = new OleiGS15DriverConfig
{
Version = connection.GetValue<int?>("Version")
?? connection.GetValue<int?>("version")
?? 3,
ScannerIp = connection["ScannerIp"]
?? connection["scanner_ip"]
?? connection["DeviceIp"]
?? "192.168.110.22",
LocalIp = connection["LocalIp"]
?? connection["local_ip"]
?? "192.168.110.114",
Transport = connection["Transport"]
?? connection["transport"]
?? "udp",
FrameId = connection["FrameId"]
?? connection["frame_id"]
?? "olelidar",
ScanTopic = connection["ScanTopic"]
?? connection["scan_topic"]
?? "scan",
PacketType = connection["PacketType"]
?? connection["packet_type"]
?? "B",
Inverted = connection.GetValue<bool?>("Inverted")
?? connection.GetValue<bool?>("inverted")
?? false,
TimeFromLidar = connection.GetValue<bool?>("TimeFromLidar")
?? connection.GetValue<bool?>("timeFromLidar")
?? true,
Ntp = connection.GetValue<bool?>("Ntp")
?? connection.GetValue<bool?>("ntp")
?? false,
RangeMin = connection.GetValue<double?>("RangeMin")
?? connection.GetValue<double?>("range_min")
?? 0.08,
RangeMax = connection.GetValue<double?>("RangeMax")
?? connection.GetValue<double?>("range_max")
?? 50.0,
AutoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled") ?? true,
ReconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs") ?? 3000,
MaxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts") ?? 0
};
var port = connection.GetValue<int?>("Port")
?? connection.GetValue<int?>("port")
?? connection.GetValue<int?>("DevicePort")
?? 2368;
if (port is < 1 or > 65535)
{
throw new InvalidOperationException($"Invalid Port: {port}. Must be between 1 and 65535");
}
cfg.Port = port;
if (!string.Equals(cfg.Transport, "udp", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("OleiGS15Driver currently supports transport=udp only");
}
if (cfg.RangeMin < 0)
{
throw new InvalidOperationException($"RangeMin must be >= 0. Current value: {cfg.RangeMin}");
}
if (cfg.RangeMax <= cfg.RangeMin)
{
throw new InvalidOperationException(
$"RangeMax must be greater than RangeMin. RangeMin={cfg.RangeMin}, RangeMax={cfg.RangeMax}");
}
return cfg;
}
}
[Device(DeviceType.Lidar, "Olei", "OleiGS15Driver", "1.0.0",
Description = "Olei GS1-5 LiDAR driver (ROS version3-compatible UDP parser)")]
public class OleiGS15Driver : DeviceBase, ILidar
{
private const int V3HeaderSize = 48;
private const ushort V3Magic = 0xFEAC;
private readonly OleiGS15DriverConfig _config;
private readonly OleiGS15UdpConnection _connection = new();
private readonly Lock _dataLock = new();
private CancellationTokenSource? _updateCts;
private Task? _updateTask;
private LaserScan? _lastLaserScan;
private DateTime? _lastScanDataTimestamp;
private int _lastPointCount;
private double? _lastScanFrequencyHz;
private bool _portOpened;
private V3ScanAccumulator? _scanAccumulator;
private ushort? _lastFirstIndex;
public OleiGS15Driver(string deviceId, string deviceName, IConfigurationSection connection)
: base(deviceId, deviceName, DeviceType.Lidar)
{
_config = OleiGS15DriverConfig.Parse(connection);
_connection.device_IP = _config.ScannerIp;
_connection.local_ip = _config.LocalIp;
_connection.device_port = _config.Port;
AutoReconnectEnabled = _config.AutoReconnectEnabled;
ReconnectDelayMs = _config.ReconnectDelayMs;
MaxReconnectAttempts = _config.MaxReconnectAttempts;
UpdateProperties();
}
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
yield return new PropertyDescription("Version", "Version", "Phiên bản protocol")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 1,
Category = "Cấu hình",
DefaultValue = "3"
};
yield return new PropertyDescription("PacketType", "Packet Type", "Loại packet A/B/C")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 2,
Category = "Cấu hình",
DefaultValue = "B"
};
yield return new PropertyDescription("ScannerIp", "Scanner IP", "IP của Olei GS1-5")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 3,
Category = "Kết nối",
DefaultValue = ""
};
yield return new PropertyDescription("LocalIp", "Local IP", "IP local nhận UDP packet")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 4,
Category = "Kết nối",
DefaultValue = ""
};
yield return new PropertyDescription("Port", "Port", "UDP port của lidar")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 5,
Category = "Kết nối",
DefaultValue = "2368"
};
yield return new PropertyDescription("FrameId", "Frame ID", "Frame ID của LaserScan")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 6,
Category = "Cấu hình",
DefaultValue = "olelidar"
};
yield return new PropertyDescription("RangeMin", "Range Min (m)", "Khoảng cách tối thiểu")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 7,
Category = "Cấu hình",
DefaultValue = "0.08"
};
yield return new PropertyDescription("RangeMax", "Range Max (m)", "Khoảng cách tối đa")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 8,
Category = "Cấu hình",
DefaultValue = "50.0"
};
yield return new PropertyDescription("ConnectionStatus", "Connection Status", "Trạng thái kết nối lidar")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 9,
Category = "Trạng thái",
DefaultValue = "Disconnected"
};
yield return new PropertyDescription("PointCount", "Point Count", "Số điểm scan gần nhất")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 10,
Category = "Trạng thái",
DefaultValue = "0"
};
yield return new PropertyDescription("LastScanTime", "Last Scan Time", "Thời gian scan gần nhất")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 11,
Category = "Trạng thái",
DefaultValue = ""
};
}
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
_portOpened = _connection.openPort();
if (!_portOpened)
{
throw new InvalidOperationException(
$"Failed to open Olei GS1-5 UDP port at {_config.LocalIp}:{_config.Port}");
}
}
await Task.CompletedTask;
}
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
{
StartUpdateLoop();
SetProperty("ConnectionStatus", "Connected");
await Task.CompletedTask;
}
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
{
StopUpdateLoop();
CloseUdpPort();
SetProperty("ConnectionStatus", "Disconnected");
await Task.CompletedTask;
}
protected override async Task OnResetAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
_lastLaserScan = null;
_lastScanDataTimestamp = null;
_lastPointCount = 0;
_lastScanFrequencyHz = null;
_scanAccumulator = null;
_lastFirstIndex = null;
}
UpdateProperties();
await Task.CompletedTask;
}
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
if (!_portOpened)
{
return false;
}
if (_updateTask == null || _updateTask.IsCompleted)
{
return false;
}
if (_lastScanDataTimestamp == null)
{
return true;
}
return (DateTime.UtcNow - _lastScanDataTimestamp.Value).TotalSeconds < 5.0;
}
}
private void StartUpdateLoop()
{
lock (_dataLock)
{
if (_updateTask != null && !_updateTask.IsCompleted)
{
return;
}
_updateCts = new CancellationTokenSource();
_updateTask = Task.Run(() => UpdateLoopAsync(_updateCts.Token), _updateCts.Token);
}
}
private void StopUpdateLoop()
{
Task? updateTask;
CancellationTokenSource? cts;
lock (_dataLock)
{
updateTask = _updateTask;
cts = _updateCts;
_updateTask = null;
_updateCts = null;
}
try
{
cts?.Cancel();
updateTask?.Wait(TimeSpan.FromSeconds(2));
}
catch
{
}
finally
{
cts?.Dispose();
}
}
private async Task UpdateLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
_connection.readRawdata();
var processedAnyPacket = false;
while (TryProcessOnePacket())
{
processedAnyPacket = true;
}
if (!processedAnyPacket)
{
await Task.Delay(5, cancellationToken);
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, "OleiGS15 update loop error");
await Task.Delay(100, cancellationToken);
}
}
}
private bool TryProcessOnePacket()
{
var packet = _connection.TryDequeuePacket();
if (packet == null)
{
return false;
}
if (!TryParseV3Packet(packet.data, out var header, out var rangesMm, out var intensities, out var hasIntensity))
{
return true;
}
var packetTimestamp = _config.TimeFromLidar
? (packet.stamp.Kind == DateTimeKind.Utc ? packet.stamp : packet.stamp.ToUniversalTime())
: DateTime.UtcNow;
V3ScanAccumulator? completedScan = null;
lock (_dataLock)
{
if (_lastFirstIndex.HasValue && _lastFirstIndex.Value > header.FirstIndex)
{
_scanAccumulator = null;
}
_lastFirstIndex = header.FirstIndex;
if (_scanAccumulator == null)
{
_scanAccumulator = new V3ScanAccumulator(header, _config);
}
_scanAccumulator.AddPacketData(rangesMm, intensities, hasIntensity, _config);
if (_scanAccumulator.IsCompleted)
{
completedScan = _scanAccumulator;
_scanAccumulator = null;
}
}
if (completedScan != null)
{
PublishCompletedScan(completedScan, packetTimestamp);
}
return true;
}
private void PublishCompletedScan(V3ScanAccumulator completed, DateTime packetTimestamp)
{
var scanTime = completed.ScanTimeSeconds > 0 ? completed.ScanTimeSeconds : 0.1f;
var scanStamp = packetTimestamp - TimeSpan.FromSeconds(scanTime);
var laserScan = new LaserScan
{
Header = new RobotNet10.Shared.Header
{
Seq = 0,
Stamp = scanStamp,
FrameId = _config.FrameId
},
AngleMin = completed.AngleMin,
AngleMax = completed.AngleMax,
AngleIncrement = completed.AngleIncrement,
TimeIncrement = completed.TimeIncrement,
ScanTime = scanTime,
RangeMin = _config.RangeMin,
RangeMax = _config.RangeMax,
Ranges = Array.ConvertAll(completed.Ranges, x => (double)x),
Intensities = Array.ConvertAll(completed.Intensities, x => (double)x)
};
lock (_dataLock)
{
_lastLaserScan = laserScan;
_lastScanDataTimestamp = scanStamp;
_lastPointCount = completed.Ranges.Length;
_lastScanFrequencyHz = completed.ScanFrequencyHz > 0 ? completed.ScanFrequencyHz : null;
}
ScanDataReceived?.Invoke(this, new LidarScanDataEventArgs(scanStamp, laserScan));
UpdateProperties();
}
private static bool TryParseV3Packet(
byte[] data,
out V3HeaderLite header,
out ushort[] rangesMm,
out ushort[] intensities,
out bool hasIntensity)
{
header = default;
rangesMm = Array.Empty<ushort>();
intensities = Array.Empty<ushort>();
hasIntensity = false;
if (data.Length < V3HeaderSize)
{
return false;
}
var magic = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(0, 2));
if (magic != V3Magic)
{
return false;
}
header = new V3HeaderLite
{
Magic = magic,
Version = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(2, 2)),
PacketSize = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(4, 4)),
HeaderSize = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(8, 2)),
DistanceRatio = data[10],
Types = data[11],
ScanNumber = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(12, 2)),
PacketNumber = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(14, 2)),
TimestampDecimal = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(16, 4)),
TimestampInteger = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(20, 4)),
ScanFrequencyRaw = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(24, 2)),
NumPointsScan = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(26, 2)),
InputStatus = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(28, 2)),
OutputStatus = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(30, 2)),
FieldStatus = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(32, 4)),
StartIndex = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(36, 2)),
EndIndex = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(38, 2)),
FirstIndex = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(40, 2)),
NumPointsPacket = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(42, 2)),
StatusFlags = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(44, 4))
};
var packetSize = header.PacketSize == 0 ? data.Length : (int)Math.Min(header.PacketSize, data.Length);
var headerSize = header.HeaderSize == 0 ? V3HeaderSize : header.HeaderSize;
if (headerSize < V3HeaderSize || headerSize > packetSize)
{
return false;
}
var bytesPerPoint = header.Types switch
{
0x00 => 2,
0x01 => 4,
0x10 => 4,
_ => 0
};
if (bytesPerPoint == 0)
{
return false;
}
var payloadBytes = packetSize - headerSize;
var pointCount = header.NumPointsPacket;
if (pointCount == 0 || pointCount * bytesPerPoint > payloadBytes)
{
pointCount = (ushort)(payloadBytes / bytesPerPoint);
}
if (pointCount == 0)
{
return false;
}
rangesMm = new ushort[pointCount];
if (header.Types == 0x01)
{
hasIntensity = true;
intensities = new ushort[pointCount];
}
var offset = headerSize;
for (int i = 0; i < pointCount; i++)
{
switch (header.Types)
{
case 0x00:
rangesMm[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset, 2));
offset += 2;
break;
case 0x01:
rangesMm[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset, 2));
intensities[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset + 2, 2));
offset += 4;
break;
case 0x10:
rangesMm[i] = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(offset + 2, 2));
offset += 4;
break;
}
}
return true;
}
private void UpdateProperties()
{
DateTime? timestamp;
int pointCount;
lock (_dataLock)
{
timestamp = _lastScanDataTimestamp;
pointCount = _lastPointCount;
}
SetProperty("Version", _config.Version.ToString());
SetProperty("PacketType", _config.PacketType);
SetProperty("ScannerIp", _config.ScannerIp);
SetProperty("LocalIp", _config.LocalIp);
SetProperty("Port", _config.Port.ToString());
SetProperty("FrameId", _config.FrameId);
SetProperty("RangeMin", _config.RangeMin.ToString("F3"));
SetProperty("RangeMax", _config.RangeMax.ToString("F3"));
SetProperty("PointCount", pointCount.ToString());
SetProperty("LastScanTime", timestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "");
}
private void CloseUdpPort()
{
lock (_dataLock)
{
_portOpened = false;
try
{
_connection.udp?.Close();
}
catch
{
}
try
{
_connection.udp?.Dispose();
}
catch
{
}
}
}
public LaserScan? CurrentMeasurementData
{
get
{
lock (_dataLock)
{
return _lastLaserScan;
}
}
}
public DateTime? LastScanDataTimestamp
{
get
{
lock (_dataLock)
{
return _lastScanDataTimestamp;
}
}
}
public double MinAngleRad
{
get
{
lock (_dataLock)
{
return _lastLaserScan?.AngleMin ?? -Math.PI;
}
}
}
public double MaxAngleRad
{
get
{
lock (_dataLock)
{
return _lastLaserScan?.AngleMax ?? Math.PI;
}
}
}
public double MinRangeM => _config.RangeMin;
public double MaxRangeM => _config.RangeMax;
public double? AngularResolutionRad
{
get
{
lock (_dataLock)
{
var scan = _lastLaserScan;
if (scan?.Ranges == null || scan.Value.Ranges.Length < 2)
{
return null;
}
return scan.Value.AngleIncrement;
}
}
}
public double? ScanFrequencyHz
{
get
{
lock (_dataLock)
{
return _lastScanFrequencyHz;
}
}
}
public double FieldOfViewRad => Math.Abs(MaxAngleRad - MinAngleRad);
public bool SupportsIntensity => true;
public double? AccuracyM => null;
public event EventHandler<LidarScanDataEventArgs>? ScanDataReceived;
protected override void Dispose(bool disposing)
{
if (disposing)
{
StopUpdateLoop();
CloseUdpPort();
}
base.Dispose(disposing);
}
private readonly struct V3HeaderLite
{
public ushort Magic { get; init; }
public ushort Version { get; init; }
public uint PacketSize { get; init; }
public ushort HeaderSize { get; init; }
public byte DistanceRatio { get; init; }
public byte Types { get; init; }
public ushort ScanNumber { get; init; }
public ushort PacketNumber { get; init; }
public uint TimestampDecimal { get; init; }
public uint TimestampInteger { get; init; }
public ushort ScanFrequencyRaw { get; init; }
public ushort NumPointsScan { get; init; }
public ushort InputStatus { get; init; }
public ushort OutputStatus { get; init; }
public uint FieldStatus { get; init; }
public ushort StartIndex { get; init; }
public ushort EndIndex { get; init; }
public ushort FirstIndex { get; init; }
public ushort NumPointsPacket { get; init; }
public uint StatusFlags { get; init; }
}
private sealed class V3ScanAccumulator
{
private int _writeIndex;
public float[] Ranges { get; }
public float[] Intensities { get; }
public bool IsCompleted => _writeIndex >= Ranges.Length;
public float AngleMin { get; }
public float AngleMax { get; }
public float AngleIncrement { get; }
public float TimeIncrement { get; }
public float ScanTimeSeconds { get; }
public double ScanFrequencyHz { get; }
public V3ScanAccumulator(V3HeaderLite header, OleiGS15DriverConfig config)
{
var actualNum = Math.Max(1, (int)(header.EndIndex - header.StartIndex));
var pointsPerScan = Math.Max(1, (int)header.NumPointsScan);
Ranges = new float[actualNum];
for (int i = 0; i < Ranges.Length; i++)
{
Ranges[i] = 0.0f;
}
Intensities = new float[actualNum];
AngleIncrement = (float)((360.0 / pointsPerScan) * Math.PI / 180.0);
AngleMin = (float)(header.StartIndex * AngleIncrement - Math.PI);
AngleMax = (float)(header.EndIndex * AngleIncrement - Math.PI);
var rpm = header.ScanFrequencyRaw & 0x7FFF;
ScanFrequencyHz = rpm > 0 ? rpm / 60.0 : 0.0;
ScanTimeSeconds = rpm > 0 ? (float)(60.0 / rpm) : 0.1f;
TimeIncrement = ScanTimeSeconds / pointsPerScan;
}
public void AddPacketData(ushort[] rangesMm, ushort[] intensities, bool hasIntensity, OleiGS15DriverConfig config)
{
for (int i = 0; i < rangesMm.Length && _writeIndex < Ranges.Length; i++)
{
var rangeM = rangesMm[i] / 1000.0f;
if (rangeM < config.RangeMin || rangeM > config.RangeMax || rangeM <= 0)
{
Ranges[_writeIndex] = 0.0f;
}
else
{
Ranges[_writeIndex] = rangeM;
}
if (hasIntensity && i < intensities.Length)
{
Intensities[_writeIndex] = intensities[i];
}
else
{
Intensities[_writeIndex] = 0.0f;
}
_writeIndex++;
}
}
}
}

View File

@@ -0,0 +1,390 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Drivers.Lidar;
/// <summary>
/// Olei 2D LiDAR driver (LR-1BS5 / olelidar ROS protocol, 1240-byte UDP packets).
/// </summary>
[Device(DeviceType.Lidar, "Olei", "OleiLidarDriver", "1.0.0")]
public class OleiLidarDriver : DeviceBase, ILidar
{
private readonly Connect _connect = new();
private readonly DecodeLidar _decode = new();
private readonly double _minRangeM;
private readonly double _maxRangeM;
private readonly double? _angularResolutionRad;
private readonly double? _scanFrequencyHz;
private readonly bool _supportsIntensity;
private readonly double? _accuracyM;
private readonly double _startAngleRad;
private readonly double _endAngleRad;
private readonly string _frameId;
private readonly int _angleMinHundredths;
private readonly int _angleMaxHundredths;
private readonly int _poly;
private DateTime? _lastScanDataTimestamp;
private LaserScan? _lastLaserScan;
private bool _portOpened;
private readonly Lock _dataLock = new();
private CancellationTokenSource? _updateCts;
private Task? _updateTask;
private uint _scanSequence;
public event EventHandler<LidarScanDataEventArgs>? ScanDataReceived;
public OleiLidarDriver(string deviceId, string deviceName, IConfigurationSection connection)
: base(deviceId, deviceName, DeviceType.Lidar)
{
_connect.device_IP = connection.GetValue<string>("DeviceIp") ?? "192.168.254.13";
_connect.device_port = connection.GetValue<int?>("DevicePort")
?? connection.GetValue<int?>("UdpPort")
?? 2368;
_connect.local_ip = connection.GetValue<string>("LocalIp") ?? string.Empty;
_connect.multiaddr_ip = connection.GetValue<string>("Multiaddr") ?? string.Empty;
var minAngleDeg = connection.GetValue<double?>("MinAngle") ?? -135.0;
var maxAngleDeg = connection.GetValue<double?>("MaxAngle") ?? 135.0;
_startAngleRad = minAngleDeg * Math.PI / 180.0;
_endAngleRad = maxAngleDeg * Math.PI / 180.0;
_angleMinHundredths = (int)(minAngleDeg * 100 + 18000);
_angleMaxHundredths = (int)(maxAngleDeg * 100 + 18000);
_minRangeM = connection.GetValue<double?>("MinRangeM") ?? 0.2;
_maxRangeM = connection.GetValue<double?>("MaxRangeM") ?? 50.0;
_scanFrequencyHz = connection.GetValue<double?>("ScanFrequencyHz");
_supportsIntensity = connection.GetValue<bool?>("SupportsIntensity") ?? true;
_accuracyM = connection.GetValue<double?>("AccuracyM") ?? 0.02;
_frameId = connection.GetValue<string>("FrameId") ?? "olei_lidar_frame";
_poly = connection.GetValue<int?>("Poly") ?? 1;
var stepDeg = connection.GetValue<double?>("StepDeg") ?? 0.225;
_angularResolutionRad = stepDeg * Math.PI / 180.0;
var decoderConfig = new DecoderConfig
{
AngleMin = minAngleDeg,
AngleMax = maxAngleDeg,
RangeMin = _minRangeM,
RangeMax = _maxRangeM,
Poly = _poly,
Inverted = connection.GetValue<bool?>("Inverted") ?? false,
StepDeg = stepDeg,
FrameId = _frameId
};
_decode.SetConfig(decoderConfig);
if (connection.GetValue<bool?>("AutoReconnectEnabled") is bool autoReconnect)
AutoReconnectEnabled = autoReconnect;
else
AutoReconnectEnabled = true;
if (connection.GetValue<int?>("ReconnectDelayMs") is int reconnectDelay)
ReconnectDelayMs = reconnectDelay;
if (connection.GetValue<int?>("MaxReconnectAttempts") is int maxAttempts)
MaxReconnectAttempts = maxAttempts;
UpdateProperties();
}
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
yield return new PropertyDescription("NumberOfPoints", "Number of Points", "Số điểm scan")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 1,
Category = "Cấu hình",
DefaultValue = "0"
};
yield return new PropertyDescription("StartAngle", "Start Angle (deg)", "Góc bắt đầu (độ)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 2,
Category = "Cấu hình",
DefaultValue = (_startAngleRad * 180.0 / Math.PI).ToString("F1")
};
yield return new PropertyDescription("EndAngle", "End Angle (deg)", "Góc kết thúc (độ)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 3,
Category = "Cấu hình",
DefaultValue = (_endAngleRad * 180.0 / Math.PI).ToString("F1")
};
yield return new PropertyDescription("MaxRange", "Max Range (m)", "Tầm quét tối đa (m)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 4,
Category = "Cấu hình",
DefaultValue = _maxRangeM.ToString("F1")
};
yield return new PropertyDescription("LastScanTime", "Last Scan Time", "Thời gian scan cuối cùng")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 5,
Category = "Trạng thái",
DefaultValue = ""
};
}
protected override Task OnInitializeAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
_portOpened = _connect.openPort();
if (!_portOpened)
throw new InvalidOperationException(
$"Failed to open Olei UDP port {_connect.device_port} on local {_connect.local_ip}");
}
return Task.CompletedTask;
}
protected override Task OnConnectAsync(CancellationToken cancellationToken)
{
StartUpdateLoop();
return Task.CompletedTask;
}
protected override Task OnDisconnectAsync(CancellationToken cancellationToken)
{
StopUpdateLoop();
return Task.CompletedTask;
}
protected override Task OnResetAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
_lastLaserScan = null;
_lastScanDataTimestamp = null;
_scanSequence = 0;
}
UpdateProperties();
return Task.CompletedTask;
}
protected override async Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
{
if (!_portOpened || !_connect.IsOpen)
return false;
// LiDAR may need a few seconds after motor start before UDP data arrives.
for (var i = 0; i < 40; i++)
{
if (_lastScanDataTimestamp.HasValue)
return true;
if (_connect.TotalPacketsReceived > 0 || _connect.GetPacketCount() > 0)
return true;
await Task.Delay(250, cancellationToken);
}
Console.WriteLine(
$"[WARN] Olei LiDAR connection check failed: packets={_connect.TotalPacketsReceived}, " +
$"queued={_connect.GetPacketCount()}, wrongSrc={_connect.DroppedWrongSource}, " +
$"wrongSize={_connect.DroppedWrongSize}, device={_connect.device_IP}:{_connect.device_port}");
return false;
}
private void StartUpdateLoop()
{
lock (_dataLock)
{
if (_updateCts != null)
return;
_updateCts = new CancellationTokenSource();
_updateTask = Task.Run(() => UpdateLoopAsync(_updateCts.Token));
}
}
private void StopUpdateLoop()
{
CancellationTokenSource? cts;
lock (_dataLock)
{
cts = _updateCts;
_updateCts = null;
_updateTask = null;
}
cts?.Cancel();
cts?.Dispose();
}
private async Task UpdateLoopAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
var framePublished = false;
while (_connect.TryDequeuePacket() is { } pkt)
{
if (_decode.PacketCb(pkt))
framePublished |= TryPublishScan(pkt.stamp);
}
if (!framePublished)
await Task.Delay(1, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, "Olei LiDAR update loop error");
await Task.Delay(500, cancellationToken);
}
}
}
private bool TryPublishScan(DateTime packetReceiveStamp)
{
var angles = _decode.scanAngleInVec;
var rangesMm = _decode.scanRangeInVec;
var intensitiesRaw = _decode.scanIntensityInVec;
if (angles.Count == 0 ||
angles.Count != rangesMm.Count ||
angles.Count != intensitiesRaw.Count)
return false;
var rangeList = new List<double>();
var intensityList = new List<double>();
var angleRadList = new List<double>();
for (var i = 0; i < angles.Count; i++)
{
if (i % _poly != 0)
continue;
var angleHundredths = angles[i];
if (angleHundredths < _angleMinHundredths || angleHundredths > _angleMaxHundredths)
continue;
var angleDeg = (angleHundredths - 18000) / 100.0;
var angleRad = angleDeg * Math.PI / 180.0;
angleRadList.Add(angleRad);
var distanceM = rangesMm[i] * OleiConstants.DistanceResolution;
var isValid = distanceM >= _minRangeM && distanceM <= _maxRangeM;
rangeList.Add(isValid ? distanceM : double.NaN);
intensityList.Add(intensitiesRaw[i]);
}
if (rangeList.Count == 0)
return false;
var frequency = _decode.Frequency > 0.001f
? _decode.Frequency
: (float)(_scanFrequencyHz ?? 10.0);
var scanTime = 1.0 / frequency;
var numberOfPoints = rangeList.Count;
var angleMin = angleRadList[0];
var angleMax = angleRadList[^1];
var angleIncrement = numberOfPoints > 1
? (angleMax - angleMin) / (numberOfPoints - 1)
: _decode.StepDeg * Math.PI / 180.0;
var receiveUtc = packetReceiveStamp.Kind == DateTimeKind.Utc
? packetReceiveStamp
: packetReceiveStamp.ToUniversalTime();
var lidarEndUtc = _decode.LastCompletedScanLidarEndUtc ?? receiveUtc;
var scanStamp = lidarEndUtc - TimeSpan.FromSeconds(scanTime);
var laserScan = new LaserScan
{
Header = new RobotNet10.Shared.Header
{
Seq = _scanSequence++,
Stamp = scanStamp,
FrameId = _frameId
},
AngleMin = angleMin,
AngleMax = angleMax,
AngleIncrement = angleIncrement,
TimeIncrement = scanTime / numberOfPoints,
ScanTime = scanTime,
RangeMin = (float)_minRangeM,
RangeMax = (float)_maxRangeM,
Ranges = rangeList.ToArray(),
Intensities = intensityList.ToArray()
};
lock (_dataLock)
{
_lastLaserScan = laserScan;
_lastScanDataTimestamp = scanStamp;
}
ScanDataReceived?.Invoke(this, new LidarScanDataEventArgs(scanStamp, laserScan));
UpdateProperties();
return true;
}
private void UpdateProperties()
{
lock (_dataLock)
{
var pointCount = _lastLaserScan?.Ranges.Length ?? 0;
SetProperty("NumberOfPoints", pointCount.ToString());
SetProperty("StartAngle", (_startAngleRad * 180.0 / Math.PI).ToString("F1"));
SetProperty("EndAngle", (_endAngleRad * 180.0 / Math.PI).ToString("F1"));
SetProperty("MaxRange", _maxRangeM.ToString("F1"));
SetProperty("LastScanTime", _lastScanDataTimestamp?.ToString("yyyy-MM-dd HH:mm:ss.fff") ?? "");
}
}
#region ILidar
public LaserScan? CurrentMeasurementData
{
get { lock (_dataLock) { return _lastLaserScan; } }
}
public DateTime? LastScanDataTimestamp
{
get { lock (_dataLock) { return _lastScanDataTimestamp; } }
}
public double MinAngleRad => _startAngleRad;
public double MaxAngleRad => _endAngleRad;
public double MinRangeM => _minRangeM;
public double MaxRangeM => _maxRangeM;
public double? AngularResolutionRad => _angularResolutionRad;
public double? ScanFrequencyHz => _decode.Frequency > 0.001f ? _decode.Frequency : _scanFrequencyHz;
public double FieldOfViewRad => Math.Abs(_endAngleRad - _startAngleRad);
public bool SupportsIntensity => _supportsIntensity;
public double? AccuracyM => _accuracyM;
#endregion
protected override void Dispose(bool disposing)
{
if (disposing)
{
StopUpdateLoop();
_connect.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,437 @@
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
namespace RobotNet10.RobotApp.Drivers.Lidar;
/// <summary>
/// UDP transport + packet decode aligned with olelidar ROS driver (driver.cpp / decoder.cpp).
/// </summary>
public sealed class Connect : IDisposable
{
public const int kPacketSize = OleiConstants.PacketSize;
public string device_IP { get; set; } = "192.168.254.13";
public int device_port { get; set; } = 2368;
public string local_ip { get; set; } = "192.168.254.10";
public string multiaddr_ip { get; set; } = string.Empty;
private readonly Queue<oleiPackage> _packetList = new();
private readonly object _packetListLock = new();
private UdpClient? _udp;
private Thread? _receiveThread;
private volatile bool _running;
private IPAddress? _deviceIpAddress;
private long _totalPacketsReceived;
private long _droppedWrongSource;
private long _droppedWrongSize;
public long TotalPacketsReceived => Interlocked.Read(ref _totalPacketsReceived);
public long DroppedWrongSource => Interlocked.Read(ref _droppedWrongSource);
public long DroppedWrongSize => Interlocked.Read(ref _droppedWrongSize);
public bool IsOpen => _udp != null && _running;
public bool openPort()
{
try
{
_deviceIpAddress = IPAddress.Parse(device_IP).MapToIPv4();
var bindAddress = string.IsNullOrWhiteSpace(local_ip)
? IPAddress.Any
: IPAddress.Parse(local_ip).MapToIPv4();
var bindEp = new IPEndPoint(bindAddress, device_port);
_udp = new UdpClient();
_udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udp.Client.Bind(bindEp);
_udp.Client.ReceiveTimeout = 500;
if (!string.IsNullOrWhiteSpace(multiaddr_ip))
{
try
{
_udp.JoinMulticastGroup(IPAddress.Parse(multiaddr_ip));
}
catch (Exception ex)
{
Console.WriteLine($"[WARN] Olei multicast join failed ({multiaddr_ip}): {ex.Message}");
}
}
StartReceiveLoop();
Console.WriteLine($"[INFO] Olei UDP listening on {bindEp.Address}:{bindEp.Port}, device {device_IP}");
return true;
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Cannot open Olei UDP port: {ex.Message}");
return false;
}
}
public void closePort()
{
_running = false;
try
{
_receiveThread?.Join(TimeSpan.FromSeconds(2));
}
catch { /* ignore */ }
_receiveThread = null;
_udp?.Close();
_udp?.Dispose();
_udp = null;
}
private void StartReceiveLoop()
{
_running = true;
_receiveThread = new Thread(ReceiveLoop)
{
IsBackground = true,
Name = $"OleiLidar-UDP-{device_port}",
Priority = ThreadPriority.Highest
};
_receiveThread.Start();
}
private void ReceiveLoop()
{
while (_running && _udp != null)
{
try
{
var remote = new IPEndPoint(IPAddress.Any, 0);
var data = _udp.Receive(ref remote);
var senderIp = remote.Address.MapToIPv4();
if (_deviceIpAddress != null && !senderIp.Equals(_deviceIpAddress))
{
Interlocked.Increment(ref _droppedWrongSource);
continue;
}
if (data.Length < kPacketSize)
{
Interlocked.Increment(ref _droppedWrongSize);
continue;
}
Interlocked.Increment(ref _totalPacketsReceived);
var packet = new oleiPackage
{
stamp = DateTime.UtcNow,
data = new byte[kPacketSize]
};
Array.Copy(data, packet.data, kPacketSize);
lock (_packetListLock)
{
_packetList.Enqueue(packet);
}
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.TimedOut)
{
// Normal when no packets (ROS poll timeout).
}
catch (SocketException ex) when (!_running)
{
break;
}
catch (ObjectDisposedException)
{
break;
}
catch (Exception ex)
{
if (_running)
Console.WriteLine($"[ERROR] Olei UDP receive: {ex.Message}");
}
}
}
public int GetPacketCount()
{
lock (_packetListLock)
return _packetList.Count;
}
public oleiPackage? TryDequeuePacket()
{
lock (_packetListLock)
return _packetList.Count == 0 ? null : _packetList.Dequeue();
}
public void Dispose() => closePort();
}
public sealed class oleiPackage
{
public DateTime stamp { get; set; }
public byte[] data { get; set; } = new byte[OleiConstants.PacketSize];
}
public sealed class DecoderConfig
{
public double AngleMin { get; set; } = 0.0;
public double AngleMax { get; set; } = 360.0;
public double RangeMin { get; set; } = 0.2;
public double RangeMax { get; set; } = 30.0;
public int Poly { get; set; } = 1;
public bool Inverted { get; set; }
public double StepDeg { get; set; } = 0.225;
public string FrameId { get; set; } = "olelidar";
}
public static class OleiConstants
{
public const int DataHeadSize = 40;
public const int PointBytes = 8;
public const int BlocksPerPacket = 150;
public const int PacketSize = DataHeadSize + BlocksPerPacket * PointBytes;
public const float DistanceResolution = 0.001f;
public const float AzimuthResolutionDeg = 0.01f;
}
public readonly struct DataPoint
{
public readonly ushort Azimuth;
public readonly ushort Distance;
public readonly ushort Reflectivity;
public DataPoint(ReadOnlySpan<byte> span)
{
Azimuth = BinaryPrimitives.ReadUInt16LittleEndian(span);
Distance = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(2));
Reflectivity = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(4));
}
}
public readonly struct DataBlock
{
public readonly DataPoint Point;
public DataBlock(ReadOnlySpan<byte> span) => Point = new DataPoint(span);
}
public readonly struct DataHeader
{
public readonly byte[] Code;
public readonly uint Timestamp;
public readonly ushort Rpm;
public readonly uint Rsv;
public DataHeader(ReadOnlySpan<byte> span)
{
Code = span.Slice(22, 2).ToArray();
Timestamp = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(28));
Rpm = BinaryPrimitives.ReadUInt16LittleEndian(span.Slice(32));
Rsv = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(36));
}
}
public readonly struct Packet
{
public readonly DataHeader Header;
public readonly DataBlock[] Blocks;
public Packet(ReadOnlySpan<byte> span)
{
if (span.Length < OleiConstants.PacketSize)
throw new ArgumentException($"packet must be {OleiConstants.PacketSize} bytes");
Header = new DataHeader(span.Slice(0, OleiConstants.DataHeadSize));
Blocks = new DataBlock[OleiConstants.BlocksPerPacket];
var offset = OleiConstants.DataHeadSize;
for (var i = 0; i < OleiConstants.BlocksPerPacket; i++)
{
Blocks[i] = new DataBlock(span.Slice(offset, OleiConstants.PointBytes));
offset += OleiConstants.PointBytes;
}
}
}
/// <summary>
/// Packet decoder — port of olelidar/src/decoder.cpp PacketCb / DecodeAndFill / PublishMsg.
/// </summary>
public sealed class DecodeLidar
{
public readonly List<ushort> scanAngleInVec = new();
public readonly List<ushort> scanRangeInVec = new();
public readonly List<ushort> scanIntensityInVec = new();
private readonly List<ushort> _scanAngleVec = new();
private readonly List<ushort> _scanRangeVec = new();
private readonly List<ushort> _scanIntensityVec = new();
public ushort AzimuthLast { get; private set; }
public ushort AzimuthNow { get; private set; }
public ushort AzimuthFirst { get; private set; } = 0xFFFF;
private DateTime _machineTimeBase;
private uint _innerTimestampBaseMs;
private bool _isTimeBase;
private uint _lastStampMs;
/// <summary>End-of-scan time mapped from lidar internal clock (UTC).</summary>
public DateTime? LastCompletedScanLidarEndUtc { get; private set; }
public float Frequency { get; private set; }
public byte LidarType { get; private set; } = 0x01;
public int Direction { get; private set; }
public double StepDeg { get; private set; } = 0.225;
private DecoderConfig _config = new();
private readonly object _locker = new();
public void SetConfig(DecoderConfig config)
{
_config = config;
StepDeg = config.StepDeg;
}
public bool PacketCb(oleiPackage dataMsg)
{
if (dataMsg.data.Length < OleiConstants.PacketSize)
return false;
var pkt = new Packet(dataMsg.data);
AzimuthNow = pkt.Blocks[0].Point.Azimuth;
if (AzimuthFirst == 0xFFFF)
{
LidarType = pkt.Header.Code.Length > 1 ? pkt.Header.Code[1] : (byte)0x01;
AzimuthFirst = AzimuthNow;
var rpm = pkt.Header.Rpm & 0x7FFF;
Direction = pkt.Header.Rpm >> 15;
if (_config.Inverted)
Direction = 1 - Direction;
if (Frequency < 0.001f && rpm > 0)
{
Frequency = rpm / 60.0f;
if (LidarType == 0x01)
StepDeg = 0.225;
}
}
if (AzimuthLast < AzimuthNow)
{
DecodeAndFill(pkt);
AzimuthLast = AzimuthNow;
return false;
}
AzimuthLast = AzimuthNow;
if (AzimuthFirst >= 200)
{
AzimuthFirst = AzimuthNow;
return false;
}
var nowStampMs = pkt.Header.Timestamp;
if (!_isTimeBase)
{
_machineTimeBase = dataMsg.stamp.Kind == DateTimeKind.Utc
? dataMsg.stamp
: dataMsg.stamp.ToUniversalTime();
_innerTimestampBaseMs = nowStampMs;
_isTimeBase = true;
}
_lastStampMs = nowStampMs;
LastCompletedScanLidarEndUtc = ComputeLidarTimeUtc(nowStampMs, dataMsg.stamp);
if (Frequency < 0.001f)
{
if (LidarType == 0x01)
{
var rpm = pkt.Header.Rpm & 0x7FFF;
Frequency = rpm / 60.0f;
StepDeg = 0.225;
}
else if (_scanAngleVec.Count > 2)
{
StepDeg = (_scanAngleVec[1] - _scanAngleVec[0]) / 100.0;
Frequency = (float)(StepDeg * 10000.0 / 60.0);
}
else
{
return false;
}
}
lock (_locker)
{
scanAngleInVec.Clear();
scanRangeInVec.Clear();
scanIntensityInVec.Clear();
scanAngleInVec.AddRange(_scanAngleVec);
scanRangeInVec.AddRange(_scanRangeVec);
scanIntensityInVec.AddRange(_scanIntensityVec);
if (Direction == 0)
{
scanRangeInVec.Reverse();
scanIntensityInVec.Reverse();
}
_scanAngleVec.Clear();
_scanRangeVec.Clear();
_scanIntensityVec.Clear();
}
DecodeAndFill(pkt);
return scanAngleInVec.Count > 0;
}
private DateTime ComputeLidarTimeUtc(uint lidarTimestampMs, DateTime receiveStamp)
{
var receiveUtc = receiveStamp.Kind == DateTimeKind.Utc
? receiveStamp
: receiveStamp.ToUniversalTime();
if (!_isTimeBase)
return receiveUtc;
var deltaMs = lidarTimestampMs - _innerTimestampBaseMs;
return _machineTimeBase.AddMilliseconds(deltaMs);
}
private void DecodeAndFill(Packet pkt)
{
var rangeMaxMm = (ushort)(_config.RangeMax * 1000);
var rangeMinMm = (ushort)(_config.RangeMin * 1000);
for (var i = 0; i < OleiConstants.BlocksPerPacket; i++)
{
var dp = pkt.Blocks[i].Point;
var azimuth = dp.Azimuth;
var range = dp.Distance;
var intensity = dp.Reflectivity;
if (range > rangeMaxMm || range < rangeMinMm)
{
range = 0;
intensity = 0;
}
if (azimuth < 0xFF00)
{
lock (_locker)
{
_scanAngleVec.Add(azimuth);
_scanRangeVec.Add(range);
_scanIntensityVec.Add(intensity);
}
}
}
}
}

View File

@@ -0,0 +1,165 @@
namespace RobotNet10.RobotApp.Drivers.PhenikaaX;
public static class CiA402Helper
{
/// <summary>
/// Extract value từ PDO data theo bit offset và bit length
/// </summary>
public static byte[] ExtractValueFromPdoData(byte[] pdoData, int bitOffset, byte bitLength)
{
int byteOffset = bitOffset / 8;
int bitOffsetInByte = bitOffset % 8;
int byteLength = (bitLength + 7) / 8; // Round up
if (byteOffset + byteLength > pdoData.Length)
{
throw new ArgumentException($"PDO data too short for extraction at bit offset {bitOffset}, length {bitLength}");
}
byte[] result = new byte[byteLength];
if (bitOffsetInByte == 0 && bitLength % 8 == 0)
{
// Aligned extraction - simple copy
Array.Copy(pdoData, byteOffset, result, 0, byteLength);
}
else
{
// Unaligned extraction - need bit manipulation
ulong value = 0;
int bitsRead = 0;
int currentByteOffset = byteOffset;
int currentBitOffset = bitOffsetInByte;
while (bitsRead < bitLength && currentByteOffset < pdoData.Length)
{
int bitsToRead = Math.Min(8 - currentBitOffset, bitLength - bitsRead);
byte mask = (byte)((1 << bitsToRead) - 1);
byte byteValue = (byte)((pdoData[currentByteOffset] >> currentBitOffset) & mask);
value |= (ulong)byteValue << bitsRead;
bitsRead += bitsToRead;
currentByteOffset++;
currentBitOffset = 0;
}
// Convert ulong to byte array
for (int i = 0; i < byteLength; i++)
{
result[i] = (byte)(value >> (i * 8));
}
}
return result;
}
/// <summary>
/// Parse object index từ string (hỗ trợ hex format: 0x6040 hoặc decimal: 24640)
/// </summary>
public static bool TryParseObjectIndex(string indexStr, out ushort index)
{
index = 0;
if (string.IsNullOrWhiteSpace(indexStr))
return false;
indexStr = indexStr.Trim();
if (indexStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
return ushort.TryParse(indexStr.AsSpan(2), System.Globalization.NumberStyles.HexNumber, null, out index);
}
else
{
return ushort.TryParse(indexStr, out index);
}
}
/// <summary>
/// Convert bytes to value type với sign extension nếu cần
/// </summary>
public static T ConvertBytesToValue<T>(byte[] bytes, byte bitLength) where T : struct
{
ulong value = 0;
for (int i = 0; i < Math.Min(bytes.Length, 8); i++)
{
value |= (ulong)bytes[i] << (i * 8);
}
var type = typeof(T);
if (type == typeof(ushort))
return (T)(object)(ushort)value;
if (type == typeof(short))
{
if (bitLength < 16 && (value & (1UL << (bitLength - 1))) != 0)
value |= 0xFFFFUL << bitLength;
return (T)(object)(short)value;
}
if (type == typeof(uint))
return (T)(object)(uint)value;
if (type == typeof(int))
{
if (bitLength < 32 && (value & (1UL << (bitLength - 1))) != 0)
value |= 0xFFFFFFFFUL << bitLength;
return (T)(object)(int)value;
}
if (type == typeof(byte))
return (T)(object)(byte)value;
if (type == typeof(sbyte))
{
if (bitLength < 8 && (value & (1UL << (bitLength - 1))) != 0)
value |= 0xFFUL << bitLength;
return (T)(object)(sbyte)value;
}
throw new NotSupportedException($"Type {type.Name} is not supported");
}
/// <summary>
/// Convert value to bytes theo bitLength được map trong PDO
/// Mục đích của bitLength:
/// 1. Truncate/mask giá trị nếu vượt quá số bits được map
/// 2. Đảm bảo chỉ gửi đúng số bytes cần thiết
/// 3. Xử lý các trường hợp partial mapping (ví dụ: map 8 bits của ushort 16 bits)
/// </summary>
public static byte[] ConvertValueToBytes<T>(T value, byte bitLength) where T : struct
{
var type = typeof(T);
int byteLength = (bitLength + 7) / 8; // Round up to nearest byte
// Convert value to ulong để xử lý mask
ulong rawValue = 0;
if (type == typeof(ushort))
rawValue = (ushort)(object)value!;
else if (type == typeof(short))
rawValue = (ulong)(ushort)(short)(object)value!; // Convert signed to unsigned
else if (type == typeof(uint))
rawValue = (uint)(object)value!;
else if (type == typeof(int))
rawValue = (ulong)(uint)(int)(object)value!; // Convert signed to unsigned
else if (type == typeof(byte))
rawValue = (byte)(object)value!;
else if (type == typeof(sbyte))
rawValue = (ulong)(byte)(sbyte)(object)value!;
else
throw new NotSupportedException($"Type {type.Name} is not supported");
// Mask để chỉ lấy số bits được map
if (bitLength < 64)
{
ulong mask = (1UL << bitLength) - 1;
rawValue &= mask;
}
// Convert to byte array với đúng số bytes
byte[] result = new byte[byteLength];
for (int i = 0; i < byteLength && i < 8; i++)
{
result[i] = (byte)(rawValue >> (i * 8));
}
return result;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
1. In ra log LaserScan
$./run-quiet.sh
$./run.sh 2>&1 | grep -A 30 "===== LaserScan"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,428 @@
using System.Timers;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared.Sensor;
using RobotNet10.Shared;
namespace RobotNet10.RobotApp.Drivers.Tada;
/// <summary>
/// BMU driver cho TADA RS485
/// </summary>
[Device(DeviceType.Battery, "Tada", "TadaBattery", "1.0.0", Description = "Battery Tada Driver")]
public class TadaBattery : DeviceBase, IBattery
{
private readonly ILogger<TadaBattery> _logger;
private readonly Lock _dataLock = new();
private TadaRs485Client? _client;
// Polling
private System.Timers.Timer? _pollingTimer;
// Cached (THEO TÀI LIỆU RS485)
private double _cachedChargeLevel; // SOC %
private double _cachedVoltage; // V
private double _cachedCurrent; // A
private bool _cachedCharging; // Current > 0
private double? _cachedHealth; // SOH %
private double? _cachedTemperature; // Celsius
private double? _cachedRemainingCapacity; // Ah
private double? _cachedFullCapacity; // Wh (RemainEnergy)
private int? _cachedChargeTime; // minutes (time to full)
private int? _cachedDischargeTime; // minutes (time to empty)
private int? _cachedStatusRaw; // raw bit flags
private DateTime _lastUpdateTime = DateTime.MinValue;
private BatteryState? _cachedBatteryState;
// IBattery Implementation
public BatteryState? CurrentBatteryState
{
get { lock (_dataLock) { return _cachedBatteryState; } }
}
private readonly string _portName;
private readonly int _baud;
public TadaBattery(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider)
: base(deviceId, deviceName, DeviceType.Battery)
{
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
_logger = loggerFactory.CreateLogger<TadaBattery>();
_portName = connection.GetValue<string>("Port") ?? throw new Exception("Port is required");
_baud = connection.GetValue<int?>("BaudRate") ?? throw new Exception("BaudRate is required");
AutoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled") ?? true;
ReconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs") ?? 2000;
MaxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts") ?? 0;
UpdateProperties();
}
// DeviceBase overrides
protected override Task OnInitializeAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
_client?.Dispose();
_client = new TadaRs485Client(_logger, _portName, _baud);
}
UpdateProperties();
return Task.CompletedTask;
}
protected override Task OnConnectAsync(CancellationToken cancellationToken)
{
StartPollingLoop();
return Task.CompletedTask;
}
protected override Task OnDisconnectAsync(CancellationToken cancellationToken)
{
StopPollingLoop();
return Task.CompletedTask;
}
protected override Task OnResetAsync(CancellationToken cancellationToken)
{
StopPollingLoop();
lock (_dataLock)
{
_client?.ForceReconnect(); // now exists
ResetCache();
}
UpdateProperties();
StartPollingLoop();
return Task.CompletedTask;
}
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
return Task.FromResult(_client != null && !_client.IsFaulted);
}
// Polling
private void StartPollingLoop()
{
lock (_dataLock)
{
if (_pollingTimer != null && _pollingTimer.Enabled)
return;
StopPollingLoop(); // Đảm bảo không có timer nào đang chạy
_pollingTimer = new System.Timers.Timer(500) // 2Hz = 500ms interval
{
AutoReset = true,
Enabled = true
};
_pollingTimer.Elapsed += PollTimer_Elapsed;
}
}
private void StopPollingLoop()
{
lock (_dataLock)
{
if (_pollingTimer != null)
{
_pollingTimer.Stop();
_pollingTimer.Elapsed -= PollTimer_Elapsed;
_pollingTimer.Dispose();
_pollingTimer = null;
}
}
}
private void PollTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
try
{
_client?.RequestStatus();
var data = _client?.ReadResponse();
if (data != null)
UpdateCache(data);
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, "RS485 polling error");
}
}
// UpdateCache
private void UpdateCache(Dictionary<string, double> data)
{
lock (_dataLock)
{
var now = DateTime.UtcNow;
// CHARGE LEVEL
if (data.TryGetValue("SOC", out var soc))
{
if (Math.Abs(soc - _cachedChargeLevel) > 0.1)
{
_cachedChargeLevel = soc;
}
}
// VOLTAGE
if (data.TryGetValue("Voltage", out var volt))
{
if (Math.Abs(volt - _cachedVoltage) > 0.05)
{
_cachedVoltage = volt;
}
}
// CURRENT
if (data.TryGetValue("Current", out var curr))
{
if (Math.Abs(curr - _cachedCurrent) > 0.05)
{
_cachedCurrent = curr;
}
}
// CHARGING
bool newCharging = _cachedCurrent > 0;
if (newCharging != _cachedCharging)
{
_cachedCharging = newCharging;
}
// TEMPERATURE
if (data.TryGetValue("Temp", out var t))
{
if (_cachedTemperature == null || Math.Abs(t - _cachedTemperature.Value) > 0.1)
{
_cachedTemperature = t;
}
}
// Optional fields (per document)
_cachedHealth = data.TryGetValue("SOH", out var soh) ? soh : null;
_cachedRemainingCapacity = data.TryGetValue("RemainCapacity", out var rc) ? rc : null;
_cachedFullCapacity = data.TryGetValue("RemainEnergy", out var re) ? re : null;
_cachedChargeTime = data.TryGetValue("ChargeTime", out var ct) ? (int)ct : null;
_cachedDischargeTime = data.TryGetValue("DischargeTime", out var dt) ? (int)dt : null;
_cachedStatusRaw = data.TryGetValue("Status", out var st) ? (int)st : null;
_lastUpdateTime = now;
// Update cached BatteryState
_cachedBatteryState = CreateBatteryStateFromCache();
UpdateProperties();
}
}
private void ResetCache()
{
_cachedChargeLevel = 0;
_cachedVoltage = 0;
_cachedCurrent = 0;
_cachedCharging = false;
_cachedHealth = null;
_cachedTemperature = null;
_cachedRemainingCapacity = null;
_cachedFullCapacity = null;
_cachedChargeTime = null;
_cachedDischargeTime = null;
_cachedStatusRaw = null;
_lastUpdateTime = DateTime.MinValue;
_cachedBatteryState = null;
}
// IBattery Implementation
public Task<BatteryState> ReadBatteryStateAsync(CancellationToken cancellationToken = default)
{
lock (_dataLock)
{
if (_cachedBatteryState.HasValue)
{
return Task.FromResult(_cachedBatteryState.Value);
}
return Task.FromResult(CreateBatteryStateFromCache());
}
}
// Helper method to create BatteryState from cached values
private BatteryState CreateBatteryStateFromCache()
{
// Map PowerSupplyStatus from charging state
byte powerSupplyStatus = BatteryState.PowerSupplyStatusUnknown;
if (_cachedCharging)
powerSupplyStatus = BatteryState.PowerSupplyStatusCharging;
else if (_cachedCurrent < 0)
powerSupplyStatus = BatteryState.PowerSupplyStatusDischarging;
else if (_cachedCurrent == 0)
powerSupplyStatus = BatteryState.PowerSupplyStatusNotCharging;
// Map PowerSupplyHealth from health percentage
byte powerSupplyHealth = BatteryState.PowerSupplyHealthUnknown;
if (_cachedHealth.HasValue)
{
if (_cachedHealth.Value >= 80)
powerSupplyHealth = BatteryState.PowerSupplyHealthGood;
else if (_cachedHealth.Value < 20)
powerSupplyHealth = BatteryState.PowerSupplyHealthDead;
}
return new BatteryState
{
Header = new Header
{
Stamp = _lastUpdateTime != DateTime.MinValue ? _lastUpdateTime : DateTime.UtcNow,
FrameId = "battery_frame"
},
Voltage = _cachedVoltage,
Current = _cachedCurrent,
Charge = _cachedRemainingCapacity.HasValue ? _cachedRemainingCapacity.Value : double.NaN,
Capacity = _cachedFullCapacity.HasValue ? _cachedFullCapacity.Value : double.NaN,
DesignCapacity = double.NaN, // Not provided by TADA BMU
Percentage = _cachedChargeLevel,
PowerSupplyStatus = powerSupplyStatus,
PowerSupplyHealth = powerSupplyHealth,
PowerSupplyTechnology = BatteryState.PowerSupplyTechnologyUnknown, // Not provided by TADA BMU
Present = true,
CellVoltage = Array.Empty<double>(), // Not provided by TADA BMU
CellTemperature = _cachedTemperature.HasValue ? new[] { _cachedTemperature.Value } : Array.Empty<double>(),
Location = string.Empty,
SerialNumber = string.Empty
};
}
// UpdateProperties
private void UpdateProperties()
{
lock (_dataLock)
{
SetProperty("ChargeLevel", _cachedChargeLevel.ToString("F1"));
SetProperty("Voltage", _cachedVoltage.ToString("F2"));
SetProperty("Current", _cachedCurrent.ToString("F2"));
SetProperty("Charging", _cachedCharging.ToString());
SetProperty("Temperature", _cachedTemperature?.ToString("F1") ?? "0");
SetProperty("Health", _cachedHealth?.ToString("F0") ?? "0");
SetProperty("RemainCapacity", _cachedRemainingCapacity?.ToString("F2") ?? "0");
SetProperty("FullCapacity", _cachedFullCapacity?.ToString("F2") ?? "0");
SetProperty("ChargeTime", _cachedChargeTime?.ToString() ?? "0");
SetProperty("DischargeTime", _cachedDischargeTime?.ToString() ?? "0");
SetProperty("StatusRaw", _cachedStatusRaw?.ToString() ?? "0");
}
}
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
yield return new PropertyDescription("ChargeLevel", "Charge Level (%)", "Mức pin (%)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 1,
Category = "Status"
};
yield return new PropertyDescription("Voltage", "Voltage (V)", "Điện áp (V)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 2,
Category = "Status"
};
yield return new PropertyDescription("Current", "Current (A)", "Dòng điện (A)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 3,
Category = "Status"
};
yield return new PropertyDescription("Charging", "Charging", "Đang sạc?")
{
DataType = "boolean",
IsReadOnly = true,
DisplayOrder = 4,
Category = "Status"
};
yield return new PropertyDescription("Temperature", "Temperature (°C)", "Nhiệt độ")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 5,
Category = "Status"
};
yield return new PropertyDescription("Health", "SOH (%)", "Sức khỏe pin")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 6,
Category = "Status"
};
yield return new PropertyDescription("RemainCapacity", "Remaining Capacity (Ah)", "Dung lượng còn lại")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 7,
Category = "Status"
};
yield return new PropertyDescription("FullCapacity", "Remaining Energy (Wh)", "Năng lượng còn lại (Wh)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 8,
Category = "Status"
};
yield return new PropertyDescription("ChargeTime", "Charge Time (min)", "Thời gian còn lại để sạc đầy")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 9,
Category = "Status"
};
yield return new PropertyDescription("DischargeTime", "Discharge Time (min)", "Thời gian còn lại để xả hết")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 10,
Category = "Status"
};
yield return new PropertyDescription("StatusRaw", "Status Flags", "Trạng thái bit (BMU Flags)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 11,
Category = "Status"
};
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
StopPollingLoop();
_client?.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,359 @@
using System;
using System.Collections.Generic;
using System.IO.Ports;
using System.Linq;
using System.Text;
using System.Threading;
namespace RobotNet10.RobotApp.Drivers.Tada;
[Flags]
public enum DataKind1 : byte
{
Voltage = 1 << 0,
Current = 1 << 1,
SOC = 1 << 2,
Status = 1 << 3,
ChargeTime = 1 << 4,
DischargeTime = 1 << 5,
Temp = 1 << 6
}
[Flags]
public enum DataKind2 : byte
{
SOH = 1 << 0,
RemainCapacity = 1 << 1,
RemainEnergy = 1 << 2
}
public class TadaRs485Client : IDisposable
{
private readonly ILogger _logger;
private SerialPort _port;
private readonly string _portName;
private readonly int _baud;
private readonly Parity _parity;
private readonly int _dataBits;
private readonly StopBits _stopBits;
private readonly int _readTimeoutMs;
private readonly int _writeTimeoutMs;
public bool IsFaulted => _faulted;
private readonly Lock _lock = new();
private bool _faulted = false;
private DateTime _lastRetry = DateTime.MinValue;
private int _retryDelayMs = 1000; // backoff min = 1s
private DateTime _lastDataTime = DateTime.MinValue;
private readonly int _dataTimeoutMs = 10_000; // 10 giây
private int _consecutiveFails = 0;
private readonly int _maxFails = 3; // sau 3 lần fail liên tiếp thì coi như lost
public TadaRs485Client(
ILogger logger,
string portName,
int baud = 19200,
Parity parity = Parity.None,
int dataBits = 8,
StopBits stopBits = StopBits.One,
int readTimeoutMs = 2000,
int writeTimeoutMs = 1000)
{
_logger = logger;
_portName = portName;
_baud = baud;
_parity = parity;
_dataBits = dataBits;
_stopBits = stopBits;
_readTimeoutMs = readTimeoutMs;
_writeTimeoutMs = writeTimeoutMs;
_lastDataTime = DateTime.Now;
_consecutiveFails = 0;
_port = null!;
EnsureConnected();
}
private void CheckDataTimeout()
{
if (_lastDataTime != DateTime.MinValue &&
(DateTime.Now - _lastDataTime).TotalMilliseconds > _dataTimeoutMs)
{
_logger.LogError("[TadaRs485Client] Communication lost (data timeout).");
_faulted = true;
_lastDataTime = DateTime.MinValue; // reset để tránh spam log
}
}
private void EnsureConnected()
{
lock (_lock)
{
if (_port != null && _port.IsOpen && !_faulted) return;
if ((DateTime.Now - _lastRetry).TotalMilliseconds < _retryDelayMs)
return;
_lastRetry = DateTime.Now;
try
{
_port?.Dispose();
_port = new SerialPort(_portName, _baud, _parity, _dataBits, _stopBits)
{
ReadTimeout = _readTimeoutMs,
WriteTimeout = _writeTimeoutMs
};
_port.Open();
_faulted = false;
_retryDelayMs = 1000;
}
catch (Exception ex)
{
_logger.LogError("[TadaRs485Client] Connect failed: {ex.Message}", ex.Message);
_faulted = true;
// exponential backoff up to 30s
_retryDelayMs = Math.Min(_retryDelayMs * 2, 30_000);
}
}
}
private static byte Checksum(byte[] data, int start, int len)
{
int sum = 0;
for (int i = start; i < start + len; i++) sum += data[i];
return (byte)(sum & 0xFF);
}
private static string ToHex(byte[] data, int len)
{
var sb = new StringBuilder();
for (int i = 0; i < len; i++)
{
sb.Append(data[i].ToString("X2"));
if (i < len - 1) sb.Append('-');
}
return sb.ToString();
}
public void RequestStatus(byte address = 0x60,
DataKind1 kind1 = DataKind1.Voltage | DataKind1.Current | DataKind1.SOC | DataKind1.Status |
DataKind1.ChargeTime | DataKind1.DischargeTime | DataKind1.Temp,
DataKind2 kind2 = DataKind2.SOH | DataKind2.RemainCapacity | DataKind2.RemainEnergy)
{
EnsureConnected();
if (_port == null || !_port.IsOpen || _faulted) return;
try
{
byte kind1Byte = (byte)kind1;
byte kind2Byte = (byte)kind2;
byte[] frame =
[
0xAF, 0xFA,
address,
0x05,
0x01,
address,
kind1Byte, kind2Byte,
0x00,
0xAF, 0xA0
];
frame[8] = Checksum(frame, 2, 6);
_port.DiscardInBuffer();
_port.DiscardOutBuffer();
_port.Write(frame, 0, frame.Length);
Thread.Sleep(50);
}
catch (Exception ex)
{
_logger.LogError("[TadaRs485Client] Write error: {ex.Message}", ex.Message);
_faulted = true;
}
}
private byte[] ReadFrame()
{
EnsureConnected();
if (_port == null || !_port.IsOpen || _faulted) return [];
var buffer = new List<byte>();
int expectedLen = -1;
var start = DateTime.Now;
try
{
while ((DateTime.Now - start).TotalMilliseconds < _readTimeoutMs)
{
int bytesAvailable = _port.BytesToRead;
if (bytesAvailable > 0)
{
byte[] tempBuffer = new byte[bytesAvailable];
int bytesRead = _port.Read(tempBuffer, 0, bytesAvailable);
buffer.AddRange(tempBuffer.Take(bytesRead));
// check start marker (chuẩn AF FA hoặc bản partial 4D)
if (buffer.Count >= 3 && expectedLen == -1)
{
if (buffer[0] == 0xAF && buffer[1] == 0xFA)
{
expectedLen = buffer[3] + 6;
}
else if (buffer[0] == 0x4D)
{
// frame thiếu AF FA -> vẫn tính chiều dài như thường
expectedLen = buffer[2] + 5; // vì mất 2 byte start
}
}
if (expectedLen > 0 && buffer.Count >= expectedLen)
{
if (buffer[^2] == 0xAF && buffer[^1] == 0xA0)
{
return [.. buffer];
}
else
{
buffer.Clear();
expectedLen = -1;
}
}
}
else
{
Thread.Sleep(2);
}
}
// hết thời gian chờ
if (buffer.Count > 0)
_logger.LogWarning("[TadaRs485Client] Timeout / partial frame ({buffer.Count} bytes): {frame}", buffer.Count, ToHex([.. buffer], buffer.Count));
return [];
}
catch (Exception ex)
{
_logger.LogError("[TadaRs485Client] Read error: {ex.Message}", ex.Message);
_faulted = true;
return [];
}
}
public Dictionary<string, double>? ReadResponse()
{
var frame = ReadFrame();
if (frame == null || frame.Length < 9)
{
_consecutiveFails++;
if (_consecutiveFails >= _maxFails)
{
_logger.LogError("[TadaRs485Client] Communication lost (too many failed reads).");
_faulted = true;
}
CheckDataTimeout();
return null;
}
if (frame[0] == 0x4D)
{
// Partial frame - fix it by prepending AF FA
var newFrame = new byte[frame.Length + 1];
newFrame[0] = 0xAF; newFrame[1] = 0xFA;
Array.Copy(frame, 1, newFrame, 2, frame.Length - 1);
frame = newFrame;
}
else if (frame[0] == 0xAF && frame.Length > 1 && frame[1] == 0xFA)
{
// Valid full frame - continue processing
}
else
{
// Invalid frame format
_consecutiveFails++;
if (_consecutiveFails >= _maxFails)
{
_logger.LogError("[TadaRs485Client] Communication lost (invalid frame format).");
_faulted = true;
}
CheckDataTimeout();
return null;
}
if (frame[^2] != 0xAF || frame[^1] != 0xA0)
{
_logger.LogError("[TadaRs485Client] Footer mismatch");
throw new Exception("invalid frame: footer");
}
if (frame[4] != 0x03)
{
_logger.LogError("[TadaRs485Client] Command code mismatch");
throw new Exception("invalid frame: command");
}
var result = new Dictionary<string, double>();
int dataLen = frame[3] - 3;
int dataStart = 6;
for (int i = 0; i + 1 < dataLen; i += 2)
{
int idx = dataStart + i;
if (idx + 1 >= frame.Length) break;
ushort raw = (ushort)((frame[idx] << 8) | frame[idx + 1]);
int index = i / 2;
switch (index)
{
case 0: result["Voltage"] = raw / 100.0; break;
case 1: result["Current"] = (short)raw / 10.0; break;
case 2: result["SOC"] = raw; break;
case 3: result["Status"] = raw; break;
case 4: result["ChargeTime"] = raw; break;
case 5: result["DischargeTime"] = raw; break;
case 6: result["Temp"] = (short)raw / 10.0; break;
case 7: result["SOH"] = raw; break;
case 8: result["RemainCapacity"] = raw / 100.0; break;
case 9: result["RemainEnergy"] = raw / 10.0; break;
}
}
_lastDataTime = DateTime.Now; // reset watchdog
_consecutiveFails = 0; // reset fail counter
return result;
}
public void ForceReconnect()
{
lock (_lock)
{
try
{
_port?.Close();
}
catch { }
_faulted = false;
_lastRetry = DateTime.MinValue;
_retryDelayMs = 1000;
EnsureConnected();
}
}
public void Dispose()
{
try
{
if (_port != null)
{
if (_port.IsOpen)
{
_port.Close();
}
_port.Dispose();
_port = null!;
}
}
catch (Exception ex)
{
_logger.LogError("[TadaRs485Client] Error in Dispose: {ex.Message}", ex.Message);
}
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,593 @@
using System.Timers;
using RobotNet10.CANOpen;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Drivers.Battery.Varta;
[Device(DeviceType.Battery, "Varta", "VartaBattery", "1.0.0", Description = "Battery Varta CAN Driver")]
public class VartaBattery : DeviceBase, IBattery
{
private readonly ILogger<VartaBattery> _logger;
private readonly ICanOpenManager _canOpenManager;
private readonly Lock _dataLock = new();
private VartaCanClient? _client;
private System.Timers.Timer? _pollingTimer;
private readonly string _canInterface;
private readonly int _readTimeoutMs;
private readonly int _pollingIntervalMs;
private readonly int _connectionTimeoutMs;
private double _cachedChargeLevel;
private double _cachedVoltage;
private double _cachedCurrent;
private bool _cachedCharging;
private double? _cachedFetTemperature;
private double? _cachedCellTemperature;
private double? _cachedChargeReqVoltage;
private double? _cachedChargeReqCurrent;
private double? _cachedNominalCapacityMah;
private double? _cachedFullCapacityMah;
private double? _cachedRemainingCapacityMah;
private double? _cachedHealth;
private int? _cachedInfo;
private int? _cachedWarn;
private int? _cachedError;
private int? _cachedChargeCtrl;
private DateTime _lastUpdateTime = DateTime.MinValue;
private DateTime _connectedAt = DateTime.MinValue;
private bool _connectionLossSignaled;
private BatteryState? _cachedBatteryState;
public BatteryState? CurrentBatteryState
{
get { lock (_dataLock) { return _cachedBatteryState; } }
}
public VartaBattery(string deviceId, string deviceName, IConfigurationSection connection, IServiceProvider serviceProvider)
: base(deviceId, deviceName, DeviceType.Battery)
{
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
_logger = loggerFactory.CreateLogger<VartaBattery>();
_canOpenManager = serviceProvider.GetRequiredService<ICanOpenManager>();
_canInterface = connection.GetValue<string>("CanInterface")
?? connection.GetValue<string>("Interface")
?? "can0";
_readTimeoutMs = connection.GetValue<int?>("ReadTimeoutMs") ?? 200;
_pollingIntervalMs = connection.GetValue<int?>("PollingIntervalMs") ?? 500;
_connectionTimeoutMs = connection.GetValue<int?>("ConnectionTimeoutMs")
?? Math.Max(5000, _pollingIntervalMs * 10);
AutoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled") ?? true;
ReconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs") ?? 2000;
MaxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts") ?? 0;
UpdateProperties();
}
protected override Task OnInitializeAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
_client?.Dispose();
_client = new VartaCanClient(_logger, _canOpenManager, _canInterface, _readTimeoutMs);
}
UpdateProperties();
return Task.CompletedTask;
}
protected override Task OnConnectAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
if (_client == null)
{
_client = new VartaCanClient(_logger, _canOpenManager, _canInterface, _readTimeoutMs);
}
else
{
_client.ForceReconnect();
}
_connectedAt = DateTime.UtcNow;
_connectionLossSignaled = false;
}
StartPollingLoop();
return Task.CompletedTask;
}
protected override Task OnDisconnectAsync(CancellationToken cancellationToken)
{
StopPollingLoop();
lock (_dataLock)
{
_connectionLossSignaled = false;
_connectedAt = DateTime.MinValue;
}
return Task.CompletedTask;
}
protected override Task OnResetAsync(CancellationToken cancellationToken)
{
StopPollingLoop();
lock (_dataLock)
{
_client?.ForceReconnect();
ResetCache();
_connectedAt = DateTime.UtcNow;
_connectionLossSignaled = false;
}
UpdateProperties();
StartPollingLoop();
return Task.CompletedTask;
}
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
{
lock (_dataLock)
{
if (_client == null || _client.IsFaulted)
{
return Task.FromResult(false);
}
var referenceTime = _lastUpdateTime != DateTime.MinValue
? _lastUpdateTime
: _connectedAt;
if (referenceTime == DateTime.MinValue)
{
return Task.FromResult(false);
}
var isRecent = (DateTime.UtcNow - referenceTime).TotalMilliseconds <= _connectionTimeoutMs;
return Task.FromResult(isRecent);
}
}
private void StartPollingLoop()
{
lock (_dataLock)
{
if (_pollingTimer != null && _pollingTimer.Enabled)
{
return;
}
StopPollingLoop();
_pollingTimer = new System.Timers.Timer(_pollingIntervalMs)
{
AutoReset = true,
Enabled = true
};
_pollingTimer.Elapsed += PollTimer_Elapsed;
}
}
private void StopPollingLoop()
{
lock (_dataLock)
{
if (_pollingTimer == null)
{
return;
}
_pollingTimer.Stop();
_pollingTimer.Elapsed -= PollTimer_Elapsed;
_pollingTimer.Dispose();
_pollingTimer = null;
}
}
private void PollTimer_Elapsed(object? sender, ElapsedEventArgs e)
{
try
{
var data = _client?.ReadResponse();
if (data != null)
{
UpdateCache(data);
return;
}
var noDataMs = _lastUpdateTime != DateTime.MinValue
? (DateTime.UtcNow - _lastUpdateTime).TotalMilliseconds
: 0;
_logger.LogWarning("[VartaBattery] No CAN data received (last update {NoDataMs:F0}ms ago, IsFaulted={IsFaulted})",
noDataMs, _client?.IsFaulted);
if (_client?.IsFaulted == true)
{
NotifyConnectionLost("Varta CAN socket faulted");
return;
}
if (_lastUpdateTime != DateTime.MinValue && noDataMs > _connectionTimeoutMs)
{
NotifyConnectionLost($"No Varta CAN data for more than {_connectionTimeoutMs}ms");
}
}
catch (Exception ex)
{
NotifyConnectionLost("Varta CAN polling exception", ex);
}
}
private void NotifyConnectionLost(string reason, Exception? cause = null)
{
lock (_dataLock)
{
if (_connectionLossSignaled)
{
return;
}
_connectionLossSignaled = true;
}
LastError = cause ?? new TimeoutException(reason);
OnErrorOccurred(LastError, reason);
_ = CheckConnectionAsync();
}
private void UpdateCache(Dictionary<string, double> data)
{
lock (_dataLock)
{
if (data.TryGetValue("SOC", out var soc))
{
_cachedChargeLevel = soc;
}
if (data.TryGetValue("Voltage", out var voltage))
{
_cachedVoltage = voltage;
}
if (data.TryGetValue("Current", out var current))
{
_cachedCurrent = current;
_cachedCharging = current > 0;
}
_cachedFetTemperature = data.TryGetValue("FetTemp", out var fetTemp) ? fetTemp : _cachedFetTemperature;
_cachedCellTemperature = data.TryGetValue("CellTemp", out var cellTemp) ? cellTemp : _cachedCellTemperature;
_cachedChargeReqVoltage = data.TryGetValue("ChargeReqVoltage", out var chargeReqVoltage) ? chargeReqVoltage : _cachedChargeReqVoltage;
_cachedChargeReqCurrent = data.TryGetValue("ChargeReqCurrent", out var chargeReqCurrent) ? chargeReqCurrent : _cachedChargeReqCurrent;
_cachedNominalCapacityMah = data.TryGetValue("NominalCapacityMah", out var nominalCap) ? nominalCap : _cachedNominalCapacityMah;
_cachedFullCapacityMah = data.TryGetValue("FullCapacityMah", out var fullCap) ? fullCap : _cachedFullCapacityMah;
_cachedRemainingCapacityMah = data.TryGetValue("RemainingCapacityMah", out var remainingCap) ? remainingCap : _cachedRemainingCapacityMah;
// If device provides SOH value directly, use it. Otherwise compute from capacities when available
if (data.TryGetValue("SOH", out var sohVal))
{
_cachedHealth = sohVal;
}
else if (_cachedFullCapacityMah.HasValue && _cachedNominalCapacityMah.HasValue && _cachedNominalCapacityMah.Value > 0)
{
try
{
_cachedHealth = (_cachedFullCapacityMah.Value / _cachedNominalCapacityMah.Value) * 100.0;
}
catch
{
_cachedHealth = null;
}
}
else
{
_cachedHealth = null;
}
_cachedInfo = data.TryGetValue("Info", out var info) ? (int)info : _cachedInfo;
_cachedWarn = data.TryGetValue("Warn", out var warn) ? (int)warn : _cachedWarn;
_cachedError = data.TryGetValue("Error", out var error) ? (int)error : _cachedError;
_cachedChargeCtrl = data.TryGetValue("ChargeCtrl", out var chargeCtrl) ? (int)chargeCtrl : _cachedChargeCtrl;
_lastUpdateTime = DateTime.UtcNow;
_connectionLossSignaled = false;
_cachedBatteryState = CreateBatteryStateFromCache();
UpdateProperties();
}
}
private void ResetCache()
{
_cachedChargeLevel = 0;
_cachedVoltage = 0;
_cachedCurrent = 0;
_cachedCharging = false;
_cachedFetTemperature = null;
_cachedCellTemperature = null;
_cachedChargeReqVoltage = null;
_cachedChargeReqCurrent = null;
_cachedNominalCapacityMah = null;
_cachedFullCapacityMah = null;
_cachedRemainingCapacityMah = null;
_cachedInfo = null;
_cachedWarn = null;
_cachedError = null;
_cachedChargeCtrl = null;
_lastUpdateTime = DateTime.MinValue;
_connectedAt = DateTime.MinValue;
_connectionLossSignaled = false;
_cachedBatteryState = null;
}
public Task<BatteryState> ReadBatteryStateAsync(CancellationToken cancellationToken = default)
{
lock (_dataLock)
{
if (_cachedBatteryState.HasValue)
{
return Task.FromResult(_cachedBatteryState.Value);
}
return Task.FromResult(CreateBatteryStateFromCache());
}
}
private BatteryState CreateBatteryStateFromCache()
{
byte powerSupplyStatus = BatteryState.PowerSupplyStatusUnknown;
if (_cachedCharging)
{
powerSupplyStatus = BatteryState.PowerSupplyStatusCharging;
}
else if (_cachedCurrent < 0)
{
powerSupplyStatus = BatteryState.PowerSupplyStatusDischarging;
}
else if (_cachedCurrent == 0)
{
powerSupplyStatus = BatteryState.PowerSupplyStatusNotCharging;
}
var cellTemperature = _cachedCellTemperature.HasValue
? new[] { _cachedCellTemperature.Value }
: Array.Empty<double>();
// Map PowerSupplyHealth from computed SOH where possible
byte powerSupplyHealth = BatteryState.PowerSupplyHealthUnknown;
if (_cachedHealth.HasValue)
{
if (_cachedHealth.Value >= 80.0)
powerSupplyHealth = BatteryState.PowerSupplyHealthGood;
else if (_cachedHealth.Value < 20.0)
powerSupplyHealth = BatteryState.PowerSupplyHealthDead;
else
powerSupplyHealth = BatteryState.PowerSupplyHealthUnknown;
}
return new BatteryState
{
Header = new Header
{
Stamp = _lastUpdateTime != DateTime.MinValue ? _lastUpdateTime : DateTime.UtcNow,
FrameId = "battery_frame"
},
Voltage = (float)_cachedVoltage,
Current = (float)_cachedCurrent,
Charge = _cachedRemainingCapacityMah.HasValue ? (float)(_cachedRemainingCapacityMah.Value / 1000.0) : float.NaN,
Capacity = _cachedFullCapacityMah.HasValue ? (float)(_cachedFullCapacityMah.Value / 1000.0) : float.NaN,
DesignCapacity = _cachedNominalCapacityMah.HasValue ? (float)(_cachedNominalCapacityMah.Value / 1000.0) : float.NaN,
Percentage = (float)_cachedChargeLevel,
PowerSupplyStatus = powerSupplyStatus,
PowerSupplyHealth = powerSupplyHealth,
PowerSupplyTechnology = BatteryState.PowerSupplyTechnologyUnknown,
Present = true,
CellVoltage = [],
CellTemperature = cellTemperature,
Location = string.Empty,
SerialNumber = string.Empty
};
}
private void UpdateProperties()
{
lock (_dataLock)
{
SetProperty("CanInterface", _canInterface);
SetProperty("ConnectionTimeoutMs", _connectionTimeoutMs.ToString());
SetProperty("ChargeLevel", _cachedChargeLevel.ToString("F1"));
SetProperty("Voltage", _cachedVoltage.ToString("F2"));
SetProperty("Current", _cachedCurrent.ToString("F2"));
SetProperty("Charging", _cachedCharging.ToString());
SetProperty("FetTemperature", _cachedFetTemperature?.ToString("F1") ?? "0");
SetProperty("CellTemperature", _cachedCellTemperature?.ToString("F1") ?? "0");
SetProperty("ChargeReqVoltage", _cachedChargeReqVoltage?.ToString("F2") ?? "0");
SetProperty("ChargeReqCurrent", _cachedChargeReqCurrent?.ToString("F2") ?? "0");
SetProperty("NominalCapacityMah", _cachedNominalCapacityMah?.ToString("F0") ?? "0");
SetProperty("FullCapacityMah", _cachedFullCapacityMah?.ToString("F0") ?? "0");
SetProperty("RemainingCapacityMah", _cachedRemainingCapacityMah?.ToString("F0") ?? "0");
SetProperty("Health", _cachedHealth?.ToString("F0") ?? "0");
SetProperty("Info", _cachedInfo?.ToString() ?? "0");
SetProperty("Warn", _cachedWarn?.ToString() ?? "0");
SetProperty("Error", _cachedError?.ToString() ?? "0");
SetProperty("ChargeCtrl", _cachedChargeCtrl?.ToString() ?? "0");
}
}
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
yield return new PropertyDescription("CanInterface", "CAN Interface", "CAN interface của pin")
{
DataType = "text",
IsReadOnly = true,
DisplayOrder = 1,
Category = "Config"
};
yield return new PropertyDescription("ConnectionTimeoutMs", "Connection Timeout (ms)", "Ngưỡng timeout phát hiện mất kết nối")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 2,
Category = "Config"
};
yield return new PropertyDescription("ChargeLevel", "Charge Level (%)", "Mức pin (%)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 3,
Category = "Status"
};
yield return new PropertyDescription("Voltage", "Voltage (V)", "Điện áp (V)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 4,
Category = "Status"
};
yield return new PropertyDescription("Current", "Current (A)", "Dòng điện (A)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 5,
Category = "Status"
};
yield return new PropertyDescription("Charging", "Charging", "Đang sạc?")
{
DataType = "boolean",
IsReadOnly = true,
DisplayOrder = 6,
Category = "Status"
};
yield return new PropertyDescription("FetTemperature", "FET Temp (C)", "Nhiệt độ FET")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 7,
Category = "Status"
};
yield return new PropertyDescription("CellTemperature", "Cell Temp (C)", "Nhiệt độ cell")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 8,
Category = "Status"
};
yield return new PropertyDescription("ChargeReqVoltage", "Charge Req Voltage (V)", "Điện áp sạc yêu cầu")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 9,
Category = "Status"
};
yield return new PropertyDescription("ChargeReqCurrent", "Charge Req Current (A)", "Dòng sạc yêu cầu")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 10,
Category = "Status"
};
yield return new PropertyDescription("NominalCapacityMah", "Nominal Capacity (mAh)", "Dung lượng danh định")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 11,
Category = "Status"
};
yield return new PropertyDescription("FullCapacityMah", "Full Capacity (mAh)", "Dung lượng đầy")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 12,
Category = "Status"
};
yield return new PropertyDescription("RemainingCapacityMah", "Remaining Capacity (mAh)", "Dung lượng còn lại")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 13,
Category = "Status"
};
yield return new PropertyDescription("Health", "SOH (%)", "Sức khỏe pin")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 14,
Category = "Status"
};
yield return new PropertyDescription("Info", "Info Flags", "Cờ thông tin")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 15,
Category = "Status"
};
yield return new PropertyDescription("Warn", "Warn Flags", "Cờ cảnh báo")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 16,
Category = "Status"
};
yield return new PropertyDescription("Error", "Error Flags", "Cờ lỗi")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 17,
Category = "Status"
};
yield return new PropertyDescription("ChargeCtrl", "Charge Control", "Trạng thái điều khiển sạc")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 18,
Category = "Status"
};
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
StopPollingLoop();
_client?.Dispose();
}
base.Dispose(disposing);
}
}

View File

@@ -0,0 +1,285 @@
using Microsoft.Extensions.Logging;
using RobotNet10.CANOpen;
using RobotNet10.CANOpen.Interfaces;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
// using
namespace RobotNet10.RobotApp.Drivers.Battery.Varta;
/// <summary>
/// Varta CAN client — đọc dữ liệu pin qua CANopen PDO.
/// Dùng SocketCAN transport có sẵn trong RobotNet10.CANOpen.
/// Protocol (CAN 11-bit):
/// 0x19B -> Voltage, Current
/// 0x281 -> FetTemp, CellTemp, ChargeReqVoltage, ChargeReqCurrent
/// 0x381 -> NominalCapacity, FullCapacity, RemainingCapacity, SOC
/// 0x481/0x581 -> Info, Warn, Error, ChargeCtrl
/// </summary>
public sealed class VartaCanClient : IDisposable
{
private readonly ILogger _logger;
private readonly ICanOpenManager _canOpenManager;
private readonly string _canInterface;
private readonly int _readTimeoutMs;
private readonly Lock _stateLock = new();
private readonly ConcurrentQueue<CanFrameReceivedEventArgs> _rxQueue = new();
private readonly SemaphoreSlim _rxSignal = new(0);
private ICanBus? _bus;
private bool _disposed;
private bool _isFaulted;
public bool IsFaulted
{
get
{
lock (_stateLock)
{
return _isFaulted;
}
}
}
public VartaCanClient(ILogger logger, ICanOpenManager canOpenManager, string canInterface, int readTimeoutMs = 200)
{
_logger = logger;
_canOpenManager = canOpenManager;
_canInterface = string.IsNullOrWhiteSpace(canInterface) ? "can0" : canInterface;
_readTimeoutMs = Math.Max(10, readTimeoutMs);
OpenBus();
}
private void OpenBus()
{
lock (_stateLock)
{
_isFaulted = false;
}
try
{
// Xóa bus cũ khỏi cache của CanOpenManager trước khi tạo lại,
// tránh GetOrCreateCanBusAsync trả về bus đã chết do caching.
_logger.LogInformation("[VartaCanClient] Removing old CAN bus from cache for {Iface}", _canInterface);
_canOpenManager.RemoveCanBusAsync(_canInterface).GetAwaiter().GetResult();
var bus = _canOpenManager.GetOrCreateCanBusAsync(_canInterface).GetAwaiter().GetResult();
_logger.LogInformation("[VartaCanClient] CAN bus recreated for {Iface}, IsConnected={IsConnected}", _canInterface, bus.IsConnected);
lock (_stateLock)
{
if (_bus != null)
{
_bus.FrameReceived -= OnFrameReceived;
}
_bus = bus;
_bus.FrameReceived += OnFrameReceived;
}
}
catch (Exception ex)
{
lock (_stateLock)
{
_isFaulted = true;
}
// _logger.LogError(ex, "[VartaCanClient] Không thể mở SocketCAN trên interface {Iface}", _canInterface);
}
}
public void ForceReconnect()
{
if (_disposed)
{
return;
}
_logger.LogInformation("[VartaCanClient] ForceReconnect start {Iface}", _canInterface);
lock (_stateLock)
{
try
{
if (_bus != null)
{
_bus.FrameReceived -= OnFrameReceived;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[VartaCanClient] No data in {Iface}", _canInterface);
}
while (_rxQueue.TryDequeue(out _)) { }
while (_rxSignal.Wait(0)) { }
}
OpenBus();
_logger.LogInformation("[VartaCanClient] ForceReconnect Finished, IsFaulted={IsFaulted}", _isFaulted);
}
/// <summary>
/// Đọc frame mới nhất từ queue receive và decode theo protocol Varta.
/// Drain toàn bộ queue, chỉ giữ frame mới nhất cho mỗi CAN ID để tránh trễ dữ liệu.
/// </summary>
public Dictionary<string, double>? ReadResponse(int maxFrames = 30)
{
if (_disposed || IsFaulted || _bus == null || !_bus.IsConnected)
{
return null;
}
// Nếu queue rỗng, chờ frame mới đến
if (_rxQueue.IsEmpty)
{
try
{
if (!_rxSignal.Wait(_readTimeoutMs))
{
return null;
ForceReconnect();
}
}
catch
{
return null;
}
}
// Drain toàn bộ queue, chỉ giữ frame mới nhất cho mỗi CAN ID
var latestFrames = new Dictionary<uint, CanFrameReceivedEventArgs>();
while (_rxQueue.TryDequeue(out var frame))
{
latestFrames[frame.CanId] = frame;
// Drain semaphore để khớp với số frame bị loại bỏ
_rxSignal.Wait(0);
}
if (latestFrames.Count == 0)
{
return null;
}
var result = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
foreach (var frame in latestFrames.Values)
{
DecodeFrame(frame, result);
}
return result.Count == 0 ? null : result;
}
private void OnFrameReceived(object? sender, CanFrameReceivedEventArgs e)
{
if (_disposed)
{
return;
}
_rxQueue.Enqueue(e);
try
{
_rxSignal.Release();
}
catch (SemaphoreFullException)
{
}
}
private static void DecodeFrame(CanFrameReceivedEventArgs frame, Dictionary<string, double> result)
{
// uint canId = frame.CanId & 0x7FFu;
var d = frame.Data;
if (d == null)
{
return;
}
// var canBase = canId & 0x780u;
switch (frame.CanId)
{
case 0x181: // TPDO1: 0x180 + NodeId
{
uint voltage = BitConverter.ToUInt32(d, 0);
int current = BitConverter.ToInt32(d, 4);
double volts = voltage / 1000.0;
double amps = current / 1000.0;
result["Voltage"] = Math.Round(volts, 1, MidpointRounding.ToZero);
result["Current"] = Math.Round(amps, 1, MidpointRounding.ToZero);
// Console.WriteLine($"[VartaCanClient] Received TPDO1: Voltage={volts} V, Current={amps} A, Timestamps: {DateTime.Now:HH:mm:ss.fff} s");
break;
}
case 0x281: // TPDO2: 0x280 + NodeId
result["FetTemp"] = BitConverter.ToInt16(d, 0) / 10.0;
result["CellTemp"] = BitConverter.ToInt16(d, 2) / 10.0;
result["ChargeReqVoltage"] = BitConverter.ToUInt16(d, 4) / 1000.0;
result["ChargeReqCurrent"] = BitConverter.ToUInt16(d, 6) / 1000.0;
// Console.WriteLine($"[VartaCanClient] Received TPDO2: FetTemp={result["FetTemp"]} °C, CellTemp={result["CellTemp"]} °C, ChargeReqVoltage={result["ChargeReqVoltage"]} V, ChargeReqCurrent={result["ChargeReqCurrent"]} A");
break;
case 0x381: // TPDO3: 0x380 + NodeId
{
var nominal = BitConverter.ToUInt16(d, 0);
var full = BitConverter.ToUInt16(d, 2);
var remaining = BitConverter.ToUInt16(d, 4);
result["NominalCapacityMah"] = nominal;
result["FullCapacityMah"] = full;
result["RemainingCapacityMah"] = remaining;
result["SOC"] = full == 0 ? 0 : remaining * 100.0 / full;
result["SOH"] = nominal == 0 ? 0 : full * 100.0 / nominal;
// Console.WriteLine($"[VartaCanClient] Received TPDO3: Nominal={nominal} mAh, Full={full} mAh, Remaining={remaining} mAh, SOC={result["SOC"]} %, SOH={result["SOH"]} %");
break;
}
case 0x481: // TPDO4: 0x480 + NodeId
case 0x581: // SDO response: 0x580 + NodeId (some firmware puts status words here)
result["Info"] = BitConverter.ToUInt16(d, 0);
result["Warn"] = BitConverter.ToUInt16(d, 2);
result["Error"] = BitConverter.ToUInt16(d, 4);
result["ChargeCtrl"] = BitConverter.ToUInt16(d, 6);
// Console.WriteLine($"[VartaCanClient] Received TPDO4/SDO: Info={result["Info"]}, Warn={result["Warn"]}, Error={result["Error"]}, ChargeCtrl={result["ChargeCtrl"]}");
break;
case 0x264:
result["ChargeControl"] = d[0]; // byte 0: uint8
result["SOC"] = d[1]; // byte 1: uint8, %
// byte 2: không sử dụng
result["ChargeVoltageRequest"] = BitConverter.ToUInt16(d, 3) / 256.0; // bytes 3-4: uint16, 1/256 V
result["ChargeCurrentRequest"] = BitConverter.ToUInt16(d, 5) / 16.0; // bytes 5-6: uint16, 1/16 A
result["BatteryStatus"] = d[7]; // byte 7: uint8
// Console.WriteLine($"[VartaCanClient] Received 0x264: ChargeControl={result["ChargeControl"]}, SOC={result["SOC"]} %, ChargeVoltageRequest={result["ChargeVoltageRequest"]:F4} V, ChargeCurrentRequest={result["ChargeCurrentRequest"]:F4} A, BatteryStatus={result["BatteryStatus"]}");
break;
}
}
public void Dispose()
{
lock (_stateLock)
{
if (_disposed)
{
return;
}
_disposed = true;
}
try
{
_bus?.FrameReceived -= OnFrameReceived;
}
catch
{
}
finally
{
_rxSignal.Dispose();
while (_rxQueue.TryDequeue(out _)) { }
}
}
}

View File

@@ -0,0 +1,165 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using RobotNet10.CANOpen;
namespace RobotNet10.RobotApp.Drivers.Battery.Varta;
public sealed class VartaChargerSimulatorService : IHostedService, IDisposable
{
private readonly ILogger<VartaChargerSimulatorService> _logger;
private readonly ICanOpenManager _canOpenManager;
private readonly VartaChargerSimulatorOptions _options;
private readonly SemaphoreSlim _controlLock = new(1, 1);
private Task? _runTask;
private CancellationTokenSource? _runCts;
private string _currentInterface = "can0";
private bool _disposed;
public bool IsRunning => _runTask is { IsCompleted: false };
public string CurrentInterface => _currentInterface;
public VartaChargerSimulatorService(
IConfiguration configuration,
ILogger<VartaChargerSimulatorService> logger,
ICanOpenManager canOpenManager)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_canOpenManager = canOpenManager ?? throw new ArgumentNullException(nameof(canOpenManager));
_options = new VartaChargerSimulatorOptions();
configuration.GetSection("Varta:Charger59VSimulator").Bind(_options);
}
public async Task StartAsync(CancellationToken cancellationToken)
{
if (_options.Enabled)
{
await StartSimulatorAsync(_options.CanInterface, cancellationToken);
return;
}
_logger.LogInformation("Varta Charger59V simulator is disabled at startup.");
}
public async Task StopAsync(CancellationToken cancellationToken)
{
await StopSimulatorAsync(cancellationToken);
}
public async Task<bool> StartSimulatorAsync(string? canInterface = null, CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
await _controlLock.WaitAsync(cancellationToken);
try
{
if (_runTask is { IsCompleted: false })
{
return false;
}
_currentInterface = string.IsNullOrWhiteSpace(canInterface) ? "can0" : canInterface;
_runCts = new CancellationTokenSource();
_runTask = RunSimulatorAsync(_currentInterface, _runCts.Token);
return true;
}
finally
{
_controlLock.Release();
}
}
public async Task<bool> StopSimulatorAsync(CancellationToken cancellationToken = default)
{
ThrowIfDisposed();
Task? runTask;
CancellationTokenSource? runCts;
await _controlLock.WaitAsync(cancellationToken);
try
{
if (_runTask is not { IsCompleted: false } || _runCts is null)
{
return false;
}
runTask = _runTask;
runCts = _runCts;
_runTask = null;
_runCts = null;
}
finally
{
_controlLock.Release();
}
runCts.Cancel();
try
{
await runTask.WaitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
}
finally
{
runCts.Dispose();
}
return true;
}
private async Task RunSimulatorAsync(string canInterface, CancellationToken token)
{
_logger.LogInformation("Starting Varta Charger59V simulator on CAN interface {CanInterface}", canInterface);
try
{
var simulator = new Charger59V(_canOpenManager, canInterface, token);
await simulator.RunAsync();
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
_logger.LogInformation("Varta Charger59V simulator stopped.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Varta Charger59V simulator crashed.");
}
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_runCts?.Cancel();
_runCts?.Dispose();
_controlLock.Dispose();
}
private void ThrowIfDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(VartaChargerSimulatorService));
}
}
}
public sealed class VartaChargerSimulatorOptions
{
public bool Enabled { get; set; }
public string CanInterface { get; set; } = "can0";
}
public sealed class VartaChargerSimulatorStartRequest
{
public string? CanInterface { get; set; }
}

View File

@@ -0,0 +1,488 @@
using RobotNet10.CANOpen;
using RobotNet10.CANOpen.Interfaces;
using System.Collections.Concurrent;
namespace RobotNet10.RobotApp.Drivers.Battery.Varta;
/// <summary>
/// Charger Simulator cho VARTA EasyBlade 59V
/// Máy tính đóng vai Charger, giao tiếp với pin thật qua USB-CAN adapter.
///
/// ── Thông số cố định (theo Technical Spec V1.8) ──────────────────────
/// Baud rate : 250 kbit/s
/// Charger Node ID : 100 (0x64)
/// Max voltage : 58.8 V (4 × 12V × 1.225 cells)
/// Max current : 25 A
/// Heartbeat : mỗi 1000 ms → COB-ID 0x764
/// RPDO1 : mỗi 200 ms → COB-ID 0x1E4
/// ─────────────────────────────────────────────────────────────────────
/// </summary>
public class Charger59V
{
// ════════════════════════════════════════════════════════════════
// ⚙️ THÔNG SỐ HARDCODE CHỈNH TẠI ĐÂY NẾU CẦN
// ════════════════════════════════════════════════════════════════
// Điện áp tối đa charger có thể cung cấp (V)
// EasyBlade 59V: pin lithium 13S → max 54.6V, để an toàn dùng 58.8V
private const double MAX_VOLTAGE_V = 58.8;
// Dòng tối đa charger có thể cung cấp (A)
private const double MAX_CURRENT_A = 25.0;
// Điện áp thực đo được (báo lại pin trong RPDO1, byte 2-3)
// Lúc chưa sạc thực thì đặt bằng Max hoặc giá trị đo thực của nguồn
private const double ACTUAL_VOLTAGE_V = 54.0;
// Dòng thực đo được (báo lại pin trong RPDO1, byte 0-1)
private const double ACTUAL_CURRENT_A = 10.0;
// COB-ID (không đổi theo spec)
private const uint COB_HEARTBEAT = 0x764; // gửi
private const uint COB_RPDO1 = 0x1E4; // gửi
private const uint COB_SDO_TX = 0x5E4; // gửi (response về battery)
private const uint COB_SDO_RX = 0x664; // nhận (request từ battery)
private const uint COB_TPDO9 = 0x264; // nhận (SoC, VReq, IReq)
private const uint COB_TPDO8 = 0x49B; // nhận (charge control status)
// ── Giá trị raw (Q8 = ×256, Q4 = ×16) ───────────────────────────
private static readonly ushort RAW_MAX_VOLTAGE = (ushort)(MAX_VOLTAGE_V * 256);
private static readonly ushort RAW_MAX_CURRENT = (ushort)(MAX_CURRENT_A * 16);
private static readonly ushort RAW_ACT_VOLTAGE = (ushort)(ACTUAL_VOLTAGE_V * 256);
private static readonly ushort RAW_ACT_CURRENT = (ushort)(ACTUAL_CURRENT_A * 256);
// ════════════════════════════════════════════════════════════════
// State
// ════════════════════════════════════════════════════════════════
private bool _sdoInitDone = false;
private bool _chargeActive = false; // true sau khi set Bit12
private bool _relayOpen = true; // true = relay mở, không có điện ra
private bool _batteryCharging = false; // true khi pin báo đang vào trạng thái sạc
// Lưu lại giá trị SDO battery ghi vào charger
private byte _batteryStatus = 0; // Object 0x6000
private byte _chargeControl = 0; // Object 0x4200
private ushort _voltageReqRaw = 0; // Object 0x2276
private ushort _currentReqRaw = 0; // Object 0x6070
private readonly ICanOpenManager _canOpenManager;
private readonly string _canInterface;
private ICanBus? _can;
private readonly CancellationToken _ct;
private readonly ConcurrentQueue<CanFrameReceivedEventArgs> _rxQueue = new();
private readonly SemaphoreSlim _rxSignal = new(0);
public Charger59V(ICanOpenManager canOpenManager, string canInterface, CancellationToken ct)
{
_canOpenManager = canOpenManager ?? throw new ArgumentNullException(nameof(canOpenManager));
_canInterface = string.IsNullOrWhiteSpace(canInterface) ? "can0" : canInterface;
_ct = ct;
}
// ════════════════════════════════════════════════════════════════
public async Task RunAsync()
{
_can = await _canOpenManager.GetOrCreateCanBusAsync(_canInterface, _ct);
_can.FrameReceived += OnFrameReceived;
// Log($"Max Voltage : {MAX_VOLTAGE_V} V (raw Q8 = {RAW_MAX_VOLTAGE})");
// Log($"Max Current : {MAX_CURRENT_A} A (raw Q4 = {RAW_MAX_CURRENT})");
// Log($"Gửi Heartbeat 0x{COB_HEARTBEAT:X3} mỗi 1000ms...");
// Log("Đang chờ pin kết nối...\n");
try
{
// Chạy song song 3 vòng lặp
await Task.WhenAll(
HeartbeatLoopAsync(), // gửi HB mỗi 1000ms
ReceiveLoopAsync(), // nhận SDO + TPDO từ pin
Rpdo1LoopAsync() // gửi RPDO1 sau khi SDO init xong
);
}
finally
{
_can.FrameReceived -= OnFrameReceived;
while (_rxQueue.TryDequeue(out _)) { }
while (_rxSignal.Wait(0)) { }
}
}
// ════════════════════════════════════════════════════════════════
// VÒNG LẶP 1 Heartbeat (mỗi 1000ms)
// ════════════════════════════════════════════════════════════════
private async Task HeartbeatLoopAsync()
{
while (!_ct.IsCancellationRequested)
{
// NMT Heartbeat: 1 byte [0x05] = Operational state
SendFrame(COB_HEARTBEAT, [0x05]);
// Dim($"♥ HB → 0x{COB_HEARTBEAT:X3}");
await Task.Delay(1000, _ct);
}
}
// ════════════════════════════════════════════════════════════════
// VÒNG LẶP 2 Nhận frame từ pin
// ════════════════════════════════════════════════════════════════
private async Task ReceiveLoopAsync()
{
while (!_ct.IsCancellationRequested)
{
if (TryReceive(out var frame))
{
switch (frame.CanId)
{
case COB_SDO_RX: HandleSdo(frame.Data); break;
case COB_TPDO9: HandleTpdo9(frame.Data); break;
case COB_TPDO8: HandleTpdo8(frame.Data); break;
}
}
else
{
await Task.Delay(1, _ct); // yield CPU khi không có frame
}
}
}
// ════════════════════════════════════════════════════════════════
// VÒNG LẶP 3 Gửi RPDO1 (mỗi 200ms, sau khi SDO init xong)
// ════════════════════════════════════════════════════════════════
private async Task Rpdo1LoopAsync()
{
// Chờ SDO init hoàn tất
while (!_sdoInitDone && !_ct.IsCancellationRequested)
await Task.Delay(50, _ct);
if (_ct.IsCancellationRequested) return;
// Delay 1s trước khi kích hoạt Bit12 (cho pin ổn định)
LogOk("SDO init xong! Chờ 1s rồi bật Bit12...");
await Task.Delay(1000, _ct);
// Bật relay và charge mode
_relayOpen = false;
_chargeActive = true;
LogOk("==> Bit12 SET Pin đang chuyển sang CHARGE MODE!");
// Gửi RPDO1 mỗi 200ms
while (!_ct.IsCancellationRequested)
{
SendRpdo1();
await Task.Delay(200, _ct);
}
}
// ════════════════════════════════════════════════════════════════
// GỬI RPDO1 (COB-ID 0x1E4)
//
// Byte 0-1: Charging Current [1/256 A, Q8]
// Byte 2-3: Charging Voltage [1/256 V, Q8]
// Byte 4-5: Max avail Current [1/16 A, Q4]
// Byte 6-7: Extended Charger Status
// → Bit12 (0x1000) = kích hoạt charge mode
// ════════════════════════════════════════════════════════════════
private void SendRpdo1()
{
ushort extStatus = (_chargeActive && !_relayOpen)
? (ushort)0x1000 // Bit12 set
: (ushort)0x0000;
byte[] data =
[
(byte)(RAW_ACT_CURRENT & 0xFF), (byte)(RAW_ACT_CURRENT >> 8), // Byte 0-1
(byte)(RAW_ACT_VOLTAGE & 0xFF), (byte)(RAW_ACT_VOLTAGE >> 8), // Byte 2-3
(byte)(RAW_MAX_CURRENT & 0xFF), (byte)(RAW_MAX_CURRENT >> 8), // Byte 4-5
(byte)(extStatus & 0xFF), (byte)(extStatus >> 8), // Byte 6-7
];
SendFrame(COB_RPDO1, data);
// Dim($"→ RPDO1 0x{COB_RPDO1:X3} [{string.Join(" ", data.Select(b => $"{b:X2}"))}] " +
// $"ExtStat=0x{extStatus:X4}");
}
// ════════════════════════════════════════════════════════════════
// XỬ LÝ SDO REQUEST TỪ PIN (COB-ID 0x664)
// ════════════════════════════════════════════════════════════════
private void HandleSdo(byte[] d)
{
if (d.Length < 8) return;
byte cmd = d[0];
ushort index = (ushort)(d[1] | (d[2] << 8));
byte sub = d[3];
switch (cmd)
{
// Pin GHI vào object của charger
case 0x2F: // Write 1 byte
OnWrite(index, sub, d[4], 0);
break;
case 0x2B: // Write 2 bytes
OnWrite(index, sub, d[4], (ushort)(d[4] | (d[5] << 8)));
break;
case 0x23: // Write 4 bytes
SdoWriteOk(index, sub); // phản hồi OK, bỏ qua giá trị
break;
// Pin ĐỌC object từ charger
case 0x40: // Read request
OnRead(index, sub);
break;
}
}
private void OnWrite(ushort index, byte sub, byte val8, ushort val16)
{
switch (index)
{
case 0x6000: // Battery Status
_batteryStatus = val8;
// Log($" [SDO] 0x6000 Battery Status ← {val8} " +
// (val8 == 1 ? "→ Relay CLOSED (power ON)" : "→ Relay OPEN (power OFF)"));
_relayOpen = (val8 == 0);
break;
case 0x4200: // Charge Control
_chargeControl = val8;
// Log($" [SDO] 0x4200 Charge Control ← {val8} " +
// (val8 == 1 ? "→ Battery READY" : "→ Battery NOT ready"));
// ChargeControl=0 → pin báo full/lỗi → tắt relay
if (val8 == 0 && _sdoInitDone)
{
LogWarn("ChargeControl=0 → TẮT RELAY (pin đầy hoặc lỗi)");
_relayOpen = true;
_chargeActive = false;
}
break;
case 0x2276: // Voltage Request
_voltageReqRaw = val16;
// Log($" [SDO] 0x2276 Voltage Request ← {val16 / 256.0:F3} V");
break;
case 0x6070: // Current Request
_currentReqRaw = val16;
// Log($" [SDO] 0x6070 Current Request ← {val16 / 16.0:F3} A");
break;
default:
// Log($" [SDO] Write idx=0x{index:X4}.{sub} val=0x{val16:X4}");
break;
}
SdoWriteOk(index, sub);
CheckInitComplete();
}
private void OnRead(ushort index, byte sub)
{
switch (index)
{
case 0x4208: // Max Charging Voltage
SdoReadOk2(index, sub, RAW_MAX_VOLTAGE);
// Log($" [SDO] 0x4208 Max Voltage → {MAX_VOLTAGE_V} V (raw=0x{RAW_MAX_VOLTAGE:X4})");
break;
case 0x4212: // Max Charging Current
SdoReadOk2(index, sub, RAW_MAX_CURRENT);
// Log($" [SDO] 0x4212 Max Current → {MAX_CURRENT_A} A (raw=0x{RAW_MAX_CURRENT:X4})");
break;
default:
// Abort: object does not exist
byte[] abort = [0x80,
(byte)(index & 0xFF), (byte)(index >> 8), sub,
0x00, 0x00, 0x02, 0x06];
SendFrame(COB_SDO_TX, abort);
break;
}
CheckInitComplete();
}
// ════════════════════════════════════════════════════════════════
// XỬ LÝ TPDO9 (COB-ID 0x264) Pin gửi mỗi 100ms
// Byte 0: ChargeControl Byte 1: SoC
// Byte 3-4: Volt Request Byte 5-6: Curr Request Byte 7: BattStatus
// ════════════════════════════════════════════════════════════════
private void HandleTpdo9(byte[] d)
{
if (d.Length < 7) return;
byte cc = d[0];
byte soc = d[1];
ushort vReq = (ushort)(d[3] | (d[4] << 8));
ushort iReq = (ushort)(d[5] | (d[6] << 8));
byte bs = d.Length > 7 ? d[7] : (byte)0;
Console.ForegroundColor = ConsoleColor.Green;
// Console.WriteLine(
// $"[{Now}] 📦 PIN " +
// $"SoC={soc,3}% " +
// $"VReq={vReq / 256.0,6:F2}V " +
// $"IReq={iReq / 16.0,6:F2}A " +
// $"ChargeCtrl={cc} BattStat={bs}");
// Console.ResetColor();
// Pin gửi ChargeControl=0 → pin đầy hoặc có lỗi → dừng sạc
if (cc == 0 && _sdoInitDone && _chargeActive)
{
LogWarn("ChargeControl=0 → PIN ĐẦY hoặc LỖI → Tắt relay!");
_chargeActive = false;
_relayOpen = true;
}
}
// ════════════════════════════════════════════════════════════════
// XỬ LÝ TPDO8 (COB-ID 0x49B) Battery Charge Control Status
// ════════════════════════════════════════════════════════════════
private void HandleTpdo8(byte[] d)
{
if (d.Length < 2) return;
ushort s = (ushort)(d[0] | (d[1] << 8));
bool chargingNow = s == 0xC011 || s == 0xC033;
if (chargingNow && !_batteryCharging)
{
_batteryCharging = true;
LogOk($"✅ PIN ĐÃ VÀO TRẠNG THÁI SẠC (TPDO8=0x{s:X4})");
}
else if (!chargingNow && _batteryCharging)
{
_batteryCharging = false;
LogWarn($"PIN THOÁT TRẠNG THÁI SẠC (TPDO8=0x{s:X4})");
}
string desc = s switch
{
0x0033 => "SDO init OK chờ Bit12",
0x4033 => "Standby chờ Bit12",
0xC011 => "⚡ CHARGING ACTIVE",
0xC033 => "⚡ Charging (normal)",
0xC000 => "Pin đầy về standby",
0xD000 => "Keep-power hết SHUTDOWN",
_ => $"bits={s:X4}"
};
Console.ForegroundColor = ConsoleColor.Cyan;
// Console.WriteLine($"[{Now}] 📊 STATUS 0x{s:X4} → {desc}");
Console.ResetColor();
}
// ════════════════════════════════════════════════════════════════
// Kiểm tra SDO init sequence đã đủ 4 bước chưa
// ════════════════════════════════════════════════════════════════
private void CheckInitComplete()
{
if (_sdoInitDone) return;
if (_batteryStatus == 1
&& _chargeControl == 1
&& _voltageReqRaw > 0
&& _currentReqRaw > 0)
{
_sdoInitDone = true;
// Console.ForegroundColor = ConsoleColor.Yellow;
// Console.WriteLine($"\n[{Now}] ══════════════════════════════════════");
// Console.WriteLine($"[{Now}] ✅ SDO INITIALIZATION HOÀN TẤT!");
// Console.WriteLine($"[{Now}] BatteryStatus={_batteryStatus} ChargeControl={_chargeControl}");
// Console.WriteLine($"[{Now}] VoltReq={_voltageReqRaw / 256.0:F3}V CurrReq={_currentReqRaw / 16.0:F3}A");
// Console.WriteLine($"[{Now}] ══════════════════════════════════════\n");
// Console.ResetColor();
}
}
// ════════════════════════════════════════════════════════════════
// SDO helpers
// ════════════════════════════════════════════════════════════════
private void SdoWriteOk(ushort index, byte sub)
{
byte[] d = [0x60, (byte)(index & 0xFF), (byte)(index >> 8), sub, 0, 0, 0, 0];
SendFrame(COB_SDO_TX, d);
// Dim($"← SDO OK 0x{COB_SDO_TX:X3} idx=0x{index:X4}");
}
private void SdoReadOk2(ushort index, byte sub, ushort value)
{
byte[] d = [0x4B,
(byte)(index & 0xFF), (byte)(index >> 8), sub,
(byte)(value & 0xFF), (byte)(value >> 8), 0, 0];
SendFrame(COB_SDO_TX, d);
// Dim($"← SDO RSP 0x{COB_SDO_TX:X3} idx=0x{index:X4} val=0x{value:X4}");
}
private void SendFrame(uint canId, byte[] data)
{
var bus = _can;
if (bus is null || !bus.IsConnected)
{
return;
}
bus.SendFrameAsync(canId, data, _ct).GetAwaiter().GetResult();
}
private bool TryReceive(out CanFrameReceivedEventArgs frame)
{
if (_rxQueue.TryDequeue(out frame!))
{
return true;
}
try
{
if (!_rxSignal.Wait(10, _ct))
{
frame = null!;
return false;
}
}
catch (OperationCanceledException)
{
frame = null!;
return false;
}
if (_rxQueue.TryDequeue(out frame!))
{
return true;
}
frame = null!;
return false;
}
private void OnFrameReceived(object? sender, CanFrameReceivedEventArgs e)
{
_rxQueue.Enqueue(e);
try
{
_rxSignal.Release();
}
catch (SemaphoreFullException)
{
}
}
// ════════════════════════════════════════════════════════════════
// Logging
// ════════════════════════════════════════════════════════════════
private static string Now => DateTime.Now.ToString("HH:mm:ss.fff");
private static void Log(string msg)
=> Console.WriteLine($"[{Now}] {msg}");
private static void LogOk(string msg)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"[{Now}] {msg}");
Console.ResetColor();
}
private static void LogWarn(string msg)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"[{Now}] ⚠️ {msg}");
Console.ResetColor();
}
private static void Dim(string msg)
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"[{Now}] {msg}");
Console.ResetColor();
}
}

View File

@@ -0,0 +1,159 @@
namespace RobotNet10.RobotApp.Drivers.WheeltecIMU
{
public class CRCTable
{
public static readonly byte[] CRC8Table =
{
0, 94, 188, 226, 97, 63, 221, 131, 194, 156, 126, 32, 163, 253, 31, 65,
157, 195, 33, 127, 252, 162, 64, 30, 95, 1, 227, 189, 62, 96, 130, 220,
35, 125, 159, 193, 66, 28, 254, 160, 225, 191, 93, 3, 128, 222, 60, 98,
190, 224, 2, 92, 223, 129, 99, 61, 124, 34, 192, 158, 29, 67, 161, 255,
70, 24, 250, 164, 39, 121, 155, 197, 132, 218, 56, 102, 229, 187, 89, 7,
219, 133, 103, 57, 186, 228, 6, 88, 25, 71, 165, 251, 120, 38, 196, 154,
101, 59, 217, 135, 4, 90, 184, 230, 167, 249, 27, 69, 198, 152, 122, 36,
248, 166, 68, 26, 153, 199, 37, 123, 58, 100, 134, 216, 91, 5, 231, 185,
140, 210, 48, 110, 237, 179, 81, 15, 78, 16, 242, 172, 47, 113, 147, 205,
17, 79, 173, 243, 112, 46, 204, 146, 211, 141, 111, 49, 178, 236, 14, 80,
175, 241, 19, 77, 206, 144, 114, 44, 109, 51, 209, 143, 12, 82, 176, 238,
50, 108, 142, 208, 83, 13, 239, 177, 240, 174, 76, 18, 145, 207, 45, 115,
202, 148, 118, 40, 171, 245, 23, 73, 8, 86, 180, 234, 105, 55, 213, 139,
87, 9, 235, 181, 54, 104, 138, 212, 149, 203, 41, 119, 244, 170, 72, 22,
233, 183, 85, 11, 136, 214, 52, 106, 43, 117, 151, 201, 74, 20, 246, 168,
116, 42, 200, 150, 21, 75, 169, 247, 182, 232, 10, 84, 215, 137, 107, 53
};
public static readonly ushort[] CRC16Table =
{
0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7,
0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF,
0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6,
0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE,
0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485,
0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D,
0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4,
0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC,
0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823,
0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B,
0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12,
0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A,
0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41,
0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49,
0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70,
0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78,
0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F,
0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067,
0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E,
0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256,
0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D,
0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405,
0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C,
0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634,
0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB,
0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3,
0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A,
0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92,
0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9,
0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1,
0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8,
0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0
};
public static readonly uint[] CRC32Table =
{
0x00000000, 0x77073096, 0xee0e612c, 0x990951ba,
0x076dc419, 0x706af48f, 0xe963a535, 0x9e6495a3,
0x0edb8832, 0x79dcb8a4, 0xe0d5e91e, 0x97d2d988,
0x09b64c2b, 0x7eb17cbd, 0xe7b82d07, 0x90bf1d91,
0x1db71064, 0x6ab020f2, 0xf3b97148, 0x84be41de,
0x1adad47d, 0x6ddde4eb, 0xf4d4b551, 0x83d385c7,
0x136c9856, 0x646ba8c0, 0xfd62f97a, 0x8a65c9ec,
0x14015c4f, 0x63066cd9, 0xfa0f3d63, 0x8d080df5,
0x3b6e20c8, 0x4c69105e, 0xd56041e4, 0xa2677172,
0x3c03e4d1, 0x4b04d447, 0xd20d85fd, 0xa50ab56b,
0x35b5a8fa, 0x42b2986c, 0xdbbbc9d6, 0xacbcf940,
0x32d86ce3, 0x45df5c75, 0xdcd60dcf, 0xabd13d59,
0x26d930ac, 0x51de003a, 0xc8d75180, 0xbfd06116,
0x21b4f4b5, 0x56b3c423, 0xcfba9599, 0xb8bda50f,
0x2802b89e, 0x5f058808, 0xc60cd9b2, 0xb10be924,
0x2f6f7c87, 0x58684c11, 0xc1611dab, 0xb6662d3d,
0x76dc4190, 0x01db7106, 0x98d220bc, 0xefd5102a,
0x71b18589, 0x06b6b51f, 0x9fbfe4a5, 0xe8b8d433,
0x7807c9a2, 0x0f00f934, 0x9609a88e, 0xe10e9818,
0x7f6a0dbb, 0x086d3d2d, 0x91646c97, 0xe6635c01,
0x6b6b51f4, 0x1c6c6162, 0x856530d8, 0xf262004e,
0x6c0695ed, 0x1b01a57b, 0x8208f4c1, 0xf50fc457,
0x65b0d9c6, 0x12b7e950, 0x8bbeb8ea, 0xfcb9887c,
0x62dd1ddf, 0x15da2d49, 0x8cd37cf3, 0xfbd44c65,
0x4db26158, 0x3ab551ce, 0xa3bc0074, 0xd4bb30e2,
0x4adfa541, 0x3dd895d7, 0xa4d1c46d, 0xd3d6f4fb,
0x4369e96a, 0x346ed9fc, 0xad678846, 0xda60b8d0,
0x44042d73, 0x33031de5, 0xaa0a4c5f, 0xdd0d7cc9,
0x5005713c, 0x270241aa, 0xbe0b1010, 0xc90c2086,
0x5768b525, 0x206f85b3, 0xb966d409, 0xce61e49f,
0x5edef90e, 0x29d9c998, 0xb0d09822, 0xc7d7a8b4,
0x59b33d17, 0x2eb40d81, 0xb7bd5c3b, 0xc0ba6cad,
0xedb88320, 0x9abfb3b6, 0x03b6e20c, 0x74b1d29a,
0xead54739, 0x9dd277af, 0x04db2615, 0x73dc1683,
0xe3630b12, 0x94643b84, 0x0d6d6a3e, 0x7a6a5aa8,
0xe40ecf0b, 0x9309ff9d, 0x0a00ae27, 0x7d079eb1,
0xf00f9344, 0x8708a3d2, 0x1e01f268, 0x6906c2fe,
0xf762575d, 0x806567cb, 0x196c3671, 0x6e6b06e7,
0xfed41b76, 0x89d32be0, 0x10da7a5a, 0x67dd4acc,
0xf9b9df6f, 0x8ebeeff9, 0x17b7be43, 0x60b08ed5,
0xd6d6a3e8, 0xa1d1937e, 0x38d8c2c4, 0x4fdff252,
0xd1bb67f1, 0xa6bc5767, 0x3fb506dd, 0x48b2364b,
0xd80d2bda, 0xaf0a1b4c, 0x36034af6, 0x41047a60,
0xdf60efc3, 0xa867df55, 0x316e8eef, 0x4669be79,
0xcb61b38c, 0xbc66831a, 0x256fd2a0, 0x5268e236,
0xcc0c7795, 0xbb0b4703, 0x220216b9, 0x5505262f,
0xc5ba3bbe, 0xb2bd0b28, 0x2bb45a92, 0x5cb36a04,
0xc2d7ffa7, 0xb5d0cf31, 0x2cd99e8b, 0x5bdeae1d,
0x9b64c2b0, 0xec63f226, 0x756aa39c, 0x026d930a,
0x9c0906a9, 0xeb0e363f, 0x72076785, 0x05005713,
0x95bf4a82, 0xe2b87a14, 0x7bb12bae, 0x0cb61b38,
0x92d28e9b, 0xe5d5be0d, 0x7cdcefb7, 0x0bdbdf21,
0x86d3d2d4, 0xf1d4e242, 0x68ddb3f8, 0x1fda836e,
0x81be16cd, 0xf6b9265b, 0x6fb077e1, 0x18b74777,
0x88085ae6, 0xff0f6a70, 0x66063bca, 0x11010b5c,
0x8f659eff, 0xf862ae69, 0x616bffd3, 0x166ccf45,
0xa00ae278, 0xd70dd2ee, 0x4e048354, 0x3903b3c2,
0xa7672661, 0xd06016f7, 0x4969474d, 0x3e6e77db,
0xaed16a4a, 0xd9d65adc, 0x40df0b66, 0x37d83bf0,
0xa9bcae53, 0xdebb9ec5, 0x47b2cf7f, 0x30b5ffe9,
0xbdbdf21c, 0xcabac28a, 0x53b39330, 0x24b4a3a6,
0xbad03605, 0xcdd70693, 0x54de5729, 0x23d967bf,
0xb3667a2e, 0xc4614ab8, 0x5d681b02, 0x2a6f2b94,
0xb40bbe37, 0xc30c8ea1, 0x5a05df1b, 0x2d02ef8d
};
public static byte CRC8_Table(byte[] buffer, int counter)
{
return CRC8_Table(buffer.AsSpan(0, counter));
}
public static byte CRC8_Table(ReadOnlySpan<byte> buffer)
{
byte crc8 = 0;
foreach (byte value in buffer)
{
byte new_index = (byte)(crc8 ^ value);
crc8 = CRC8Table[new_index];
}
return crc8;
}
// Fixed CRC16_Table method - accepts byte[] and int counter parameter
public static ushort CRC16_Table(byte[] buffer, int counter)
{
return CRC16_Table(buffer.AsSpan(0, counter));
}
public static ushort CRC16_Table(ReadOnlySpan<byte> buffer)
{
ushort crc16 = 0;
foreach (byte value in buffer)
{
crc16 = (ushort)(CRC16Table[((crc16 >> 8) ^ value) & 0xFF] ^ (crc16 << 8));
}
return crc16;
}
}
}

View File

@@ -0,0 +1,19 @@
1.Config USB
$sudo chmod 666 /dev/ttyUSB0
$sudo nano /etc/udev/rules.d/99-usb-serial.rules
``SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", MODE="0666", GROUP="dialout"``
2. Kill process robotnet10
$ps aux | grep -i robotnet | grep -v grep
$lsof /dev/ttyUSB0 2>&1 || echo "Port is free"
$kill 2381 4242 8011
$kill -9 2381 4242 8011
$lsof /dev/ttyUSB0
$./run.sh
3. HTML test
# Option A: Dùng default browser
xdg-open /home/robotics/sonvh/odometry_comparison_test.html
# Option B: Dùng specific browser
firefox /home/robotics/sonvh/odometry_comparison_test.html
# hoặc
google-chrome /home/robotics/sonvh/odometry_comparison_test.html

View File

@@ -0,0 +1,863 @@
using System.Diagnostics;
using System.Threading;
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.Shared;
using RobotNet10.Shared.Geometry;
using RobotNet10.Shared.Sensor;
namespace RobotNet10.RobotApp.Drivers.WheeltecIMU
{
[Device(DeviceType.Imu, "WheeltecN100IMU", "WheeltecN100IMU", "1.0.0", Description = "IMU Simulation Driver")]
public class WheeltecN100IMU : DeviceBase, IInertialMeasurementUnit
{
private readonly WheeltecReader IMU;
// Cached data
private AccelStamped _cachedAcceleration;
private Vector3Stamped _cachedAngularVelocity;
private Vector3Stamped _cachedMagnetometer;
private Vector3Stamped _cachedOrientation;
private QuaternionStamped _cachedQuaternion;
private double _cachedTemperature = 25.0;
private bool _isCalibrated = false;
private DateTime _lastUpdateTime = DateTime.UtcNow;
private double _sampleRate = 0.0;
private readonly string _portName;
private readonly int _timeOut;
private readonly int _baudRate;
private readonly bool _printDataEnabled;
private readonly TimeSpan _printDataInterval;
private Timer? _printDataTimer;
// Sample rate calculation
private int _sampleCount = 0;
private DateTime _sampleRateStartTime = DateTime.UtcNow;
private readonly TimeSpan _sampleRateWindow = TimeSpan.FromSeconds(1.0);
// Calibration: 2 giay dau sau lan nhan du lieu dau tien de thu thap bias (robot dung yen tuyet doi)
private static readonly TimeSpan CalibrationDuration = TimeSpan.FromSeconds(2.0);
private static readonly TimeSpan CalibrationWaitTimeout = TimeSpan.FromSeconds(5.0);
private const double GravityMps2 = 9.81;
private const double GravityToleranceMps2 = 2.0;
// IMU outlier validation thresholds
private const double MaxValidAccelerationMps2 = 50.0;
private const double MaxValidAngularVelocityRadS = 20.0;
// Thread-safety: Lock for cached sensor data (struct assignments are NOT atomic)
private readonly object _dataLock = new();
// High-precision timestamp using Stopwatch (DateTime.UtcNow has ~10-15ms precision)
private readonly Stopwatch _highPrecisionTimer = Stopwatch.StartNew();
private DateTime _timerStartUtc = DateTime.UtcNow;
private DateTime? _calibrationStartUtc;
private bool _calibrationDone;
private TaskCompletionSource<bool>? _calibrationCompletedTcs;
private double _calibrationSumAccX, _calibrationSumAccY, _calibrationSumAccZ;
private double _calibrationSumGx, _calibrationSumGy, _calibrationSumGz;
private double _calibrationSumRoll, _calibrationSumPitch, _calibrationSumYaw;
private int _calibrationCount;
private double _accBiasX, _accBiasY, _accBiasZ;
private double _gyroBiasX, _gyroBiasY, _gyroBiasZ;
private double _orientationBiasRoll, _orientationBiasPitch, _orientationBiasYaw;
// Yaw integration: tich phan CalibratedGz thay vi dung firmware AHRS Yaw (firmware drift ~0.009 rad/s)
// Roll/Pitch van dung firmware AHRS vi co gravity reference (khong drift)
private double _integratedYaw;
private DateTime _lastIntegrationTime;
// Diagnostic: log drift moi 5 giay
private DateTime _lastDiagnosticLog = DateTime.MinValue;
private static readonly TimeSpan DiagnosticLogInterval = TimeSpan.FromSeconds(5.0);
// Events (interface)
public event EventHandler<AccelerationChangedEventArgs>? AccelerationChanged;
public event EventHandler<AngularVelocityChangedEventArgs>? AngularVelocityChanged;
public event EventHandler<MagnetometerChangedEventArgs>? MagnetometerChanged;
public event EventHandler<OrientationChangedEventArgs>? OrientationChanged;
// Event thong nhat cho SensorPipeline
public event EventHandler<ImuDataChangedEventArgs>? ImuDataChanged;
public WheeltecN100IMU(string deviceId, string deviceName, IConfigurationSection connection)
: base(deviceId, deviceName, DeviceType.Imu)
{
_portName = connection.GetValue<string>("Port") ?? throw new Exception("Port is required");
_baudRate = connection.GetValue<int?>("BaudRate") ?? throw new Exception("BaudRate is required");
_timeOut = connection.GetValue<int?>("TimeOut") ?? throw new Exception("Timeout is required");
IMU = new WheeltecReader(_portName, _baudRate, _timeOut);
// Continuous IMU data printing (optional)
_printDataEnabled = connection.GetValue<bool?>("DebugEnabled") ?? false;
_printDataInterval = TimeSpan.FromMilliseconds(connection.GetValue<int?>("PrintDataIntervalMs") ?? 200);
// Doc cau hinh neu co
var autoReconnectEnabled = connection.GetValue<bool?>("AutoReconnectEnabled");
var reconnectDelayMs = connection.GetValue<int?>("ReconnectDelayMs");
var maxReconnectAttempts = connection.GetValue<int?>("MaxReconnectAttempts");
if (autoReconnectEnabled.HasValue)
AutoReconnectEnabled = autoReconnectEnabled.Value;
else
AutoReconnectEnabled = true;
if (reconnectDelayMs.HasValue)
ReconnectDelayMs = reconnectDelayMs.Value;
if (maxReconnectAttempts.HasValue)
MaxReconnectAttempts = maxReconnectAttempts.Value;
_cachedAcceleration = CreateAccelStamped(0, 0, 9.81, DateTime.UtcNow);
_cachedAngularVelocity = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
_cachedMagnetometer = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
_cachedOrientation = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
_cachedQuaternion = CreateQuaternionStamped(1, 0, 0, 0, DateTime.UtcNow);
// Khoi tao gia tri properties
UpdateProperties();
}
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
yield return new PropertyDescription("IsCalibrated", "Calibrated", "Trang thai calibrate")
{
DataType = "boolean",
IsReadOnly = true,
DisplayOrder = 1,
Category = "Trang thai",
DefaultValue = "true"
};
yield return new PropertyDescription("SampleRate", "Sample Rate (Hz)", "Tan so lay mau (Hz)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 2,
Category = "Cau hinh",
DefaultValue = "100"
};
yield return new PropertyDescription("Acceleration", "Acceleration (m/s²)", "Gia toc 3 truc (m/s²)")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 3,
Category = "Du lieu",
DefaultValue = "0, 0, 9.81"
};
yield return new PropertyDescription("AngularVelocity", "Angular Velocity (rad/s)", "Van toc goc 3 truc (rad/s)")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 4,
Category = "Du lieu",
DefaultValue = "0, 0, 0"
};
yield return new PropertyDescription("Orientation", "Orientation (rad)", "Huong Euler angles (rad)")
{
DataType = "string",
IsReadOnly = true,
DisplayOrder = 5,
Category = "Du lieu",
DefaultValue = "0, 0, 0"
};
yield return new PropertyDescription("Temperature", "Temperature (°C)", "Nhiet do cam bien (°C)")
{
DataType = "number",
IsReadOnly = true,
DisplayOrder = 6,
Category = "Du lieu",
DefaultValue = "25"
};
}
protected override async Task OnInitializeAsync(CancellationToken cancellationToken)
{
IMU.Connect();
await Task.CompletedTask;
}
protected override async Task OnConnectAsync(CancellationToken cancellationToken)
{
// Dam bao port da mo (sau Disconnect can Connect lai)
if (!IMU.IsConnected)
IMU.Connect();
if (_printDataEnabled)
{
_printDataTimer?.Dispose();
_printDataTimer = new Timer(_ =>
{
try
{
var acc = CachedAcceleration.Accel.Linear;
var gyro = CachedAngularVelocity.Vector;
var ori = CachedOrientation.Vector;
var temp = CachedTemperature;
var t = ((IInertialMeasurementUnit)this).LastUpdateTime;
var tempStr = temp.HasValue ? temp.Value.ToString("F2") : "NA";
Console.WriteLine(
$"{DateTime.Now:HH:mm:ss.ffffff} [IMU_DATA] " +
$"t={t:HH:mm:ss.fff} " +
$"acc=({acc.X:F3},{acc.Y:F3},{acc.Z:F3}) " +
$"gyro=({gyro.X:F5},{gyro.Y:F5},{gyro.Z:F5}) " +
$"rpy=({ori.X:F5},{ori.Y:F5},{ori.Z:F5}) " +
$"temp={tempStr}");
}
catch { }
}, null, dueTime: TimeSpan.Zero, period: _printDataInterval);
}
// Reset high-precision timer for accurate timestamps
_timerStartUtc = DateTime.UtcNow;
_highPrecisionTimer.Restart();
// Reset calibration de moi lan connect thu thap lai 2 giay dau
ResetCalibrationState();
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
// Dang ky event handler cho DataReceived tu WheeltecReader
IMU.DataReceived += IMU_DataReceived;
// Doi xu ly _calibrationDone (toi da CalibrationWaitTimeout), de connect chi hoan tat sau khi da calibrate
try
{
await Task.WhenAny(
_calibrationCompletedTcs.Task,
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// cancellationToken bi huy
}
_calibrationCompletedTcs = null;
}
protected override async Task OnDisconnectAsync(CancellationToken cancellationToken)
{
// Huy dang ky event handler
IMU.DataReceived -= IMU_DataReceived;
// Disconnect IMU de dung processing thread va serial port
IMU.Disconnect();
_printDataTimer?.Dispose();
_printDataTimer = null;
await Task.CompletedTask;
}
protected override async Task OnResetAsync(CancellationToken cancellationToken)
{
// Huy event va disconnect
IMU.DataReceived -= IMU_DataReceived;
IMU.Disconnect();
_printDataTimer?.Dispose();
_printDataTimer = null;
// Reset high-precision timer
_timerStartUtc = DateTime.UtcNow;
_highPrecisionTimer.Restart();
lock (_dataLock)
{
_cachedAcceleration = CreateAccelStamped(0, 0, 9.81, DateTime.UtcNow);
_cachedAngularVelocity = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
_cachedMagnetometer = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
_cachedOrientation = CreateVector3Stamped(0, 0, 0, DateTime.UtcNow);
_cachedQuaternion = CreateQuaternionStamped(1, 0, 0, 0, DateTime.UtcNow);
_cachedTemperature = 25.0;
_lastUpdateTime = DateTime.UtcNow;
}
// Reset calibration de sau khi connect lai thu thap 2 giay dau
ResetCalibrationState();
// Reset sample rate
_sampleCount = 0;
_sampleRate = 0.0;
_sampleRateStartTime = DateTime.UtcNow;
// Reconnect va bat dau calibration lai
IMU.Connect();
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
IMU.DataReceived += IMU_DataReceived;
// Doi calibration hoan tat
try
{
await Task.WhenAny(
_calibrationCompletedTcs.Task,
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// cancellationToken bi huy
}
_calibrationCompletedTcs = null;
UpdateProperties();
}
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
{
return Task.FromResult(IMU.IsConnected);
}
/// <summary>
/// Reset toan bo trang thai calibration ve gia tri ban dau
/// </summary>
private void ResetCalibrationState()
{
_calibrationStartUtc = null;
_calibrationDone = false;
_isCalibrated = false;
_calibrationSumAccX = _calibrationSumAccY = _calibrationSumAccZ = 0;
_calibrationSumGx = _calibrationSumGy = _calibrationSumGz = 0;
_calibrationSumRoll = _calibrationSumPitch = _calibrationSumYaw = 0;
_calibrationCount = 0;
_accBiasX = _accBiasY = _accBiasZ = 0;
_gyroBiasX = _gyroBiasY = _gyroBiasZ = 0;
_orientationBiasRoll = _orientationBiasPitch = _orientationBiasYaw = 0;
_integratedYaw = 0;
_lastIntegrationTime = DateTime.MinValue;
}
/// <summary>
/// Event handler cho DataReceived tu WheeltecReader.
/// 2 giay dau ke tu lan nhan du lieu dau tien: chi thu thap mau de tinh bias (robot dung yen tuyet doi).
/// Sau 2 giay: ap dung bias de calibrate, roi moi cap nhat _cached*, fire events, _sampleCount.
/// </summary>
private void IMU_DataReceived(object? sender, EventArgs e)
{
try
{
var snapshot = IMU.GetSnapshot();
// Use high-precision timer instead of DateTime.UtcNow (which has ~10-15ms precision)
var timestamp = _timerStartUtc + _highPrecisionTimer.Elapsed;
// Bat dau cua so calibration khi nhan du lieu lan dau
if (_calibrationStartUtc == null)
{
_calibrationStartUtc = timestamp;
_calibrationSumAccX = _calibrationSumAccY = _calibrationSumAccZ = 0;
_calibrationSumGx = _calibrationSumGy = _calibrationSumGz = 0;
_calibrationSumRoll = _calibrationSumPitch = _calibrationSumYaw = 0;
_calibrationCount = 0;
}
if (!_calibrationDone)
{
var calElapsed = timestamp - _calibrationStartUtc.Value;
if (calElapsed < CalibrationDuration)
{
// Trong 2 giay dau: chi tich luy mau, khong cap nhat cache / fire events / sampleCount
_calibrationSumAccX += snapshot.AccX;
_calibrationSumAccY += snapshot.AccY;
_calibrationSumAccZ += snapshot.AccZ;
_calibrationSumGx += snapshot.Gx;
_calibrationSumGy += snapshot.Gy;
_calibrationSumGz += snapshot.Gz;
_calibrationSumRoll += snapshot.Roll;
_calibrationSumPitch += snapshot.Pitch;
_calibrationSumYaw += snapshot.Yaw;
_calibrationCount++;
return;
}
// Het 2 giay: tinh bias va danh dau da calibrate
if (_calibrationCount > 0)
{
double n = _calibrationCount;
double meanAccX = _calibrationSumAccX / n;
double meanAccY = _calibrationSumAccY / n;
double meanAccZ = _calibrationSumAccZ / n;
// Validate gravity magnitude — neu lech qua xa 9.81 thi robot bi rung/di chuyen
double gravityMagnitude = Math.Sqrt(meanAccX * meanAccX + meanAccY * meanAccY + meanAccZ * meanAccZ);
if (Math.Abs(gravityMagnitude - GravityMps2) > GravityToleranceMps2)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecN100IMU] Calibration REJECTED: " +
$"GravityMag={gravityMagnitude:F4} (expected ~{GravityMps2}, tolerance ±{GravityToleranceMps2}). " +
$"Robot co the dang rung/di chuyen. Thu lai...");
_calibrationStartUtc = null;
_calibrationCount = 0;
_calibrationSumAccX = _calibrationSumAccY = _calibrationSumAccZ = 0;
_calibrationSumGx = _calibrationSumGy = _calibrationSumGz = 0;
_calibrationSumRoll = _calibrationSumPitch = _calibrationSumYaw = 0;
return;
}
// FIXED: Khong tru gravity khoi acceleration data!
// ImuTracker trong Cartographer CAN gravity de estimate orientation.
// Khi dung yen (Z up): acc ~ (0, 0, +9.81) — day la luc phan ung tu mat dat
//
// Chi calibrate bias nho (sensor offset) cho X va Y.
// Voi Z: tinh bias = meanAccZ - expected_gravity
double expectedGravityZ = meanAccZ > 0 ? GravityMps2 : -GravityMps2;
// Bias X,Y: offset khi dung yen (nen ~ 0 neu robot dat phang)
_accBiasX = meanAccX;
_accBiasY = meanAccY;
// Bias Z: chi tru phan offset, GIU NGUYEN gravity
_accBiasZ = meanAccZ - expectedGravityZ;
// Gyro bias: dung — khi dung yen angular velocity = 0
_gyroBiasX = _calibrationSumGx / n;
_gyroBiasY = _calibrationSumGy / n;
_gyroBiasZ = _calibrationSumGz / n;
// Orientation bias: giu lai cho display purposes
_orientationBiasRoll = _calibrationSumRoll / n;
_orientationBiasPitch = _calibrationSumPitch / n;
_orientationBiasYaw = _calibrationSumYaw / n;
_isCalibrated = true;
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecN100IMU] Calibration done (n={_calibrationCount}): " +
$"MeanAcc=({meanAccX:F4},{meanAccY:F4},{meanAccZ:F4}), GravityMag={gravityMagnitude:F4}, " +
$"AccBias=({_accBiasX:F4},{_accBiasY:F4},{_accBiasZ:F4}), " +
$"GyroBias=({_gyroBiasX:F6},{_gyroBiasY:F6},{_gyroBiasZ:F6})");
}
_calibrationDone = true;
_calibrationCompletedTcs?.TrySetResult(true);
_calibrationCompletedTcs = null;
// Khoi tao yaw integration tu thoi diem calibration xong
_integratedYaw = 0;
_lastIntegrationTime = timestamp;
// Bat dau dem sample rate tu sau calibration
_sampleRateStartTime = timestamp;
_sampleCount = 0;
}
// Ap dung bias: du lieu da calibrate (sau 2 giay moi chay toi day)
double accX = snapshot.AccX - _accBiasX;
double accY = snapshot.AccY - _accBiasY;
double accZ = snapshot.AccZ - _accBiasZ;
double gx = snapshot.Gx - _gyroBiasX;
double gy = snapshot.Gy - _gyroBiasY;
double gz = snapshot.Gz - _gyroBiasZ;
double roll = snapshot.Roll - _orientationBiasRoll;
double pitch = snapshot.Pitch - _orientationBiasPitch;
// Tich phan CalibratedGz de tinh Yaw thay vi dung firmware AHRS Yaw
// Firmware AHRS Yaw drift ~0.009 rad/s do tich phan gyro noi bo khong chinh xac
// CalibratedGz sau khi tru bias chi con ~0.00006 rad/s trung binh → giam drift 150 lan
double yaw;
if (_lastIntegrationTime != DateTime.MinValue)
{
double dt = (timestamp - _lastIntegrationTime).TotalSeconds;
_integratedYaw += gz * dt;
yaw = _integratedYaw;
}
else
{
yaw = 0;
}
_lastIntegrationTime = timestamp;
// Diagnostic log moi 5 giay: theo doi drift
if (timestamp - _lastDiagnosticLog >= DiagnosticLogInterval)
{
_lastDiagnosticLog = timestamp;
var elapsedSec = (timestamp - _timerStartUtc).TotalSeconds;
double firmwareYaw = snapshot.Yaw - _orientationBiasYaw;
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [IMU_DIAG] t={elapsedSec:F1}s | " +
$"CalibratedGz={gz:F6} | " +
$"IntegratedYaw={yaw:F6} FirmwareYaw={firmwareYaw:F6} | " +
$"Temp={snapshot.Temp:F2}");
}
// Outlier validation: reject data with unrealistic values
// This prevents ImuTracker corruption from EMI spikes or communication errors
double accMagnitude = Math.Sqrt(accX * accX + accY * accY + accZ * accZ);
double gyroMagnitude = Math.Sqrt(gx * gx + gy * gy + gz * gz);
if (accMagnitude > MaxValidAccelerationMps2 || gyroMagnitude > MaxValidAngularVelocityRadS)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecN100IMU] OUTLIER REJECTED: " +
$"accMag={accMagnitude:F2} m/s² (max={MaxValidAccelerationMps2}), " +
$"gyroMag={gyroMagnitude:F2} rad/s (max={MaxValidAngularVelocityRadS})");
return;
}
// Quaternion tu goc Euler da calibrate
var cosRoll = Math.Cos(roll / 2);
var sinRoll = Math.Sin(roll / 2);
var cosPitch = Math.Cos(pitch / 2);
var sinPitch = Math.Sin(pitch / 2);
var cosYaw = Math.Cos(yaw / 2);
var sinYaw = Math.Sin(yaw / 2);
var qw = cosRoll * cosPitch * cosYaw + sinRoll * sinPitch * sinYaw;
var qx = sinRoll * cosPitch * cosYaw - cosRoll * sinPitch * sinYaw;
var qy = cosRoll * sinPitch * cosYaw + sinRoll * cosPitch * sinYaw;
var qz = cosRoll * cosPitch * sinYaw - sinRoll * sinPitch * cosYaw;
var newAcceleration = CreateAccelStamped(accX, accY, accZ, timestamp);
var newAngularVelocity = CreateVector3Stamped(gx, gy, gz, timestamp);
var newMagnetometer = CreateVector3Stamped(snapshot.MagX, snapshot.MagY, snapshot.MagZ, timestamp);
var newOrientation = CreateVector3Stamped(roll, pitch, yaw, timestamp);
var newQuaternion = CreateQuaternionStamped(qw, qx, qy, qz, timestamp);
AccelStamped previousAcceleration;
Vector3Stamped previousOrientation;
// Thread-safe update of cached data using lock
// Struct assignments are NOT atomic — without lock, readers could get partially updated data
lock (_dataLock)
{
previousAcceleration = _cachedAcceleration;
previousOrientation = _cachedOrientation;
_cachedAcceleration = newAcceleration;
_cachedAngularVelocity = newAngularVelocity;
_cachedMagnetometer = newMagnetometer;
_cachedOrientation = newOrientation;
_cachedQuaternion = newQuaternion;
_cachedTemperature = snapshot.Temp;
_lastUpdateTime = timestamp;
}
// Fire unified event (SensorPipeline)
ImuDataChanged?.Invoke(this, new ImuDataChangedEventArgs(
newAcceleration,
newAngularVelocity,
newMagnetometer,
newOrientation,
timestamp));
// Fire individual events (interface — XlocIntegrationService, etc.)
if (Math.Abs(previousAcceleration.Accel.Linear.X - newAcceleration.Accel.Linear.X) > 0.1 ||
Math.Abs(previousAcceleration.Accel.Linear.Y - newAcceleration.Accel.Linear.Y) > 0.1 ||
Math.Abs(previousAcceleration.Accel.Linear.Z - newAcceleration.Accel.Linear.Z) > 0.1)
{
AccelerationChanged?.Invoke(this, new AccelerationChangedEventArgs(newAcceleration));
}
// Xloc requires a continuous IMU stream even when the robot is stationary.
// Emit angular velocity updates every sample instead of threshold-based changes.
AngularVelocityChanged?.Invoke(this, new AngularVelocityChangedEventArgs(newAngularVelocity));
MagnetometerChanged?.Invoke(this, new MagnetometerChangedEventArgs(newMagnetometer));
if (Math.Abs(previousOrientation.Vector.X - newOrientation.Vector.X) > 0.01 ||
Math.Abs(previousOrientation.Vector.Y - newOrientation.Vector.Y) > 0.01 ||
Math.Abs(previousOrientation.Vector.Z - newOrientation.Vector.Z) > 0.01)
{
OrientationChanged?.Invoke(this, new OrientationChangedEventArgs(newOrientation));
}
_sampleCount++;
var elapsed = timestamp - _sampleRateStartTime;
if (elapsed >= _sampleRateWindow)
{
_ = Task.Run(() =>
{
_sampleRate = _sampleCount / elapsed.TotalSeconds;
_sampleCount = 0;
_sampleRateStartTime = timestamp;
UpdateProperties();
});
}
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, "Error updating data from IMU");
}
}
/// <summary>
/// Cap nhat properties hien thi
/// </summary>
private void UpdateProperties()
{
var accel = _cachedAcceleration;
var angularVel = _cachedAngularVelocity;
var orientation = _cachedOrientation;
var temp = _cachedTemperature;
var sampleRate = _sampleRate;
var isCalibrated = _isCalibrated;
SetProperty("IsCalibrated", isCalibrated.ToString());
SetProperty("SampleRate", sampleRate.ToString("F1"));
SetProperty("Acceleration", $"{accel.Accel.Linear.X:F2}, {accel.Accel.Linear.Y:F2}, {accel.Accel.Linear.Z:F2}");
SetProperty("AngularVelocity", $"{angularVel.Vector.X:F3}, {angularVel.Vector.Y:F3}, {angularVel.Vector.Z:F3}");
SetProperty("Orientation", $"{orientation.Vector.X:F3}, {orientation.Vector.Y:F3}, {orientation.Vector.Z:F3}");
SetProperty("Temperature", temp.ToString("F2"));
}
private static AccelStamped CreateAccelStamped(double x, double y, double z, DateTime timestamp)
{
return new AccelStamped
{
Header = new Header
{
Stamp = timestamp,
FrameId = "imu_frame"
},
Accel = new Accel
{
Linear = new Vector3(x, y, z),
Angular = new Vector3(0, 0, 0)
}
};
}
private static Vector3Stamped CreateVector3Stamped(double x, double y, double z, DateTime timestamp)
{
return new Vector3Stamped
{
Header = new Header
{
Stamp = timestamp,
FrameId = "imu_frame"
},
Vector = new Vector3(x, y, z)
};
}
private static QuaternionStamped CreateQuaternionStamped(double w, double x, double y, double z, DateTime timestamp)
{
return new QuaternionStamped
{
Header = new Header
{
Stamp = timestamp,
FrameId = "imu_frame"
},
Quaternion = new Quaternion(x, y, z, w)
};
}
#region IInertialMeasurementUnit Implementation
bool IInertialMeasurementUnit.IsConnected => base.IsConnected;
public bool IsCalibrated
{
get { return _isCalibrated; }
}
public double SampleRate
{
get
{
Thread.MemoryBarrier();
return _sampleRate;
}
}
public AccelStamped CachedAcceleration
{
get { lock (_dataLock) { return _cachedAcceleration; } }
}
public Vector3Stamped CachedAngularVelocity
{
get { lock (_dataLock) { return _cachedAngularVelocity; } }
}
public Vector3Stamped? CachedMagnetometer
{
get { lock (_dataLock) { return _cachedMagnetometer; } }
}
public Vector3Stamped CachedOrientation
{
get { lock (_dataLock) { return _cachedOrientation; } }
}
public QuaternionStamped? CachedQuaternion
{
get { lock (_dataLock) { return _cachedQuaternion; } }
}
public double? CachedTemperature
{
get { lock (_dataLock) { return _cachedTemperature; } }
}
DateTime IInertialMeasurementUnit.LastUpdateTime
{
get { lock (_dataLock) { return _lastUpdateTime; } }
}
public async Task<AccelStamped> ReadAccelerationAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
return CachedAcceleration;
}
public async Task<Vector3Stamped> ReadAngularVelocityAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
return CachedAngularVelocity;
}
public async Task<Vector3Stamped?> ReadMagnetometerAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
return CachedMagnetometer;
}
public async Task<Vector3Stamped> ReadOrientationAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
return CachedOrientation;
}
public async Task<QuaternionStamped?> ReadQuaternionAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
return CachedQuaternion;
}
public async Task<Imu> ReadAllDataAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
lock (_dataLock)
{
return CreateImuFromCachedData();
}
}
private Imu CreateImuFromCachedData()
{
var timestamp = _lastUpdateTime != default ? _lastUpdateTime : DateTime.UtcNow;
var orientation = _cachedQuaternion.Quaternion;
var orientationCovariance = new double[Imu.OrientationCovarianceSize];
var angularVelocityCovariance = new double[Imu.AngularVelocityCovarianceSize];
var linearAccelerationCovariance = new double[Imu.LinearAccelerationCovarianceSize];
double gyroVariance = 1e-4;
angularVelocityCovariance[0] = gyroVariance;
angularVelocityCovariance[4] = gyroVariance;
angularVelocityCovariance[8] = gyroVariance;
double accelVariance = 1e-3;
linearAccelerationCovariance[0] = accelVariance;
linearAccelerationCovariance[4] = accelVariance;
linearAccelerationCovariance[8] = accelVariance;
orientationCovariance[0] = 0.001;
orientationCovariance[4] = 0.001;
orientationCovariance[8] = 0.002;
return new Imu(
header: new Header
{
Stamp = timestamp,
FrameId = "imu_frame"
},
orientation: orientation,
orientationCovariance: orientationCovariance,
angularVelocity: _cachedAngularVelocity.Vector,
angularVelocityCovariance: angularVelocityCovariance,
linearAcceleration: _cachedAcceleration.Accel.Linear,
linearAccelerationCovariance: linearAccelerationCovariance
);
}
public async Task<double?> ReadTemperatureAsync(CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
return CachedTemperature;
}
public async Task CalibrateAsync(CancellationToken cancellationToken = default)
{
// Huy event, reset calibration, dang ky lai event va doi calib
IMU.DataReceived -= IMU_DataReceived;
ResetCalibrationState();
_timerStartUtc = DateTime.UtcNow;
_highPrecisionTimer.Restart();
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
IMU.DataReceived += IMU_DataReceived;
try
{
await Task.WhenAny(
_calibrationCompletedTcs.Task,
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// cancellationToken bi huy
}
_calibrationCompletedTcs = null;
UpdateProperties();
}
public async Task CalibrateMagnetometerAsync(CancellationToken cancellationToken = default)
{
await Task.Delay(2000, cancellationToken);
}
public async Task ResetCalibrationAsync(CancellationToken cancellationToken = default)
{
// Huy event, reset state, dang ky lai va doi calib moi
IMU.DataReceived -= IMU_DataReceived;
ResetCalibrationState();
_timerStartUtc = DateTime.UtcNow;
_highPrecisionTimer.Restart();
_calibrationCompletedTcs = new TaskCompletionSource<bool>();
IMU.DataReceived += IMU_DataReceived;
try
{
await Task.WhenAny(
_calibrationCompletedTcs.Task,
Task.Delay(CalibrationWaitTimeout, cancellationToken)).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// cancellationToken bi huy
}
_calibrationCompletedTcs = null;
UpdateProperties();
}
public async Task SetSampleRateAsync(double sampleRate, CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
_sampleRate = Math.Max(1, Math.Min(1000, sampleRate));
UpdateProperties();
}
public async Task SetAccelerometerRangeAsync(double range, CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
}
public async Task SetGyroscopeRangeAsync(double range, CancellationToken cancellationToken = default)
{
await Task.CompletedTask;
}
#endregion
protected override void Dispose(bool disposing)
{
if (disposing)
{
IMU.DataReceived -= IMU_DataReceived;
IMU.Disconnect();
IMU.Dispose();
_printDataTimer?.Dispose();
_printDataTimer = null;
}
base.Dispose(disposing);
}
}
}

View File

@@ -0,0 +1,620 @@
using System;
using System.Collections.Generic;
using System.IO.Ports;
using System.Threading;
namespace RobotNet10.RobotApp.Drivers.WheeltecIMU
{
public class WheeltecReader : IDisposable
{
// Buffer nho de tich luy du lieu cho den khi co du mot frame hoan chinh
// Frame lon nhat: INSGPS = 8 + 84 = 92 bytes, dung 256 bytes de dam bao an toan
private const int FRAME_BUFFER_SIZE = 256;
private readonly byte[] _frameBuffer = new byte[FRAME_BUFFER_SIZE];
private int _frameBufferLength = 0;
// Non-volatile field de dung voi Volatile.Write lam memory barrier
private int _memoryBarrier = 0;
// Thread doc du lieu tu SerialPort voi priority cao
private Thread? _readingThread;
private volatile bool _shouldRead = false;
private CancellationTokenSource? _readingCts;
// Event de thong bao khi co du lieu moi duoc decode
public event EventHandler? DataReceived;
// Properties - lock-free voi memory barriers (volatile khong ho tro double)
public double Roll { get; private set; }
public double Pitch { get; private set; }
public double Yaw { get; private set; }
public uint Time_stamp { get; private set; }
public double Gx { get; private set; }
public double Gy { get; private set; }
public double Gz { get; private set; }
public double AccX { get; private set; }
public double AccY { get; private set; }
public double AccZ { get; private set; }
public double MagX { get; private set; }
public double MagY { get; private set; }
public double MagZ { get; private set; }
public double Temp { get; private set; }
public double Rollspeed { get; private set; }
public double Pitchspeed { get; private set; }
public double Yawspeed { get; private set; }
const byte FRAME_HEAD = 0xFC;
const byte FRAME_END = 0xFD;
// Loai goi
const byte TYPE_IMU = 0x40;
const byte TYPE_AHRS = 0x41;
const byte TYPE_INSGPS = 0x42;
const byte TYPE_GROUND = 0xF0;
// Chieu dai payload
const byte IMU_LEN = 0x38; // 56
const byte AHRS_LEN = 0x30; // 48
const byte INSGPS_LEN = 0x54; // 84
// Dictionary de map datatype -> expectedLength
private static readonly Dictionary<byte, byte> DataTypeLengthMap = new()
{
{ TYPE_IMU, IMU_LEN },
{ TYPE_AHRS, AHRS_LEN },
{ TYPE_INSGPS, INSGPS_LEN }
};
private SerialPort? serial;
// Luu thong so port de tao lai SerialPort khi reconnect
private readonly string _portName;
private readonly int _baudRate;
private readonly int _timeOut;
// Thoi gian backoff giua cac lan thu reconnect (ms)
private const int RECONNECT_BACKOFF_MS = 1000;
// Watchdog: neu qua khoang thoi gian nay khong co frame hop le -> coi nhu mat ket noi
// va trigger reconnect. Dung cho truong hop USB "chet mem" khong ne'm exception.
private const long DATA_TIMEOUT_TICKS = 3 * TimeSpan.TicksPerSecond;
private long _lastFrameTicks;
public bool IsConnected => serial != null && serial.IsOpen;
public WheeltecReader(string portName, int baudRate, int timeOut)
{
_portName = portName;
_baudRate = baudRate;
_timeOut = timeOut;
serial = CreateSerialPort();
}
private SerialPort CreateSerialPort()
{
return new SerialPort()
{
PortName = _portName,
BaudRate = _baudRate,
ReadTimeout = _timeOut,
Parity = Parity.None,
StopBits = StopBits.One,
DataBits = 8,
};
}
/// <summary>
/// Snapshot structure de lay tat ca du lieu cung luc mot cach thread-safe
/// </summary>
public struct DataSnapshot
{
public double AccX, AccY, AccZ;
public double Gx, Gy, Gz;
public double MagX, MagY, MagZ;
public double Roll, Pitch, Yaw;
public double Temp;
}
/// <summary>
/// Lay snapshot cua tat ca du lieu hien tai mot cach thread-safe
/// Dam bao tat ca cac gia tri deu tu cung mot thoi diem
/// </summary>
public DataSnapshot GetSnapshot()
{
Volatile.Read(ref _memoryBarrier);
return new DataSnapshot
{
AccX = AccX,
AccY = AccY,
AccZ = AccZ,
Gx = Gx,
Gy = Gy,
Gz = Gz,
MagX = MagX,
MagY = MagY,
MagZ = MagZ,
Roll = Roll,
Pitch = Pitch,
Yaw = Yaw,
Temp = Temp
};
}
public void Connect()
{
// Reset frame buffer khi ket noi moi
_frameBufferLength = 0;
Array.Clear(_frameBuffer, 0, FRAME_BUFFER_SIZE);
// Reading thread se tu mo port trong outer loop va tu reconnect khi mat ket noi
StartReadingThread();
}
/// <summary>
/// Khoi dong reading thread voi priority cao de doc du lieu tu SerialPort
/// </summary>
private void StartReadingThread()
{
if (_readingThread != null && _readingThread.IsAlive)
return;
_shouldRead = true;
// Tao moi CancellationTokenSource cho thread moi
_readingCts?.Dispose();
_readingCts = new CancellationTokenSource();
_readingThread = new Thread(() => ReadingThreadLoop(_readingCts.Token))
{
Name = "WheeltecIMU-Reading",
IsBackground = false,
Priority = ThreadPriority.Highest
};
_readingThread.Start();
}
/// <summary>
/// Dung reading thread
/// </summary>
private void StopReadingThread()
{
_shouldRead = false;
_readingCts?.Cancel();
if (_readingThread != null)
{
if (!_readingThread.Join(1000))
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Reading thread did not stop gracefully");
}
_readingThread = null;
}
// Dispose CancellationTokenSource sau khi thread da dung
_readingCts?.Dispose();
_readingCts = null;
}
/// <summary>
/// Reading thread loop - outer loop xu ly reconnect, inner loop doc du lieu
/// Moi exception tu SerialPort deu duoc bat de tranh crash thread va tu dong
/// reconnect sau RECONNECT_BACKOFF_MS
/// </summary>
private void ReadingThreadLoop(CancellationToken cancellationToken)
{
Thread.BeginThreadAffinity();
byte[] readBuffer = new byte[256];
while (_shouldRead && !cancellationToken.IsCancellationRequested)
{
try
{
if (serial == null || !serial.IsOpen)
{
SafeCloseSerial();
serial = CreateSerialPort();
serial.Open();
_frameBufferLength = 0;
serial.DiscardInBuffer();
_lastFrameTicks = DateTime.UtcNow.Ticks;
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Connected to {_portName}");
}
InnerReadLoop(readBuffer, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] IMU disconnected: {ex.Message}");
SafeCloseSerial();
}
if (_shouldRead && !cancellationToken.IsCancellationRequested)
{
cancellationToken.WaitHandle.WaitOne(RECONNECT_BACKOFF_MS);
}
}
SafeCloseSerial();
Thread.EndThreadAffinity();
}
/// <summary>
/// Inner loop doc du lieu tu serial port. Thoat khi port dong hoac co exception
/// de outer loop xu ly reconnect
/// </summary>
private void InnerReadLoop(byte[] readBuffer, CancellationToken cancellationToken)
{
while (_shouldRead && !cancellationToken.IsCancellationRequested
&& serial != null && serial.IsOpen)
{
int bytesToRead = serial.BytesToRead;
if (bytesToRead <= 0)
{
if (DateTime.UtcNow.Ticks - _lastFrameTicks > DATA_TIMEOUT_TICKS)
{
throw new TimeoutException($"No IMU frame for > {DATA_TIMEOUT_TICKS / TimeSpan.TicksPerSecond}s");
}
Thread.Sleep(1);
continue;
}
int bytesRead = serial.Read(readBuffer, 0, Math.Min(bytesToRead, readBuffer.Length));
if (bytesRead <= 0) continue;
ProcessIncomingData(readBuffer, bytesRead);
}
}
/// <summary>
/// Dong va dispose SerialPort an toan, set serial=null de lan sau tao moi.
/// SerialPort sau IOException thuong khong Open() lai duoc tren Linux nen phai tao moi.
/// </summary>
private void SafeCloseSerial()
{
if (serial == null) return;
try
{
serial.Close();
}
catch (Exception ex)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Error closing serial: {ex.Message}");
}
try { serial.Dispose(); }
catch { }
serial = null;
}
/// <summary>
/// Xu ly du lieu moi nhan duoc tu serial port
/// Them vao frame buffer va tim, parse cac frame hoan chinh
/// </summary>
private void ProcessIncomingData(byte[] data, int length)
{
int dataOffset = 0;
while (dataOffset < length)
{
ProcessCompleteFramesInBuffer();
int availableSpace = FRAME_BUFFER_SIZE - _frameBufferLength;
if (availableSpace == 0)
{
RemoveIncompleteFrameAtStart();
availableSpace = FRAME_BUFFER_SIZE - _frameBufferLength;
if (availableSpace == 0)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Buffer still full after removing incomplete frame, clearing buffer");
_frameBufferLength = 0;
availableSpace = FRAME_BUFFER_SIZE;
}
}
int bytesToAdd = Math.Min(length - dataOffset, availableSpace);
Array.Copy(data, dataOffset, _frameBuffer, _frameBufferLength, bytesToAdd);
_frameBufferLength += bytesToAdd;
dataOffset += bytesToAdd;
ProcessCompleteFramesInBuffer();
}
}
/// <summary>
/// Xu ly tat ca cac frame hoan chinh trong buffer hien tai
/// </summary>
private void ProcessCompleteFramesInBuffer()
{
while (_frameBufferLength > 0)
{
ReadOnlySpan<byte> bufferSpan = new(_frameBuffer, 0, _frameBufferLength);
int headIndex = bufferSpan.IndexOf(FRAME_HEAD);
if (headIndex < 0)
{
_frameBufferLength = 0;
break;
}
if (headIndex > 0)
{
int remainingBytes = _frameBufferLength - headIndex;
Array.Copy(_frameBuffer, headIndex, _frameBuffer, 0, remainingBytes);
_frameBufferLength = remainingBytes;
continue;
}
if (_frameBufferLength < 8)
{
break;
}
byte datatype = _frameBuffer[1];
byte payloadLength = _frameBuffer[2];
if (!DataTypeLengthMap.TryGetValue(datatype, out byte expectedLength))
{
int remainingBytes = _frameBufferLength - 1;
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingBytes);
_frameBufferLength = remainingBytes;
continue;
}
if (payloadLength != expectedLength)
{
int remainingBytes = _frameBufferLength - 1;
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingBytes);
_frameBufferLength = remainingBytes;
continue;
}
int totalFrameLength = 8 + payloadLength;
if (_frameBufferLength < totalFrameLength)
{
break;
}
if (_frameBuffer[7 + payloadLength] != FRAME_END)
{
int remainingBytes = _frameBufferLength - 1;
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingBytes);
_frameBufferLength = remainingBytes;
continue;
}
byte[] frame = new byte[totalFrameLength];
Array.Copy(_frameBuffer, 0, frame, 0, totalFrameLength);
int remainingAfterFrame = _frameBufferLength - totalFrameLength;
if (remainingAfterFrame > 0)
{
Array.Copy(_frameBuffer, totalFrameLength, _frameBuffer, 0, remainingAfterFrame);
}
_frameBufferLength = remainingAfterFrame;
try
{
if (ParseFrame(frame))
{
_lastFrameTicks = DateTime.UtcNow.Ticks;
try
{
DataReceived?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] DataReceived subscriber error: {ex.Message}");
}
}
}
catch (Exception ex)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss.ffffff} [WheeltecReader] Error parsing frame: {ex.Message}");
}
}
}
/// <summary>
/// Loai bo frame thieu o dau buffer va tim frame head tiep theo
/// </summary>
private void RemoveIncompleteFrameAtStart()
{
if (_frameBufferLength == 0)
return;
ReadOnlySpan<byte> bufferSpan = new(_frameBuffer, 0, _frameBufferLength);
int headIndex = bufferSpan.IndexOf(FRAME_HEAD);
if (headIndex < 0)
{
_frameBufferLength = 0;
return;
}
if (headIndex == 0)
{
if (_frameBufferLength < 8)
return;
byte datatype = _frameBuffer[1];
if (!DataTypeLengthMap.TryGetValue(datatype, out byte expectedLength))
{
int remainingAfterSkip = _frameBufferLength - 1;
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingAfterSkip);
_frameBufferLength = remainingAfterSkip;
return;
}
byte payloadLength = _frameBuffer[2];
if (payloadLength != expectedLength)
{
int remainingAfterSkip = _frameBufferLength - 1;
Array.Copy(_frameBuffer, 1, _frameBuffer, 0, remainingAfterSkip);
_frameBufferLength = remainingAfterSkip;
return;
}
int totalFrameLength = 8 + payloadLength;
if (_frameBufferLength < totalFrameLength)
return;
return;
}
int remainingAfterHead = _frameBufferLength - headIndex;
Array.Copy(_frameBuffer, headIndex, _frameBuffer, 0, remainingAfterHead);
_frameBufferLength = remainingAfterHead;
}
public void Disconnect()
{
StopReadingThread();
SafeCloseSerial();
_frameBufferLength = 0;
Array.Clear(_frameBuffer, 0, FRAME_BUFFER_SIZE);
}
private void DecodeIMU(byte[] payload)
{
Gx = BitConverter.ToSingle(payload, 0);
Gy = BitConverter.ToSingle(payload, 4);
Gz = BitConverter.ToSingle(payload, 8);
AccX = BitConverter.ToSingle(payload, 12);
AccY = BitConverter.ToSingle(payload, 16);
AccZ = BitConverter.ToSingle(payload, 20);
MagX = BitConverter.ToSingle(payload, 24);
MagY = BitConverter.ToSingle(payload, 28);
MagZ = BitConverter.ToSingle(payload, 32);
Temp = BitConverter.ToSingle(payload, 36);
Time_stamp = BitConverter.ToUInt32(payload, 40);
Volatile.Write(ref _memoryBarrier, 0);
}
private void DecodeAHRS(byte[] payload)
{
double rollspeed = BitConverter.ToSingle(payload, 0);
double pitchspeed = BitConverter.ToSingle(payload, 4);
double yawspeed = BitConverter.ToSingle(payload, 8);
double roll = BitConverter.ToSingle(payload, 12);
double pitch = BitConverter.ToSingle(payload, 16);
double yaw = BitConverter.ToSingle(payload, 20);
Rollspeed = rollspeed;
Pitchspeed = pitchspeed;
Yawspeed = yawspeed;
Roll = roll;
Pitch = pitch;
Yaw = yaw;
Volatile.Write(ref _memoryBarrier, 0);
}
private void DecodeINSGPS(byte[] payload)
{
double latitude = BitConverter.ToDouble(payload, 0);
double longitude = BitConverter.ToDouble(payload, 8);
double altitude = BitConverter.ToSingle(payload, 16);
double vn = BitConverter.ToSingle(payload, 20);
double ve = BitConverter.ToSingle(payload, 24);
double vd = BitConverter.ToSingle(payload, 28);
double roll = BitConverter.ToSingle(payload, 32);
double pitch = BitConverter.ToSingle(payload, 36);
double yaw = BitConverter.ToSingle(payload, 40);
double qw = BitConverter.ToSingle(payload, 44);
double qx = BitConverter.ToSingle(payload, 48);
double qy = BitConverter.ToSingle(payload, 52);
double qz = BitConverter.ToSingle(payload, 56);
}
/// <summary>
/// Parse mot frame hoan chinh tu buffer
/// </summary>
private bool ParseFrame(byte[] frame)
{
if (frame.Length < 8)
return false;
byte head = frame[0];
if (head != FRAME_HEAD)
return false;
byte datatype = frame[1];
byte length = frame[2];
byte sn = frame[3];
byte crc8 = frame[4];
byte crc16_h = frame[5];
byte crc16_l = frame[6];
ushort head_crc16 = (ushort)(crc16_l + (crc16_h << 8));
Span<byte> header = [head, datatype, length, sn];
byte crc8_calc = CRCTable.CRC8_Table(header);
if (crc8_calc != crc8)
{
throw new Exception($"CRC8 header error: recv={crc8:X2}, calc={crc8_calc:X2}");
}
if (frame[7 + length] != FRAME_END)
{
throw new Exception($"Frame end error: {BitConverter.ToString(frame)}");
}
ReadOnlySpan<byte> payload = frame.AsSpan(7, length);
ushort crc16_calc = CRCTable.CRC16_Table(payload);
if (crc16_calc != head_crc16)
{
throw new Exception($"CRC16 payload error: recv={head_crc16:X4}, calc={crc16_calc:X4}");
}
switch (datatype)
{
case TYPE_AHRS:
DecodeAHRS([..payload]);
break;
case TYPE_IMU:
DecodeIMU([..payload]);
break;
case TYPE_INSGPS:
DecodeINSGPS([..payload]);
break;
}
return true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
Disconnect();
}
}
}
}

View File

@@ -0,0 +1,380 @@
using System.Diagnostics;
using System.IO.Ports;
using Microsoft.Extensions.Logging;
namespace RobotNet10.RobotApp.Drivers.YNZDH;
public class ModbusRtuClient : IDisposable
{
private readonly ILogger? _logger;
private SerialPort? _port;
private readonly string _portName;
private readonly int _baud;
private readonly Parity _parity;
private readonly int _dataBits;
private readonly StopBits _stopBits;
private readonly int _readTimeoutMs;
private readonly int _writeTimeoutMs;
private readonly object _sync = new();
private bool _faulted = false;
private DateTime _lastRetry = DateTime.MinValue;
private int _retryDelayMs = 1000; // backoff min = 1s
private DateTime _lastDataTime = DateTime.MinValue;
private readonly int _dataTimeoutMs = 10_000; // 10 giây
private int _consecutiveFails = 0;
private readonly int _maxFails = 3; // sau 3 lần fail liên tiếp thì coi như lost
public bool IsFaulted => _faulted;
public ModbusRtuClient(string portName,
int baud = 9600,
Parity parity = Parity.None,
int dataBits = 8,
StopBits stopBits = StopBits.One,
int readTimeoutMs = 50,
int writeTimeoutMs = 50,
ILogger? logger = null)
{
_logger = logger;
_portName = portName;
_baud = baud;
_parity = parity;
_dataBits = dataBits;
_stopBits = stopBits;
_readTimeoutMs = readTimeoutMs;
_writeTimeoutMs = writeTimeoutMs;
_lastDataTime = DateTime.Now;
_consecutiveFails = 0;
EnsureConnected();
}
// -------------------------
// AUTO RECONNECT (giống TadaRs485Client)
// -------------------------
// NOTE: This method uses lock (_sync) which may cause contention if called from multiple threads.
// If called from a non-realtime thread, it may delay realtime polling thread.
private void EnsureConnected()
{
var ensureStartTicks = Stopwatch.GetTimestamp();
double lockAcquisitionMs = 0;
double checkTimeMs = 0;
double disposeTimeMs = 0;
double createTimeMs = 0;
double openTimeMs = 0;
int currentThreadId = Thread.CurrentThread.ManagedThreadId;
string? currentThreadName = Thread.CurrentThread.Name;
var lockStartTicks = Stopwatch.GetTimestamp();
lock (_sync)
{
var lockEndTicks = Stopwatch.GetTimestamp();
lockAcquisitionMs = ((lockEndTicks - lockStartTicks) * 1000.0) / Stopwatch.Frequency;
var checkStartTicks = Stopwatch.GetTimestamp();
if (_port != null && _port.IsOpen && !_faulted)
{
var checkEndTicks = Stopwatch.GetTimestamp();
checkTimeMs = ((checkEndTicks - checkStartTicks) * 1000.0) / Stopwatch.Frequency;
return;
}
if ((DateTime.Now - _lastRetry).TotalMilliseconds < _retryDelayMs)
{
var checkEndTicks = Stopwatch.GetTimestamp();
checkTimeMs = ((checkEndTicks - checkStartTicks) * 1000.0) / Stopwatch.Frequency;
return;
}
var checkEndTicks2 = Stopwatch.GetTimestamp();
checkTimeMs = ((checkEndTicks2 - checkStartTicks) * 1000.0) / Stopwatch.Frequency;
_lastRetry = DateTime.Now;
try
{
var disposeStartTicks = Stopwatch.GetTimestamp();
_port?.Dispose();
var disposeEndTicks = Stopwatch.GetTimestamp();
disposeTimeMs = ((disposeEndTicks - disposeStartTicks) * 1000.0) / Stopwatch.Frequency;
var createStartTicks = Stopwatch.GetTimestamp();
_port = new SerialPort(_portName, _baud, _parity, _dataBits, _stopBits)
{
ReadTimeout = _readTimeoutMs,
WriteTimeout = _writeTimeoutMs,
// Set buffer sizes to minimize kernel delays
ReadBufferSize = 4096,
WriteBufferSize = 4096
};
var createEndTicks = Stopwatch.GetTimestamp();
createTimeMs = ((createEndTicks - createStartTicks) * 1000.0) / Stopwatch.Frequency;
var openStartTicks = Stopwatch.GetTimestamp();
_port.Open();
var openEndTicks = Stopwatch.GetTimestamp();
openTimeMs = ((openEndTicks - openStartTicks) * 1000.0) / Stopwatch.Frequency;
_faulted = false;
_retryDelayMs = 1000; // reset backoff
}
catch (Exception ex)
{
_logger?.LogError("[ModbusRtuClient] Connect failed: {ex.Message}", ex.Message);
_faulted = true;
// exponential backoff giống Tada
_retryDelayMs = Math.Min(_retryDelayMs * 2, 30_000);
}
}
var ensureEndTicks = Stopwatch.GetTimestamp();
var ensureTotalMs = ((ensureEndTicks - ensureStartTicks) * 1000.0) / Stopwatch.Frequency;
// Log if EnsureConnected took longer than 10ms (should be very fast if already connected)
if (ensureTotalMs > 10.0 && _logger != null)
{
_logger.LogWarning(
"[ModbusRtuClient] Slow EnsureConnected: Total={TotalMs:F1}ms, ThreadId={ThreadId}, ThreadName={ThreadName}, " +
"LockAcquisition={LockAcquisitionMs:F1}ms, Check={CheckTimeMs:F1}ms, " +
"Dispose={DisposeTimeMs:F1}ms, Create={CreateTimeMs:F1}ms, Open={OpenTimeMs:F1}ms. " +
"NOTE: High LockAcquisition time indicates lock contention from other threads.",
ensureTotalMs, currentThreadId, currentThreadName ?? "Unknown",
lockAcquisitionMs, checkTimeMs, disposeTimeMs, createTimeMs, openTimeMs);
}
}
private void CheckDataTimeout()
{
if (_lastDataTime != DateTime.MinValue &&
(DateTime.Now - _lastDataTime).TotalMilliseconds > _dataTimeoutMs)
{
_logger?.LogError("[ModbusRtuClient] Communication lost (data timeout).");
_faulted = true;
_lastDataTime = DateTime.MinValue; // reset để tránh spam log
}
}
public void ForceReconnect()
{
lock (_sync)
{
try
{
if (_port != null)
{
try { if (_port.IsOpen) _port.Close(); } catch { }
_port.Dispose();
_port = null;
}
}
catch { }
_faulted = false;
_lastRetry = DateTime.MinValue;
_retryDelayMs = 1000;
_consecutiveFails = 0;
_lastDataTime = DateTime.Now;
EnsureConnected();
}
}
// -------------------------
// CRC
// -------------------------
private static ushort Crc16(byte[] data, int len)
{
ushort crc = 0xFFFF;
for (int i = 0; i < len; i++)
{
crc ^= data[i];
for (int j = 0; j < 8; j++)
{
bool lsb = (crc & 0x0001) != 0;
crc >>= 1;
if (lsb) crc ^= 0xA001;
}
}
return crc;
}
// -------------------------
// TX/RX WITH RECONNECT
// -------------------------
private byte[] TxRx(byte[] req, int respLen)
{
EnsureConnected();
if (_port == null || !_port.IsOpen || _faulted)
throw new Exception("Modbus port not available");
try
{
// Build frame
ushort crc = Crc16(req, req.Length);
byte[] frame = new byte[req.Length + 2];
Array.Copy(req, frame, req.Length);
frame[^2] = (byte)(crc & 0xFF); // CRC Lo
frame[^1] = (byte)(crc >> 8 & 0xFF); // CRC Hi
// Discard buffer and write
_port.DiscardInBuffer();
_port.DiscardOutBuffer();
_port.Write(frame, 0, frame.Length);
// Read response
byte[] buf = new byte[respLen];
int got = 0;
while (got < respLen)
{
int bytesToRead = respLen - got;
int bytesRead = _port.Read(buf, got, bytesToRead); // may throw TimeoutException
got += bytesRead;
}
// Verify CRC
if (got < 3)
{
_consecutiveFails++;
if (_consecutiveFails >= _maxFails)
{
_logger?.LogError("[ModbusRtuClient] Communication lost (response too short).");
_faulted = true;
}
CheckDataTimeout();
throw new Exception("Response too short");
}
ushort rxCrc = (ushort)(buf[got - 2] | buf[got - 1] << 8);
ushort calc = Crc16(buf, got - 2);
if (rxCrc != calc)
{
_consecutiveFails++;
if (_consecutiveFails >= _maxFails)
{
_logger?.LogError("[ModbusRtuClient] Communication lost (CRC mismatch).");
_faulted = true;
}
CheckDataTimeout();
throw new Exception("CRC mismatch");
}
// Success - reset fail counter and update last data time
_lastDataTime = DateTime.Now;
_consecutiveFails = 0;
return buf;
}
catch (Exception ex)
{
_logger?.LogError("[ModbusRtuClient] IO error: {ExMessage}", ex.Message);
_consecutiveFails++;
if (_consecutiveFails >= _maxFails)
{
_logger?.LogError("[ModbusRtuClient] Communication lost (too many failed reads).");
_faulted = true;
}
CheckDataTimeout();
EnsureConnected(); // thử reconnect
throw;
}
}
/// <summary>
/// Read Holding Registers (FC 0x03)
/// </summary>
public ushort[] ReadHoldingRegisters(byte slave, ushort startAddr, ushort quantity)
{
byte[] pdu =
[
slave, 0x03,
(byte)(startAddr >> 8), (byte)(startAddr & 0xFF),
(byte)(quantity >> 8), (byte)(quantity & 0xFF),
];
// Expected response: [slave][0x03][byteCount][data...][CRClo][CRChi]
int byteCount = quantity * 2;
int respLen = 3 + byteCount + 2;
var resp = TxRx(pdu, respLen);
if (resp[0] != slave || resp[1] != 0x03)
{
_consecutiveFails++;
if (_consecutiveFails >= _maxFails)
{
_logger?.LogError("[ModbusRtuClient] Communication lost (invalid response function).");
_faulted = true;
}
CheckDataTimeout();
throw new Exception("Invalid response function");
}
if (resp[2] != byteCount)
{
_consecutiveFails++;
if (_consecutiveFails >= _maxFails)
{
_logger?.LogError("[ModbusRtuClient] Communication lost (unexpected byte count).");
_faulted = true;
}
CheckDataTimeout();
throw new Exception("Unexpected byte count");
}
// Success - reset fail counter and update last data time (TxRx already did this, but ensure it's updated)
// Note: _consecutiveFails and _lastDataTime are already reset in TxRx() on success
_lastDataTime = DateTime.Now;
ushort[] regs = new ushort[quantity];
for (int i = 0; i < quantity; i++)
{
int idx = 3 + i * 2;
regs[i] = (ushort)(resp[idx] << 8 | resp[idx + 1]); // Big-endian to ushort
}
return regs;
}
public void WriteMultipleRegisters(byte slave, ushort startAddr, ushort[] values)
{
int byteCount = values.Length * 2;
byte[] pdu = new byte[7 + byteCount];
pdu[0] = slave;
pdu[1] = 0x10;
pdu[2] = (byte)(startAddr >> 8);
pdu[3] = (byte)startAddr;
pdu[4] = (byte)(values.Length >> 8);
pdu[5] = (byte)values.Length;
pdu[6] = (byte)byteCount;
for (int i = 0; i < values.Length; i++)
{
pdu[7 + i * 2] = (byte)(values[i] >> 8);
pdu[7 + i * 2 + 1] = (byte)values[i];
}
int respLen = 8;
TxRx(pdu, respLen);
}
// -------------------------
// Dispose
// -------------------------
public void Dispose()
{
lock (_sync)
{
try
{
if (_port != null)
{
if (_port.IsOpen) _port.Close();
_port.Dispose();
}
}
catch { }
_port = null;
}
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,432 @@
using RobotNet10.RobotApp.Client.Shared.Devices;
using RobotNet10.RobotApp.Devices;
using RobotNet10.RobotApp.Interfaces;
using RobotNet10.Shared;
using RobotNet10.Shared.Sensor;
using System.Diagnostics;
namespace RobotNet10.RobotApp.Drivers.YNZDH;
[Device(DeviceType.RfHandle, "YNZDH", "YNZDH_RfHandle", "1.0.0",
Description = "YNZDH RF Handle (minimal but full simulation properties)")]
public class YNZDH_RfHandle : DeviceBase, IRfHandle
{
private readonly string _port;
private readonly int _baud;
private readonly ILogger<YNZDH_RfHandle> _logger;
private ModbusRtuClient? _modbus;
// High-priority polling thread for real-time data acquisition
private Thread? _pollingThread;
private CancellationTokenSource? _pollingCts;
private volatile bool _shouldPoll = false;
private readonly Lock _lock = new();
public event Action? Updated;
public YNZDH_RfHandle(string deviceId, string deviceName, IConfigurationSection cfg, IServiceProvider serviceProvider)
: base(deviceId, deviceName, DeviceType.RfHandle)
{
_port = cfg.GetValue<string>("Port") ?? throw new Exception("Port is required");
_baud = cfg.GetValue<int?>("BaudRate") ?? throw new Exception("BaudRate is required");
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
_logger = loggerFactory.CreateLogger<YNZDH_RfHandle>();
AutoReconnectEnabled = true;
ReconnectDelayMs = 2000;
MaxReconnectAttempts = 0;
// Khởi tạo PropertyDescriptions → DeviceBase validation pass
UpdateProperties();
}
// ====================== STATES ===========================
public DateTime LastUpdateTime { get; private set; }
public int Heartbeat { get; private set; }
public bool RemoteReady { get; private set; }
public bool EStop { get; private set; }
public bool LiftUp { get; private set; }
public bool LiftDown { get; private set; }
public bool RotateLeft { get; private set; }
public bool RotateRight { get; private set; }
public bool ModeSelect { get; private set; }
public bool Enable { get; private set; }
public int Speed { get; private set; } // 0100
public double Linear { get; private set; } // -1 → +1
public double Angular { get; private set; } // -1 → +1
public RFMode Mode { get; private set; } = RFMode.None;
private Joy? _cachedJoyState;
// IRfHandle Implementation
public Joy? CurrentJoyState
{
get { lock (_lock) { return _cachedJoyState; } }
}
// ====================== SIM PROPERTIES ====================
protected override IEnumerable<PropertyDescription> CreatePropertyDescriptions()
{
return
[
new("Heartbeat", "Heartbeat"),
new("RemoteReady", "Remote Ready"),
new("EStop", "Emergency Stop"),
new("LiftUp", "Lift Up"),
new("LiftDown", "Lift Down"),
new("RotateLeft", "Rotate Left"),
new("RotateRight", "Rotate Right"),
new("ModeSelect", "Mode Select"),
new("Enable", "Enable"),
new("Speed", "Speed"),
new("Mode", "Mode"),
new("LastUpdate", "Last Update Time")
];
}
private void UpdateProperties()
{
SetProperty("Heartbeat", Heartbeat.ToString());
SetProperty("RemoteReady", RemoteReady.ToString());
SetProperty("EStop", EStop.ToString());
SetProperty("LiftUp", LiftUp.ToString());
SetProperty("LiftDown", LiftDown.ToString());
SetProperty("RotateLeft", RotateLeft.ToString());
SetProperty("RotateRight", RotateRight.ToString());
SetProperty("ModeSelect", ModeSelect.ToString());
SetProperty("Enable", Enable.ToString());
SetProperty("Speed", Speed.ToString());
SetProperty("Mode", Mode.ToString());
SetProperty("LastUpdate", LastUpdateTime == default
? "Never"
: LastUpdateTime.ToString("yyyy-MM-dd HH:mm:ss"));
}
// ====================== DEVICEBASE ========================
protected override Task OnInitializeAsync(CancellationToken cancellationToken)
{
try
{
//_modbus = new ModbusRtuClient(_port, _baud, logger: _logger);
_modbus = new ModbusRtuClient(_port, _baud);
}
catch (Exception ex)
{
_logger.LogError(ex, "Modbus init failed");
LastError = ex;
OnErrorOccurred(ex, "Modbus init failed");
}
return Task.CompletedTask;
}
protected override Task OnConnectAsync(CancellationToken cancellationToken)
{
StartPolling();
return Task.CompletedTask;
}
protected override Task OnDisconnectAsync(CancellationToken cancellationToken)
{
StopPolling();
return Task.CompletedTask;
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
// Stop polling thread before disposing base class
StopPolling();
}
base.Dispose(disposing);
}
protected override Task OnResetAsync(CancellationToken cancellationToken)
{
StopPolling();
StartPolling();
return Task.CompletedTask;
}
protected override Task<bool> OnCheckConnectionAsync(CancellationToken cancellationToken)
=> Task.FromResult(_modbus != null);
// ===================== POLLING LOOP ======================
/// <summary>
/// Start high-priority polling thread for real-time data acquisition at 10Hz
/// </summary>
private void StartPolling()
{
StopPolling(); // Đảm bảo không có thread nào đang chạy
_shouldPoll = true;
// Tạo mới CancellationTokenSource cho thread mới
_pollingCts?.Dispose();
_pollingCts = new CancellationTokenSource();
_pollingThread = new Thread(() => PollingThreadLoop(_pollingCts.Token))
{
Name = $"YNZDH-RfHandle-Polling-{DeviceId}",
IsBackground = false, // Không phải background thread để đảm bảo chạy liên tục
Priority = ThreadPriority.Highest // Priority cao để đảm bảo real-time polling
};
_pollingThread.Start();
_logger.LogDebug("YNZDH_RfHandle: Started high-priority polling thread at 10Hz for device {DeviceId}", DeviceId);
}
/// <summary>
/// Stop polling thread gracefully
/// </summary>
private void StopPolling()
{
_shouldPoll = false;
_pollingCts?.Cancel();
if (_pollingThread != null)
{
if (!_pollingThread.Join(1000)) // Đợi tối đa 1 giây
{
_logger.LogWarning("YNZDH_RfHandle: Polling thread did not stop gracefully for device {DeviceId}", DeviceId);
}
_pollingThread = null;
}
// Dispose CancellationTokenSource sau khi thread đã dừng
_pollingCts?.Dispose();
_pollingCts = null;
}
/// <summary>
/// High-priority polling thread loop - runs at 10Hz (100ms interval)
/// Uses Stopwatch for high-precision timing to ensure accurate 10Hz polling rate
/// </summary>
private void PollingThreadLoop(CancellationToken cancellationToken)
{
var modbusClient = _modbus;
if (modbusClient == null)
{
_logger.LogError("YNZDH_RfHandle: Modbus client is null");
return;
}
Thread.BeginThreadAffinity();
try
{
const int pollingIntervalMs = 100; // 10Hz = 100ms
var intervalTicks = pollingIntervalMs * TimeSpan.TicksPerMillisecond;
var stopwatch = Stopwatch.StartNew();
var nextPollTime = stopwatch.ElapsedTicks + intervalTicks;
var spinWait = new SpinWait();
long currentTicks = 0;
while (_shouldPoll && !cancellationToken.IsCancellationRequested)
{
currentTicks = stopwatch.ElapsedTicks;
// Check if it's time to poll
if (currentTicks >= nextPollTime)
{
try
{
ushort[] regs = modbusClient.ReadHoldingRegisters(1, 1, 4);
DecodeRegisters(regs);
}
catch (Exception ex)
{
LastError = ex;
OnErrorOccurred(ex, "Polling error");
ResetToDefaultValues();
}
// Calculate next poll time
nextPollTime = currentTicks + intervalTicks;
}
// SpinWait for precise timing (10 spins then reset)
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.SpinOnce();
spinWait.Reset();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "YNZDH_RfHandle: Error in polling thread loop for device {DeviceId}", DeviceId);
LastError = ex;
OnErrorOccurred(ex, "Polling thread error");
}
finally
{
Thread.EndThreadAffinity();
}
}
// ===================== DECODE ============================
private void ResetToDefaultValues()
{
lock (_lock)
{
Heartbeat = 0;
RemoteReady = false;
EStop = false;
LiftUp = false;
LiftDown = false;
RotateLeft = false;
RotateRight = false;
ModeSelect = false;
Enable = false;
Speed = 0;
Linear = 0.0;
Angular = 0.0;
Mode = RFMode.None;
_cachedJoyState = null;
UpdateProperties();
}
Updated?.Invoke();
}
private void DecodeRegisters(ushort[] regs)
{
byte d0 = (byte)(regs[0] >> 8); // Word0H
byte d1 = (byte)(regs[0]); // Word0L
byte d2 = (byte)(regs[1] >> 8); // Word1H
byte d3 = (byte)(regs[1]); // Word1L
byte d6 = (byte)(regs[3] >> 8); // Word3H (JOY FB)
byte d7 = (byte)(regs[3]); // Word3L (JOY LR)
lock (_lock)
{
// ===== System =====
Heartbeat = (d0 >> 4) & 0x0F;
RemoteReady = (d0 & 0x04) != 0;
RemoteReady = !RemoteReady;
EStop = (d0 & 0x01) != 0;
// ===== Buttons =====
Enable = (d1 & 0x80) != 0;
ModeSelect = (d1 & 0x40) != 0;
LiftUp = (d1 & 0x01) != 0;
LiftDown = (d1 & 0x02) != 0;
RotateLeft = (d1 & 0x04) != 0;
RotateRight = (d1 & 0x08) != 0;
// ===== Speed =====
Speed = Math.Clamp((int)d3, 0, 100);
// ===== Mode =====
Mode = DecodeMode(d2);
// ===== Safety =====
if (!RemoteReady || !Enable || EStop)
{
Linear = 0;
Angular = 0;
}
else
{
// ===== Joystick ANALOG =====
Linear = (d6 - 127f) / 127f;
Angular = (127f - d7) / 127f;
}
LastUpdateTime = DateTime.UtcNow;
// Update cached JoyState
_cachedJoyState = CreateJoyStateFromCache();
UpdateProperties();
}
Updated?.Invoke();
}
private static RFMode DecodeMode(byte d2) =>
(d2 & 0x0F) switch
{
0x00 => RFMode.Default,
0x01 => RFMode.Maintenance,
0x02 => RFMode.Override,
_ => RFMode.None
};
// ===================== IRfHandle Implementation ===========
public Task<Joy> ReadJoyStateAsync(CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (_cachedJoyState.HasValue)
{
return Task.FromResult(_cachedJoyState.Value);
}
return Task.FromResult(CreateJoyStateFromCache());
}
}
private Joy CreateJoyStateFromCache()
{
return new Joy
{
Header = new Header
{
Stamp = LastUpdateTime == default ? DateTime.UtcNow : LastUpdateTime,
FrameId = "rfhandle_frame"
},
Axes =
[
Linear, // Axis 0: Forward / Backward
Angular, // Axis 1: Left / Right
Speed / 100f // Axis 2: Speed
],
Buttons =
[
LiftUp ? 1 : 0,
LiftDown ? 1 : 0,
RotateLeft ? 1 : 0,
RotateRight ? 1 : 0,
ModeSelect ? 1 : 0,
Enable ? 1 : 0,
EStop ? 1 : 0
]
};
}
}

View File

@@ -0,0 +1,8 @@
using RobotNet.VDA5050.InstantAction;
namespace RobotNet10.RobotApp.Events.Events;
public class InstantActionChangedEvent : EventArgs
{
public InstantActionsMsg InstantActionMessage { get; set; } = new();
}

View File

@@ -0,0 +1,8 @@
using RobotNet.VDA5050.Order;
namespace RobotNet10.RobotApp.Events.Events;
public class OrderChangedEvent : EventArgs
{
public OrderMsg OrderMessage { get; set; } = new();
}

View File

@@ -0,0 +1,19 @@
using RobotNet.VDA5050.InstantAction;
using RobotNet.VDA5050.Order;
using RobotNet10.RobotApp.Events.Events;
namespace RobotNet10.RobotApp.Events;
/// <summary>
/// In-memory event bus for VDA5050 robot messages and domain events
/// </summary>
public interface IRobotEventBus
{
// VDA5050 Events
event EventHandler<OrderChangedEvent>? OrderMessageReceived;
event EventHandler<InstantActionChangedEvent>? InstantActionReceived;
// VDA5050 Event Publishers
void PublishOrderMessageReceived(OrderMsg orderMsg);
void PublishInstantActionMessageReceived(InstantActionsMsg instantActionMsg);
}

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