using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using MudBlazor.Services; using NLog.Web; using RobotNet10.CANOpen; using RobotNet10.CustomConfiguration.Extensions; using RobotNet10.MapManager.Extensions; using RobotNet10.NavigationTune.Data; using RobotNet10.NavigationTune.Execution; using RobotNet10.NavigationTune.Extensions; using RobotNet10.NavigationTune.Hubs; using RobotNet10.RobotApp.Client; using RobotNet10.RobotApp.Components; using RobotNet10.RobotApp.Components.Account; using RobotNet10.RobotApp.Data; using RobotNet10.RobotApp.Detection; using RobotNet10.RobotApp.Devices; using RobotNet10.RobotApp.Hubs; using RobotNet10.RobotApp.Interfaces; using RobotNet10.RobotApp.Modules; using RobotNet10.RobotApp.Motion; using RobotNet10.RobotApp.Services; using RobotNet10.RobotApp.Services.Navigation; using RobotNet10.RobotApp.Services.Robot; using RobotNet10.RobotApp.Services.NavigationMonitor; using RobotNet10.RobotApp.Services.Simulation; using RobotNet10.RobotApp.Navigation; using RobotNet10.RobotApp.TF3; using RobotNet10.RobotApp.Xloc; using RobotNet10.ScriptEngine; using RobotNet10.ScriptEngine.Helpers; using System.Text.Json; using Microsoft.AspNetCore.Http.Features; // Print Ceres library information at startup //CeresLibraryInfo.PrintLibraryInfo(); var builder = WebApplication.CreateBuilder(args); var robotConfig = builder.Configuration["RobotConfig"]; if(string.IsNullOrEmpty(robotConfig) || !File.Exists(robotConfig)) { throw new Exception("RobotConfig not found"); } builder.Configuration.AddJsonFile(robotConfig); // Add NLog //builder.Logging.ClearProviders(); //builder.Logging.SetMinimumLevel(LogLevel.Trace); builder.Host.UseNLog(); // Add services to the container. builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddInteractiveWebAssemblyComponents() .AddAuthenticationStateSerialization(); builder.Services.AddCascadingAuthenticationState(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddAuthentication() .AddBearerToken(IdentityConstants.BearerScheme); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' not found."); builder.Services.AddDbContext(options => options.UseSqlite(connectionString, b => b.MigrationsAssembly("RobotNet10.RobotApp").UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddIdentity(options => { options.SignIn.RequireConfirmedAccount = true; options.Lockout.AllowedForNewUsers = false; options.Password.RequireNonAlphanumeric = false; options.Password.RequireUppercase = false; options.Password.RequireLowercase = false; options.Password.RequireDigit = false; }) .AddEntityFrameworkStores() .AddSignInManager() .AddDefaultTokenProviders(); builder.Services.AddSingleton, IdentityNoOpEmailSender>(); builder.Services.AddMudServices(); builder.Services.AddNavigationMenu(); builder.Services.Configure(o => { o.MultipartBodyLengthLimit = 200L * 1024 * 1024; }); builder.WebHost.ConfigureKestrel(o => { o.Limits.MaxRequestBodySize = 200L * 1024 * 1024; }); // Add HttpClient for Blazor Server components (server-side calls into the same Kestrel app). // BaseAddress must be loopback: using the browser's WAN/MOXA host from this machine often fails (hairpin NAT). builder.Services.AddScoped(sp => { var navigationManager = sp.GetRequiredService(); var handler = new HttpClientHandler { ServerCertificateCustomValidationCallback = (message, cert, chain, sslPolicyErrors) => true }; var publicBase = new Uri(navigationManager.BaseUri); var loopbackBase = new UriBuilder(publicBase) { Host = "127.0.0.1" }.Uri; return new HttpClient(handler) { BaseAddress = loopbackBase }; }); builder.Services.AddControllers(); builder.Services.AddSignalR(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); var scriptConnectionString = builder.Configuration.GetConnectionString("ScriptEngineConnection") ?? throw new InvalidOperationException("Connection string 'ScriptEngineConnection' not found."); builder.Services.AddScriptEngine(options => options.UseSqlite(scriptConnectionString, b => b.MigrationsAssembly("RobotNet10.RobotApp"))); var mapConnectionString = builder.Configuration.GetConnectionString("MapEditorConnection") ?? throw new InvalidOperationException("Connection string 'MapEditorConnection' not found."); builder.Services.AddMapManager(builder.Configuration, options => options.UseSqlite(mapConnectionString, b => b.MigrationsAssembly("RobotNet10.RobotApp").UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)), "LayoutImage"); // var navTuneConnectionString = builder.Configuration.GetConnectionString("NavigationTuneConnection") ?? throw new InvalidOperationException("Connection string 'NavigationTuneConnection' not found."); // builder.Services.AddNavigationTuningWithRobot(options => options.UseSqlite(navTuneConnectionString, b => b.MigrationsAssembly("RobotNet10.RobotApp").UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery))); // Register adapters for Navigation Tuning // builder.Services.AddScoped(sp => // { // var localization = sp.GetRequiredService(); // return new LocalizationAdapter( // () => localization.X, // () => localization.Y, // () => localization.Theta // ); // }); builder.Services.AddScoped(sp => { var velocityController = sp.GetRequiredService(); return new VelocityControllerAdapter( () => velocityController.RawVelocity, (linear, angular) => velocityController.SetVelocity(linear, angular), () => velocityController.GetModelConfidence(), acc => velocityController.SetAcceleration(acc), dec => velocityController.SetDeceleration(dec) ); }); // Add Custom Configuration builder.Services.AddCustomConfiguration(builder.Configuration, "StorageConfig"); builder.Services.AddCanOpenManager(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(typeof(RobotNet10.RobotApp.Services.Logger<>)); builder.Services.AddRobotSimulation(); // Core C API services (TF3 -> XLOC -> Navigation) builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // XLOC integration is required by RobotLocalization. builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddRobot(); // Motion services builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddHostedService(); // Rotation Module services // builder.Services.AddSingleton(); // builder.Services.AddSingleton(sp => sp.GetRequiredService()); // builder.Services.AddHostedService(sp => sp.GetRequiredService()); // Lift Module: bật khi Modules:LiftModule:Enable = true var liftModuleEnabled = builder.Configuration.GetValue("Modules:LiftModule:Enable"); if (liftModuleEnabled) { builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddHostedService(sp => sp.GetRequiredService()); } else { builder.Services.AddSingleton(); } // Rotation Module (vẫn dùng NoOp khi chưa bật) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // builder.Services.AddCartographer(builder.Configuration.GetSection("Cartographer")); // builder.Services.AddSingleton(); // Navigation Monitor (Telemetry & Safety) // builder.Services.AddSingleton(); // builder.Services.AddHostedService(sp => sp.GetRequiredService()); #if DEBUG var dllPath = builder.Configuration["ScriptEngine:RuntimeDllFolder"] ?? "dlls"; if(!Directory.Exists(dllPath)) { Directory.CreateDirectory(dllPath); } var netCoreDir = Path.GetDirectoryName(typeof(object).Assembly.Location); if (!string.IsNullOrEmpty(netCoreDir)) { var coreSrc = Path.Combine(netCoreDir, "System.Private.CoreLib.dll"); var runtimeSrc = Path.Combine(netCoreDir, "System.Runtime.dll"); var linqSrc = Path.Combine(netCoreDir, "System.Linq.Expressions.dll"); var scriptSrc = typeof(RobotNet10.Script.ILogger).Assembly.Location; var robotAppSrc = typeof(RobotNet10.RobotApp.Script.IRobot).Assembly.Location; var coreDest = Path.Combine(dllPath, "System.Private.CoreLib.dll"); var runtimeDest = Path.Combine(dllPath, "System.Runtime.dll"); var linqDest = Path.Combine(dllPath, "System.Linq.Expressions.dll"); var scriptDest = Path.Combine(dllPath, "RobotNet10.Script.dll"); var robotAppDest = Path.Combine(dllPath, "RobotNet10.RobotApp.Script.dll"); if(!File.Exists(coreDest)) File.Copy(coreSrc, coreDest); if(!File.Exists(runtimeDest)) File.Copy(runtimeSrc, runtimeDest); if(!File.Exists(linqDest)) File.Copy(linqSrc, linqDest); File.Copy(scriptSrc, scriptDest, true); File.Copy(robotAppSrc, robotAppDest, true); } #endif var app = builder.Build(); await app.Services.SeedApplicationDbAsync(); await app.Services.SeedScriptEngineDbAsync(); await app.Services.SeedMapManagerAsync(); var serviceProbe = app.Services.GetService(); if (serviceProbe?.IsService(typeof(TuningDbContext)) == true) { await app.Services.SeedTuningDbAsync(); } // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseWebAssemblyDebugging(); app.UseMigrationsEndPoint(); } else { app.UseExceptionHandler("/Error", createScopeForErrors: true); // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseHttpsRedirection(); app.UseAntiforgery(); app.MapControllers(); app.MapStaticAssets(); app.MapRazorComponents() .AddInteractiveServerRenderMode() .AddInteractiveWebAssemblyRenderMode() .AddAdditionalAssemblies(typeof(RobotNet10.RobotApp.Client._Imports).Assembly); // Add additional endpoints required by the Identity /Account Razor components. app.MapAdditionalIdentityEndpoints(); app.MapScriptEngineHubs(); app.MapHub("/hubs/devices"); app.MapHub("/hubs/devices/battery"); app.MapHub("/hubs/devices/inertialmeasurementunit"); app.MapHub("/hubs/devices/lidar"); app.MapHub("/hubs/devices/modbustcp"); // app.MapHub("/hubs/devices/rfhandle"); app.MapHub("/hubs/devices/cameraqr"); app.MapHub("/hubs/devices/cia402servo"); app.MapHub("/hubs/motion"); app.MapHub("/hubs/odometry"); app.MapHub("/hubs/xloc/pose"); // app.MapHub("/hubs/slam"); // app.MapHub("/tuninghub"); app.MapHub("/hubs/plc/controller"); // app.MapHub("hubs/marker-detect"); // app.MapHub("/hubs/motion/navigation-monitor"); var logger = app.Services.GetRequiredService>(); app.MapGet("/api/motion/ps5/status", (PS5ControllerService svc) => { return Results.Json(new { state = svc.State.ToString() }); }); app.MapPost("/api/motion/ps5/enable", async (PS5ControllerService svc) => { svc.Enable(); return Results.Ok("PS5 controller enabled"); }); app.MapPost("/api/motion/ps5/disable", (PS5ControllerService svc) => { svc.Disable(); return Results.Ok("PS5 controller disabled"); }); // PLC Lift module API (giao diện sửa: homing, velocity, position — 10000 = 0.01m) var plcApi = app.MapGroup("/api/plc").DisableAntiforgery(); plcApi.MapPost("/lift/homing", (IPlcController plc) => { plc.LiftHoming(); return Results.Ok(new { status = "ok", message = "Lift homing triggered" }); }); plcApi.MapPost("/lift/velocity", async (HttpRequest req, IPlcController plc) => { var body = await req.ReadFromJsonAsync(); if (body == null) return Results.BadRequest(new { status = "error", message = "Body required: { up: bool, down: bool }" }); plc.SetLiftVelocity(body.Up, body.Down); return Results.Ok(new { status = "ok", up = body.Up, down = body.Down }); }); plcApi.MapPost("/lift/position", async (HttpRequest req, IPlcController plc) => { var body = await req.ReadFromJsonAsync(); if (body == null) return Results.BadRequest(new { status = "error", message = "Body required: { position: int } (10000 = 0.01m)" }); plc.SetLiftPositionAndGo(body.Position); return Results.Ok(new { status = "ok", position = body.Position }); }); // XLOC Control API var xlocApi = app.MapGroup("/api/xloc").DisableAntiforgery(); // Mapping control xlocApi.MapPost("/mapping/start", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { return service.StartMapping() ? Results.Ok(new { status = "success", message = "Mapping started" }) : Results.BadRequest(new { status = "error", message = "Failed to start mapping" }); }); xlocApi.MapPost("/mapping/stop", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var body = await request.ReadFromJsonAsync>(); var mapFile = body?["map_file_path"] ?? "/home/mic-733ao/.local/share/xloc/resources/maps"; return service.StopMapping(mapFile) ? Results.Ok(new { status = "success", message = "Mapping stopped", map_file = mapFile }) : Results.BadRequest(new { status = "error", message = "Failed to stop mapping" }); }); // Localization control xlocApi.MapPost("/localization/start", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { return service.StartLocalization() ? Results.Ok(new { status = "success", message = "Localization started" }) : Results.BadRequest(new { status = "error", message = "Failed to start localization" }); }); xlocApi.MapPost("/localization/stop", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { return service.StopLocalization() ? Results.Ok(new { status = "success", message = "Localization stopped" }) : Results.BadRequest(new { status = "error", message = "Failed to stop localization" }); }); // Update map control (only allowed when in localization mode) xlocApi.MapPost("/update-map/start", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { return service.StartUpdateMap() ? Results.Ok(new { status = "success", message = "Map update started" }) : Results.BadRequest(new { status = "error", message = "Failed to start map update. Robot must be in localization mode." }); }); xlocApi.MapPost("/update-map/stop", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var body = await request.ReadFromJsonAsync>(); bool saveUpdatedMap = true; if (body != null && body.TryGetValue("save_updated_map", out var saveVal)) { if (saveVal is bool saveBool) saveUpdatedMap = saveBool; else if (saveVal is JsonElement jsonElem && jsonElem.ValueKind == JsonValueKind.True) saveUpdatedMap = true; else if (saveVal is JsonElement jsonElem2 && jsonElem2.ValueKind == JsonValueKind.False) saveUpdatedMap = false; } return service.StopUpdateMap(saveUpdatedMap) ? Results.Ok(new { status = "success", message = "Map update stopped", save_updated_map = saveUpdatedMap }) : Results.BadRequest(new { status = "error", message = "Failed to stop map update" }); }); // Reset SLAM error xlocApi.MapPost("/reset-error", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { return service.ResetSlamError() ? Results.Ok(new { status = "success", message = "SLAM error reset successfully" }) : Results.BadRequest(new { status = "error", message = "Failed to reset SLAM error" }); }); // Initial pose control xlocApi.MapPost("/pose/initial", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var body = await request.ReadFromJsonAsync>(); if (body == null) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); if (!body.TryGetValue("x", out var x) || !body.TryGetValue("y", out var y)) return Results.BadRequest(new { status = "error", message = "x and y are required" }); var z = body.TryGetValue("z", out var zVal) ? zVal : 0.0; var roll = body.TryGetValue("roll", out var rollVal) ? rollVal : 0.0; var pitch = body.TryGetValue("pitch", out var pitchVal) ? pitchVal : 0.0; var yaw = body.TryGetValue("yaw", out var yawVal) ? yawVal : 0.0; return service.SetInitialPose(x, y, z, roll, pitch, yaw) ? Results.Ok(new { status = "success", message = "Initial pose set", position = new { x, y, z }, orientation = new { roll, pitch, yaw } }) : Results.BadRequest(new { status = "error", message = "Failed to set initial pose" }); }); // Map management // xlocApi.MapPost("/map/activate", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { // var body = await request.ReadFromJsonAsync>(); // var mapFile = body?["map_file_path"]; // if (string.IsNullOrEmpty(mapFile)) // return Results.BadRequest(new { status = "error", message = "map_file_path required" }); // // UI may send only the map folder name (e.g. "map_20260307_103646"). // // Resolve it to an absolute pbstream path so native XLOC doesn't depend on HOME defaults (e.g. /root vs /home/...). // var resolved = XlocPaths.ResolvePbstreamPath(mapFile); // if (resolved == null) // return Results.BadRequest(new // { // status = "error", // message = "Failed to resolve pbstream path for map_file_path. " + // "Expected either an absolute .pbstream file, or a map folder name under: " + // XlocPaths.GetMapsDirectory() // }); // return service.ActivateMap(resolved) // ? Results.Ok(new { status = "success", message = "Map activated", map_file = resolved }) // : Results.BadRequest(new { status = "error", message = $"Failed to activate map pbstream: {resolved}" }); // }); xlocApi.MapPost("/map/activate", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var body = await request.ReadFromJsonAsync>(); var mapFile = body?["map_file_path"]; if (string.IsNullOrEmpty(mapFile)) return Results.BadRequest(new { status = "error", message = "map_file_path required" }); return service.ActivateMap(mapFile) ? Results.Ok(new { status = "success", message = "Map activated", map_file = mapFile }) : Results.BadRequest(new { status = "error", message = "Failed to activate map" }); }); // Grid map endpoints xlocApi.MapGet("/gridmap/static", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service, [FromServices] ILogger logger, [FromQuery] bool reload = false) => { // Log when map is being requested (to track if it's called after initial pose) // logger.LogWarning("🔍 API /gridmap/static called (reload={Reload})", reload); var grid = service.GetStaticGridMap(reload); if (grid == null) return Results.NotFound(new { status = "error", message = "No static grid map available" }); // Prefer origin from active map's YAML (Map frame in World frame); native often returns (0,0,0) double ox = grid.Origin.X, oy = grid.Origin.Y, oz = grid.Origin.Z; var diag = service.GetDiagnostics(); if (!string.IsNullOrEmpty(diag?.CurrentActiveMap)) { var (yx, yy, yz) = ReadOriginFromMapYaml(diag.CurrentActiveMap); ox = yx; oy = yy; oz = yz; } //logger.LogWarning("🔍 API /gridmap/static returning origin: ({X}, {Y}, {Z})", ox, oy, oz); return Results.Ok(new { status = "success", resolution = grid.Resolution, width = grid.Width, height = grid.Height, origin = new { x = ox, y = oy, z = oz, qx = grid.Origin.Qx, qy = grid.Origin.Qy, qz = grid.Origin.Qz, qw = grid.Origin.Qw }, data = Convert.ToBase64String(grid.Data), frameId = grid.FrameId }); }); xlocApi.MapGet("/gridmap/online", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var grid = service.GetOnlineGridMap(); if (grid == null) return Results.NotFound(new { status = "error", message = "No online grid map available" }); // CRITICAL: Use origin directly from online grid map to ensure alignment with LIDAR // Do NOT override with YAML origin during mapping, as online map origin may change // and must match the actual grid data for proper rendering double ox = grid.Origin.X, oy = grid.Origin.Y, oz = grid.Origin.Z; // Only use YAML origin for static maps, not for online maps during mapping // Online map origin should come directly from xloc_get_online_grid_map() // var diag = service.GetDiagnostics(); // if (!string.IsNullOrEmpty(diag?.CurrentActiveMap)) // { // var (yx, yy, yz) = ReadOriginFromMapYaml(diag.CurrentActiveMap); // ox = yx; oy = yy; oz = yz; // } return Results.Ok(new { status = "success", resolution = grid.Resolution, width = grid.Width, height = grid.Height, origin = new { x = ox, y = oy, z = oz, qx = grid.Origin.Qx, qy = grid.Origin.Qy, qz = grid.Origin.Qz, qw = grid.Origin.Qw }, data = Convert.ToBase64String(grid.Data), frameId = grid.FrameId }); }); // List available maps xlocApi.MapGet("/maps/list", () => { try { var mapsDir = XlocPaths.GetMapsDirectory(); if (!Directory.Exists(mapsDir)) return Results.Ok(new { status = "success", maps = Array.Empty() }); var maps = Directory.GetDirectories(mapsDir) .Select(dir => Path.GetFileName(dir)) .Where(name => !string.IsNullOrEmpty(name) && name != "tmp") .OrderBy(name => name) .ToArray(); return Results.Ok(new { status = "success", maps }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = ex.Message }); } }); // Load map from folder xlocApi.MapGet("/maps/load/{mapName}", (string mapName) => { try { var mapsDir = XlocPaths.GetMapsDirectory(); var mapFolder = Path.Combine(mapsDir, mapName); if (!Directory.Exists(mapFolder)) return Results.NotFound(new { status = "error", message = $"Map '{mapName}' not found" }); // Find YAML file var yamlFiles = Directory.GetFiles(mapFolder, "*.yaml"); if (yamlFiles.Length == 0) return Results.BadRequest(new { status = "error", message = "YAML file not found" }); var yamlFile = yamlFiles[0]; var yamlContent = File.ReadAllText(yamlFile); // Parse YAML (simple parsing) float resolution = 0.05f; double originX = 0.0, originY = 0.0, originZ = 0.0; string? imageFile = null; foreach (var line in yamlContent.Split('\n')) { var trimmed = line.Trim(); if (trimmed.StartsWith("resolution:")) { if (float.TryParse(trimmed.Substring("resolution:".Length).Trim(), out float res)) resolution = res; } else if (trimmed.StartsWith("origin:")) { var originStr = trimmed.Substring("origin:".Length).Trim(); if (originStr.StartsWith("[") && originStr.EndsWith("]")) { var coords = originStr.Substring(1, originStr.Length - 2).Split(','); if (coords.Length >= 1) double.TryParse(coords[0].Trim(), out originX); if (coords.Length >= 2) double.TryParse(coords[1].Trim(), out originY); if (coords.Length >= 3) double.TryParse(coords[2].Trim(), out originZ); } } else if (trimmed.StartsWith("image:")) { imageFile = trimmed.Substring("image:".Length).Trim(); } } // Find PGM file - always prefer .pgm over .png from YAML string? pgmFile = null; // First try to find .pgm file directly var pgmFiles = Directory.GetFiles(mapFolder, "*.pgm"); if (pgmFiles.Length > 0) { pgmFile = pgmFiles[0]; } else if (imageFile != null) { // If YAML specifies .png, try replacing extension with .pgm var pgmFromYaml = Path.Combine(mapFolder, Path.ChangeExtension(imageFile, ".pgm")); if (File.Exists(pgmFromYaml)) pgmFile = pgmFromYaml; else { // Last resort: use image file from YAML (might be .png) var imageFromYaml = Path.Combine(mapFolder, imageFile); if (File.Exists(imageFromYaml) && imageFile.EndsWith(".pgm", StringComparison.OrdinalIgnoreCase)) pgmFile = imageFromYaml; } } if (pgmFile == null) return Results.BadRequest(new { status = "error", message = "PGM file not found" }); // Read PGM file and convert to occupancy grid var (width, height, data) = ReadPgmFile(pgmFile); // Convert to base64 var dataBase64 = Convert.ToBase64String(data); return Results.Ok(new { status = "success", mapName, width, height, resolution, origin = new { x = originX, y = originY, z = originZ, qx = 0.0, qy = 0.0, qz = 0.0, qw = 1.0 }, data = dataBase64 }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = ex.Message }); } }); // Download map folder as .zip (single top-level folder matching map name inside the archive on re-import) xlocApi.MapGet("/maps/download/{mapName}", (string mapName) => XlocMapFileEndpoints.DownloadMapAsZip(mapName)); // Import map from .zip into maps directory xlocApi.MapPost("/maps/import", async (HttpRequest request) => await XlocMapFileEndpoints.ImportMapFromZipAsync(request)); // Delete map folder (explicit path avoids conflicting with /maps/list, /maps/load/...) xlocApi.MapDelete("/maps/delete/{mapName}", (string mapName) => XlocMapFileEndpoints.DeleteMapFolder(mapName)); // Helper: read origin (Map frame in World frame) from active map's YAML static (double x, double y, double z) ReadOriginFromMapYaml(string mapName) { try { var mapsDir = XlocPaths.GetMapsDirectory(); var folderName = Path.GetFileName(mapName.TrimEnd(Path.DirectorySeparatorChar, '/')); if (string.IsNullOrEmpty(folderName)) return (0, 0, 0); var mapFolder = Path.Combine(mapsDir, folderName); if (!Directory.Exists(mapFolder)) return (0, 0, 0); var yamlFiles = Directory.GetFiles(mapFolder, "*.yaml"); if (yamlFiles.Length == 0) return (0, 0, 0); var content = File.ReadAllText(yamlFiles[0]); double ox = 0, oy = 0, oz = 0; foreach (var line in content.Split('\n')) { var t = line.Trim(); if (!t.StartsWith("origin:")) continue; var s = t.Substring("origin:".Length).Trim(); if (s.StartsWith("[") && s.EndsWith("]")) { var parts = s.Substring(1, s.Length - 2).Split(','); if (parts.Length >= 1) double.TryParse(parts[0].Trim(), out ox); if (parts.Length >= 2) double.TryParse(parts[1].Trim(), out oy); if (parts.Length >= 3) double.TryParse(parts[2].Trim(), out oz); } break; } return (ox, oy, oz); } catch { return (0, 0, 0); } } // Helper function to read PGM file static (uint width, uint height, byte[] data) ReadPgmFile(string pgmPath) { // Read entire file as bytes first var allBytes = File.ReadAllBytes(pgmPath); // Parse header manually to avoid StreamReader buffering issues int pos = 0; // Read magic number (P5\n) string magic = ""; while (pos < allBytes.Length && allBytes[pos] != '\n') { magic += (char)allBytes[pos]; pos++; } pos++; // Skip newline magic = magic.Trim(); if (magic != "P5") throw new InvalidDataException($"Not a P5 PGM file, got: {magic}"); // Skip comments and read dimensions uint width = 0, height = 0; bool gotDimensions = false; while (pos < allBytes.Length && !gotDimensions) { // Skip whitespace while (pos < allBytes.Length && (allBytes[pos] == ' ' || allBytes[pos] == '\t' || allBytes[pos] == '\r' || allBytes[pos] == '\n')) pos++; // Check for comment if (pos < allBytes.Length && allBytes[pos] == '#') { // Skip comment line while (pos < allBytes.Length && allBytes[pos] != '\n') pos++; continue; } // Read dimensions line string line = ""; while (pos < allBytes.Length && allBytes[pos] != '\n') { line += (char)allBytes[pos]; pos++; } pos++; // Skip newline var parts = line.Trim().Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); if (parts.Length >= 2 && uint.TryParse(parts[0], out width) && uint.TryParse(parts[1], out height)) { gotDimensions = true; } } if (!gotDimensions || width == 0 || height == 0) throw new InvalidDataException($"Invalid PGM dimensions: {width}x{height}"); // Read max value while (pos < allBytes.Length && (allBytes[pos] == ' ' || allBytes[pos] == '\t' || allBytes[pos] == '\r' || allBytes[pos] == '\n')) pos++; string maxValStr = ""; while (pos < allBytes.Length && allBytes[pos] != '\n') { maxValStr += (char)allBytes[pos]; pos++; } pos++; // Skip newline if (!int.TryParse(maxValStr.Trim(), out int maxVal)) throw new InvalidDataException($"Invalid max value in PGM: {maxValStr}"); // Read binary data int dataSize = (int)(width * height); if (pos + dataSize > allBytes.Length) throw new InvalidDataException($"Not enough data: expected {dataSize} bytes at position {pos}, file size {allBytes.Length}"); var rawData = new byte[dataSize]; Array.Copy(allBytes, pos, rawData, 0, dataSize); // Convert grayscale to occupancy grid format (int8_t): // - 0 (black/occupied) -> 100 // - 255 (white/free) -> 0 // - 205 (unknown) -> -1 (255 in unsigned byte) // IMPORTANT: Flip Y axis because PGM has row 0 at top, but ROS map has cell (0,0) at bottom-left var occupancyData = new byte[dataSize]; for (int row = 0; row < height; row++) { for (int col = 0; col < width; col++) { // PGM index: row 0 = top int pgmIndex = row * (int)width + col; // ROS index: cell (x=col, y=0) at bottom → flip Y int rosIndex = ((int)height - 1 - row) * (int)width + col; var pixel = rawData[pgmIndex]; if (pixel >= 205) // Unknown (typically 205 in ROS maps) occupancyData[rosIndex] = 255; // Will be converted to -1 in JS else if (pixel < 128) // Dark = occupied occupancyData[rosIndex] = 100; // Occupied else // Light = free occupancyData[rosIndex] = 0; // Free } } return (width, height, occupancyData); } // Map upload endpoint (kept for backward compatibility) xlocApi.MapPost("/map/upload", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { try { if (!request.HasFormContentType) return Results.BadRequest(new { status = "error", message = "Request must be multipart/form-data" }); var form = await request.ReadFormAsync(); var file = form.Files["mapFile"]; if (file == null || file.Length == 0) return Results.BadRequest(new { status = "error", message = "No file uploaded" }); // Validate PNG if (!file.FileName.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) return Results.BadRequest(new { status = "error", message = "File must be a PNG image" }); // Parse origin and resolution from form if (!float.TryParse(form["resolution"], out float resolution)) resolution = 0.05f; // default 5cm if (!double.TryParse(form["originX"], out double originX)) originX = 0.0; if (!double.TryParse(form["originY"], out double originY)) originY = 0.0; if (!double.TryParse(form["originZ"], out double originZ)) originZ = 0.0; if (!double.TryParse(form["originQx"], out double originQx)) originQx = 0.0; if (!double.TryParse(form["originQy"], out double originQy)) originQy = 0.0; if (!double.TryParse(form["originQz"], out double originQz)) originQz = 0.0; if (!double.TryParse(form["originQw"], out double originQw)) originQw = 1.0; // Save file to temp directory var uploadsDir = Path.Combine(Path.GetTempPath(), "xloc_maps"); Directory.CreateDirectory(uploadsDir); var filePath = Path.Combine(uploadsDir, file.FileName); using (var stream = File.Create(filePath)) { await file.CopyToAsync(stream); } // Activate map with origin var originPose = new { x = originX, y = originY, z = originZ, qx = originQx, qy = originQy, qz = originQz, qw = originQw }; // Note: xloc may need the map in a specific format, so we save the PNG and metadata // The actual activation might need conversion to xloc's map format var metadata = new { filePath, resolution, origin = originPose }; return Results.Ok(new { status = "success", message = "Map uploaded successfully", filePath, resolution, origin = originPose }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = ex.Message }); } }); // Pose data xlocApi.MapGet("/pose/current", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var pose = service.GetCurrentPose(); return pose.HasValue ? Results.Ok(new { status = "success", pose = new { position = new { x = pose.Value.x, y = pose.Value.y, z = pose.Value.z }, orientation = new { x = pose.Value.qx, y = pose.Value.qy, z = pose.Value.qz, w = pose.Value.qw } } }) : Results.NotFound(new { status = "error", message = "No pose available" }); }); xlocApi.MapGet("/pose/current2d", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var pose = service.GetCurrentPose2D(); return pose.HasValue ? Results.Ok(new { status = "success", pose = new { x = pose.Value.x, y = pose.Value.y, yaw = pose.Value.yaw } }) : Results.NotFound(new { status = "error", message = "No pose available" }); }); // Diagnostics data xlocApi.MapGet("/diagnostics", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var diagnostics = service.GetDiagnostics(); return diagnostics != null ? Results.Ok(new { status = "success", diagnostics = new { header = new { seq = diagnostics.HeaderSeq, stamp = new { sec = diagnostics.HeaderStampSec, nsec = diagnostics.HeaderStampNsec }, frameId = diagnostics.HeaderFrameId }, xlocState = diagnostics.XlocState, stateString = diagnostics.StateString, currentActiveMap = diagnostics.CurrentActiveMap, reliability = diagnostics.Reliability, matchingScore = diagnostics.MatchingScore } }) : Results.NotFound(new { status = "error", message = "No diagnostics available" }); }); // Laser scan data (sampled at 1 degree intervals) xlocApi.MapGet("/laser/scan", ([FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service) => { var laserData = service.GetSampledLaserScan(); if (laserData == null || laserData.Count == 0) return Results.NotFound(new { status = "error", message = "No laser scan available" }); var points = laserData.Select(p => new { angle = p.angle, range = p.range }).ToArray(); return Results.Ok(new { status = "success", pointCount = points.Length, points = points, timestamp = DateTime.UtcNow }); }); // All 3 lidars combined with mode support (full = all points, minimal = 120 sampled points) xlocApi.MapGet("/laser/scan/all", ( [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService service, [FromQuery] string? mode1 = "minimal", [FromQuery] string? mode2 = "minimal", [FromQuery] string? mode3 = "minimal") => { // Get laser data based on mode (full = all points, minimal = ~120 sampled points) var laser1 = mode1 == "full" ? service.GetFullLaserScan() : service.GetSampledLaserScan(); var laser2 = mode2 == "full" ? service.GetFullLaserScan2() : service.GetSampledLaserScan2(); var laser3 = mode3 == "full" ? service.GetFullLaserScan3() : service.GetSampledLaserScan3(); return Results.Ok(new { status = "success", timestamp = DateTime.UtcNow, lidar1 = laser1 != null ? new { pointCount = laser1.Count, points = laser1.Select(p => new { angle = p.angle, range = p.range }).ToArray() } : null, lidar2 = laser2 != null ? new { pointCount = laser2.Count, points = laser2.Select(p => new { angle = p.angle, range = p.range }).ToArray() } : null, lidar3 = laser3 != null ? new { pointCount = laser3.Count, points = laser3.Select(p => new { angle = p.angle, range = p.range }).ToArray() } : null }); }); // Velocity data (OdomVel and CmdVel) xlocApi.MapGet("/velocity", ([FromServices] OdometryService odometryService, [FromServices] ManualControlService manualControlService, [FromServices] PS5ControllerService ps5ControllerService, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService navigationService) => { try { // Get odometry velocity directly from OdometryService (raw encoder odometry, not EKF filtered) var odom = odometryService.CurrentOdometry; double odomLinearVel = odom.Twist.Twist.Linear.X; double odomAngularVel = odom.Twist.Twist.Angular.Z; // Get command velocity from the active control service // Priority: PS5Controller > ManualControl > Navigation twist double cmdLinearVel = 0.0; double cmdAngularVel = 0.0; // Check PS5Controller first (if active) if (ps5ControllerService.State == PS5ControllerState.Active) { var ps5Twist = ps5ControllerService.CurrentTwist; cmdLinearVel = ps5Twist.Linear.X; cmdAngularVel = ps5Twist.Angular.Z; } // Otherwise check ManualControl (if active) else if (manualControlService.State == ManualControlState.Active) { var manualTwist = manualControlService.CurrentTwist; cmdLinearVel = manualTwist.Linear.X; cmdAngularVel = manualTwist.Angular.Z; } // Otherwise try to get navigation twist (from GetTwist in navigation system) else { var navTwist = navigationService.GetTwist(); if (navTwist.HasValue) { cmdLinearVel = navTwist.Value.x; cmdAngularVel = navTwist.Value.theta; } else { // Fallback: last known value from ManualControl var manualTwist = manualControlService.CurrentTwist; cmdLinearVel = manualTwist.Linear.X; cmdAngularVel = manualTwist.Angular.Z; } } return Results.Ok(new { status = "success", odomVel = new { linear = odomLinearVel, angular = odomAngularVel }, cmdVel = new { linear = cmdLinearVel, angular = cmdAngularVel } }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = ex.Message }); } }); // Diagnostics endpoint // DISABLED: GetDiagnostics method removed from XlocIntegrationService /* app.MapGet("/api/xloc/diagnostics", ([FromServices] XlocIntegrationService xlocService) => { var diagnostics = xlocService.GetDiagnostics(); if (diagnostics.HasValue) { return Results.Ok(new { status = "success", diagnostics = new { state = diagnostics.Value.state, map_name = diagnostics.Value.mapName, reliability = diagnostics.Value.reliability, matching_score = diagnostics.Value.matchingScore }}); } return Results.Ok(new { status = "error", message = "No diagnostics available" }); }); */ // Register XLOC SignalR Hub // app.MapHub("/hubs/xloc"); // Navigation Control API var navApi = app.MapGroup("/api/navigation").DisableAntiforgery(); // Navigation control - Move to goal navApi.MapPost("/move_to", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { try { var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); if (!body.TryGetProperty("x", out var xProp) || !body.TryGetProperty("y", out var yProp)) return Results.BadRequest(new { status = "error", message = "x and y are required" }); double x = xProp.GetDouble(); double y = yProp.GetDouble(); double z = body.TryGetProperty("z", out var zProp) ? zProp.GetDouble() : 0.0; // Orientation (quaternion or euler angles) double qx = 0.0, qy = 0.0, qz = 0.0, qw = 1.0; if (body.TryGetProperty("qx", out var qxProp) && body.TryGetProperty("qy", out var qyProp) && body.TryGetProperty("qz", out var qzProp) && body.TryGetProperty("qw", out var qwProp)) { qx = qxProp.GetDouble(); qy = qyProp.GetDouble(); qz = qzProp.GetDouble(); qw = qwProp.GetDouble(); } else if (body.TryGetProperty("yaw", out var yawProp)) { // Convert yaw to quaternion double yaw = yawProp.GetDouble(); qw = Math.Cos(yaw / 2.0); qz = Math.Sin(yaw / 2.0); } string frameId = body.TryGetProperty("frame_id", out var frameIdProp) ? frameIdProp.GetString() ?? "map" : "map"; double xyTolerance = body.TryGetProperty("xy_tolerance", out var xyTolProp) ? xyTolProp.GetDouble() : 0.1; double yawTolerance = body.TryGetProperty("yaw_tolerance", out var yawTolProp) ? yawTolProp.GetDouble() : 0.1; // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result = await Task.Run(() => service.MoveTo(x, y, z, qx, qy, qz, qw, frameId, xyTolerance, yawTolerance)); return result ? Results.Ok(new { status = "success", message = "Move to goal sent", goal = new { x, y, z, qx, qy, qz, qw, frameId } }) : Results.BadRequest(new { status = "error", message = "Failed to send move to goal" }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation control - Move to goal with order navApi.MapPost("/move_to_order", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service, [FromServices] ILogger logger) => { try { logger.LogInformation("Received move_to_order request"); var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); if (!body.TryGetProperty("x", out var xProp) || !body.TryGetProperty("y", out var yProp)) return Results.BadRequest(new { status = "error", message = "x and y are required" }); // Order handle - can be provided as: // 1. order_handle: IntPtr value (number or hex string) // 2. order_data: OrderData object (will be converted to OrderHandle) IntPtr orderHandle = IntPtr.Zero; OrderHandle? orderHandleWrapper = null; if (body.TryGetProperty("order_handle", out var orderHandleProp)) { if (orderHandleProp.ValueKind == JsonValueKind.String) { string orderHandleStr = orderHandleProp.GetString() ?? "0"; // Try parse as hex if starts with 0x, otherwise as decimal if (orderHandleStr.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { orderHandle = new IntPtr(Convert.ToInt64(orderHandleStr, 16)); } else { orderHandle = new IntPtr(Convert.ToInt64(orderHandleStr)); } } else if (orderHandleProp.ValueKind == JsonValueKind.Number) { orderHandle = new IntPtr(orderHandleProp.GetInt64()); } // Validate order handle is a reasonable pointer value (not too small) // Typical valid pointers on 64-bit systems are > 0x1000 if (orderHandle != IntPtr.Zero && orderHandle.ToInt64() < 0x1000) { return Results.BadRequest(new { status = "error", message = $"Invalid order_handle value: {orderHandle.ToInt64()}. Order handle must be a valid pointer to an Order object created by the navigation system. Use a value > 0x1000 or provide order_data instead.", order_handle = orderHandle.ToInt64() }); } if (orderHandle != IntPtr.Zero) { orderHandleWrapper = new OrderHandle(orderHandle); } } else if (body.TryGetProperty("order_data", out var orderDataProp)) { // Try to deserialize OrderData from JSON try { var orderData = JsonSerializer.Deserialize(orderDataProp.GetRawText()); if (orderData != null) { // Note: Currently, we cannot create OrderHandle from OrderData without C API support // For now, return error suggesting to use order_handle return Results.BadRequest(new { status = "error", message = "order_data is provided but OrderHandle creation from OrderData is not yet supported. Please use order_handle (IntPtr) instead, or implement C API function to create OrderHandle from Order data." }); } } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse order_data: {ex.Message}" }); } } if (orderHandle == IntPtr.Zero && (orderHandleWrapper == null || !orderHandleWrapper.IsValid)) return Results.BadRequest(new { status = "error", message = "Either order_handle or order_data is required" }); double x = xProp.GetDouble(); double y = yProp.GetDouble(); double z = body.TryGetProperty("z", out var zProp) ? zProp.GetDouble() : 0.0; // Orientation (quaternion or euler angles) double qx = 0.0, qy = 0.0, qz = 0.0, qw = 1.0; if (body.TryGetProperty("qx", out var qxProp) && body.TryGetProperty("qy", out var qyProp) && body.TryGetProperty("qz", out var qzProp) && body.TryGetProperty("qw", out var qwProp)) { qx = qxProp.GetDouble(); qy = qyProp.GetDouble(); qz = qzProp.GetDouble(); qw = qwProp.GetDouble(); } else if (body.TryGetProperty("yaw", out var yawProp)) { // Convert yaw to quaternion double yaw = yawProp.GetDouble(); qw = Math.Cos(yaw / 2.0); qz = Math.Sin(yaw / 2.0); } string frameId = body.TryGetProperty("frame_id", out var frameIdProp) ? frameIdProp.GetString() ?? "map" : "map"; double xyTolerance = body.TryGetProperty("xy_tolerance", out var xyTolProp) ? xyTolProp.GetDouble() : 0.1; double yawTolerance = body.TryGetProperty("yaw_tolerance", out var yawTolProp) ? yawTolProp.GetDouble() : 0.1; try { // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result; long orderHandleValue = orderHandle.ToInt64(); if (orderHandleWrapper != null && orderHandleWrapper.IsValid) { try { logger.LogInformation("Calling MoveToOrder with OrderHandle wrapper: {OrderHandle}, goal: ({X}, {Y}, {Z}), frame: {FrameId}", orderHandleWrapper.Handle.ToInt64(), x, y, z, frameId); result = await Task.Run(() => service.MoveToOrder(orderHandleWrapper, x, y, z, qx, qy, qz, qw, frameId, xyTolerance, yawTolerance)); orderHandleValue = orderHandleWrapper.Handle.ToInt64(); logger.LogInformation("MoveToOrder completed with result: {Result}, order_handle: {OrderHandle}", result, orderHandleValue); } catch (AccessViolationException avex) { return Results.BadRequest(new { status = "error", message = $"Access violation: Order handle {orderHandleValue} (0x{orderHandleValue:X}) is not a valid pointer to an Order object. The order handle must be created by the navigation system.", order_handle = orderHandleValue, error_type = "AccessViolationException" }); } catch (ArgumentException aex) { return Results.BadRequest(new { status = "error", message = aex.Message, order_handle = orderHandleValue, error_type = "ArgumentException" }); } } else if (orderHandle != IntPtr.Zero) { try { logger.LogInformation("Calling MoveToOrder with IntPtr: {OrderHandle}, goal: ({X}, {Y}, {Z}), frame: {FrameId}", orderHandleValue, x, y, z, frameId); result = await Task.Run(() => service.MoveToOrder(orderHandle, x, y, z, qx, qy, qz, qw, frameId, xyTolerance, yawTolerance)); logger.LogInformation("MoveToOrder completed with result: {Result}, order_handle: {OrderHandle}", result, orderHandleValue); } catch (AccessViolationException avex) { return Results.BadRequest(new { status = "error", message = $"Access violation: Order handle {orderHandleValue} (0x{orderHandleValue:X}) is not a valid pointer to an Order object. The order handle must be created by the navigation system.", order_handle = orderHandleValue, error_type = "AccessViolationException" }); } catch (ArgumentException aex) { return Results.BadRequest(new { status = "error", message = aex.Message, order_handle = orderHandleValue, error_type = "ArgumentException" }); } } else { return Results.BadRequest(new { status = "error", message = "Invalid order handle" }); } return result ? Results.Ok(new { status = "success", message = "Move to goal with order sent", goal = new { x, y, z, qx, qy, qz, qw, frameId }, order_handle = orderHandleValue }) : Results.BadRequest(new { status = "error", message = "Failed to send move to goal with order. Order handle may be invalid or navigation system rejected the command.", order_handle = orderHandleValue }); } catch (AccessViolationException avex) { return Results.BadRequest(new { status = "error", message = $"Access violation: Order handle {orderHandle.ToInt64()} (0x{orderHandle.ToInt64():X}) is not a valid pointer to an Order object.", order_handle = orderHandle.ToInt64(), error_type = "AccessViolationException" }); } catch (ArgumentException ex) { return Results.BadRequest(new { status = "error", message = ex.Message, order_handle = orderHandle.ToInt64(), error_type = "ArgumentException" }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Internal error: {ex.Message}", order_handle = orderHandle.ToInt64(), error_type = ex.GetType().Name, stack_trace = ex.StackTrace }); } } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation control - Move to goal with OrderData (JSON) navApi.MapPost("/move_to_order_data", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service, [FromServices] ILogger logger) => { try { logger.LogInformation("Received move_to_order_data request"); var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); // Parse goal position if (!body.TryGetProperty("x", out var xProp) || !body.TryGetProperty("y", out var yProp)) return Results.BadRequest(new { status = "error", message = "x and y are required" }); double x = xProp.GetDouble(); double y = yProp.GetDouble(); double z = body.TryGetProperty("z", out var zProp) ? zProp.GetDouble() : 0.0; // Orientation (quaternion or euler angles) double qx = 0.0, qy = 0.0, qz = 0.0, qw = 1.0; if (body.TryGetProperty("qx", out var qxProp) && body.TryGetProperty("qy", out var qyProp) && body.TryGetProperty("qz", out var qzProp) && body.TryGetProperty("qw", out var qwProp)) { qx = qxProp.GetDouble(); qy = qyProp.GetDouble(); qz = qzProp.GetDouble(); qw = qwProp.GetDouble(); } else if (body.TryGetProperty("yaw", out var yawProp)) { // Convert yaw to quaternion double yaw = yawProp.GetDouble(); qw = Math.Cos(yaw / 2.0); qz = Math.Sin(yaw / 2.0); } string frameId = body.TryGetProperty("frame_id", out var frameIdProp) ? frameIdProp.GetString() ?? "map" : "map"; double xyTolerance = body.TryGetProperty("xy_tolerance", out var xyTolProp) ? xyTolProp.GetDouble() : 0.1; double yawTolerance = body.TryGetProperty("yaw_tolerance", out var yawTolProp) ? yawTolProp.GetDouble() : 0.1; // Parse OrderData from "order" field if (!body.TryGetProperty("order", out var orderProp)) return Results.BadRequest(new { status = "error", message = "order is required" }); OrderData? orderData = null; try { orderData = JsonSerializer.Deserialize(orderProp.GetRawText(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (orderData == null) return Results.BadRequest(new { status = "error", message = "Failed to parse order data" }); logger.LogInformation("Parsed OrderData: OrderId={OrderId}, Nodes={NodeCount}, Edges={EdgeCount}", orderData.OrderId, orderData.Nodes?.Count ?? 0, orderData.Edges?.Count ?? 0); } catch (Exception ex) { logger.LogError(ex, "Failed to deserialize OrderData"); return Results.BadRequest(new { status = "error", message = $"Failed to parse order: {ex.Message}" }); } try { // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result = await Task.Run(() => service.MoveToOrder(orderData, x, y, z, qx, qy, qz, qw, frameId, xyTolerance, yawTolerance)); if (result) { logger.LogInformation("MoveToOrder successfully sent with OrderData: {OrderId}", orderData.OrderId); return Results.Ok(new { status = "success", message = "Move to goal with order sent", goal = new { x, y, z, qx, qy, qz, qw, frameId }, order = new { orderId = orderData.OrderId, orderUpdateId = orderData.OrderUpdateId, nodeCount = orderData.Nodes?.Count ?? 0, edgeCount = orderData.Edges?.Count ?? 0 } }); } else { logger.LogWarning("MoveToOrder failed for OrderData: {OrderId}", orderData.OrderId); return Results.BadRequest(new { status = "error", message = "Failed to send move to goal with order. Navigation system rejected the command.", orderId = orderData.OrderId }); } } catch (Exception ex) { logger.LogError(ex, "Error in MoveToOrder with OrderData: {OrderId}", orderData?.OrderId ?? "null"); return Results.BadRequest(new { status = "error", message = $"Internal error: {ex.Message}", error_type = ex.GetType().Name }); } } catch (Exception ex) { logger.LogError(ex, "Failed to parse move_to_order_data request"); return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation control - Dock to marker navApi.MapPost("/dock_to", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { try { var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); if (!body.TryGetProperty("marker", out var markerProp)) return Results.BadRequest(new { status = "error", message = "marker is required" }); string marker = markerProp.GetString() ?? ""; if (string.IsNullOrEmpty(marker)) return Results.BadRequest(new { status = "error", message = "marker cannot be empty" }); if (!body.TryGetProperty("x", out var xProp) || !body.TryGetProperty("y", out var yProp)) return Results.BadRequest(new { status = "error", message = "x and y are required" }); double x = xProp.GetDouble(); double y = yProp.GetDouble(); double z = body.TryGetProperty("z", out var zProp) ? zProp.GetDouble() : 0.0; double qx = 0.0, qy = 0.0, qz = 0.0, qw = 1.0; if (body.TryGetProperty("qx", out var qxProp) && body.TryGetProperty("qy", out var qyProp) && body.TryGetProperty("qz", out var qzProp) && body.TryGetProperty("qw", out var qwProp)) { qx = qxProp.GetDouble(); qy = qyProp.GetDouble(); qz = qzProp.GetDouble(); qw = qwProp.GetDouble(); } else if (body.TryGetProperty("yaw", out var yawProp)) { double yaw = yawProp.GetDouble(); qw = Math.Cos(yaw / 2.0); qz = Math.Sin(yaw / 2.0); } string frameId = body.TryGetProperty("frame_id", out var frameIdProp) ? frameIdProp.GetString() ?? "map" : "map"; double xyTolerance = body.TryGetProperty("xy_tolerance", out var xyTolProp) ? xyTolProp.GetDouble() : 0.05; double yawTolerance = body.TryGetProperty("yaw_tolerance", out var yawTolProp) ? yawTolProp.GetDouble() : 0.05; // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result = await Task.Run(() => service.DockTo(marker, x, y, z, qx, qy, qz, qw, frameId, xyTolerance, yawTolerance)); return result ? Results.Ok(new { status = "success", message = "Dock to goal sent", marker, goal = new { x, y, z, qx, qy, qz, qw, frameId } }) : Results.BadRequest(new { status = "error", message = "Failed to send dock to goal" }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation control - Move straight by distance (meters) in current direction navApi.MapPost("/move_straight_to", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { try { var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); if (!body.TryGetProperty("distance", out var distProp)) return Results.BadRequest(new { status = "error", message = "distance is required" }); double distance = distProp.GetDouble(); // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result = await Task.Run(() => service.MoveStraightTo(distance)); return result ? Results.Ok(new { status = "success", message = "Move straight to goal sent", distance = distance }) : Results.BadRequest(new { status = "error", message = "Failed to send move straight to goal" }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation control - Rotate to orientation navApi.MapPost("/rotate_to", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { try { var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); // For rotation, we need current position and target orientation double x = 0.0, y = 0.0, z = 0.0; if (body.TryGetProperty("x", out var xProp)) x = xProp.GetDouble(); if (body.TryGetProperty("y", out var yProp)) y = yProp.GetDouble(); if (body.TryGetProperty("z", out var zProp)) z = zProp.GetDouble(); double qx = 0.0, qy = 0.0, qz = 0.0, qw = 1.0; if (body.TryGetProperty("qx", out var qxProp) && body.TryGetProperty("qy", out var qyProp) && body.TryGetProperty("qz", out var qzProp) && body.TryGetProperty("qw", out var qwProp)) { qx = qxProp.GetDouble(); qy = qyProp.GetDouble(); qz = qzProp.GetDouble(); qw = qwProp.GetDouble(); } else if (body.TryGetProperty("yaw", out var yawProp)) { double yaw = yawProp.GetDouble(); qw = Math.Cos(yaw / 2.0); qz = Math.Sin(yaw / 2.0); } else { return Results.BadRequest(new { status = "error", message = "yaw or quaternion (qx, qy, qz, qw) is required" }); } string frameId = body.TryGetProperty("frame_id", out var frameIdProp) ? frameIdProp.GetString() ?? "map" : "map"; double yawTolerance = body.TryGetProperty("yaw_tolerance", out var yawTolProp) ? yawTolProp.GetDouble() : 0.1; // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result = await Task.Run(() => service.RotateTo(x, y, z, qx, qy, qz, qw, frameId, yawTolerance)); return result ? Results.Ok(new { status = "success", message = "Rotate to goal sent", goal = new { x, y, z, qx, qy, qz, qw, frameId } }) : Results.BadRequest(new { status = "error", message = "Failed to send rotate to goal" }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation control - Pause navApi.MapPost("/pause", async ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { // Wrap synchronous native calls in Task.Run to avoid blocking the request thread await Task.Run(() => service.Pause()); return Results.Ok(new { status = "success", message = "Navigation paused" }); }); // Navigation control - Resume navApi.MapPost("/resume", async ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { // Wrap synchronous native calls in Task.Run to avoid blocking the request thread await Task.Run(() => service.Resume()); return Results.Ok(new { status = "success", message = "Navigation resumed" }); }); // Navigation control - Cancel navApi.MapPost("/cancel", async ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { // Wrap synchronous native calls in Task.Run to avoid blocking the request thread await Task.Run(() => service.Cancel()); return Results.Ok(new { status = "success", message = "Navigation cancelled" }); }); // Navigation control - Set linear twist navApi.MapPost("/twist/linear", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { try { var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); double linearX = body.TryGetProperty("x", out var xProp) ? xProp.GetDouble() : 0.0; double linearY = body.TryGetProperty("y", out var yProp) ? yProp.GetDouble() : 0.0; double linearZ = body.TryGetProperty("z", out var zProp) ? zProp.GetDouble() : 0.0; // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result = await Task.Run(() => service.SetTwistLinear(linearX, linearY, linearZ)); return result ? Results.Ok(new { status = "success", message = "Linear twist set", twist = new { x = linearX, y = linearY, z = linearZ } }) : Results.BadRequest(new { status = "error", message = "Failed to set linear twist" }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation control - Set angular twist navApi.MapPost("/twist/angular", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { try { var body = await request.ReadFromJsonAsync(); if (body.ValueKind != JsonValueKind.Object) return Results.BadRequest(new { status = "error", message = "Invalid request body" }); double angularX = body.TryGetProperty("x", out var xProp) ? xProp.GetDouble() : 0.0; double angularY = body.TryGetProperty("y", out var yProp) ? yProp.GetDouble() : 0.0; double angularZ = body.TryGetProperty("z", out var zProp) ? zProp.GetDouble() : 0.0; // Wrap synchronous native calls in Task.Run to avoid blocking the request thread bool result = await Task.Run(() => service.SetTwistAngular(angularX, angularY, angularZ)); return result ? Results.Ok(new { status = "success", message = "Angular twist set", twist = new { x = angularX, y = angularY, z = angularZ } }) : Results.BadRequest(new { status = "error", message = "Failed to set angular twist" }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = $"Failed to parse request: {ex.Message}" }); } }); // Navigation data - Current pose navApi.MapGet("/pose/current", ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { var pose = service.GetRobotPose(); return pose.HasValue ? Results.Ok(new { status = "success", pose = new { position = new { x = pose.Value.x, y = pose.Value.y, z = pose.Value.z }, orientation = new { x = pose.Value.qx, y = pose.Value.qy, z = pose.Value.qz, w = pose.Value.qw }, frameId = pose.Value.frameId } }) : Results.NotFound(new { status = "error", message = "No pose available" }); }); // Navigation data - Current pose 2D navApi.MapGet("/pose/current2d", ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { var pose = service.GetRobotPose2D(); return pose.HasValue ? Results.Ok(new { status = "success", pose = new { x = pose.Value.x, y = pose.Value.y, theta = pose.Value.theta } }) : Results.NotFound(new { status = "error", message = "No pose available" }); }); // Navigation data - Current twist navApi.MapGet("/twist", ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { var twist = service.GetTwist(); return twist.HasValue ? Results.Ok(new { status = "success", twist = new { linear = new { x = twist.Value.x, y = twist.Value.y }, angular = new { z = twist.Value.theta }, frameId = twist.Value.frameId } }) : Results.NotFound(new { status = "error", message = "No twist available" }); }); // Navigation data - Feedback navApi.MapGet("/feedback", ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { var feedback = service.GetFeedback(); return feedback != null ? Results.Ok(new { status = "success", feedback = new { navigationState = feedback.NavigationState, stateString = feedback.StateString, feedbackString = feedback.FeedbackString, currentPose = new { x = feedback.CurrentPose.X, y = feedback.CurrentPose.Y, theta = feedback.CurrentPose.Theta }, goalChecked = feedback.GoalChecked, isReady = feedback.IsReady } }) : Results.NotFound(new { status = "error", message = "No feedback available" }); }); // Navigation data - Global path (navigation_get_global_data) navApi.MapGet("/global_data/path", async ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { var globalPath = await Task.Run(() => service.GetGlobalPathData()); if (globalPath == null) { // Return OK with "no_data" status instead of NotFound to prevent log spam return Results.Ok(new { status = "no_data", message = "No global planner data available yet" }); } return Results.Ok(new { status = "success", source = "navigation_get_global_data", plan = new { frameId = globalPath.FrameId, pointCount = globalPath.Points.Count, points = globalPath.Points.Select(point => new { x = point.X, y = point.Y, theta = point.Theta }) } }); }); // Navigation data - Local path (navigation_get_local_data) navApi.MapGet("/local_data/path", async ( [FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service, [FromServices] RobotNet10.RobotApp.Xloc.XlocIntegrationService xlocService, [FromServices] OdometryService odometryService) => { var localPath = await Task.Run(() => service.GetLocalPathData()); if (localPath == null) { // Return OK with "no_data" status instead of NotFound to prevent log spam return Results.Ok(new { status = "no_data", message = "No local planner path data available yet" }); } // Transform local path to map frame so it is anchored to robot on the map view. // Local path is typically in "odom" (or base frame). Map view is in "map". var maybeMapPose = xlocService.GetCurrentPose2D(); // map->base pose var odom = odometryService.CurrentOdometry; // odom->base pose var oq = odom.Pose.Pose.Orientation; var odomYaw = Math.Atan2( 2.0 * (oq.W * oq.Z + oq.X * oq.Y), 1.0 - 2.0 * (oq.Y * oq.Y + oq.Z * oq.Z) ); double mapBaseX = maybeMapPose?.x ?? 0.0; double mapBaseY = maybeMapPose?.y ?? 0.0; double mapBaseYaw = maybeMapPose?.yaw ?? 0.0; // odom->base double odomBaseX = odom.Pose.Pose.Position.X; double odomBaseY = odom.Pose.Pose.Position.Y; double odomBaseYaw = odomYaw; // Compute map->odom = map->base ⊕ inverse(odom->base) // 2D composition: // inv(odom->base): (-R^T t, -yaw) // map->odom translation: // t_mo = t_mb + R(yaw_mb) * (-R(yaw_ob)^T * t_ob) // yaw_mo = yaw_mb - yaw_ob double yawMapOdom = mapBaseYaw - odomBaseYaw; double cosOb = Math.Cos(odomBaseYaw); double sinOb = Math.Sin(odomBaseYaw); // -R^T * t_ob double invX = -(cosOb * odomBaseX + sinOb * odomBaseY); double invY = -(-sinOb * odomBaseX + cosOb * odomBaseY); double cosMb = Math.Cos(mapBaseYaw); double sinMb = Math.Sin(mapBaseYaw); double mapOdomX = mapBaseX + (cosMb * invX - sinMb * invY); double mapOdomY = mapBaseY + (sinMb * invX + cosMb * invY); // Helper: transform point from odom to map using map->odom static (double x, double y) TransformOdomToMap(double mapOdomX, double mapOdomY, double yawMapOdom, double xOdom, double yOdom) { double c = Math.Cos(yawMapOdom); double s = Math.Sin(yawMapOdom); return (mapOdomX + c * xOdom - s * yOdom, mapOdomY + s * xOdom + c * yOdom); } // Helper: transform point from base to map using map->base static (double x, double y) TransformBaseToMap(double mapBaseX, double mapBaseY, double yawMapBase, double xBase, double yBase) { double c = Math.Cos(yawMapBase); double s = Math.Sin(yawMapBase); return (mapBaseX + c * xBase - s * yBase, mapBaseY + s * xBase + c * yBase); } string originalFrameId = localPath.FrameId; var pointsOut = localPath.Points.Select(point => { double x = point.X; double y = point.Y; double theta = point.Theta; // If planner reports odom frame, convert to map frame. // If planner reports base frame, convert using map->base. if (string.Equals(originalFrameId, "odom", StringComparison.OrdinalIgnoreCase)) { var (xm, ym) = TransformOdomToMap(mapOdomX, mapOdomY, yawMapOdom, x, y); return new { x = xm, y = ym, theta = theta + yawMapOdom }; } if (string.Equals(originalFrameId, "base_link", StringComparison.OrdinalIgnoreCase) || string.Equals(originalFrameId, "base_footprint", StringComparison.OrdinalIgnoreCase)) { var (xm, ym) = TransformBaseToMap(mapBaseX, mapBaseY, mapBaseYaw, x, y); return new { x = xm, y = ym, theta = theta + mapBaseYaw }; } // Unknown frame: return as-is. return new { x, y, theta }; }).ToList(); return Results.Ok(new { status = "success", source = "navigation_get_local_data", plan = new { frameId = "map", originalFrameId = originalFrameId, pointCount = pointsOut.Count, points = pointsOut } }); }); // Navigation data - Local cost map (navigation_get_local_data) navApi.MapGet("/local_data/costmap", async ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service, [FromServices] OdometryService odometryService) => { var costMap = await Task.Run(() => service.GetCostMapData()); if (costMap == null) { // Return OK with "no_data" status instead of NotFound to prevent log spam return Results.Ok(new { status = "no_data", message = "No local planner cost map data available yet" }); } var odom = odometryService.CurrentOdometry; var q = odom.Pose.Pose.Orientation; var odomYaw = Math.Atan2( 2.0 * (q.W * q.Z + q.X * q.Y), 1.0 - 2.0 * (q.Y * q.Y + q.Z * q.Z) ); return Results.Ok(new { status = "success", source = "navigation_get_local_data", hasFullMap = costMap.HasFullMap, isCostmapUpdated = costMap.IsCostmapUpdated, odometry = new { x = odom.Pose.Pose.Position.X, y = odom.Pose.Pose.Position.Y, yaw = odomYaw }, costmap = new { frameId = costMap.FrameId, resolution = costMap.Resolution, width = costMap.Width, height = costMap.Height, origin = new { x = costMap.OriginX, y = costMap.OriginY, theta = costMap.OriginTheta }, dataSize = costMap.HasFullMap ? costMap.Data.Length : 0, data = costMap.HasFullMap ? Convert.ToBase64String(costMap.Data) : null }, costmapUpdate = new { frameId = costMap.UpdateFrameId, x = costMap.UpdateX, y = costMap.UpdateY, width = costMap.UpdateWidth, height = costMap.UpdateHeight, dataSize = costMap.IsCostmapUpdated ? costMap.UpdateData.Length : 0, data = costMap.IsCostmapUpdated ? Convert.ToBase64String(costMap.UpdateData) : null } }); }); // Navigation data - Robot footprint configuration navApi.MapGet("/robot_footprint", ([FromServices] RobotNet10.RobotApp.Navigation.NavigationIntegrationService service) => { var footprint = service.GetRobotFootprint(); if (footprint == null || footprint.Length == 0) { return Results.NotFound(new { status = "error", message = "Robot footprint not configured" }); } return Results.Ok(new { status = "success", footprint = footprint.Select(point => new { x = point.X, y = point.Y, z = point.Z }).ToArray() }); }); // Marker Detection Control API var markerApi = app.MapGroup("/api/marker-detection").DisableAntiforgery(); // Enable or disable marker detection markerApi.MapPost("/detection/enable", async (HttpRequest request, [FromServices] RobotNet10.RobotApp.MarkerDetection.MarkerDetectionIntegrationService service) => { try { var body = await request.ReadFromJsonAsync>(); var enable = body?["enable"] ?? true; service.SetEnableDetection(enable); return Results.Ok(new { status = "success", message = $"Marker detection {(enable ? "enabled" : "disabled")}", enabled = enable }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = ex.Message }); } }); // Get current marker pose markerApi.MapGet("/pose/current", ([FromServices] RobotNet10.RobotApp.MarkerDetection.MarkerDetectionIntegrationService service) => { try { var pose = service.GetMarkerPose(); if (pose == null) return Results.NotFound(new { status = "error", message = "No marker pose available" }); return Results.Ok(new { status = "success", pose = new { header = new { seq = pose.Header.Seq, stamp = pose.Header.Stamp, frameId = pose.Header.FrameId }, position = new { x = pose.Pose.Position[0], y = pose.Pose.Position[1], z = pose.Pose.Position[2] }, orientation = new { x = pose.Pose.Orientation[0], y = pose.Pose.Orientation[1], z = pose.Pose.Orientation[2], w = pose.Pose.Orientation[3] } } }); } catch (Exception ex) { return Results.BadRequest(new { status = "error", message = ex.Message }); } }); app.Run(); // DTOs for PLC Lift API public record LiftVelocityRequest(bool Up, bool Down); public record LiftPositionRequest(int Position);