diff --git a/backend/api.test/EndpointTest.cs b/backend/api.test/EndpointTest.cs index e90269c1d..6fa066256 100644 --- a/backend/api.test/EndpointTest.cs +++ b/backend/api.test/EndpointTest.cs @@ -558,27 +558,13 @@ public async Task SafePositionTest() var area = await areaResponse.Content.ReadFromJsonAsync(_serializerOptions); Assert.True(area != null); - // Arrange - Get a Robot - string url = "/robots"; - var robotResponse = await _client.GetAsync(url); - Assert.True(robotResponse.IsSuccessStatusCode); - var robots = await robotResponse.Content.ReadFromJsonAsync>(_serializerOptions); - Assert.True(robots != null); - var robot = robots[0]; - string robotId = robot.Id; - // Act - string goToSafePositionUrl = $"/robots/{robotId}/{testInstallation}/{testArea}/go-to-safe-position"; + string goToSafePositionUrl = $"/emergency-action/{testInstallation}/abort-current-missions-and-send-all-robots-to-safe-zone"; var missionResponse = await _client.PostAsync(goToSafePositionUrl, null); // Assert Assert.True(missionResponse.IsSuccessStatusCode); - var missionRun = await missionResponse.Content.ReadFromJsonAsync(_serializerOptions); - Assert.True(missionRun != null); - Assert.True( - JsonSerializer.Serialize(missionRun.Tasks[0].RobotPose.Position) == - JsonSerializer.Serialize(testPosition) - ); + } [Fact] diff --git a/backend/api.test/EventHandlers/TestMissionEventHandler.cs b/backend/api.test/EventHandlers/TestMissionEventHandler.cs index 8ab6956e6..14c315110 100644 --- a/backend/api.test/EventHandlers/TestMissionEventHandler.cs +++ b/backend/api.test/EventHandlers/TestMissionEventHandler.cs @@ -47,6 +47,14 @@ public class TestMissionEventHandler : IDisposable private readonly RobotControllerMock _robotControllerMock; private readonly IRobotModelService _robotModelService; private readonly IRobotService _robotService; + private readonly IMissionScheduling _missionScheduling; + private readonly IIsarService _isarServiceMock; + private readonly IInstallationService _installationService; + private readonly IDefaultLocalizationPoseService _defaultLocalisationPoseService; + private readonly IPlantService _plantService; + private readonly IDeckService _deckService; + private readonly IAreaService _areaService; + private readonly IMissionSchedulingService _missionSchedulingService; public TestMissionEventHandler(DatabaseFixture fixture) { @@ -54,6 +62,8 @@ public TestMissionEventHandler(DatabaseFixture fixture) var mqttServiceLogger = new Mock>().Object; var mqttEventHandlerLogger = new Mock>().Object; var missionLogger = new Mock>().Object; + var missionSchedulingLogger = new Mock>().Object; + var missionSchedulingServiceLogger = new Mock>().Object; var configuration = WebApplication.CreateBuilder().Configuration; @@ -65,6 +75,14 @@ public TestMissionEventHandler(DatabaseFixture fixture) _robotService = new RobotService(_context, _robotModelService); _robotModelService = new RobotModelService(_context); _robotControllerMock = new RobotControllerMock(); + _isarServiceMock = new MockIsarService(); + _installationService = new InstallationService(_context); + _defaultLocalisationPoseService = new DefaultLocalizationPoseService(_context); + _plantService = new PlantService(_context, _installationService); + _deckService = new DeckService(_context, _defaultLocalisationPoseService, _installationService, _plantService); + _areaService = new AreaService(_context, _installationService, _plantService, _deckService, _defaultLocalisationPoseService); + _missionSchedulingService = new MissionSchedulingService(missionSchedulingServiceLogger, _missionRunService, _robotService, _robotControllerMock.Mock.Object); + _missionScheduling = new MissionScheduling(missionSchedulingLogger, _missionRunService, _isarServiceMock, _robotService, _areaService, _missionSchedulingService); var mockServiceProvider = new Mock(); @@ -75,6 +93,9 @@ public TestMissionEventHandler(DatabaseFixture fixture) mockServiceProvider .Setup(p => p.GetService(typeof(IRobotService))) .Returns(_robotService); + mockServiceProvider + .Setup(p => p.GetService(typeof(IMissionScheduling))) + .Returns(_missionScheduling); mockServiceProvider .Setup(p => p.GetService(typeof(RobotController))) .Returns(_robotControllerMock.Mock.Object); @@ -119,6 +140,7 @@ public TestMissionEventHandler(DatabaseFixture fixture) { Name = "testMission", MissionId = Guid.NewGuid().ToString(), + MissionRunPriority = MissionRunPriority.Normal, Status = MissionStatus.Pending, DesiredStartTime = DateTimeOffset.Now, Area = NewArea, diff --git a/backend/api.test/Mocks/RobotControllerMock.cs b/backend/api.test/Mocks/RobotControllerMock.cs index 591dc9f05..33d097a2d 100644 --- a/backend/api.test/Mocks/RobotControllerMock.cs +++ b/backend/api.test/Mocks/RobotControllerMock.cs @@ -13,7 +13,6 @@ internal class RobotControllerMock public Mock MissionServiceMock; public Mock Mock; public Mock AreaServiceMock; - public Mock EchoServiceMock; public RobotControllerMock() { @@ -22,7 +21,6 @@ public RobotControllerMock() RobotServiceMock = new Mock(); RobotModelServiceMock = new Mock(); AreaServiceMock = new Mock(); - EchoServiceMock = new Mock(); var mockLoggerController = new Mock>(); @@ -32,8 +30,7 @@ public RobotControllerMock() IsarServiceMock.Object, MissionServiceMock.Object, RobotModelServiceMock.Object, - AreaServiceMock.Object, - EchoServiceMock.Object + AreaServiceMock.Object ) { CallBase = true diff --git a/backend/api/Controllers/EmergencyActionController.cs b/backend/api/Controllers/EmergencyActionController.cs new file mode 100644 index 000000000..797c746f6 --- /dev/null +++ b/backend/api/Controllers/EmergencyActionController.cs @@ -0,0 +1,85 @@ +using System.Globalization; +using Api.Controllers.Models; +using Api.Services; +using Api.Services.Events; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +namespace Api.Controllers +{ + [ApiController] + [Route("emergency-action")] + public class EmergencyActionController : ControllerBase + { + private readonly IEmergencyActionService _emergencyActionService; + private readonly IRobotService _robotService; + + public EmergencyActionController(IRobotService robotService, IEmergencyActionService emergencyActionService) + { + _robotService = robotService; + _emergencyActionService = emergencyActionService; + } + + /// + /// This endpoint will abort the current running mission run and attempt to return the robot to a safe position in the + /// area. The mission run queue for the robot will be frozen and no further missions will run until the emergency + /// action has been reversed. + /// + /// + /// The endpoint fires an event which is then processed to stop the robot and schedule the next mission + /// + [HttpPost] + [Route("{installationCode}/abort-current-missions-and-send-all-robots-to-safe-zone")] + [Authorize(Roles = Role.User)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult AbortCurrentMissionAndSendAllRobotsToSafeZone( + [FromRoute] string installationCode) + { + + var robots = _robotService.ReadAll().Result.ToList().FindAll(a => + a.CurrentInstallation.ToLower(CultureInfo.CurrentCulture).Equals(installationCode.ToLower(CultureInfo.CurrentCulture), StringComparison.Ordinal) && + a.CurrentArea != null); + + foreach (var robot in robots) + { + _emergencyActionService.TriggerEmergencyButtonPressedForRobot(new EmergencyButtonPressedForRobotEventArgs(robot.Id)); + + } + + return NoContent(); + + } + + /// + /// This query will clear the emergency state that is introduced by aborting the current mission and returning to a + /// safe zone. Clearing the emergency state means that mission runs that may be in the robots queue will start." + /// + [HttpPost] + [Route("{installationCode}/clear-emergency-state")] + [Authorize(Roles = Role.User)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public ActionResult ClearEmergencyStateForAllRobots( + [FromRoute] string installationCode) + { + var robots = _robotService.ReadAll().Result.ToList().FindAll(a => + a.CurrentInstallation.ToLower(CultureInfo.CurrentCulture).Equals(installationCode.ToLower(CultureInfo.CurrentCulture), StringComparison.Ordinal) && + a.CurrentArea != null); + + foreach (var robot in robots) + { + _emergencyActionService.TriggerEmergencyButtonDepressedForRobot(new EmergencyButtonPressedForRobotEventArgs(robot.Id)); + } + + return NoContent(); + } + } +} diff --git a/backend/api/Controllers/MissionSchedulingController.cs b/backend/api/Controllers/MissionSchedulingController.cs index df3398f2e..9c2620c9e 100644 --- a/backend/api/Controllers/MissionSchedulingController.cs +++ b/backend/api/Controllers/MissionSchedulingController.cs @@ -19,7 +19,7 @@ public class MissionSchedulingController : ControllerBase private readonly IMapService _mapService; private readonly IMissionDefinitionService _missionDefinitionService; private readonly IMissionRunService _missionRunService; - private readonly IMissionSchedulingService _missionSchedulingService; + private readonly ICustomMissionSchedulingService _customMissionSchedulingService; private readonly IRobotService _robotService; private readonly ISourceService _sourceService; private readonly IStidService _stidService; @@ -35,7 +35,7 @@ public MissionSchedulingController( IMapService mapService, IStidService stidService, ISourceService sourceService, - IMissionSchedulingService missionSchedulingService + ICustomMissionSchedulingService customMissionSchedulingService ) { _missionDefinitionService = missionDefinitionService; @@ -48,7 +48,7 @@ IMissionSchedulingService missionSchedulingService _stidService = stidService; _sourceService = sourceService; _missionDefinitionService = missionDefinitionService; - _missionSchedulingService = missionSchedulingService; + _customMissionSchedulingService = customMissionSchedulingService; _logger = logger; } @@ -96,6 +96,7 @@ [FromBody] ScheduleMissionQuery scheduledMissionQuery Robot = robot, MissionId = missionDefinition.Id, Status = MissionStatus.Pending, + MissionRunPriority = MissionRunPriority.Normal, DesiredStartTime = scheduledMissionQuery.DesiredStartTime, Tasks = missionTasks, InstallationCode = missionDefinition.InstallationCode, @@ -228,6 +229,7 @@ [FromBody] ScheduledMissionQuery scheduledMissionQuery Robot = robot, MissionId = scheduledMissionDefinition.Id, Status = MissionStatus.Pending, + MissionRunPriority = MissionRunPriority.Normal, DesiredStartTime = scheduledMissionQuery.DesiredStartTime, Tasks = missionTasks, InstallationCode = scheduledMissionQuery.InstallationCode, @@ -287,11 +289,11 @@ [FromBody] CustomMissionQuery customMissionQuery var missionTasks = customMissionQuery.Tasks.Select(task => new MissionTask(task)).ToList(); MissionDefinition? customMissionDefinition; - try { customMissionDefinition = await _missionSchedulingService.FindExistingOrCreateCustomMissionDefinition(customMissionQuery, missionTasks); } + try { customMissionDefinition = await _customMissionSchedulingService.FindExistingOrCreateCustomMissionDefinition(customMissionQuery, missionTasks); } catch (SourceException e) { return StatusCode(StatusCodes.Status502BadGateway, e.Message); } MissionRun? newMissionRun; - try { newMissionRun = await _missionSchedulingService.QueueCustomMissionRun(customMissionQuery, customMissionDefinition.Id, robot.Id, missionTasks); } + try { newMissionRun = await _customMissionSchedulingService.QueueCustomMissionRun(customMissionQuery, customMissionDefinition.Id, robot.Id, missionTasks); } catch (Exception e) when (e is RobotNotFoundException or MissionNotFoundException) { return NotFound(e.Message); } return CreatedAtAction(nameof(Create), new diff --git a/backend/api/Controllers/RobotController.cs b/backend/api/Controllers/RobotController.cs index 9fc8814f6..b94c2b747 100644 --- a/backend/api/Controllers/RobotController.cs +++ b/backend/api/Controllers/RobotController.cs @@ -6,905 +6,765 @@ using Api.Utilities; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; - -namespace Api.Controllers; - -[ApiController] -[Route("robots")] -public class RobotController : ControllerBase +namespace Api.Controllers { - private readonly ILogger _logger; - private readonly IRobotService _robotService; - private readonly IIsarService _isarService; - private readonly IMissionRunService _missionRunService; - private readonly IRobotModelService _robotModelService; - private readonly IAreaService _areaService; - private readonly IEchoService _echoService; - - public RobotController( - ILogger logger, - IRobotService robotService, - IIsarService isarService, - IMissionRunService missionRunService, - IRobotModelService robotModelService, - IAreaService areaService, - IEchoService echoService - ) - { - _logger = logger; - _robotService = robotService; - _isarService = isarService; - _missionRunService = missionRunService; - _robotModelService = robotModelService; - _areaService = areaService; - _echoService = echoService; - } - - /// - /// List all robots on the installation. - /// - /// - /// This query gets all robots - /// - [HttpGet] - [Authorize(Roles = Role.Any)] - [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetRobots() - { - try - { - var robots = await _robotService.ReadAll(); - return Ok(robots); - } - catch (Exception e) - { - _logger.LogError(e, "Error during GET of robots from database"); - throw; - } - } - - /// - /// Gets the robot with the specified id - /// - /// - /// This query gets the robot with the specified id - /// - [HttpGet] - [Authorize(Roles = Role.Any)] - [Route("{id}")] - [ProducesResponseType(typeof(Robot), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> GetRobotById([FromRoute] string id) + [ApiController] + [Route("robots")] + public class RobotController : ControllerBase { - _logger.LogInformation("Getting robot with id={id}", id); - try - { - var robot = await _robotService.ReadById(id); - if (robot == null) + private readonly IAreaService _areaService; + private readonly IIsarService _isarService; + private readonly ILogger _logger; + private readonly IMissionRunService _missionRunService; + private readonly IRobotModelService _robotModelService; + private readonly IRobotService _robotService; + + public RobotController( + ILogger logger, + IRobotService robotService, + IIsarService isarService, + IMissionRunService missionRunService, + IRobotModelService robotModelService, + IAreaService areaService + ) + { + _logger = logger; + _robotService = robotService; + _isarService = isarService; + _missionRunService = missionRunService; + _robotModelService = robotModelService; + _areaService = areaService; + } + + /// + /// List all robots on the installation. + /// + /// + /// This query gets all robots + /// + [HttpGet] + [Authorize(Roles = Role.Any)] + [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetRobots() + { + try { - _logger.LogWarning("Could not find robot with id={id}", id); - return NotFound(); + var robots = await _robotService.ReadAll(); + return Ok(robots); + } + catch (Exception e) + { + _logger.LogError(e, "Error during GET of robots from database"); + throw; } - - _logger.LogInformation("Successful GET of robot with id={id}", id); - return Ok(robot); - } - catch (Exception e) - { - _logger.LogError(e, "Error during GET of robot with id={id}", id); - throw; - } - } - - /// - /// Create robot and add to database - /// - /// - /// This query creates a robot and adds it to the database - /// - [HttpPost] - [Authorize(Roles = Role.Admin)] - [ProducesResponseType(typeof(Robot), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> CreateRobot([FromBody] CreateRobotQuery robotQuery) - { - _logger.LogInformation("Creating new robot"); - try - { - var robotModel = await _robotModelService.ReadByRobotType(robotQuery.RobotType); - if (robotModel == null) - return BadRequest( - $"No robot model exists with robot type '{robotQuery.RobotType}'" - ); - - var robot = new Robot(robotQuery) { Model = robotModel }; - - var newRobot = await _robotService.Create(robot); - _logger.LogInformation("Succesfully created new robot"); - return CreatedAtAction(nameof(GetRobotById), new { id = newRobot.Id }, newRobot); - } - catch (Exception e) - { - _logger.LogError(e, "Error while creating new robot"); - throw; - } - } - - /// - /// Updates a robot in the database - /// - /// - /// - /// The robot was successfully updated - /// The robot data is invalid - /// There was no robot with the given ID in the database - [HttpPut] - [Authorize(Roles = Role.Admin)] - [Route("{id}")] - [ProducesResponseType(typeof(Robot), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> UpdateRobot( - [FromRoute] string id, - [FromBody] Robot robot - ) - { - _logger.LogInformation("Updating robot with id={id}", id); - - if (!ModelState.IsValid) - return BadRequest("Invalid data."); - - if (id != robot.Id) - { - _logger.LogWarning("Id: {id} not corresponding to updated robot", id); - return BadRequest("Inconsistent Id"); - } - - try - { - var updatedRobot = await _robotService.Update(robot); - - _logger.LogInformation("Successful PUT of robot to database"); - - return Ok(updatedRobot); - } - catch (Exception e) - { - _logger.LogError(e, "Error while updating robot with id={id}", id); - throw; - } - } - - /// - /// Deletes the robot with the specified id from the database. - /// - [HttpDelete] - [Authorize(Roles = Role.Admin)] - [Route("{id}")] - [ProducesResponseType(typeof(MissionRun), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> DeleteRobot([FromRoute] string id) - { - var robot = await _robotService.Delete(id); - if (robot is null) - return NotFound($"Robot with id {id} not found"); - return Ok(robot); - } - - /// - /// Updates a robot's status in the database - /// - /// - /// - /// The robot status was succesfully updated - /// The robot data is invalid - /// There was no robot with the given ID in the database - [HttpPut] - [Authorize(Roles = Role.Admin)] - [Route("{id}/status")] - [ProducesResponseType(typeof(Robot), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> UpdateRobotStatus( - [FromRoute] string id, - [FromBody] RobotStatus robotStatus - ) - { - _logger.LogInformation("Updating robot status with id={id}", id); - - if (!ModelState.IsValid) - return BadRequest("Invalid data."); - - var robot = await _robotService.ReadById(id); - if (robot == null) - return NotFound($"No robot with id: {id} could be found"); - - robot.Status = robotStatus; - try - { - var updatedRobot = await _robotService.Update(robot); - - _logger.LogInformation("Successful PUT of robot to database"); - - return Ok(updatedRobot); - } - catch (Exception e) - { - _logger.LogError(e, "Error while updating status for robot with id={id}", id); - throw; } - } - /// - /// Get video streams for a given robot - /// - /// - /// Retrieves the video streams available for the given robot - /// - [HttpGet] - [Authorize(Roles = Role.User)] - [Route("{robotId}/video-streams/")] - [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task>> GetVideoStreams([FromRoute] string robotId) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", robotId); - return NotFound(); + /// + /// Gets the robot with the specified id + /// + /// + /// This query gets the robot with the specified id + /// + [HttpGet] + [Authorize(Roles = Role.Any)] + [Route("{id}")] + [ProducesResponseType(typeof(Robot), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetRobotById([FromRoute] string id) + { + _logger.LogInformation("Getting robot with id={id}", id); + try + { + var robot = await _robotService.ReadById(id); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", id); + return NotFound(); + } + + _logger.LogInformation("Successful GET of robot with id={id}", id); + return Ok(robot); + } + catch (Exception e) + { + _logger.LogError(e, "Error during GET of robot with id={id}", id); + throw; + } } - return Ok(robot.VideoStreams); - } - - /// - /// Add a video stream to a given robot - /// - /// - /// Adds a provided video stream to the given robot - /// - [HttpPost] - [Authorize(Roles = Role.Admin)] - [Route("{robotId}/video-streams/")] - [ProducesResponseType(typeof(Robot), StatusCodes.Status201Created)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> CreateVideoStream( - [FromRoute] string robotId, - [FromBody] VideoStream videoStream - ) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", robotId); - return NotFound(); + /// + /// Create robot and add to database + /// + /// + /// This query creates a robot and adds it to the database + /// + [HttpPost] + [Authorize(Roles = Role.Admin)] + [ProducesResponseType(typeof(Robot), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> CreateRobot([FromBody] CreateRobotQuery robotQuery) + { + _logger.LogInformation("Creating new robot"); + try + { + var robotModel = await _robotModelService.ReadByRobotType(robotQuery.RobotType); + if (robotModel == null) + { + return BadRequest( + $"No robot model exists with robot type '{robotQuery.RobotType}'" + ); + } + + var robot = new Robot(robotQuery) + { + Model = robotModel + }; + + var newRobot = await _robotService.Create(robot); + _logger.LogInformation("Succesfully created new robot"); + return CreatedAtAction(nameof(GetRobotById), new + { + id = newRobot.Id + }, newRobot); + } + catch (Exception e) + { + _logger.LogError(e, "Error while creating new robot"); + throw; + } } - robot.VideoStreams.Add(videoStream); - - try - { - var updatedRobot = await _robotService.Update(robot); - - return CreatedAtAction( - nameof(GetVideoStreams), - new { robotId = updatedRobot.Id }, - updatedRobot - ); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error adding video stream to robot"); - throw; - } - } + /// + /// Updates a robot in the database + /// + /// + /// + /// The robot was successfully updated + /// The robot data is invalid + /// There was no robot with the given ID in the database + [HttpPut] + [Authorize(Roles = Role.Admin)] + [Route("{id}")] + [ProducesResponseType(typeof(Robot), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> UpdateRobot( + [FromRoute] string id, + [FromBody] Robot robot + ) + { + _logger.LogInformation("Updating robot with id={id}", id); + + if (!ModelState.IsValid) + { + return BadRequest("Invalid data."); + } - /// - /// Start the mission in the database with the corresponding 'missionRunId' for the robot with id 'robotId' - /// - /// - /// This query starts a mission for a given robot - /// - [HttpPost] - [Authorize(Roles = Role.Admin)] - [Route("{robotId}/start/{missionRunId}")] - [ProducesResponseType(typeof(MissionRun), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> StartMission( - [FromRoute] string robotId, - [FromRoute] string missionRunId - ) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", robotId); - return NotFound("Robot not found"); - } + if (id != robot.Id) + { + _logger.LogWarning("Id: {id} not corresponding to updated robot", id); + return BadRequest("Inconsistent Id"); + } - if (robot.Status is not RobotStatus.Available) - { - _logger.LogWarning( - "Robot '{id}' is not available ({status})", - robotId, - robot.Status.ToString() - ); - return Conflict($"The Robot is not available ({robot.Status})"); - } + try + { + var updatedRobot = await _robotService.Update(robot); - var missionRun = await _missionRunService.ReadById(missionRunId); + _logger.LogInformation("Successful PUT of robot to database"); - if (missionRun == null) - { - _logger.LogWarning("Could not find mission with id={id}", missionRunId); - return NotFound("Mission not found"); + return Ok(updatedRobot); + } + catch (Exception e) + { + _logger.LogError(e, "Error while updating robot with id={id}", id); + throw; + } } - IsarMission isarMission; - try - { - isarMission = await _isarService.StartMission(robot, missionRun); - } - catch (HttpRequestException e) - { - string message = $"Could not reach ISAR at {robot.IsarUri}"; - _logger.LogError(e, "{message}", message); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, message); - } - catch (MissionException e) - { - _logger.LogError(e, "Error while starting ISAR mission"); - return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); - } - catch (JsonException e) - { - string message = "Error while processing of the response from ISAR"; - _logger.LogError(e, "{message}", message); - return StatusCode(StatusCodes.Status500InternalServerError, message); - } - catch (RobotPositionNotFoundException e) - { - string message = - "A suitable robot position could not be found for one or more of the desired tags"; - _logger.LogError(e, "{message}", message); - return StatusCode(StatusCodes.Status500InternalServerError, message); + /// + /// Deletes the robot with the specified id from the database. + /// + [HttpDelete] + [Authorize(Roles = Role.Admin)] + [Route("{id}")] + [ProducesResponseType(typeof(MissionRun), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> DeleteRobot([FromRoute] string id) + { + var robot = await _robotService.Delete(id); + if (robot is null) + { + return NotFound($"Robot with id {id} not found"); + } + return Ok(robot); } - missionRun.UpdateWithIsarInfo(isarMission); - missionRun.Status = MissionStatus.Ongoing; - - await _missionRunService.Update(missionRun); + /// + /// Updates a robot's status in the database + /// + /// + /// + /// The robot status was succesfully updated + /// The robot data is invalid + /// There was no robot with the given ID in the database + [HttpPut] + [Authorize(Roles = Role.Admin)] + [Route("{id}/status")] + [ProducesResponseType(typeof(Robot), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> UpdateRobotStatus( + [FromRoute] string id, + [FromBody] RobotStatus robotStatus + ) + { + _logger.LogInformation("Updating robot status with id={id}", id); + + if (!ModelState.IsValid) + { + return BadRequest("Invalid data."); + } - if (robot.CurrentMissionId != null) - { - var orphanedMissionRun = await _missionRunService.ReadById(robot.CurrentMissionId); - if (orphanedMissionRun != null) + var robot = await _robotService.ReadById(id); + if (robot == null) { - orphanedMissionRun.SetToFailed(); - await _missionRunService.Update(orphanedMissionRun); + return NotFound($"No robot with id: {id} could be found"); } - } - robot.Status = RobotStatus.Busy; - robot.CurrentMissionId = missionRun.Id; - await _robotService.Update(robot); + robot.Status = robotStatus; + try + { + var updatedRobot = await _robotService.Update(robot); - return Ok(missionRun); - } + _logger.LogInformation("Successful PUT of robot to database"); - /// - /// Stops the current mission on a robot - /// - /// - /// This query stops the current mission for a given robot - /// - [HttpPost] - [Authorize(Roles = Role.User)] - [Route("{robotId}/stop/")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task StopMission([FromRoute] string robotId) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", robotId); - return NotFound(); + return Ok(updatedRobot); + } + catch (Exception e) + { + _logger.LogError(e, "Error while updating status for robot with id={id}", id); + throw; + } } - try - { - await _isarService.StopMission(robot); - robot.CurrentMissionId = null; - await _robotService.Update(robot); - } - catch (HttpRequestException e) - { - string message = "Error connecting to ISAR while stopping mission"; - _logger.LogError(e, "{message}", message); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, message); - } - catch (MissionException e) - { - _logger.LogError(e, "Error while stopping ISAR mission"); - return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); - } - catch (JsonException e) - { - string message = "Error while processing the response from ISAR"; - _logger.LogError(e, "{message}", message); - return StatusCode(StatusCodes.Status500InternalServerError, message); - } + /// + /// Get video streams for a given robot + /// + /// + /// Retrieves the video streams available for the given robot + /// + [HttpGet] + [Authorize(Roles = Role.User)] + [Route("{robotId}/video-streams/")] + [ProducesResponseType(typeof(IList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetVideoStreams([FromRoute] string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", robotId); + return NotFound(); + } - return NoContent(); - } + return Ok(robot.VideoStreams); + } + + /// + /// Add a video stream to a given robot + /// + /// + /// Adds a provided video stream to the given robot + /// + [HttpPost] + [Authorize(Roles = Role.Admin)] + [Route("{robotId}/video-streams/")] + [ProducesResponseType(typeof(Robot), StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> CreateVideoStream( + [FromRoute] string robotId, + [FromBody] VideoStream videoStream + ) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", robotId); + return NotFound(); + } - /// - /// Pause the current mission on a robot - /// - /// - /// This query pauses the current mission for a robot - /// - [HttpPost] - [Authorize(Roles = Role.User)] - [Route("{robotId}/pause/")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task PauseMission([FromRoute] string robotId) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", robotId); - return NotFound(); - } + robot.VideoStreams.Add(videoStream); - try - { - await _isarService.PauseMission(robot); - } - catch (HttpRequestException e) - { - string message = "Error connecting to ISAR while pausing mission"; - _logger.LogError(e, "{message}", message); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, message); - } - catch (MissionException e) - { - _logger.LogError(e, "Error while pausing ISAR mission"); - return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); - } - catch (JsonException e) - { - string message = "Error while processing of the response from ISAR"; - _logger.LogError(e, "{message}", message); - return StatusCode(StatusCodes.Status500InternalServerError, message); + try + { + var updatedRobot = await _robotService.Update(robot); + + return CreatedAtAction( + nameof(GetVideoStreams), + new + { + robotId = updatedRobot.Id + }, + updatedRobot + ); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding video stream to robot"); + throw; + } } - return NoContent(); - } + /// + /// Start the mission in the database with the corresponding 'missionRunId' for the robot with id 'robotId' + /// + /// + /// This query starts a mission for a given robot + /// + [HttpPost] + [Authorize(Roles = Role.Admin)] + [Route("{robotId}/start/{missionRunId}")] + [ProducesResponseType(typeof(MissionRun), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> StartMission( + [FromRoute] string robotId, + [FromRoute] string missionRunId + ) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", robotId); + return NotFound("Robot not found"); + } - /// - /// Resume paused mission on a robot - /// - /// - /// This query resumes the currently paused mission for a robot - /// - [HttpPost] - [Authorize(Roles = Role.User)] - [Route("{robotId}/resume/")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task ResumeMission([FromRoute] string robotId) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", robotId); - return NotFound(); - } + if (robot.Status is not RobotStatus.Available) + { + _logger.LogWarning( + "Robot '{id}' is not available ({status})", + robotId, + robot.Status.ToString() + ); + return Conflict($"The Robot is not available ({robot.Status})"); + } - try - { - await _isarService.ResumeMission(robot); - } - catch (HttpRequestException e) - { - string message = "Error connecting to ISAR while resuming mission"; - _logger.LogError(e, "{message}", message); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, message); - } - catch (MissionException e) - { - _logger.LogError(e, "Error while resuming ISAR mission"); - return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); - } - catch (JsonException e) - { - string message = "Error while processing of the response from ISAR"; - _logger.LogError(e, "{message}", message); - return StatusCode(StatusCodes.Status500InternalServerError, message); - } + var missionRun = await _missionRunService.ReadById(missionRunId); - return NoContent(); - } + if (missionRun == null) + { + _logger.LogWarning("Could not find mission with id={id}", missionRunId); + return NotFound("Mission not found"); + } + IsarMission isarMission; + try + { + isarMission = await _isarService.StartMission(robot, missionRun); + } + catch (HttpRequestException e) + { + string message = $"Could not reach ISAR at {robot.IsarUri}"; + _logger.LogError(e, "{message}", message); + OnIsarUnavailable(robot); + return StatusCode(StatusCodes.Status502BadGateway, message); + } + catch (MissionException e) + { + _logger.LogError(e, "Error while starting ISAR mission"); + return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); + } + catch (JsonException e) + { + string message = "Error while processing of the response from ISAR"; + _logger.LogError(e, "{message}", message); + return StatusCode(StatusCodes.Status500InternalServerError, message); + } + catch (RobotPositionNotFoundException e) + { + string message = + "A suitable robot position could not be found for one or more of the desired tags"; + _logger.LogError(e, "{message}", message); + return StatusCode(StatusCodes.Status500InternalServerError, message); + } - /// - /// Post new arm position ("battery_change", "transport", "lookout") for the robot with id 'robotId' - /// - /// - /// This query moves the arm to a given position for a given robot - /// - [HttpPut] - [Authorize(Roles = Role.User)] - [Route("{robotId}/SetArmPosition/{armPosition}")] - [ProducesResponseType(StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task SetArmPosition( - [FromRoute] string robotId, - [FromRoute] string armPosition - ) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - string errorMessage = $"Could not find robot with id {robotId}"; - _logger.LogWarning(errorMessage); - return NotFound(errorMessage); - } + missionRun.UpdateWithIsarInfo(isarMission); + missionRun.Status = MissionStatus.Ongoing; - if (robot.Status is not RobotStatus.Available) - { - string errorMessage = $"Robot {robotId} has status ({robot.Status}) and is not available"; - _logger.LogWarning(errorMessage); - return Conflict(errorMessage); - } - try - { - await _isarService.StartMoveArm(robot, armPosition); - } - catch (HttpRequestException e) - { - string errorMessage = $"Error connecting to ISAR at {robot.IsarUri}"; - _logger.LogError(e, "{Message}", errorMessage); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, errorMessage); - } - catch (MissionException e) - { - string errorMessage = $"An error occurred while setting the arm position mission"; - _logger.LogError(e, "{Message}", errorMessage); - return StatusCode(StatusCodes.Status502BadGateway, errorMessage); - } - catch (JsonException e) - { - string errorMessage = "Error while processing of the response from ISAR"; - _logger.LogError(e, "{Message}", errorMessage); - return StatusCode(StatusCodes.Status500InternalServerError, errorMessage); - } + await _missionRunService.Update(missionRun); - return NoContent(); - } + if (robot.CurrentMissionId != null) + { + var orphanedMissionRun = await _missionRunService.ReadById(robot.CurrentMissionId); + if (orphanedMissionRun != null) + { + orphanedMissionRun.SetToFailed(); + await _missionRunService.Update(orphanedMissionRun); + } + } - /// - /// Start a localization mission with localization in the pose 'localizationPose' for the robot with id 'robotId' - /// - /// - /// This query starts a localization for a given robot - /// - [HttpPost] - [Authorize(Roles = Role.User)] - [Route("start-localization")] - [ProducesResponseType(typeof(MissionRun), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> StartLocalizationMission( - [FromBody] ScheduleLocalizationMissionQuery scheduleLocalizationMissionQuery - ) - { - var robot = await _robotService.ReadById(scheduleLocalizationMissionQuery.RobotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", scheduleLocalizationMissionQuery.RobotId); - return NotFound("Robot not found"); - } + robot.Status = RobotStatus.Busy; + robot.CurrentMissionId = missionRun.Id; + await _robotService.Update(robot); - if (robot.Status is not RobotStatus.Available) - { - _logger.LogWarning( - "Robot '{id}' is not available ({status})", - scheduleLocalizationMissionQuery.RobotId, - robot.Status.ToString() - ); - return Conflict($"The Robot is not available ({robot.Status})"); - } + return Ok(missionRun); + } + + /// + /// Stops the current mission on a robot + /// + /// + /// This query stops the current mission for a given robot + /// + [HttpPost] + [Authorize(Roles = Role.User)] + [Route("{robotId}/stop/")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task StopMission([FromRoute] string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", robotId); + return NotFound(); + } - var area = await _areaService.ReadById(scheduleLocalizationMissionQuery.AreaId); + try + { + await _isarService.StopMission(robot); + robot.CurrentMissionId = null; + await _robotService.Update(robot); + } + catch (HttpRequestException e) + { + string message = "Error connecting to ISAR while stopping mission"; + _logger.LogError(e, "{message}", message); + OnIsarUnavailable(robot); + return StatusCode(StatusCodes.Status502BadGateway, message); + } + catch (MissionException e) + { + _logger.LogError(e, "Error while stopping ISAR mission"); + return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); + } + catch (JsonException e) + { + string message = "Error while processing the response from ISAR"; + _logger.LogError(e, "{message}", message); + return StatusCode(StatusCodes.Status500InternalServerError, message); + } - if (area == null) - { - _logger.LogWarning("Could not find area with id={id}", scheduleLocalizationMissionQuery.AreaId); - return NotFound("Area not found"); - } + return NoContent(); + } + + /// + /// Pause the current mission on a robot + /// + /// + /// This query pauses the current mission for a robot + /// + [HttpPost] + [Authorize(Roles = Role.User)] + [Route("{robotId}/pause/")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task PauseMission([FromRoute] string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", robotId); + return NotFound(); + } - var missionRun = new MissionRun - { - Name = "Localization Mission", - Robot = robot, - InstallationCode = "NA", - Area = area, - Status = MissionStatus.Pending, - DesiredStartTime = DateTimeOffset.UtcNow, - Tasks = new List(), - Map = new MapMetadata() - }; - - IsarMission isarMission; - try - { - isarMission = await _isarService.StartLocalizationMission(robot, scheduleLocalizationMissionQuery.LocalizationPose); - } - catch (HttpRequestException e) - { - string message = $"Could not reach ISAR at {robot.IsarUri}"; - _logger.LogError(e, "{Message}", message); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, message); - } - catch (MissionException e) - { - _logger.LogError(e, "Error while starting ISAR localization mission"); - return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); - } - catch (JsonException e) - { - string message = "Error while processing of the response from ISAR"; - _logger.LogError(e, "{Message}", message); - return StatusCode(StatusCodes.Status500InternalServerError, message); - } + try + { + await _isarService.PauseMission(robot); + } + catch (HttpRequestException e) + { + string message = "Error connecting to ISAR while pausing mission"; + _logger.LogError(e, "{message}", message); + OnIsarUnavailable(robot); + return StatusCode(StatusCodes.Status502BadGateway, message); + } + catch (MissionException e) + { + _logger.LogError(e, "Error while pausing ISAR mission"); + return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); + } + catch (JsonException e) + { + string message = "Error while processing of the response from ISAR"; + _logger.LogError(e, "{message}", message); + return StatusCode(StatusCodes.Status500InternalServerError, message); + } - missionRun.UpdateWithIsarInfo(isarMission); - missionRun.Status = MissionStatus.Ongoing; + return NoContent(); + } + + /// + /// Resume paused mission on a robot + /// + /// + /// This query resumes the currently paused mission for a robot + /// + [HttpPost] + [Authorize(Roles = Role.User)] + [Route("{robotId}/resume/")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task ResumeMission([FromRoute] string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", robotId); + return NotFound(); + } - await _missionRunService.Create(missionRun); + try + { + await _isarService.ResumeMission(robot); + } + catch (HttpRequestException e) + { + string message = "Error connecting to ISAR while resuming mission"; + _logger.LogError(e, "{message}", message); + OnIsarUnavailable(robot); + return StatusCode(StatusCodes.Status502BadGateway, message); + } + catch (MissionException e) + { + _logger.LogError(e, "Error while resuming ISAR mission"); + return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); + } + catch (JsonException e) + { + string message = "Error while processing of the response from ISAR"; + _logger.LogError(e, "{message}", message); + return StatusCode(StatusCodes.Status500InternalServerError, message); + } - robot.Status = RobotStatus.Busy; - robot.CurrentMissionId = missionRun.Id; - await _robotService.Update(robot); - robot.CurrentArea = area; - return Ok(missionRun); - } + return NoContent(); + } + + + /// + /// Post new arm position ("battery_change", "transport", "lookout") for the robot with id 'robotId' + /// + /// + /// This query moves the arm to a given position for a given robot + /// + [HttpPut] + [Authorize(Roles = Role.User)] + [Route("{robotId}/SetArmPosition/{armPosition}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task SetArmPosition( + [FromRoute] string robotId, + [FromRoute] string armPosition + ) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + string errorMessage = $"Could not find robot with id {robotId}"; + _logger.LogWarning(errorMessage); + return NotFound(errorMessage); + } - /// - /// Starts a mission which drives the robot to the nearest safe position - /// - /// - /// This query starts a localization for a given robot - /// - [HttpPost] - [Route("{robotId}/{installation}/{areaName}/go-to-safe-position")] - [Authorize(Roles = Role.User)] - [ProducesResponseType(typeof(MissionRun), StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(StatusCodes.Status401Unauthorized)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status404NotFound)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task> SendRobotToSafePosition( - [FromRoute] string robotId, - [FromRoute] string installation, - [FromRoute] string areaName - ) - { - var robot = await _robotService.ReadById(robotId); - if (robot == null) - { - _logger.LogWarning("Could not find robot with id={id}", robotId); - return NotFound("Robot not found"); - } + if (robot.Status is not RobotStatus.Available) + { + string errorMessage = $"Robot {robotId} has status ({robot.Status}) and is not available"; + _logger.LogWarning(errorMessage); + return Conflict(errorMessage); + } + try + { + await _isarService.StartMoveArm(robot, armPosition); + } + catch (HttpRequestException e) + { + string errorMessage = $"Error connecting to ISAR at {robot.IsarUri}"; + _logger.LogError(e, "{Message}", errorMessage); + OnIsarUnavailable(robot); + return StatusCode(StatusCodes.Status502BadGateway, errorMessage); + } + catch (MissionException e) + { + string errorMessage = "An error occurred while setting the arm position mission"; + _logger.LogError(e, "{Message}", errorMessage); + return StatusCode(StatusCodes.Status502BadGateway, errorMessage); + } + catch (JsonException e) + { + string errorMessage = "Error while processing of the response from ISAR"; + _logger.LogError(e, "{Message}", errorMessage); + return StatusCode(StatusCodes.Status500InternalServerError, errorMessage); + } - var installations = await _areaService.ReadByInstallation(installation); + return NoContent(); + } + + /// + /// Start a localization mission with localization in the pose 'localizationPose' for the robot with id 'robotId' + /// + /// + /// This query starts a localization for a given robot + /// + [HttpPost] + [Authorize(Roles = Role.User)] + [Route("start-localization")] + [ProducesResponseType(typeof(MissionRun), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> StartLocalizationMission( + [FromBody] ScheduleLocalizationMissionQuery scheduleLocalizationMissionQuery + ) + { + var robot = await _robotService.ReadById(scheduleLocalizationMissionQuery.RobotId); + if (robot == null) + { + _logger.LogWarning("Could not find robot with id={id}", scheduleLocalizationMissionQuery.RobotId); + return NotFound("Robot not found"); + } - if (!installations.Any()) - { - _logger.LogWarning("Could not find installation={installation}", installation); - return NotFound("No installation found"); - } + if (robot.Status is not RobotStatus.Available) + { + _logger.LogWarning( + "Robot '{id}' is not available ({status})", + scheduleLocalizationMissionQuery.RobotId, + robot.Status.ToString() + ); + return Conflict($"The Robot is not available ({robot.Status})"); + } - var area = await _areaService.ReadByInstallationAndName(installation, areaName); - if (area is null) - { - _logger.LogWarning("Could not find area={areaName}", areaName); - return NotFound("No area found"); - } + var area = await _areaService.ReadById(scheduleLocalizationMissionQuery.AreaId); - if (area.SafePositions.Count < 1) - { - _logger.LogWarning("No safe position for installation={installation}, area={areaName}", installation, areaName); - return NotFound("No safe positions found"); - } + if (area == null) + { + _logger.LogWarning("Could not find area with id={id}", scheduleLocalizationMissionQuery.AreaId); + return NotFound("Area not found"); + } - try - { - await _isarService.StopMission(robot); - } - catch (MissionException e) - { - // We want to continue driving to a safe position if the isar state is idle - if (e.IsarStatusCode != 409) + var missionRun = new MissionRun { - _logger.LogError(e, "Error while stopping ISAR mission"); + Name = "Localization Mission", + Robot = robot, + MissionRunPriority = MissionRunPriority.Normal, + InstallationCode = "NA", + Area = area, + Status = MissionStatus.Pending, + DesiredStartTime = DateTimeOffset.UtcNow, + Tasks = new List(), + Map = new MapMetadata() + }; + + IsarMission isarMission; + try + { + isarMission = await _isarService.StartLocalizationMission(robot, scheduleLocalizationMissionQuery.LocalizationPose); + } + catch (HttpRequestException e) + { + string message = $"Could not reach ISAR at {robot.IsarUri}"; + _logger.LogError(e, "{Message}", message); + OnIsarUnavailable(robot); + return StatusCode(StatusCodes.Status502BadGateway, message); + } + catch (MissionException e) + { + _logger.LogError(e, "Error while starting ISAR localization mission"); return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); } - } - catch (Exception e) - { - string message = "Error in ISAR while stopping current mission, cannot drive to safe position"; - _logger.LogError(e, "{message}", message); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, message); - } - - var closestSafePosition = ClosestSafePosition(robot.Pose, area.SafePositions); - // Cloning to avoid tracking same object - var clonedPose = ObjectCopier.Clone(closestSafePosition); - var customTaskQuery = new CustomTaskQuery - { - RobotPose = clonedPose, - Inspections = new List(), - InspectionTarget = new Position(), - TaskOrder = 0 - }; - // TODO: The MissionId is nullable because of this mission - var missionRun = new MissionRun - { - Name = "Drive to Safe Position", - Robot = robot, - InstallationCode = installation, - Area = area, - Status = MissionStatus.Pending, - DesiredStartTime = DateTimeOffset.UtcNow, - Tasks = new List(new[] { new MissionTask(customTaskQuery) }), - Map = new MapMetadata() - }; - - IsarMission isarMission; - try - { - isarMission = await _isarService.StartMission(robot, missionRun); - } - catch (HttpRequestException e) - { - string message = $"Could not reach ISAR at {robot.IsarUri}"; - _logger.LogError(e, "{message}", message); - OnIsarUnavailable(robot); - return StatusCode(StatusCodes.Status502BadGateway, message); - } - catch (MissionException e) - { - _logger.LogError(e, "Error while starting ISAR mission"); - return StatusCode(StatusCodes.Status502BadGateway, $"{e.Message}"); - } - catch (JsonException e) - { - string message = "Error while processing of the response from ISAR"; - _logger.LogError(e, "{message}", message); - return StatusCode(StatusCodes.Status500InternalServerError, message); - } - - missionRun.UpdateWithIsarInfo(isarMission); - missionRun.Status = MissionStatus.Ongoing; - - await _missionRunService.Create(missionRun); - - robot.Status = RobotStatus.Busy; - robot.CurrentMissionId = missionRun.Id; - await _robotService.Update(robot); - return Ok(missionRun); - } - - private async void OnIsarUnavailable(Robot robot) - { - robot.Enabled = false; - robot.Status = RobotStatus.Offline; - if (robot.CurrentMissionId != null) - { - var missionRun = await _missionRunService.ReadById(robot.CurrentMissionId); - if (missionRun != null) + catch (JsonException e) { - missionRun.SetToFailed(); - await _missionRunService.Update(missionRun); - _logger.LogWarning( - "Mission '{id}' failed because ISAR could not be reached", - missionRun.Id - ); + string message = "Error while processing of the response from ISAR"; + _logger.LogError(e, "{Message}", message); + return StatusCode(StatusCodes.Status500InternalServerError, message); } - } - robot.CurrentMissionId = null; - await _robotService.Update(robot); - } - private static Pose ClosestSafePosition(Pose robotPose, IList safePositions) - { - if (safePositions == null || !safePositions.Any()) - { - throw new ArgumentException("List of safe positions cannot be null or empty."); - } + missionRun.UpdateWithIsarInfo(isarMission); + missionRun.Status = MissionStatus.Ongoing; + + await _missionRunService.Create(missionRun); - var closestPose = safePositions[0].Pose; - float minDistance = CalculateDistance(robotPose, closestPose); + robot.Status = RobotStatus.Busy; + robot.CurrentMissionId = missionRun.Id; + robot.CurrentArea = area; + await _robotService.Update(robot); + return Ok(missionRun); + } - for (int i = 1; i < safePositions.Count; i++) + private async void OnIsarUnavailable(Robot robot) { - float currentDistance = CalculateDistance(robotPose, safePositions[i].Pose); - if (currentDistance < minDistance) + robot.Enabled = false; + robot.Status = RobotStatus.Offline; + if (robot.CurrentMissionId != null) { - minDistance = currentDistance; - closestPose = safePositions[i].Pose; + var missionRun = await _missionRunService.ReadById(robot.CurrentMissionId); + if (missionRun != null) + { + missionRun.SetToFailed(); + await _missionRunService.Update(missionRun); + _logger.LogWarning( + "Mission '{id}' failed because ISAR could not be reached", + missionRun.Id + ); + } } + robot.CurrentMissionId = null; + await _robotService.Update(robot); } - return closestPose; - } - - private static float CalculateDistance(Pose pose1, Pose pose2) - { - var pos1 = pose1.Position; - var pos2 = pose2.Position; - return (float)Math.Sqrt(Math.Pow(pos1.X - pos2.X, 2) + Math.Pow(pos1.Y - pos2.Y, 2) + Math.Pow(pos1.Z - pos2.Z, 2)); } } diff --git a/backend/api/Database/Context/InitDb.cs b/backend/api/Database/Context/InitDb.cs index 8a05e325c..0aa50ae2a 100644 --- a/backend/api/Database/Context/InitDb.cs +++ b/backend/api/Database/Context/InitDb.cs @@ -68,7 +68,7 @@ private static List GetDecks() Id = Guid.NewGuid().ToString(), Plant = plants[0], Installation = plants[0].Installation, - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), Name = "TestDeck" }; @@ -77,7 +77,7 @@ private static List GetDecks() Id = Guid.NewGuid().ToString(), Plant = plants[0], Installation = plants[0].Installation, - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), Name = "TestDeck2" }; @@ -86,7 +86,7 @@ private static List GetDecks() Id = Guid.NewGuid().ToString(), Plant = plants[0], Installation = plants[0].Installation, - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), Name = "TestDeck3" }; @@ -95,7 +95,7 @@ private static List GetDecks() Id = Guid.NewGuid().ToString(), Plant = plants[0], Installation = plants[0].Installation, - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), Name = "TestDeck4" }; @@ -112,7 +112,7 @@ private static List GetAreas() Installation = decks[0].Plant!.Installation, Name = "AP320", MapMetadata = new MapMetadata(), - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), SafePositions = new List() }; @@ -124,7 +124,7 @@ private static List GetAreas() Installation = decks[0].Plant!.Installation, Name = "AP330", MapMetadata = new MapMetadata(), - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), SafePositions = new List() }; @@ -136,8 +136,11 @@ private static List GetAreas() Installation = decks[0].Plant!.Installation, Name = "testArea", MapMetadata = new MapMetadata(), - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), SafePositions = new List() + { + new() + } }; var area4 = new Area @@ -148,7 +151,7 @@ private static List GetAreas() Installation = decks[1].Plant.Installation, Name = "testArea2", MapMetadata = new MapMetadata(), - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), SafePositions = new List() }; @@ -160,7 +163,7 @@ private static List GetAreas() Installation = decks[2].Plant.Installation, Name = "testArea3", MapMetadata = new MapMetadata(), - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), SafePositions = new List() }; @@ -172,7 +175,7 @@ private static List GetAreas() Installation = decks[3].Plant.Installation, Name = "testArea4", MapMetadata = new MapMetadata(), - DefaultLocalizationPose = null, + DefaultLocalizationPose = new DefaultLocalizationPose(), SafePositions = new List() }; diff --git a/backend/api/Database/Models/MissionRun.cs b/backend/api/Database/Models/MissionRun.cs index 15a9724e8..e89c75920 100644 --- a/backend/api/Database/Models/MissionRun.cs +++ b/backend/api/Database/Models/MissionRun.cs @@ -1,12 +1,15 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Api.Services.Models; - #pragma warning disable CS8618 namespace Api.Database.Models { public class MissionRun : SortableRecord { + + private MissionStatus _status; + + private IList _tasks; [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public string Id { get; set; } @@ -14,22 +17,22 @@ public class MissionRun : SortableRecord //[Required] // See "Drive to Safe Position" mission in RobotController.cs public string? MissionId { get; set; } - [Required] - [MaxLength(200)] - public string Name { get; set; } - [Required] public MissionStatus Status { - get { return _status; } + get => _status; set { _status = value; if (IsCompleted && EndTime is null) + { EndTime = DateTimeOffset.UtcNow; + } if (_status is MissionStatus.Ongoing && StartTime is null) + { StartTime = DateTimeOffset.UtcNow; + } } } @@ -47,10 +50,13 @@ public MissionStatus Status [Required] public IList Tasks { - get { return _tasks.OrderBy(t => t.TaskOrder).ToList(); } - set { _tasks = value; } + get => _tasks.OrderBy(t => t.TaskOrder).ToList(); + set => _tasks = value; } + [Required] + public MissionRunPriority MissionRunPriority { get; set; } + [MaxLength(200)] public string? IsarMissionId { get; set; } @@ -65,15 +71,13 @@ public IList Tasks public Area? Area { get; set; } - private MissionStatus _status; - public bool IsCompleted => _status is MissionStatus.Aborted - or MissionStatus.Cancelled - or MissionStatus.Successful - or MissionStatus.PartiallySuccessful - or MissionStatus.Failed; + or MissionStatus.Cancelled + or MissionStatus.Successful + or MissionStatus.PartiallySuccessful + or MissionStatus.Failed; public MapMetadata? Map { get; set; } @@ -82,11 +86,13 @@ or MissionStatus.PartiallySuccessful public DateTimeOffset? EndTime { get; private set; } /// - /// The estimated duration of the mission in seconds + /// The estimated duration of the mission in seconds /// public uint? EstimatedDuration { get; set; } - private IList _tasks; + [Required] + [MaxLength(200)] + public string Name { get; set; } public void UpdateWithIsarInfo(IsarMission isarMission) { @@ -98,7 +104,6 @@ public void UpdateWithIsarInfo(IsarMission isarMission) } } -#nullable enable public MissionTask? GetTaskByIsarId(string isarTaskId) { return Tasks.FirstOrDefault( @@ -108,8 +113,6 @@ public void UpdateWithIsarInfo(IsarMission isarMission) ); } -#nullable disable - public static MissionStatus MissionStatusFromString(string status) { return status switch @@ -122,9 +125,9 @@ public static MissionStatus MissionStatusFromString(string status) "paused" => MissionStatus.Paused, "partially_successful" => MissionStatus.PartiallySuccessful, _ - => throw new ArgumentException( - $"Failed to parse mission status '{status}' as it's not supported" - ) + => throw new ArgumentException( + $"Failed to parse mission status '{status}' as it's not supported" + ) }; } @@ -198,4 +201,11 @@ public enum MissionStatus Successful, PartiallySuccessful } + + public enum MissionRunPriority + { + Normal, + Response, + Emergency + } } diff --git a/backend/api/Database/Models/Robot.cs b/backend/api/Database/Models/Robot.cs index ae9a8263c..53c9a300b 100644 --- a/backend/api/Database/Models/Robot.cs +++ b/backend/api/Database/Models/Robot.cs @@ -1,12 +1,52 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using Api.Controllers.Models; - #pragma warning disable CS8618 namespace Api.Database.Models { public class Robot { + + public Robot() + { + VideoStreams = new List(); + IsarId = "defaultIsarId"; + Name = "defaultId"; + SerialNumber = "defaultSerialNumber"; + CurrentInstallation = "defaultAsset"; + Status = RobotStatus.Offline; + Enabled = false; + Host = "localhost"; + Port = 3000; + Pose = new Pose(); + } + + public Robot(CreateRobotQuery createQuery) + { + var videoStreams = new List(); + foreach (var videoStreamQuery in createQuery.VideoStreams) + { + var videoStream = new VideoStream + { + Name = videoStreamQuery.Name, + Url = videoStreamQuery.Url, + Type = videoStreamQuery.Type + }; + videoStreams.Add(videoStream); + } + + IsarId = createQuery.IsarId; + Name = createQuery.Name; + SerialNumber = createQuery.SerialNumber; + CurrentInstallation = createQuery.CurrentInstallation; + CurrentArea = createQuery.CurrentArea; + VideoStreams = videoStreams; + Host = createQuery.Host; + Port = createQuery.Port; + Enabled = createQuery.Enabled; + Status = createQuery.Status; + Pose = new Pose(); + } [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public string Id { get; set; } @@ -46,6 +86,9 @@ public class Robot [Required] public bool Enabled { get; set; } + [Required] + public bool MissionQueueFrozen { get; set; } + [Required] public RobotStatus Status { get; set; } @@ -61,52 +104,13 @@ public string IsarUri const string Method = "http"; string host = Host; if (host == "0.0.0.0") + { host = "localhost"; + } return $"{Method}://{host}:{Port}"; } } - - public Robot() - { - VideoStreams = new List(); - IsarId = "defaultIsarId"; - Name = "defaultId"; - SerialNumber = "defaultSerialNumber"; - CurrentInstallation = "defaultAsset"; - Status = RobotStatus.Offline; - Enabled = false; - Host = "localhost"; - Port = 3000; - Pose = new Pose(); - } - - public Robot(CreateRobotQuery createQuery) - { - var videoStreams = new List(); - foreach (var videoStreamQuery in createQuery.VideoStreams) - { - var videoStream = new VideoStream - { - Name = videoStreamQuery.Name, - Url = videoStreamQuery.Url, - Type = videoStreamQuery.Type - }; - videoStreams.Add(videoStream); - } - - IsarId = createQuery.IsarId; - Name = createQuery.Name; - SerialNumber = createQuery.SerialNumber; - CurrentInstallation = createQuery.CurrentInstallation; - CurrentArea = createQuery.CurrentArea; - VideoStreams = videoStreams; - Host = createQuery.Host; - Port = createQuery.Port; - Enabled = createQuery.Enabled; - Status = createQuery.Status; - Pose = new Pose(); - } } public enum RobotStatus diff --git a/backend/api/EventHandlers/MissionEventHandler.cs b/backend/api/EventHandlers/MissionEventHandler.cs index fa3fa4ef2..7b6cfcea4 100644 --- a/backend/api/EventHandlers/MissionEventHandler.cs +++ b/backend/api/EventHandlers/MissionEventHandler.cs @@ -1,12 +1,11 @@ -using Api.Controllers; -using Api.Controllers.Models; +using Api.Controllers.Models; using Api.Database.Models; using Api.Services; using Api.Services.Events; using Api.Utilities; -using Microsoft.AspNetCore.Mvc; namespace Api.EventHandlers { + public class MissionEventHandler : EventHandlerBase { private readonly ILogger _logger; @@ -31,8 +30,11 @@ IServiceScopeFactory scopeFactory private IRobotService RobotService => _scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); - private RobotController RobotController => - _scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + private IAreaService AreaService => _scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + + private IMissionScheduling MissionSchedulingService => _scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); + + private IMqttEventHandler MqttEventHandlerService => _scopeFactory.CreateScope().ServiceProvider.GetRequiredService(); private IList MissionRunQueue(string robotId) { @@ -56,12 +58,16 @@ public override void Subscribe() { MissionRunService.MissionRunCreated += OnMissionRunCreated; MqttEventHandler.RobotAvailable += OnRobotAvailable; + EmergencyActionService.EmergencyButtonPressedForRobot += OnEmergencyButtonPressedForRobot; + EmergencyActionService.EmergencyButtonDepressedForRobot += OnEmergencyButtonDepressedForRobot; } public override void Unsubscribe() { MissionRunService.MissionRunCreated -= OnMissionRunCreated; MqttEventHandler.RobotAvailable -= OnRobotAvailable; + EmergencyActionService.EmergencyButtonPressedForRobot -= OnEmergencyButtonPressedForRobot; + EmergencyActionService.EmergencyButtonDepressedForRobot -= OnEmergencyButtonDepressedForRobot; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -81,14 +87,14 @@ private void OnMissionRunCreated(object? sender, MissionRunCreatedEventArgs e) return; } - if (MissionRunQueueIsEmpty(MissionRunQueue(missionRun.Robot.Id))) + if (MissionScheduling.MissionRunQueueIsEmpty(MissionRunQueue(missionRun.Robot.Id))) { _logger.LogInformation("Mission run {MissionRunId} was not started as there are no mission runs on the queue", e.MissionRunId); return; } _scheduleMissionMutex.WaitOne(); - StartMissionRunIfSystemIsAvailable(missionRun); + MissionSchedulingService.StartMissionRunIfSystemIsAvailable(missionRun); _scheduleMissionMutex.ReleaseMutex(); } @@ -102,111 +108,112 @@ private async void OnRobotAvailable(object? sender, RobotAvailableEventArgs e) return; } - if (MissionRunQueueIsEmpty(MissionRunQueue(robot.Id))) + if (MissionScheduling.MissionRunQueueIsEmpty(MissionRunQueue(robot.Id))) { _logger.LogInformation("The robot was changed to available but there are no mission runs in the queue to be scheduled"); return; } - var missionRun = MissionRunQueue(robot.Id).First(missionRun => missionRun.Robot.Id == robot.Id); + var missionRun = (MissionRun?)null; + + if (robot.MissionQueueFrozen == true) + { + missionRun = MissionRunQueue(robot.Id).FirstOrDefault(missionRun => missionRun.Robot.Id == robot.Id && + missionRun.MissionRunPriority == MissionRunPriority.Emergency); + } + else + { + missionRun = MissionRunQueue(robot.Id).FirstOrDefault(missionRun => missionRun.Robot.Id == robot.Id); + } + + if (missionRun == null) + { + _logger.LogInformation("The robot was changed to available but no mission is scheduled"); + return; + } _scheduleMissionMutex.WaitOne(); - StartMissionRunIfSystemIsAvailable(missionRun); + MissionSchedulingService.StartMissionRunIfSystemIsAvailable(missionRun); _scheduleMissionMutex.ReleaseMutex(); } - private void StartMissionRunIfSystemIsAvailable(MissionRun missionRun) + private async void OnEmergencyButtonPressedForRobot(object? sender, EmergencyButtonPressedForRobotEventArgs e) { - if (!TheSystemIsAvailableToRunAMission(missionRun.Robot, missionRun).Result) + _logger.LogInformation("Triggered EmergencyButtonPressed event for robot ID: {RobotId}", e.RobotId); + var robot = await RobotService.ReadById(e.RobotId); + if (robot == null) { - _logger.LogInformation("Mission {MissionRunId} was put on the queue as the system may not start a mission now", missionRun.Id); + _logger.LogError("Robot with ID: {RobotId} was not found in the database", e.RobotId); return; } + var area = await AreaService.ReadById(robot.CurrentArea!.Id); + if (area == null) + { + _logger.LogError("Could not find area with ID {AreaId}", robot.CurrentArea!.Id); + return; + } + + await MissionSchedulingService.FreezeMissionRunQueueForRobot(e.RobotId); + try { - StartMissionRun(missionRun); + await MissionSchedulingService.StopCurrentMissionRun(e.RobotId); } catch (MissionException ex) { - const MissionStatus NewStatus = MissionStatus.Failed; - _logger.LogWarning( - "Mission run {MissionRunId} was not started successfully. Status updated to '{Status}'.\nReason: {FailReason}", - missionRun.Id, - NewStatus, - ex.Message - ); - missionRun.Status = NewStatus; - missionRun.StatusReason = $"Failed to start: '{ex.Message}'"; - MissionService.Update(missionRun); + // We want to continue driving to a safe position if the isar state is idle + if (ex.IsarStatusCode != StatusCodes.Status409Conflict) + { + _logger.LogError(ex, "Failed to stop the current mission on robot {RobotName} because: {ErrorMessage}", robot.Name, ex.Message); + return; + } + } + catch (Exception ex) + { + string message = "Error in ISAR while stopping current mission, cannot drive to safe position"; + _logger.LogError(ex, "{Message}", message); + return; } - } - - private static bool MissionRunQueueIsEmpty(IList missionRunQueue) - { - return !missionRunQueue.Any(); - } - - private async Task TheSystemIsAvailableToRunAMission(Robot robot, MissionRun missionRun) - { - bool ongoingMission = await OngoingMission(robot.Id); - if (ongoingMission) + try { - _logger.LogInformation("Mission run {MissionRunId} was not started as there is already an ongoing mission", missionRun.Id); - return false; + await MissionSchedulingService.ScheduleMissionToReturnToSafePosition(e.RobotId, area.Id); } - if (robot.Status is not RobotStatus.Available) + catch (SafeZoneException ex) { - _logger.LogInformation("Mission run {MissionRunId} was not started as the robot is not available", missionRun.Id); - return false; + _logger.LogError(ex, "Failed to schedule return to safe zone mission on robot {RobotName} because: {ErrorMessage}", robot.Name, ex.Message); + await MissionSchedulingService.UnfreezeMissionRunQueueForRobot(e.RobotId); } - if (!robot.Enabled) + + MqttEventHandlerService.TriggerRobotAvailable(new RobotAvailableEventArgs(robot.Id)); + + } + + private async void OnEmergencyButtonDepressedForRobot(object? sender, EmergencyButtonPressedForRobotEventArgs e) + { + _logger.LogInformation("Triggered EmergencyButtonPressed event for robot ID: {RobotId}", e.RobotId); + var robot = await RobotService.ReadById(e.RobotId); + if (robot == null) { - _logger.LogWarning("Mission run {MissionRunId} was not started as the robot {RobotId} is not enabled", missionRun.Id, robot.Id); - return false; + _logger.LogError("Robot with ID: {RobotId} was not found in the database", e.RobotId); + return; } - if (missionRun.DesiredStartTime > DateTimeOffset.UtcNow) + + var area = await AreaService.ReadById(robot.CurrentArea!.Id); + if (area == null) { - _logger.LogInformation("Mission run {MissionRunId} was not started as the start time is in the future", missionRun.Id); - return false; + _logger.LogError("Could not find area with ID {AreaId}", robot.CurrentArea!.Id); } - return true; - } - private async Task OngoingMission(string robotId) - { - var ongoingMissions = await MissionService.ReadAll( - new MissionRunQueryStringParameters - { - Statuses = new List - { - MissionStatus.Ongoing - }, - RobotId = robotId, - OrderBy = "DesiredStartTime", - PageSize = 100 - }); - - return ongoingMissions.Any(); - } + await MissionSchedulingService.UnfreezeMissionRunQueueForRobot(e.RobotId); - private void StartMissionRun(MissionRun queuedMissionRun) - { - var result = RobotController.StartMission( - queuedMissionRun.Robot.Id, - queuedMissionRun.Id - ).Result; - if (result.Result is not OkObjectResult) - { - string errorMessage = "Unknown error from robot controller"; - if (result.Result is ObjectResult returnObject) - { - errorMessage = returnObject.Value?.ToString() ?? errorMessage; - } - throw new MissionException(errorMessage); + if (await MissionSchedulingService.OngoingMission(robot.Id)) + { + _logger.LogInformation("Robot {RobotName} was unfrozen but the mission to return to safe zone will be completed before further missions are started", robot.Id); } - _logger.LogInformation("Started mission run '{Id}'", queuedMissionRun.Id); + + MqttEventHandlerService.TriggerRobotAvailable(new RobotAvailableEventArgs(robot.Id)); } } } diff --git a/backend/api/EventHandlers/MissionScheduling.cs b/backend/api/EventHandlers/MissionScheduling.cs new file mode 100644 index 000000000..c5050a85f --- /dev/null +++ b/backend/api/EventHandlers/MissionScheduling.cs @@ -0,0 +1,295 @@ +using System.Text.Json; +using Api.Controllers.Models; +using Api.Database.Models; +using Api.Services; +using Api.Utilities; +namespace Api.EventHandlers +{ + public interface IMissionScheduling + { + public void StartMissionRunIfSystemIsAvailable(MissionRun missionRun); + + public Task TheSystemIsAvailableToRunAMission(string robotId, MissionRun missionRun); + + public Task OngoingMission(string robotId); + + public Task?> GetOngoingMission(string robotId); + + public Task FreezeMissionRunQueueForRobot(string robotId); + + public Task StopCurrentMissionRun(string robotId); + + public Task ScheduleMissionToReturnToSafePosition(string robotId, string areaId); + + public Task UnfreezeMissionRunQueueForRobot(string robotId); + + } + + public class MissionScheduling : IMissionScheduling + { + private readonly IIsarService _isarService; + private readonly ILogger _logger; + private readonly IMissionRunService _missionRunService; + private readonly IAreaService _areaService; + private readonly IRobotService _robotService; + private readonly IMissionSchedulingService _missionSchedulingService; + + public MissionScheduling(ILogger logger, IMissionRunService missionRunService, IIsarService isarService, IRobotService robotService, IAreaService areaService, IMissionSchedulingService missionSchedulingService) + { + _logger = logger; + _missionRunService = missionRunService; + _isarService = isarService; + _robotService = robotService; + _areaService = areaService; + _missionSchedulingService = missionSchedulingService; + } + + public void StartMissionRunIfSystemIsAvailable(MissionRun missionRun) + { + if (!TheSystemIsAvailableToRunAMission(missionRun.Robot, missionRun).Result) + { + _logger.LogInformation("Mission {MissionRunId} was put on the queue as the system may not start a mission now", missionRun.Id); + return; + } + + try + { + _missionSchedulingService.StartMissionRun(missionRun); + } + catch (MissionException ex) + { + const MissionStatus NewStatus = MissionStatus.Failed; + _logger.LogWarning( + "Mission run {MissionRunId} was not started successfully. Status updated to '{Status}'.\nReason: {FailReason}", + missionRun.Id, + NewStatus, + ex.Message + ); + missionRun.Status = NewStatus; + missionRun.StatusReason = $"Failed to start: '{ex.Message}'"; + _missionRunService.Update(missionRun); + } + } + + public async Task TheSystemIsAvailableToRunAMission(string robotId, MissionRun missionRun) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogError("Robot with ID: {RobotId} was not found in the database", robotId); + return false; + } + return await TheSystemIsAvailableToRunAMission(robot, missionRun); + } + + public async Task TheSystemIsAvailableToRunAMission(Robot robot, MissionRun missionRun) + { + bool ongoingMission = await OngoingMission(robot.Id); + + if (robot.MissionQueueFrozen && missionRun.MissionRunPriority != MissionRunPriority.Emergency) + { + _logger.LogInformation("Mission run {MissionRunId} was not started as the mission run queue for robot {RobotName} is frozen", missionRun.Id, robot.Name); + return false; + } + + if (ongoingMission) + { + _logger.LogInformation("Mission run {MissionRunId} was not started as there is already an ongoing mission", missionRun.Id); + return false; + } + if (robot.Status is not RobotStatus.Available) + { + _logger.LogInformation("Mission run {MissionRunId} was not started as the robot is not available", missionRun.Id); + return false; + } + if (!robot.Enabled) + { + _logger.LogWarning("Mission run {MissionRunId} was not started as the robot {RobotId} is not enabled", missionRun.Id, robot.Id); + return false; + } + if (missionRun.DesiredStartTime > DateTimeOffset.UtcNow) + { + _logger.LogInformation("Mission run {MissionRunId} was not started as the start time is in the future", missionRun.Id); + return false; + } + return true; + } + + public async Task OngoingMission(string robotId) + { + var ongoingMissions = await _missionRunService.ReadAll( + new MissionRunQueryStringParameters + { + Statuses = new List + { + MissionStatus.Ongoing + }, + RobotId = robotId, + OrderBy = "DesiredStartTime", + PageSize = 100 + }); + + return ongoingMissions.Any(); + } + + public async Task?> GetOngoingMission(string robotId) + { + var ongoingMissions = await _missionRunService.ReadAll( + new MissionRunQueryStringParameters + { + Statuses = new List + { + MissionStatus.Ongoing + }, + RobotId = robotId, + OrderBy = "DesiredStartTime", + PageSize = 100 + }); + + return ongoingMissions; + } + + + public async Task FreezeMissionRunQueueForRobot(string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogError("Robot with ID: {RobotId} was not found in the database", robotId); + return; + } + robot.MissionQueueFrozen = true; + await _robotService.Update(robot); + _logger.LogInformation("Mission queue for robot {RobotName} with ID {RobotId} was frozen", robot.Name, robot.Id); + } + + public async Task UnfreezeMissionRunQueueForRobot(string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogError("Robot with ID: {RobotId} was not found in the database", robotId); + return; + } + robot.MissionQueueFrozen = false; + await _robotService.Update(robot); + _logger.LogInformation("Mission queue for robot {RobotName} with ID {RobotId} was unfrozen", robot.Name, robot.Id); + } + + public async Task StopCurrentMissionRun(string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogError("Robot with ID: {RobotId} was not found in the database", robotId); + return; + } + + var ongoingMissions = await GetOngoingMission(robot.Id); + + if (ongoingMissions == null) + { + _logger.LogWarning("Flotilla has no mission running for robot {RobotName} but an attempt to stop will be made regardless", robot.Name); + } + else + { + foreach (var mission in ongoingMissions) + { + if (mission.MissionRunPriority == MissionRunPriority.Emergency) continue; + + var newMission = new MissionRun + { + Name = mission.Name, + Robot = robot, + MissionRunPriority = MissionRunPriority.Normal, + InstallationCode = mission.InstallationCode, + Area = mission.Area, + Status = MissionStatus.Pending, + DesiredStartTime = DateTimeOffset.UtcNow, + Tasks = mission.Tasks, + Map = new MapMetadata() + }; + + await _missionRunService.Create(newMission); + } + } + + try + { + await _isarService.StopMission(robot); + } + catch (HttpRequestException e) + { + string message = "Error connecting to ISAR while stopping mission"; + _logger.LogError(e, "{Message}", message); + _missionSchedulingService.OnIsarUnavailable(robot.Id); + throw new MissionException(message, (int)e.StatusCode!); + } + catch (MissionException e) + { + string message = "Error while stopping ISAR mission"; + _logger.LogError(e, "{Message}", message); + throw; + } + catch (JsonException e) + { + string message = "Error while processing the response from ISAR"; + _logger.LogError(e, "{Message}", message); + throw new MissionException(message, 0); + } + + robot.CurrentMissionId = null; + await _robotService.Update(robot); + } + + public async Task ScheduleMissionToReturnToSafePosition(string robotId, string areaId) + { + var area = await _areaService.ReadById(areaId); + if (area == null) + { + _logger.LogError("Could not find area with ID {AreaId}", areaId); + return; + } + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogError("Robot with ID: {RobotId} was not found in the database", robotId); + return; + } + var closestSafePosition = _missionSchedulingService.ClosestSafePosition(robot.Pose, area.SafePositions); + // Cloning to avoid tracking same object + var clonedPose = ObjectCopier.Clone(closestSafePosition); + var customTaskQuery = new CustomTaskQuery + { + RobotPose = clonedPose, + Inspections = new List(), + InspectionTarget = new Position(), + TaskOrder = 0 + }; + + var missionRun = new MissionRun + { + Name = "Drive to Safe Position", + Robot = robot, + MissionRunPriority = MissionRunPriority.Emergency, + InstallationCode = area.Installation!.InstallationCode, + Area = area, + Status = MissionStatus.Pending, + DesiredStartTime = DateTimeOffset.UtcNow, + Tasks = new List(new[] + { + new MissionTask(customTaskQuery) + }), + Map = new MapMetadata() + }; + + await _missionRunService.Create(missionRun); + } + + public static bool MissionRunQueueIsEmpty(IList missionRunQueue) + { + return !missionRunQueue.Any(); + } + + } +} diff --git a/backend/api/EventHandlers/MqttEventHandler.cs b/backend/api/EventHandlers/MqttEventHandler.cs index 9f12da93b..4751240f9 100644 --- a/backend/api/EventHandlers/MqttEventHandler.cs +++ b/backend/api/EventHandlers/MqttEventHandler.cs @@ -16,7 +16,13 @@ namespace Api.EventHandlers /// /// A background service which listens to events and performs callback functions. /// - public class MqttEventHandler : EventHandlerBase + /// + public interface IMqttEventHandler + { + public void TriggerRobotAvailable(RobotAvailableEventArgs e); + } + + public class MqttEventHandler : EventHandlerBase, IMqttEventHandler { private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; @@ -58,7 +64,15 @@ public override void Unsubscribe() protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await stoppingToken; } - protected virtual void OnRobotAvailable(RobotAvailableEventArgs e) { RobotAvailable?.Invoke(this, e); } + public void TriggerRobotAvailable(RobotAvailableEventArgs e) + { + OnRobotAvailable(e); + } + + protected virtual void OnRobotAvailable(RobotAvailableEventArgs e) + { + RobotAvailable?.Invoke(this, e); + } public static event EventHandler? RobotAvailable; @@ -229,14 +243,13 @@ private async void OnMissionUpdate(object? sender, MqttReceivedArgs mqttArgs) await robotService.Update(robot); _logger.LogInformation("Robot '{Id}' ('{Name}') - completed mission {MissionId}", robot.IsarId, robot.Name, flotillaMissionRun.MissionId); - if (!flotillaMissionRun.IsCompleted) { return; } - + if (!flotillaMissionRun.IsCompleted) return; await taskDurationService.UpdateAverageDurationPerTask(robot.Model.Type); - if (flotillaMissionRun.MissionId == null) { return; } + if (flotillaMissionRun.MissionId == null) return; var missionDefinition = await missionDefinitionService.ReadById(flotillaMissionRun.MissionId); - if (missionDefinition == null) { return; } + if (missionDefinition == null) return; missionDefinition.LastRun = flotillaMissionRun; await missionDefinitionService.Update(missionDefinition); diff --git a/backend/api/Program.cs b/backend/api/Program.cs index dc2de23a8..80dbce91f 100644 --- a/backend/api/Program.cs +++ b/backend/api/Program.cs @@ -50,6 +50,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -64,8 +66,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); + bool useInMemoryDatabase = builder.Configuration .GetSection("Database") .GetValue("UseInMemoryDatabase"); @@ -79,7 +83,9 @@ builder.Services.AddScoped(); } builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/backend/api/Services/ActionServices/MissionSchedulingService.cs b/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs similarity index 95% rename from backend/api/Services/ActionServices/MissionSchedulingService.cs rename to backend/api/Services/ActionServices/CustomMissionSchedulingService.cs index 0b13f36db..90776a15a 100644 --- a/backend/api/Services/ActionServices/MissionSchedulingService.cs +++ b/backend/api/Services/ActionServices/CustomMissionSchedulingService.cs @@ -3,14 +3,14 @@ using Api.Utilities; namespace Api.Services.ActionServices { - public interface IMissionSchedulingService + public interface ICustomMissionSchedulingService { public Task FindExistingOrCreateCustomMissionDefinition(CustomMissionQuery customMissionQuery, List missionTasks); public Task QueueCustomMissionRun(CustomMissionQuery customMissionQuery, string missionDefinitionId, string robotId, IList missionTasks); } - public class MissionSchedulingService : IMissionSchedulingService + public class CustomMissionSchedulingService : ICustomMissionSchedulingService { private readonly IAreaService _areaService; private readonly ICustomMissionService _customMissionService; @@ -21,7 +21,7 @@ public class MissionSchedulingService : IMissionSchedulingService private readonly IRobotService _robotService; private readonly ISourceService _sourceService; - public MissionSchedulingService( + public CustomMissionSchedulingService( ILogger logger, ICustomMissionService customMissionService, IAreaService areaService, @@ -117,6 +117,7 @@ public async Task QueueCustomMissionRun(CustomMissionQuery customMis Comment = customMissionQuery.Comment, Robot = robot, Status = MissionStatus.Pending, + MissionRunPriority = MissionRunPriority.Normal, DesiredStartTime = customMissionQuery.DesiredStartTime ?? DateTimeOffset.UtcNow, Tasks = missionTasks, InstallationCode = customMissionQuery.InstallationCode, diff --git a/backend/api/Services/AreaService.cs b/backend/api/Services/AreaService.cs index c555c9ab8..fc685567a 100644 --- a/backend/api/Services/AreaService.cs +++ b/backend/api/Services/AreaService.cs @@ -93,7 +93,7 @@ private IQueryable GetAreas() return await _context.Areas.Where(a => a.Name.ToLower().Equals(areaName.ToLower()) && - a.Installation != null && a.Installation.Id.Equals(installation.Id) + a.Installation.InstallationCode.Equals(installation.InstallationCode) ).Include(a => a.SafePositions).Include(a => a.Installation) .Include(a => a.Plant).Include(a => a.Deck).FirstOrDefaultAsync(); } diff --git a/backend/api/Services/EchoService.cs b/backend/api/Services/EchoService.cs index c45236696..2198b8c89 100644 --- a/backend/api/Services/EchoService.cs +++ b/backend/api/Services/EchoService.cs @@ -137,7 +137,8 @@ private List ProcessPlanItems(List planItems, string installa $"https://stid.equinor.com/{installationCode}/tag?tagNo={planItem.Tag}" ), Inspections = planItem.SensorTypes - .Select(sensor => new EchoInspection(sensor)).Distinct(new EchoInspectionComparer()).ToList() + .Select(sensor => new EchoInspection(sensor)) + .ToList() }; if (tag.Inspections.IsNullOrEmpty()) diff --git a/backend/api/Services/EmergencyActionService.cs b/backend/api/Services/EmergencyActionService.cs new file mode 100644 index 000000000..d35cb3856 --- /dev/null +++ b/backend/api/Services/EmergencyActionService.cs @@ -0,0 +1,42 @@ +using Api.Services.Events; +namespace Api.Services +{ + public interface IEmergencyActionService + { + public void TriggerEmergencyButtonPressedForRobot(EmergencyButtonPressedForRobotEventArgs e); + + public void TriggerEmergencyButtonDepressedForRobot(EmergencyButtonPressedForRobotEventArgs e); + } + + public class EmergencyActionService : IEmergencyActionService + { + + public EmergencyActionService() + { + } + + public void TriggerEmergencyButtonPressedForRobot(EmergencyButtonPressedForRobotEventArgs e) + { + OnEmergencyButtonPressedForRobot(e); + } + + public void TriggerEmergencyButtonDepressedForRobot(EmergencyButtonPressedForRobotEventArgs e) + { + OnEmergencyButtonDepressedForRobot(e); + } + + public static event EventHandler? EmergencyButtonPressedForRobot; + + protected virtual void OnEmergencyButtonPressedForRobot(EmergencyButtonPressedForRobotEventArgs e) + { + EmergencyButtonPressedForRobot?.Invoke(this, e); + } + + public static event EventHandler? EmergencyButtonDepressedForRobot; + + protected virtual void OnEmergencyButtonDepressedForRobot(EmergencyButtonPressedForRobotEventArgs e) + { + EmergencyButtonDepressedForRobot?.Invoke(this, e); + } + } +} diff --git a/backend/api/Services/Events/MissionEventArgs.cs b/backend/api/Services/Events/MissionEventArgs.cs index def250d43..7c3f3e0f1 100644 --- a/backend/api/Services/Events/MissionEventArgs.cs +++ b/backend/api/Services/Events/MissionEventArgs.cs @@ -19,4 +19,15 @@ public RobotAvailableEventArgs(string robotId) } public string RobotId { get; set; } } + + public class EmergencyButtonPressedForRobotEventArgs : EventArgs + { + public EmergencyButtonPressedForRobotEventArgs(string robotId) + { + RobotId = robotId; + } + + public string RobotId { get; set; } + + } } diff --git a/backend/api/Services/MissionSchedulingService.cs b/backend/api/Services/MissionSchedulingService.cs new file mode 100644 index 000000000..fbbe9f6d2 --- /dev/null +++ b/backend/api/Services/MissionSchedulingService.cs @@ -0,0 +1,106 @@ +using Api.Controllers; +using Api.Database.Models; +using Api.Utilities; +using Microsoft.AspNetCore.Mvc; +namespace Api.Services +{ + public interface IMissionSchedulingService + { + public void StartMissionRun(MissionRun queuedMissionRun); + public Pose ClosestSafePosition(Pose robotPose, IList safePositions); + public void OnIsarUnavailable(string robotId); + } + + public class MissionSchedulingService : IMissionSchedulingService + { + private readonly ILogger _logger; + private readonly IMissionRunService _missionRunService; + private readonly RobotController _robotController; + private readonly IRobotService _robotService; + + public MissionSchedulingService(ILogger logger, IMissionRunService missionRunService, IRobotService robotService, RobotController robotController) + { + _logger = logger; + _missionRunService = missionRunService; + _robotService = robotService; + _robotController = robotController; + } + + public void StartMissionRun(MissionRun queuedMissionRun) + { + var result = _robotController.StartMission( + queuedMissionRun.Robot.Id, + queuedMissionRun.Id + ).Result; + if (result.Result is not OkObjectResult) + { + string errorMessage = "Unknown error from robot controller"; + if (result.Result is ObjectResult returnObject) + { + errorMessage = returnObject.Value?.ToString() ?? errorMessage; + } + throw new MissionException(errorMessage); + } + _logger.LogInformation("Started mission run '{Id}'", queuedMissionRun.Id); + } + + public async void OnIsarUnavailable(string robotId) + { + var robot = await _robotService.ReadById(robotId); + if (robot == null) + { + _logger.LogError("Robot with ID: {RobotId} was not found in the database", robotId); + return; + } + + robot.Enabled = false; + robot.Status = RobotStatus.Offline; + if (robot.CurrentMissionId != null) + { + var missionRun = await _missionRunService.ReadById(robot.CurrentMissionId); + if (missionRun != null) + { + missionRun.SetToFailed(); + await _missionRunService.Update(missionRun); + _logger.LogWarning( + "Mission '{Id}' failed because ISAR could not be reached", + missionRun.Id + ); + } + } + robot.CurrentMissionId = null; + await _robotService.Update(robot); + } + + public Pose ClosestSafePosition(Pose robotPose, IList safePositions) + { + if (safePositions == null || !safePositions.Any()) + { + string message = "No safe position for area the robot is localized in"; + throw new SafeZoneException(message); + } + + var closestPose = safePositions[0].Pose; + float minDistance = CalculateDistance(robotPose, closestPose); + + for (int i = 1; i < safePositions.Count; i++) + { + float currentDistance = CalculateDistance(robotPose, safePositions[i].Pose); + if (currentDistance < minDistance) + { + minDistance = currentDistance; + closestPose = safePositions[i].Pose; + } + } + return closestPose; + } + + private static float CalculateDistance(Pose pose1, Pose pose2) + { + var pos1 = pose1.Position; + var pos2 = pose2.Position; + return (float)Math.Sqrt(Math.Pow(pos1.X - pos2.X, 2) + Math.Pow(pos1.Y - pos2.Y, 2) + Math.Pow(pos1.Z - pos2.Z, 2)); + } + + } +} diff --git a/backend/api/Services/RobotService.cs b/backend/api/Services/RobotService.cs index bfa50f537..f2459b2b4 100644 --- a/backend/api/Services/RobotService.cs +++ b/backend/api/Services/RobotService.cs @@ -101,7 +101,11 @@ public async Task Update(Robot robot) private IQueryable GetRobotsWithSubModels() { - return _context.Robots.Include(r => r.VideoStreams).Include(r => r.Model).Include(r => r.CurrentArea); + return _context.Robots + .Include(r => r.VideoStreams) + .Include(r => r.Model) + .Include(r => r.CurrentArea) + .ThenInclude(r => r != null ? r.SafePositions : null); } } } diff --git a/backend/api/Utilities/Exceptions.cs b/backend/api/Utilities/Exceptions.cs index 63012a333..f96d81710 100644 --- a/backend/api/Utilities/Exceptions.cs +++ b/backend/api/Utilities/Exceptions.cs @@ -80,4 +80,9 @@ public class DeckExistsException : Exception { public DeckExistsException(string message) : base(message) { } } + + public class SafeZoneException : Exception + { + public SafeZoneException(string message) : base(message) { } + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e272e90ac..b2c76953a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,27 +5,30 @@ import { LanguageProvider } from 'components/Contexts/LanguageContext' import { MissionControlProvider } from 'components/Contexts/MissionControlContext' import { MissionFilterProvider } from 'components/Contexts/MissionFilterContext' import { MissionsProvider } from 'components/Contexts/MissionListsContext' +import { SafeZoneProvider } from 'components/Contexts/SafeZoneContext' function App() { return ( - - - - <> - -
- -
-
- - - - - - -
-
-
+ + + + + <> + +
+ +
+
+ + + + + + +
+
+
+
) } diff --git a/frontend/src/api/ApiCaller.tsx b/frontend/src/api/ApiCaller.tsx index bcce63977..81958fa70 100644 --- a/frontend/src/api/ApiCaller.tsx +++ b/frontend/src/api/ApiCaller.tsx @@ -481,4 +481,26 @@ export class BackendAPICaller { }) return result.content } + + static async sendRobotsToSafePosition(installationCode: string) { + const path: string = `emergency-action/${installationCode}/abort-current-missions-and-send-all-robots-to-safe-zone` + const body = {} + + const result = await this.POST(path, body).catch((e) => { + console.error(`Failed to POST /${path}: ` + e) + throw e + }) + return result.content + } + + static async clearEmergencyState(installationCode: string) { + const path: string = `emergency-action/${installationCode}/clear-emergency-state` + const body = {} + + const result = await this.POST(path, body).catch((e) => { + console.error(`Failed to POST /${path}: ` + e) + throw e + }) + return result.content + } } diff --git a/frontend/src/components/Contexts/MissionControlContext.tsx b/frontend/src/components/Contexts/MissionControlContext.tsx index 28b193d56..fedb6cd20 100644 --- a/frontend/src/components/Contexts/MissionControlContext.tsx +++ b/frontend/src/components/Contexts/MissionControlContext.tsx @@ -1,7 +1,7 @@ import { createContext, useContext, useState, FC } from 'react' import { BackendAPICaller } from 'api/ApiCaller' import { Mission } from 'models/Mission' -import { MissionStatusRequest } from 'components/Pages/FrontPage/MissionOverview/StopMissionDialog' +import { MissionStatusRequest } from 'components/Pages/FrontPage/MissionOverview/StopDialogs' interface IMissionControlState { isWaitingForResponse: boolean diff --git a/frontend/src/components/Contexts/SafeZoneContext.tsx b/frontend/src/components/Contexts/SafeZoneContext.tsx new file mode 100644 index 000000000..5fa4fc05a --- /dev/null +++ b/frontend/src/components/Contexts/SafeZoneContext.tsx @@ -0,0 +1,40 @@ +import { BackendAPICaller } from 'api/ApiCaller' +import { createContext, FC, useContext, useEffect, useState } from 'react' + +interface ISafeZoneContext { + safeZoneStatus: boolean + switchSafeZoneStatus: (newSafeZoneStatus: boolean) => void +} + +interface Props { + children: React.ReactNode +} + +const defaultSafeZoneInterface = { + safeZoneStatus: JSON.parse(localStorage.getItem('safeZoneStatus') ?? 'false'), + switchSafeZoneStatus: (newSafeZoneStatus: boolean) => {}, +} + +export const SafeZoneContext = createContext(defaultSafeZoneInterface) + +export const SafeZoneProvider: FC = ({ children }) => { + const [safeZoneStatus, setSafeZoneStatus] = useState(defaultSafeZoneInterface.safeZoneStatus) + + const switchSafeZoneStatus = (newSafeZoneStatus: boolean) => { + localStorage.setItem('safeZoneStatus', String(newSafeZoneStatus)) + setSafeZoneStatus(newSafeZoneStatus) + } + + return ( + + {children} + + ) +} + +export const useSafeZoneContext = () => useContext(SafeZoneContext) diff --git a/frontend/src/components/Pages/FrontPage/MissionOverview/MissionControlButtons.tsx b/frontend/src/components/Pages/FrontPage/MissionOverview/MissionControlButtons.tsx index c0233f4bd..6ef39d35f 100644 --- a/frontend/src/components/Pages/FrontPage/MissionOverview/MissionControlButtons.tsx +++ b/frontend/src/components/Pages/FrontPage/MissionOverview/MissionControlButtons.tsx @@ -6,7 +6,7 @@ import styled from 'styled-components' import { Typography } from '@equinor/eds-core-react' import { useLanguageContext } from 'components/Contexts/LanguageContext' import { useMissionControlContext } from 'components/Contexts/MissionControlContext' -import { StopMissionDialog, MissionStatusRequest } from './StopMissionDialog' +import { StopMissionDialog, MissionStatusRequest } from './StopDialogs' interface MissionProps { mission: Mission diff --git a/frontend/src/components/Pages/FrontPage/MissionOverview/OngoingMissionView.tsx b/frontend/src/components/Pages/FrontPage/MissionOverview/OngoingMissionView.tsx index 1e3be4b3d..3954d1a2c 100644 --- a/frontend/src/components/Pages/FrontPage/MissionOverview/OngoingMissionView.tsx +++ b/frontend/src/components/Pages/FrontPage/MissionOverview/OngoingMissionView.tsx @@ -9,6 +9,7 @@ import { config } from 'config' import { Icons } from 'utils/icons' /* import { useOngoingMissionsContext } from 'components/Contexts/OngoingMissionsContext' */ import { useMissionsContext } from 'components/Contexts/MissionListsContext' +import { StopRobotDialog } from './StopDialogs' const StyledOngoingMissionView = styled.div` display: flex; @@ -25,6 +26,12 @@ const ButtonStyle = styled.div` display: block; ` +const OngoingMissionHeader = styled.div` + display: grid; + grid-direction: column; + gap: 0.5rem; +` + export function OngoingMissionView({ refreshInterval }: RefreshProps) { const { TranslateText } = useLanguageContext() const { ongoingMissions } = useMissionsContext() @@ -40,9 +47,12 @@ export function OngoingMissionView({ refreshInterval }: RefreshProps) { return ( - - {TranslateText('Ongoing Missions')} - + + + {TranslateText('Ongoing Missions')} + + + {ongoingMissions.length > 0 && ongoingMissionscard} {ongoingMissions.length === 0 && } diff --git a/frontend/src/components/Pages/FrontPage/MissionOverview/StopDialogs.tsx b/frontend/src/components/Pages/FrontPage/MissionOverview/StopDialogs.tsx new file mode 100644 index 000000000..774e741c3 --- /dev/null +++ b/frontend/src/components/Pages/FrontPage/MissionOverview/StopDialogs.tsx @@ -0,0 +1,231 @@ +import { Button, Dialog, Typography, Icon } from '@equinor/eds-core-react' +import styled from 'styled-components' +import { useLanguageContext } from 'components/Contexts/LanguageContext' +import { Icons } from 'utils/icons' +import { useState, useEffect } from 'react' +import { tokens } from '@equinor/eds-tokens' +import { Mission } from 'models/Mission' +import { useMissionControlContext } from 'components/Contexts/MissionControlContext' +import { BackendAPICaller } from 'api/ApiCaller' +import { useInstallationContext } from 'components/Contexts/InstallationContext' +import { useSafeZoneContext } from 'components/Contexts/SafeZoneContext' + +const StyledDisplayButtons = styled.div` + display: flex; + width: 410px; + flex-direction: columns; + justify-content: flex-end; + gap: 0.5rem; +` + +const StyledDialog = styled(Dialog)` + display: grid; + width: 450px; +` + +const StyledText = styled.div` + display: grid; + gird-template-rows: auto, auto; + gap: 1rem; +` + +const StyledButton = styled.div` + width: 250px; +` + +const Square = styled.div` + width: 12px; + height: 12px; +` + +interface MissionProps { + mission: Mission +} + +export enum MissionStatusRequest { + Pause, + Stop, + Resume, +} + +export const StopMissionDialog = ({ mission }: MissionProps): JSX.Element => { + const { TranslateText } = useLanguageContext() + const [isStopMissionDialogOpen, setIsStopMissionDialogOpen] = useState(false) + const [missionId, setMissionId] = useState() + const { updateMissionState } = useMissionControlContext() + + const openDialog = () => { + setIsStopMissionDialogOpen(true) + setMissionId(mission.id) + } + + useEffect(() => { + if (missionId !== mission.id) setIsStopMissionDialogOpen(false) + }, [mission.id]) + + return ( + <> + + + + + + + {TranslateText('Stop mission')} '{mission.name}'?{' '} + + + + + + {TranslateText('Stop button pressed warning text')} + + {TranslateText('Stop button pressed confirmation text')} + + + + + + + + + + + + ) +} + +export const StopRobotDialog = (): JSX.Element => { + const [isStopRobotDialogOpen, setIsStopRobotDialogOpen] = useState(false) + const { safeZoneStatus } = useSafeZoneContext() + const { TranslateText } = useLanguageContext() + const { installationCode } = useInstallationContext() + + const openDialog = async () => { + setIsStopRobotDialogOpen(true) + } + + const closeDialog = async () => { + setIsStopRobotDialogOpen(false) + } + + const stopAll = () => { + BackendAPICaller.sendRobotsToSafePosition(installationCode) + closeDialog() + return + } + + const resetRobots = () => { + BackendAPICaller.clearEmergencyState(installationCode) + closeDialog() + } + + return ( + <> + {!safeZoneStatus && ( + <> + + + + + + + {TranslateText('Send robots to safe zone') + '?'} + + + + + + {TranslateText('Send robots to safe zone long text')} + + + {TranslateText('Send robots to safe confirmation text')} + + + + + + + + + + + + )} + {safeZoneStatus && ( + <> + + + + + + + + {TranslateText('Dismiss robots from safe zone') + '?'} + + + + + + + {TranslateText('Dismiss robots from safe zone long text')} + + + + + + + + + + + + )} + + ) +} diff --git a/frontend/src/components/Pages/FrontPage/MissionOverview/StopMissionDialog.tsx b/frontend/src/components/Pages/FrontPage/MissionOverview/StopMissionDialog.tsx deleted file mode 100644 index dcb9f186d..000000000 --- a/frontend/src/components/Pages/FrontPage/MissionOverview/StopMissionDialog.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { Button, Dialog, Typography, Icon } from '@equinor/eds-core-react' -import styled from 'styled-components' -import { useLanguageContext } from 'components/Contexts/LanguageContext' -import { Icons } from 'utils/icons' -import { useState, useEffect } from 'react' -import { tokens } from '@equinor/eds-tokens' -import { Mission } from 'models/Mission' -import { useMissionControlContext } from 'components/Contexts/MissionControlContext' - -const StyledDisplayButtons = styled.div` - display: flex; - width: 410px; - flex-direction: columns; - justify-content: flex-end; - gap: 0.5rem; -` - -const StyledDialog = styled(Dialog)` - display: grid; - width: 450px; -` - -const StyledText = styled.div` - display: grid; - gird-template-rows: auto, auto; - gap: 1rem; -` - -interface MissionProps { - mission: Mission -} - -export enum MissionStatusRequest { - Pause, - Stop, - Resume, -} - -export const StopMissionDialog = ({ mission }: MissionProps): JSX.Element => { - const { TranslateText } = useLanguageContext() - const { updateMissionState } = useMissionControlContext() - const [isStopMissionDialogOpen, setIsStopMissionDialogOpen] = useState(false) - const [missionId, setMissionId] = useState() - - const openDialog = () => { - setIsStopMissionDialogOpen(true) - setMissionId(mission.id) - } - - useEffect(() => { - if (missionId !== mission.id) setIsStopMissionDialogOpen(false) - }, [mission.id, missionId]) - - return ( - <> - - - - - - - {TranslateText('Stop mission')} '{mission.name}'?{' '} - - - - - - {TranslateText('Stop button pressed warning text')} - - {TranslateText('Stop button pressed confirmation text')} - - - - - - - - - - - - ) -} diff --git a/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusChip.tsx b/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusChip.tsx index 75d5588c0..c9cf3543b 100644 --- a/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusChip.tsx +++ b/frontend/src/components/Pages/FrontPage/RobotCards/RobotStatusChip.tsx @@ -2,6 +2,7 @@ import { Chip } from '@equinor/eds-core-react' import { RobotStatus } from 'models/Robot' import { tokens } from '@equinor/eds-tokens' import { useLanguageContext } from 'components/Contexts/LanguageContext' +import { useSafeZoneContext } from 'components/Contexts/SafeZoneContext' interface StatusProps { status?: RobotStatus @@ -12,11 +13,14 @@ enum StatusColors { Offline = '#F7F7F7', Busy = '#FFC67A', Blocked = '#FFC67A', + SafeZone = '#FF0000', } export function RobotStatusChip({ status }: StatusProps) { const { TranslateText } = useLanguageContext() - let chipColor = StatusColors.Offline + const { safeZoneStatus } = useSafeZoneContext() + + var chipColor = StatusColors.Offline switch (status) { case RobotStatus.Available: { chipColor = StatusColors.Available @@ -36,6 +40,12 @@ export function RobotStatusChip({ status }: StatusProps) { break } } + + if (safeZoneStatus) { + chipColor = StatusColors.SafeZone + status = RobotStatus.SafeZone + } + return ( ([]) + const { safeZoneStatus, switchSafeZoneStatus } = useSafeZoneContext() const sortRobotsByStatus = useCallback((robots: Robot[]): Robot[] => { const sortedRobots = robots.sort((robot, robotToCompareWith) => robot.status! > robotToCompareWith.status! ? 1 : -1 ) - return sortedRobots }, []) const updateRobots = useCallback(() => { BackendAPICaller.getEnabledRobots().then((result: Robot[]) => { setRobots(sortRobotsByStatus(result)) + const missionQueueFozenStatus = result + .map((robot: Robot) => { + return robot.missionQueueFrozen + }) + .filter((status) => status === true) + + if (missionQueueFozenStatus.length > 0 && !safeZoneStatus) switchSafeZoneStatus(true) + else switchSafeZoneStatus(false) }) }, [sortRobotsByStatus]) diff --git a/frontend/src/components/Pages/InspectionPage/InspectionTable.tsx b/frontend/src/components/Pages/InspectionPage/InspectionTable.tsx index 050cb9349..1678c5e5f 100644 --- a/frontend/src/components/Pages/InspectionPage/InspectionTable.tsx +++ b/frontend/src/components/Pages/InspectionPage/InspectionTable.tsx @@ -193,7 +193,7 @@ const InspectionRow = ({ {mission.comment} - {mission.area.areaName} + {mission.area ? mission.area.areaName : '-'} {lastCompleted} {inspection.deadline ? formatDateString(inspection.deadline.toISOString()) : ''} diff --git a/frontend/src/components/Pages/MissionDefinitionPage/MissionDefinitionPage.tsx b/frontend/src/components/Pages/MissionDefinitionPage/MissionDefinitionPage.tsx index d45faef99..95e353010 100644 --- a/frontend/src/components/Pages/MissionDefinitionPage/MissionDefinitionPage.tsx +++ b/frontend/src/components/Pages/MissionDefinitionPage/MissionDefinitionPage.tsx @@ -79,8 +79,8 @@ function MissionDefinitionPageBody({ missionDefinition, updateMissionDefinition const { TranslateText } = useLanguageContext() let navigate = useNavigate() - const displayInspectionFrequency = (inspectionFrequency: string) => { - if (inspectionFrequency === null) return TranslateText('No inspection frequency set') + const displayInspectionFrequency = (inspectionFrequency: string | undefined) => { + if (inspectionFrequency === undefined) return TranslateText('No inspection frequency set') const timeArray = inspectionFrequency.split(':') const days: number = +timeArray[0] const hours: number = +timeArray[1] @@ -101,10 +101,22 @@ function MissionDefinitionPageBody({ missionDefinition, updateMissionDefinition left={TranslateText('Inspection frequency')} right={displayInspectionFrequency(missionDefinition.inspectionFrequency)} /> - - - - + + + + { } export const getInspectionDeadline = ( - inspectionFrequency: string | null, + inspectionFrequency: string | undefined, lastRunTime: Date | null ): Date | undefined => { if (!inspectionFrequency || !lastRunTime) return undefined diff --git a/frontend/src/utils/icons.tsx b/frontend/src/utils/icons.tsx index 8c746120d..5933f671e 100644 --- a/frontend/src/utils/icons.tsx +++ b/frontend/src/utils/icons.tsx @@ -37,6 +37,7 @@ import { settings, platform, library_add, + play, } from '@equinor/eds-icons' Icon.add({ @@ -77,6 +78,7 @@ Icon.add({ settings, platform, library_add, + play, }) export enum Icons { @@ -117,4 +119,5 @@ export enum Icons { Settings = 'settings', Platform = 'platform', LibraryAdd = 'library_add', + PlayTriangle = 'play', }