diff --git a/src/main/java/io/mapsmessaging/state/config/DroneInfoDTO.java b/src/main/java/io/mapsmessaging/state/config/DroneInfoDTO.java index 8a755f37b..838617b97 100644 --- a/src/main/java/io/mapsmessaging/state/config/DroneInfoDTO.java +++ b/src/main/java/io/mapsmessaging/state/config/DroneInfoDTO.java @@ -42,6 +42,13 @@ public class DroneInfoDTO { @Schema(description = "Total battery capacity in hours.") private double batteryCapacityHours = 0.0; + @Schema( + description = "Maximum route range in metres when the battery is at 100%. When omitted, battery-adjusted task range validation is disabled.", + example = "25000.0", + minimum = "0.0", + nullable = true) + private Double rangeMeters; + @Schema( description = "Survey coverage radius in metres measured from the vehicle centreline. " diff --git a/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java b/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java index b6f0b29c4..d2c339e22 100644 --- a/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java +++ b/src/main/java/io/mapsmessaging/state/config/TwinManagerConfig.java @@ -31,10 +31,9 @@ import io.mapsmessaging.state.config.n2k.N2KAisConfig; import io.mapsmessaging.state.config.n2k.N2KTwinConfig; import io.mapsmessaging.utilities.configuration.ConfigurationManager; -import lombok.NoArgsConstructor; - import java.io.IOException; import java.util.*; +import lombok.NoArgsConstructor; @NoArgsConstructor public class TwinManagerConfig extends TwinManagerConfigDTO implements Config, ConfigManager { @@ -139,9 +138,7 @@ public ConfigurationProperties toConfigurationProperties() { props.put("tak", takProps); } - if (this.geospatial != null - && this.geospatial.getAreas() != null - && !this.geospatial.getAreas().isEmpty()) { + if (this.geospatial != null && this.geospatial.getAreas() != null && !this.geospatial.getAreas().isEmpty()) { props.put("geospatial", GeoSpatialConfigSupport.toConfigurationProperties(this.geospatial)); } @@ -377,18 +374,13 @@ private DroneInfoDTO parseDroneInfo(ConfigurationProperties properties) { droneInfo.setName(properties.getProperty("name", droneInfo.getName())); droneInfo.setModelName(properties.getProperty("modelName", droneInfo.getModelName())); droneInfo.setGeospatialArea(properties.getProperty("geospatialArea", droneInfo.getGeospatialArea())); - if(properties.containsKey("surveyRadiusMeters") && properties.getDoubleProperty("surveyRadiusMeters", 0.0) > 0.0) { - droneInfo.setSurveyRadiusMeters(properties.getDoubleProperty("surveyRadiusMeters", 0.0)); - } + droneInfo.setRangeMeters(readOptionalPositiveDouble(properties, "rangeMeters")); + droneInfo.setSurveyRadiusMeters(readOptionalPositiveDouble(properties, "surveyRadiusMeters")); droneInfo.setMessageEncoding(parseMessageEncoding(properties.getProperty("messageEncoding", null), droneInfo.getMessageEncoding())); StopActionEnum legacyStopAction = parseTerminalAction(properties.getProperty("stopAction", null), droneInfo.getCancelAction()); droneInfo.setCancelAction(parseTerminalAction(properties.getProperty("cancelAction", null), legacyStopAction)); droneInfo.setMissionEndAction(parseTerminalAction(properties.getProperty("missionEndAction", null), droneInfo.getMissionEndAction())); - - droneInfo.setMissionTimeoutAction( - parseTerminalAction( - properties.getProperty("missionTimeoutAction", null), - droneInfo.getMissionTimeoutAction())); + droneInfo.setMissionTimeoutAction(parseTerminalAction(properties.getProperty("missionTimeoutAction", null), droneInfo.getMissionTimeoutAction())); if (properties.get("description") instanceof ConfigurationProperties descriptionProperties) { droneInfo.setDescription(descriptionProperties.getMap()); } else if (properties.get("description") instanceof Map descriptionMap) { @@ -399,6 +391,14 @@ private DroneInfoDTO parseDroneInfo(ConfigurationProperties properties) { return droneInfo; } + private Double readOptionalPositiveDouble(ConfigurationProperties properties, String propertyName) { + if (!properties.containsKey(propertyName)) { + return null; + } + double value = properties.getDoubleProperty(propertyName, 0.0d); + return Double.isFinite(value) && value > 0.0d ? value : null; + } + private MessageEncodingEnum parseMessageEncoding(String value, MessageEncodingEnum defaultValue) { if (value == null || value.isBlank()) { return defaultValue; @@ -434,6 +434,14 @@ private List toDroneInfoConfigurationProperties(List 0.0d) { + properties.put("rangeMeters", droneInfo.getRangeMeters()); + } + + if (droneInfo.getSurveyRadiusMeters() != null && droneInfo.getSurveyRadiusMeters() > 0.0d) { + properties.put("surveyRadiusMeters", droneInfo.getSurveyRadiusMeters()); + } + if (droneInfo.getMessageEncoding() != null) { properties.put("messageEncoding", droneInfo.getMessageEncoding().name()); } @@ -490,15 +498,8 @@ private TaskCapabilities parseTaskCapabilities(Object value) { capabilities.setTasks(parseTaskCapabilityList(configurationProperties.get("task_capabilities"))); } - capabilities.setTaskConditionsMode( - parseTaskConditionMode( - configurationProperties.getProperty("task_conditions_mode", null), - capabilities.getTaskConditionsMode())); - - capabilities.setTaskConditionsTemplate( - parseTaskTemplateMode( - configurationProperties.getProperty("task_conditions_template", null), - capabilities.getTaskConditionsTemplate())); + capabilities.setTaskConditionsMode(parseTaskConditionMode(configurationProperties.getProperty("task_conditions_mode", null), capabilities.getTaskConditionsMode())); + capabilities.setTaskConditionsTemplate(parseTaskTemplateMode(configurationProperties.getProperty("task_conditions_template", null), capabilities.getTaskConditionsTemplate())); } else if (value instanceof List list) { capabilities.setTasks(parseTaskCapabilityList(list)); } @@ -581,20 +582,9 @@ private List parseTaskCapabilityList(Object value) { private TaskCapability parseTaskCapability(ConfigurationProperties properties) { TaskCapability taskCapability = new TaskCapability(); - taskCapability.setTaskType( - parsePlanTaskType( - properties.getProperty("task_type", null), - taskCapability.getTaskType())); - - taskCapability.setSpecialization( - parseTaskSpecialization( - properties.getProperty("task_specialization", null), - taskCapability.getSpecialization())); - - taskCapability.setAuthorities( - parseTaskAuthorities( - properties.get("authorities"))); - + taskCapability.setTaskType(parsePlanTaskType(properties.getProperty("task_type", null), taskCapability.getTaskType())); + taskCapability.setSpecialization(parseTaskSpecialization(properties.getProperty("task_specialization", null), taskCapability.getSpecialization())); + taskCapability.setAuthorities(parseTaskAuthorities(properties.get("authorities"))); return taskCapability; } diff --git a/src/test/java/io/mapsmessaging/state/config/DroneInfoRangeConfigTest.java b/src/test/java/io/mapsmessaging/state/config/DroneInfoRangeConfigTest.java new file mode 100644 index 000000000..ddf60d164 --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/config/DroneInfoRangeConfigTest.java @@ -0,0 +1,104 @@ +/* + * Copyright [ 2024 - 2026 ] MapsMessaging B.V. + * + * Licensed under the Apache License, Version 2.0 with the Commons Clause + * (the "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at: + * + * http://www.apache.org/licenses/LICENSE-2.0 + * https://commonsclause.com/ + */ + +package io.mapsmessaging.state.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.mapsmessaging.configuration.ConfigurationProperties; +import java.lang.reflect.Constructor; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class DroneInfoRangeConfigTest { + + @Test + void constructor_readsOptionalRangeAndSurveyRadius() throws ReflectiveOperationException { + TwinManagerConfig config = newTwinManagerConfig(rootWithDrone(25_000.0d, 200.0d)); + + assertEquals(1, config.getDroneInfo().size()); + assertEquals(25_000.0d, config.getDroneInfo().get(0).getRangeMeters()); + assertEquals(200.0d, config.getDroneInfo().get(0).getSurveyRadiusMeters()); + } + + @Test + void toConfigurationProperties_writesOptionalRangeAndSurveyRadius() throws ReflectiveOperationException { + TwinManagerConfig config = newTwinManagerConfig(rootWithDrone(25_000.0d, 200.0d)); + + ConfigurationProperties saved = config.toConfigurationProperties(); + List droneInfos = assertInstanceOf(List.class, saved.get("droneInfo")); + ConfigurationProperties drone = assertInstanceOf(ConfigurationProperties.class, droneInfos.get(0)); + + assertEquals(25_000.0d, drone.getDoubleProperty("rangeMeters", 0.0d)); + assertEquals(200.0d, drone.getDoubleProperty("surveyRadiusMeters", 0.0d)); + } + + @Test + void configurationRoundTrip_preservesOptionalRangeAndSurveyRadius() throws ReflectiveOperationException { + TwinManagerConfig original = newTwinManagerConfig(rootWithDrone(25_000.0d, 200.0d)); + + TwinManagerConfig reloaded = newTwinManagerConfig(original.toConfigurationProperties()); + + assertEquals(25_000.0d, reloaded.getDroneInfo().get(0).getRangeMeters()); + assertEquals(200.0d, reloaded.getDroneInfo().get(0).getSurveyRadiusMeters()); + } + + @Test + void constructor_whenRangeIsMissing_leavesRangeValidationDisabled() throws ReflectiveOperationException { + ConfigurationProperties root = new ConfigurationProperties(); + ConfigurationProperties drone = baseDrone(); + root.put("droneInfo", List.of(drone)); + + TwinManagerConfig config = newTwinManagerConfig(root); + ConfigurationProperties saved = config.toConfigurationProperties(); + List droneInfos = assertInstanceOf(List.class, saved.get("droneInfo")); + ConfigurationProperties savedDrone = assertInstanceOf(ConfigurationProperties.class, droneInfos.get(0)); + + assertNull(config.getDroneInfo().get(0).getRangeMeters()); + assertNull(savedDrone.get("rangeMeters")); + } + + @Test + void constructor_whenRangeIsNotPositive_ignoresRange() throws ReflectiveOperationException { + TwinManagerConfig config = newTwinManagerConfig(rootWithDrone(0.0d, null)); + + assertNull(config.getDroneInfo().get(0).getRangeMeters()); + } + + private ConfigurationProperties rootWithDrone(Double rangeMeters, Double surveyRadiusMeters) { + ConfigurationProperties root = new ConfigurationProperties(); + ConfigurationProperties drone = baseDrone(); + if (rangeMeters != null) { + drone.put("rangeMeters", rangeMeters); + } + if (surveyRadiusMeters != null) { + drone.put("surveyRadiusMeters", surveyRadiusMeters); + } + root.put("droneInfo", List.of(drone)); + return root; + } + + private ConfigurationProperties baseDrone() { + ConfigurationProperties drone = new ConfigurationProperties(); + drone.put("name", "USV-001"); + drone.put("uuid", UUID.fromString("bd6bfbc4-22b8-5915-90d9-f9e23e7e63e6").toString()); + return drone; + } + + private TwinManagerConfig newTwinManagerConfig(ConfigurationProperties properties) throws ReflectiveOperationException { + Constructor constructor = TwinManagerConfig.class.getDeclaredConstructor(ConfigurationProperties.class); + constructor.setAccessible(true); + return constructor.newInstance(properties); + } +}