Initial commit
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.NavigationTune.Shared.Interfaces;
|
||||
using RobotNet10.NavigationTune.Shared.Models;
|
||||
|
||||
namespace RobotNet10.NavigationTune.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API controller for parameter sets management
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ParameterSetsController(
|
||||
IParameterManager parameterManager,
|
||||
ILogger<ParameterSetsController> logger) : ControllerBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Get all parameter sets
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<NavigationParameterSet>>> GetAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameterSets = await parameterManager.GetAllAsync();
|
||||
return Ok(parameterSets);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting all parameter sets");
|
||||
return StatusCode(500, new { error = "Failed to retrieve parameter sets" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get parameter set by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<NavigationParameterSet>> GetById(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameterSet = await parameterManager.GetByIdAsync(id);
|
||||
if (parameterSet == null)
|
||||
return NotFound(new { error = $"Parameter set with ID {id} not found" });
|
||||
|
||||
return Ok(parameterSet);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting parameter set {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to retrieve parameter set" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get parameter set by name
|
||||
/// </summary>
|
||||
[HttpGet("name/{name}")]
|
||||
public async Task<ActionResult<NavigationParameterSet>> GetByName(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameterSet = await parameterManager.GetByNameAsync(name);
|
||||
if (parameterSet == null)
|
||||
return NotFound(new { error = $"Parameter set with name '{name}' not found" });
|
||||
|
||||
return Ok(parameterSet);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting parameter set {Name}", name);
|
||||
return StatusCode(500, new { error = "Failed to retrieve parameter set" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create new parameter set
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<NavigationParameterSet>> Create([FromBody] NavigationParameterSet parameterSet)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate
|
||||
if(await parameterManager.GetByNameAsync(parameterSet.Name) is not null) return StatusCode(500, new { error = "Paramter name is existed" });
|
||||
var validation = parameterManager.Validate(parameterSet);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = "Validation failed",
|
||||
errors = validation.Errors,
|
||||
warnings = validation.Warnings
|
||||
});
|
||||
}
|
||||
|
||||
var id = await parameterManager.SaveAsync(parameterSet);
|
||||
var created = await parameterManager.GetByIdAsync(id);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, created);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error creating parameter set");
|
||||
return StatusCode(500, new { error = "Failed to create parameter set" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update existing parameter set
|
||||
/// </summary>
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult> Update(Guid id, [FromBody] NavigationParameterSet parameterSet)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await parameterManager.GetByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound(new { error = $"Parameter set with ID {id} not found" });
|
||||
|
||||
// Validate
|
||||
var validation = parameterManager.Validate(parameterSet);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
error = "Validation failed",
|
||||
errors = validation.Errors,
|
||||
warnings = validation.Warnings
|
||||
});
|
||||
}
|
||||
existing.ControllerType = parameterSet.ControllerType;
|
||||
existing.PurePursuitConfig = parameterSet.PurePursuitConfig;
|
||||
existing.StanleyConfig = parameterSet.StanleyConfig ?? new StanleyConfig();
|
||||
existing.EstimatorConfig = parameterSet.EstimatorConfig;
|
||||
existing.NavigationConfig = parameterSet.NavigationConfig;
|
||||
existing.MovePidConfig = parameterSet.MovePidConfig;
|
||||
existing.RotatePidConfig = parameterSet.RotatePidConfig;
|
||||
existing.SignalConfig = parameterSet.SignalConfig;
|
||||
existing.MotorDynamicsConfig = parameterSet.MotorDynamicsConfig;
|
||||
await parameterManager.UpdateAsync(existing);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error updating parameter set {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to update parameter set" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete parameter set
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await parameterManager.GetByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound(new { error = $"Parameter set with ID {id} not found" });
|
||||
|
||||
await parameterManager.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error deleting parameter set {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to delete parameter set" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate parameter set
|
||||
/// </summary>
|
||||
[HttpPost("validate")]
|
||||
public ActionResult<ValidationResult> Validate([FromBody] NavigationParameterSet parameterSet)
|
||||
{
|
||||
try
|
||||
{
|
||||
var validation = parameterManager.Validate(parameterSet);
|
||||
return Ok(validation);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error validating parameter set");
|
||||
return StatusCode(500, new { error = "Failed to validate parameter set" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get default preset
|
||||
/// </summary>
|
||||
[HttpGet("presets/default")]
|
||||
public ActionResult<NavigationParameterSet> GetDefaultPreset()
|
||||
{
|
||||
try
|
||||
{
|
||||
var preset = parameterManager.GetDefaultPreset();
|
||||
return Ok(preset);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting default preset");
|
||||
return StatusCode(500, new { error = "Failed to get default preset" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get aggressive preset
|
||||
/// </summary>
|
||||
[HttpGet("presets/aggressive")]
|
||||
public ActionResult<NavigationParameterSet> GetAggressivePreset()
|
||||
{
|
||||
try
|
||||
{
|
||||
var preset = parameterManager.GetAggressivePreset();
|
||||
return Ok(preset);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting aggressive preset");
|
||||
return StatusCode(500, new { error = "Failed to get aggressive preset" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get smooth preset
|
||||
/// </summary>
|
||||
[HttpGet("presets/smooth")]
|
||||
public ActionResult<NavigationParameterSet> GetSmoothPreset()
|
||||
{
|
||||
try
|
||||
{
|
||||
var preset = parameterManager.GetSmoothPreset();
|
||||
return Ok(preset);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error getting smooth preset");
|
||||
return StatusCode(500, new { error = "Failed to get smooth preset" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.NavigationTune.Data;
|
||||
using RobotNet10.NavigationTune.Shared.Models;
|
||||
using StraightLineScenario = RobotNet10.NavigationTune.Scenarios.StraightLineScenario;
|
||||
using CircleScenario = RobotNet10.NavigationTune.Scenarios.CircleScenario;
|
||||
using CustomPathScenario = RobotNet10.NavigationTune.Scenarios.CustomPathScenario;
|
||||
|
||||
namespace RobotNet10.NavigationTune.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for TestScenario API responses
|
||||
/// </summary>
|
||||
public class TestScenarioDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public TrajectoryType Type { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public string ConfigJson { get; set; } = string.Empty;
|
||||
|
||||
public static TestScenarioDto FromTestScenario(TestScenario scenario, TestScenarioEntity entity)
|
||||
{
|
||||
return new TestScenarioDto
|
||||
{
|
||||
Id = scenario.Id,
|
||||
Name = scenario.Name,
|
||||
Description = scenario.Description,
|
||||
Type = scenario.Type,
|
||||
CreatedAt = scenario.CreatedAt,
|
||||
IsDefault = scenario.IsDefault,
|
||||
ConfigJson = entity.ConfigJson
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request DTO for Create/Update scenario. Uses Type + ConfigJson to avoid deserializing abstract TestScenario.
|
||||
/// </summary>
|
||||
public class CreateOrUpdateScenarioRequest
|
||||
{
|
||||
public Guid? Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public TrajectoryType Type { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
/// <summary>JSON string of the scenario-specific config (serialized StraightLineScenario, CircleScenario, or CustomPathScenario).</summary>
|
||||
public string ConfigJson { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// REST API controller for test scenarios management
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ScenariosController(
|
||||
IScenarioRepository scenarioRepository,
|
||||
ILogger<ScenariosController> logger) : ControllerBase
|
||||
{
|
||||
private readonly IScenarioRepository _scenarioRepository = scenarioRepository;
|
||||
private readonly ILogger<ScenariosController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Get all scenarios
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<TestScenarioDto>>> GetAll()
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = await _scenarioRepository.GetAllEntitiesAsync();
|
||||
var dtos = new List<TestScenarioDto>();
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var scenario = entity.ToTestScenario();
|
||||
dtos.Add(TestScenarioDto.FromTestScenario(scenario, entity));
|
||||
}
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting all scenarios");
|
||||
return StatusCode(500, new { error = "Failed to retrieve scenarios" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get default scenarios
|
||||
/// </summary>
|
||||
[HttpGet("defaults")]
|
||||
public async Task<ActionResult<List<TestScenarioDto>>> GetDefaults()
|
||||
{
|
||||
try
|
||||
{
|
||||
var entities = await _scenarioRepository.GetDefaultScenarioEntitiesAsync();
|
||||
var dtos = new List<TestScenarioDto>();
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
var scenario = entity.ToTestScenario();
|
||||
dtos.Add(TestScenarioDto.FromTestScenario(scenario, entity));
|
||||
}
|
||||
|
||||
return Ok(dtos);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting default scenarios");
|
||||
return StatusCode(500, new { error = "Failed to retrieve default scenarios" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get scenario by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<TestScenarioDto>> GetById(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _scenarioRepository.GetEntityByIdAsync(id);
|
||||
if (entity == null)
|
||||
return NotFound(new { error = $"Scenario with ID {id} not found" });
|
||||
|
||||
var scenario = entity.ToTestScenario();
|
||||
return Ok(TestScenarioDto.FromTestScenario(scenario, entity));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting scenario {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to retrieve scenario" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create new scenario. Accepts Type + ConfigJson to avoid deserializing abstract TestScenario.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<TestScenarioDto>> Create([FromBody] CreateOrUpdateScenarioRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scenario = DeserializeScenarioFromRequest(request);
|
||||
if (scenario.Id == Guid.Empty)
|
||||
scenario.Id = Guid.NewGuid();
|
||||
|
||||
var id = await _scenarioRepository.SaveAsync(scenario);
|
||||
var entity = await _scenarioRepository.GetEntityByIdAsync(id);
|
||||
if (entity == null)
|
||||
return StatusCode(500, new { error = "Failed to retrieve created scenario" });
|
||||
|
||||
var dto = TestScenarioDto.FromTestScenario(entity.ToTestScenario(), entity);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, dto);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating scenario");
|
||||
return StatusCode(500, new { error = "Failed to create scenario" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update existing scenario. Accepts Type + ConfigJson to avoid deserializing abstract TestScenario.
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<ActionResult> Update(Guid id, [FromBody] CreateOrUpdateScenarioRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _scenarioRepository.GetEntityByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound(new { error = $"Scenario with ID {id} not found" });
|
||||
|
||||
var scenario = DeserializeScenarioFromRequest(request);
|
||||
scenario.Id = id;
|
||||
await _scenarioRepository.UpdateAsync(scenario);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating scenario {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to update scenario" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialize ConfigJson to concrete TestScenario based on Type (avoids abstract type deserialization).
|
||||
/// </summary>
|
||||
private static TestScenario DeserializeScenarioFromRequest(CreateOrUpdateScenarioRequest request)
|
||||
{
|
||||
var jsonOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
TestScenario scenario = request.Type switch
|
||||
{
|
||||
TrajectoryType.StraightLine => JsonSerializer.Deserialize<StraightLineScenario>(request.ConfigJson, jsonOptions)
|
||||
?? new StraightLineScenario(),
|
||||
TrajectoryType.Circle => JsonSerializer.Deserialize<CircleScenario>(request.ConfigJson, jsonOptions)
|
||||
?? new CircleScenario(),
|
||||
TrajectoryType.Custom => JsonSerializer.Deserialize<CustomPathScenario>(request.ConfigJson, jsonOptions)
|
||||
?? new CustomPathScenario(),
|
||||
_ => throw new NotSupportedException($"Scenario type {request.Type} is not supported")
|
||||
};
|
||||
scenario.Id = request.Id ?? Guid.Empty;
|
||||
scenario.Name = request.Name;
|
||||
scenario.Description = request.Description;
|
||||
scenario.Type = request.Type;
|
||||
scenario.IsDefault = request.IsDefault;
|
||||
return scenario;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete scenario. Only custom (non-default) scenarios can be deleted.
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<ActionResult> Delete(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _scenarioRepository.GetByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound(new { error = $"Scenario with ID {id} not found" });
|
||||
|
||||
if (existing.IsDefault)
|
||||
return BadRequest(new { error = "Cannot delete default scenario. Only custom scenarios can be deleted." });
|
||||
|
||||
await _scenarioRepository.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting scenario {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to delete scenario" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.NavigationTune.Shared.Interfaces;
|
||||
using RobotNet10.NavigationTune.Shared.Models;
|
||||
|
||||
namespace RobotNet10.NavigationTune.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API controller for test runs (test history)
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TestRunsController : ControllerBase
|
||||
{
|
||||
private readonly ITestRepository _testRepository;
|
||||
private readonly ILogger<TestRunsController> _logger;
|
||||
|
||||
public TestRunsController(
|
||||
ITestRepository testRepository,
|
||||
ILogger<TestRunsController> logger)
|
||||
{
|
||||
_testRepository = testRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get test runs with optional pagination (returns DTO to avoid abstract TestScenario deserialization on client).
|
||||
/// Use skip/take for paging; totalCount is returned for paginator.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<TestRunDto>>> GetAll(
|
||||
[FromQuery] int? skip = null,
|
||||
[FromQuery] int? take = null,
|
||||
[FromQuery] int? limit = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
int skipVal = skip ?? 0;
|
||||
int takeVal;
|
||||
if (limit.HasValue && limit.Value > 0 && !take.HasValue)
|
||||
takeVal = limit.Value;
|
||||
else
|
||||
takeVal = Math.Min(take ?? 50, 500);
|
||||
|
||||
var totalCount = await _testRepository.GetCountAsync();
|
||||
var testRuns = await _testRepository.GetPagedAsync(skipVal, takeVal);
|
||||
var items = testRuns.Select(ToDto).ToList();
|
||||
return Ok(new PagedResult<TestRunDto> { TotalCount = totalCount, Items = items });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting all test runs");
|
||||
return StatusCode(500, new { error = "Failed to retrieve test runs" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get test run by ID
|
||||
/// </summary>
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<ActionResult<TestRunDto>> GetById(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testRun = await _testRepository.GetByIdAsync(id);
|
||||
if (testRun == null)
|
||||
return NotFound(new { error = $"Test run with ID {id} not found" });
|
||||
return Ok(ToDto(testRun));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting test run {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to retrieve test run" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get test runs by scenario ID
|
||||
/// </summary>
|
||||
[HttpGet("scenario/{scenarioId}")]
|
||||
public async Task<ActionResult<List<TestRunDto>>> GetByScenario(Guid scenarioId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testRuns = await _testRepository.GetByScenarioAsync(scenarioId);
|
||||
return Ok(testRuns.Select(ToDto).ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting test runs for scenario {ScenarioId}", scenarioId);
|
||||
return StatusCode(500, new { error = "Failed to retrieve test runs" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get test runs by parameter set ID
|
||||
/// </summary>
|
||||
[HttpGet("parameterset/{parameterSetId}")]
|
||||
public async Task<ActionResult<List<TestRunDto>>> GetByParameterSet(Guid parameterSetId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testRuns = await _testRepository.GetByParameterSetAsync(parameterSetId);
|
||||
return Ok(testRuns.Select(ToDto).ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting test runs for parameter set {ParameterSetId}", parameterSetId);
|
||||
return StatusCode(500, new { error = "Failed to retrieve test runs" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get test runs by date range
|
||||
/// </summary>
|
||||
[HttpGet("daterange")]
|
||||
public async Task<ActionResult<List<TestRunDto>>> GetByDateRange(
|
||||
[FromQuery] DateTime from,
|
||||
[FromQuery] DateTime to)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testRuns = await _testRepository.GetByDateRangeAsync(from, to);
|
||||
return Ok(testRuns.Select(ToDto).ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting test runs for date range {From} to {To}", from, to);
|
||||
return StatusCode(500, new { error = "Failed to retrieve test runs" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete test run
|
||||
/// </summary>
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await _testRepository.GetByIdAsync(id);
|
||||
if (existing == null)
|
||||
return NotFound(new { error = $"Test run with ID {id} not found" });
|
||||
|
||||
await _testRepository.DeleteAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting test run {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to delete test run" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple test runs by IDs
|
||||
/// </summary>
|
||||
[HttpPost("delete-batch")]
|
||||
public async Task<ActionResult> DeleteBatch([FromBody] DeleteBatchRequest request)
|
||||
{
|
||||
if (request?.Ids == null || request.Ids.Count == 0)
|
||||
return BadRequest(new { error = "Ids is required and must not be empty" });
|
||||
try
|
||||
{
|
||||
await _testRepository.DeleteManyAsync(request.Ids);
|
||||
return NoContent();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting test runs batch");
|
||||
return StatusCode(500, new { error = "Failed to delete test runs" });
|
||||
}
|
||||
}
|
||||
|
||||
private static TestRunDto ToDto(TestRun r)
|
||||
{
|
||||
return new TestRunDto
|
||||
{
|
||||
Id = r.Id,
|
||||
ScenarioId = r.ScenarioId,
|
||||
ScenarioName = r.Scenario?.Name ?? string.Empty,
|
||||
ParameterSetId = r.ParameterSetId,
|
||||
ParameterSetName = r.ParameterSet?.Name ?? string.Empty,
|
||||
StartTime = r.StartTime,
|
||||
EndTime = r.EndTime,
|
||||
Status = r.Status,
|
||||
Duration = r.Duration,
|
||||
Notes = r.Notes,
|
||||
ErrorMessage = r.ErrorMessage,
|
||||
Metrics = r.Metrics,
|
||||
SafetyViolations = r.SafetyViolations ?? new List<SafetyViolation>()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.NavigationTune.Shared.Interfaces;
|
||||
using RobotNet10.NavigationTune.Shared.Models;
|
||||
|
||||
namespace RobotNet10.NavigationTune.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API controller for tuning advisor - re-analyze tests and apply suggestions
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TuningAdvisorController(
|
||||
ITuningAdvisor tuningAdvisor,
|
||||
ITestRepository testRepository,
|
||||
IParameterManager parameterManager,
|
||||
ILogger<TuningAdvisorController> logger) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Re-analyze a completed test run using telemetry provided in the request body.
|
||||
/// Note: Telemetry data is not persisted to database, so the client must supply it.
|
||||
/// The primary analysis is done automatically in the orchestrator after test completion.
|
||||
/// </summary>
|
||||
[HttpPost("analyze/{testRunId}")]
|
||||
public async Task<ActionResult<TuningReport>> AnalyzeTestRun(
|
||||
Guid testRunId, [FromBody] List<TelemetryData>? telemetry = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var testRun = await testRepository.GetByIdAsync(testRunId);
|
||||
if (testRun == null)
|
||||
return NotFound(new { error = $"Test run {testRunId} not found" });
|
||||
|
||||
if (testRun.Status != TestStatus.Completed)
|
||||
return BadRequest(new { error = "Only completed tests can be analyzed" });
|
||||
|
||||
var parameterSet = await parameterManager.GetByIdAsync(testRun.ParameterSetId);
|
||||
if (parameterSet == null)
|
||||
return NotFound(new { error = $"Parameter set {testRun.ParameterSetId} not found" });
|
||||
|
||||
if (telemetry == null || telemetry.Count < 10)
|
||||
return BadRequest(new { error = "Telemetry data is required for analysis (min 10 samples). Telemetry is not stored in the database; it is only available during test execution via SignalR." });
|
||||
|
||||
var metrics = testRun.Metrics;
|
||||
if (metrics == null)
|
||||
return BadRequest(new { error = "No metrics available for this test run" });
|
||||
|
||||
var report = tuningAdvisor.Analyze(telemetry, metrics, parameterSet);
|
||||
report.TestRunId = testRunId;
|
||||
|
||||
return Ok(report);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error analyzing test run {TestRunId}", testRunId);
|
||||
return StatusCode(500, new { error = "Failed to analyze test run", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply selected suggestions to a parameter set (returns new set, does not persist).
|
||||
/// </summary>
|
||||
[HttpPost("apply")]
|
||||
public async Task<ActionResult<NavigationParameterSet>> ApplySuggestions(
|
||||
[FromBody] ApplySuggestionsRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
|
||||
if (parameterSet == null)
|
||||
return NotFound(new { error = $"Parameter set {request.ParameterSetId} not found" });
|
||||
|
||||
var selectedSuggestions = request.Report.Suggestions
|
||||
.Where(s => request.SuggestionIds.Contains(s.Id))
|
||||
.ToList();
|
||||
|
||||
if (selectedSuggestions.Count == 0)
|
||||
return BadRequest(new { error = "No valid suggestions selected" });
|
||||
|
||||
var modified = tuningAdvisor.ApplyAllSuggestions(parameterSet, selectedSuggestions);
|
||||
return Ok(modified);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error applying suggestions");
|
||||
return StatusCode(500, new { error = "Failed to apply suggestions", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply suggestions and save as a new parameter set.
|
||||
/// </summary>
|
||||
[HttpPost("apply-and-save")]
|
||||
public async Task<ActionResult<NavigationParameterSet>> ApplyAndSave(
|
||||
[FromBody] ApplyAndSaveRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
|
||||
if (parameterSet == null)
|
||||
return NotFound(new { error = $"Parameter set {request.ParameterSetId} not found" });
|
||||
|
||||
var selectedSuggestions = request.Report.Suggestions
|
||||
.Where(s => request.SuggestionIds.Contains(s.Id))
|
||||
.ToList();
|
||||
|
||||
if (selectedSuggestions.Count == 0)
|
||||
return BadRequest(new { error = "No valid suggestions selected" });
|
||||
|
||||
var modified = tuningAdvisor.ApplyAllSuggestions(parameterSet, selectedSuggestions);
|
||||
|
||||
// Create new parameter set with unique name
|
||||
modified.Id = Guid.NewGuid();
|
||||
modified.Name = request.NewParameterSetName
|
||||
?? $"{parameterSet.Name}_tuned_{DateTime.UtcNow:yyyyMMdd_HHmmss}";
|
||||
modified.Description = $"Auto-tuned from '{parameterSet.Name}'. Applied {selectedSuggestions.Count} suggestion(s).";
|
||||
modified.IsDefault = false;
|
||||
modified.CreatedAt = DateTime.UtcNow;
|
||||
modified.Version = parameterSet.Version + 1;
|
||||
|
||||
await parameterManager.SaveAsync(modified);
|
||||
|
||||
return Ok(modified);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error applying and saving suggestions");
|
||||
return StatusCode(500, new { error = "Failed to apply and save suggestions", details = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for applying suggestions to a parameter set.
|
||||
/// </summary>
|
||||
public class ApplySuggestionsRequest
|
||||
{
|
||||
public Guid ParameterSetId { get; set; }
|
||||
public List<Guid> SuggestionIds { get; set; } = new();
|
||||
public TuningReport Report { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for applying suggestions and saving as a new parameter set.
|
||||
/// </summary>
|
||||
public class ApplyAndSaveRequest
|
||||
{
|
||||
public Guid ParameterSetId { get; set; }
|
||||
public List<Guid> SuggestionIds { get; set; } = new();
|
||||
public TuningReport Report { get; set; } = null!;
|
||||
public string? NewParameterSetName { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.NavigationTune.Data;
|
||||
using RobotNet10.NavigationTune.Hubs;
|
||||
using RobotNet10.NavigationTune.Services;
|
||||
using RobotNet10.NavigationTune.Shared.Interfaces;
|
||||
using RobotNet10.NavigationTune.Shared.Models;
|
||||
|
||||
namespace RobotNet10.NavigationTune.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API controller for test execution and control
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class TuningController(
|
||||
ITuningOrchestrator orchestrator,
|
||||
IParameterManager parameterManager,
|
||||
IScenarioRepository scenarioRepository,
|
||||
ILogger<TuningController> logger) : ControllerBase
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Execute a single test
|
||||
/// </summary>
|
||||
[HttpPost("execute")]
|
||||
public async Task<ActionResult<TestExecutionResult>> ExecuteTest(
|
||||
[FromBody] ExecuteTestRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load scenario and parameter set
|
||||
var scenario = await scenarioRepository.GetByIdAsync(request.ScenarioId);
|
||||
if (scenario == null)
|
||||
return NotFound(new { error = $"Scenario with ID {request.ScenarioId} not found" });
|
||||
|
||||
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
|
||||
if (parameterSet == null)
|
||||
return NotFound(new { error = $"Parameter set with ID {request.ParameterSetId} not found" });
|
||||
|
||||
// Get connection ID from request headers or query
|
||||
var connectionId = Request.Headers["X-Connection-Id"].FirstOrDefault()
|
||||
?? Request.Query["connectionId"].FirstOrDefault();
|
||||
|
||||
// Start test and return immediately so UI can enable Stop/Pause; completion is sent via SignalR (ReceiveTestResult)
|
||||
var result = await orchestrator.StartTestAsync(
|
||||
scenario,
|
||||
parameterSet,
|
||||
connectionId,
|
||||
request.TestRunId,
|
||||
cancellationToken);
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Invalid operation executing test");
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error executing test");
|
||||
return StatusCode(500, new { error = "Failed to execute test", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run batch tests with multiple scenarios
|
||||
/// </summary>
|
||||
[HttpPost("batch")]
|
||||
public async Task<ActionResult<BatchTestResult>> RunBatchTests(
|
||||
[FromBody] BatchTestRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load parameter set
|
||||
var parameterSet = await parameterManager.GetByIdAsync(request.ParameterSetId);
|
||||
if (parameterSet == null)
|
||||
return NotFound(new { error = $"Parameter set with ID {request.ParameterSetId} not found" });
|
||||
|
||||
// Load scenarios
|
||||
var scenarios = new List<TestScenario>();
|
||||
foreach (var scenarioId in request.ScenarioIds)
|
||||
{
|
||||
var scenario = await scenarioRepository.GetByIdAsync(scenarioId);
|
||||
if (scenario == null)
|
||||
{
|
||||
logger.LogWarning("Scenario {ScenarioId} not found, skipping", scenarioId);
|
||||
continue;
|
||||
}
|
||||
scenarios.Add(scenario);
|
||||
}
|
||||
|
||||
if (scenarios.Count == 0)
|
||||
return BadRequest(new { error = "No valid scenarios found" });
|
||||
|
||||
// Execute batch
|
||||
var result = await orchestrator.RunBatchTestsAsync(scenarios, parameterSet, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error running batch tests");
|
||||
return StatusCode(500, new { error = "Failed to run batch tests", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compare multiple parameter configurations
|
||||
/// </summary>
|
||||
[HttpPost("compare")]
|
||||
public async Task<ActionResult<ComparisonResult>> CompareConfigurations(
|
||||
[FromBody] CompareConfigurationsRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Load scenario
|
||||
var scenario = await scenarioRepository.GetByIdAsync(request.ScenarioId);
|
||||
if (scenario == null)
|
||||
return NotFound(new { error = $"Scenario with ID {request.ScenarioId} not found" });
|
||||
|
||||
// Load parameter sets
|
||||
var parameterSets = new List<NavigationParameterSet>();
|
||||
foreach (var parameterSetId in request.ParameterSetIds)
|
||||
{
|
||||
var parameterSet = await parameterManager.GetByIdAsync(parameterSetId);
|
||||
if (parameterSet == null)
|
||||
{
|
||||
logger.LogWarning("Parameter set {ParameterSetId} not found, skipping", parameterSetId);
|
||||
continue;
|
||||
}
|
||||
parameterSets.Add(parameterSet);
|
||||
}
|
||||
|
||||
if (parameterSets.Count == 0)
|
||||
return BadRequest(new { error = "No valid parameter sets found" });
|
||||
|
||||
// Compare
|
||||
var result = await orchestrator.CompareConfigurationsAsync(parameterSets, scenario, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error comparing configurations");
|
||||
return StatusCode(500, new { error = "Failed to compare configurations", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pause running test
|
||||
/// </summary>
|
||||
[HttpPost("pause/{testRunId}")]
|
||||
public ActionResult PauseTest(string testRunId)
|
||||
{
|
||||
try
|
||||
{
|
||||
orchestrator.PauseTest(testRunId);
|
||||
return Ok(new { message = "Test paused" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error pausing test {TestRunId}", testRunId);
|
||||
return StatusCode(500, new { error = "Failed to pause test" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resume paused test
|
||||
/// </summary>
|
||||
[HttpPost("resume/{testRunId}")]
|
||||
public ActionResult ResumeTest(string testRunId)
|
||||
{
|
||||
try
|
||||
{
|
||||
orchestrator.ResumeTest(testRunId);
|
||||
return Ok(new { message = "Test resumed" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error resuming test {TestRunId}", testRunId);
|
||||
return StatusCode(500, new { error = "Failed to resume test" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop running test
|
||||
/// </summary>
|
||||
[HttpPost("stop/{testRunId}")]
|
||||
public ActionResult StopTest(string testRunId)
|
||||
{
|
||||
try
|
||||
{
|
||||
orchestrator.StopTest(testRunId);
|
||||
return Ok(new { message = "Test stopped" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error stopping test {TestRunId}", testRunId);
|
||||
return StatusCode(500, new { error = "Failed to stop test" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emergency stop
|
||||
/// </summary>
|
||||
[HttpPost("emergency-stop/{testRunId}")]
|
||||
public ActionResult EmergencyStop(string testRunId)
|
||||
{
|
||||
try
|
||||
{
|
||||
orchestrator.EmergencyStop(testRunId);
|
||||
return Ok(new { message = "Emergency stop activated" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Error emergency stopping test {TestRunId}", testRunId);
|
||||
return StatusCode(500, new { error = "Failed to emergency stop test" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for executing a single test.
|
||||
/// When TestRunId is provided, client should call JoinTestSession(TestRunId) before execute so real-time telemetry is received.
|
||||
/// </summary>
|
||||
public class ExecuteTestRequest
|
||||
{
|
||||
public Guid ScenarioId { get; set; }
|
||||
public Guid ParameterSetId { get; set; }
|
||||
/// <summary>Optional. If set, this Id is used for the test run and for SignalR group test_{TestRunId}.</summary>
|
||||
public Guid? TestRunId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for batch test execution
|
||||
/// </summary>
|
||||
public class BatchTestRequest
|
||||
{
|
||||
public List<Guid> ScenarioIds { get; set; } = new();
|
||||
public Guid ParameterSetId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for configuration comparison
|
||||
/// </summary>
|
||||
public class CompareConfigurationsRequest
|
||||
{
|
||||
public Guid ScenarioId { get; set; }
|
||||
public List<Guid> ParameterSetIds { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RobotNet10.NavigationTune.Execution;
|
||||
|
||||
namespace RobotNet10.NavigationTune.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// REST API controller for manual velocity control
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class VelocityControlController(
|
||||
IVelocityProvider? velocityProvider,
|
||||
ILogger<VelocityControlController> logger) : ControllerBase
|
||||
{
|
||||
private readonly IVelocityProvider? _velocityProvider = velocityProvider;
|
||||
private readonly ILogger<VelocityControlController> _logger = logger;
|
||||
|
||||
/// <summary>
|
||||
/// Set manual velocity command
|
||||
/// </summary>
|
||||
[HttpPost("set-velocity")]
|
||||
public ActionResult SetVelocity([FromBody] VelocityCommandRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_velocityProvider == null)
|
||||
{
|
||||
return BadRequest(new { error = "Velocity provider is not available. Robot connection required." });
|
||||
}
|
||||
|
||||
_velocityProvider.SetVelocity(request.LinearVelocity, request.AngularVelocity);
|
||||
return Ok(new { message = "Velocity command sent", linear = request.LinearVelocity, angular = request.AngularVelocity });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error setting velocity");
|
||||
return StatusCode(500, new { error = "Failed to set velocity", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop all velocities (set to zero)
|
||||
/// </summary>
|
||||
[HttpPost("stop")]
|
||||
public ActionResult Stop()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_velocityProvider == null)
|
||||
{
|
||||
return BadRequest(new { error = "Velocity provider is not available. Robot connection required." });
|
||||
}
|
||||
|
||||
_velocityProvider.SetVelocity(0, 0);
|
||||
return Ok(new { message = "All velocities stopped" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error stopping velocity");
|
||||
return StatusCode(500, new { error = "Failed to stop velocity", details = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current velocity
|
||||
/// </summary>
|
||||
[HttpGet("current")]
|
||||
public ActionResult GetCurrentVelocity()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_velocityProvider == null)
|
||||
{
|
||||
return BadRequest(new { error = "Velocity provider is not available. Robot connection required." });
|
||||
}
|
||||
|
||||
var (linear, angular) = _velocityProvider.GetActualVelocity();
|
||||
return Ok(new { linear, angular });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting current velocity");
|
||||
return StatusCode(500, new { error = "Failed to get current velocity", details = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for velocity command
|
||||
/// </summary>
|
||||
public class VelocityCommandRequest
|
||||
{
|
||||
public double LinearVelocity { get; set; }
|
||||
public double AngularVelocity { get; set; }
|
||||
}
|
||||
Reference in New Issue
Block a user