diff --git a/.github/workflows/mavlink-area-mission-validation.yml b/.github/workflows/mavlink-area-mission-validation.yml index 965f73a77..6eca40151 100644 --- a/.github/workflows/mavlink-area-mission-validation.yml +++ b/.github/workflows/mavlink-area-mission-validation.yml @@ -25,3 +25,8 @@ jobs: mvn -B -Dtest=MissionPlanRepeatTest,MavlinkCommandSetPreparerTest,MavlinkEventListSenderConcurrencyTest,SticklebackPassiveDetectionCapabilityTest,PlanTaskTypeTest,TwinManagerConfigPlanTaskTypeTest test + - name: Run MAVLink bootstrap tests + run: >- + mvn -B + -Dtest=MavlinkBootstrapRequestPublisherTest,AutopilotVersionListenerTest,MavlinkBootstrapStateEngineTest + test diff --git a/src/main/java/io/mapsmessaging/rest/RestApiServerManager.java b/src/main/java/io/mapsmessaging/rest/RestApiServerManager.java index 223902024..b7b63f08b 100644 --- a/src/main/java/io/mapsmessaging/rest/RestApiServerManager.java +++ b/src/main/java/io/mapsmessaging/rest/RestApiServerManager.java @@ -339,7 +339,6 @@ private List buildInbuilt(){ endpoints.add("io.mapsmessaging.rest.api.impl.logging"); endpoints.add("io.mapsmessaging.rest.api.impl.ml"); endpoints.add("io.mapsmessaging.rest.api.impl.config"); - endpoints.add("io.mapsmessaging.rest.api.impl.twins"); return endpoints; } diff --git a/src/main/java/io/mapsmessaging/state/StateManagerAgent.java b/src/main/java/io/mapsmessaging/state/StateManagerAgent.java index 0d97dd4f4..4aef8bd88 100644 --- a/src/main/java/io/mapsmessaging/state/StateManagerAgent.java +++ b/src/main/java/io/mapsmessaging/state/StateManagerAgent.java @@ -132,7 +132,7 @@ public GeoSpatialAreaRegistry getGeoSpatialAreaRegistry() { private void loadStateMessageAdapters(TwinManagerConfigDTO config) { StateMessageAdapterContext context = new StateMessageAdapterContext(twinManager, config); ServiceLoader adapterFactories = ServiceLoader.load(StateMessageAdapterFactory.class); - + restApiPackageList.add("io.mapsmessaging.state.rest.twins"); for (StateMessageAdapterFactory adapterFactory : adapterFactories) { Optional optionalAdapter = adapterFactory.create(context); if (optionalAdapter.isPresent()) { diff --git a/src/main/java/io/mapsmessaging/state/logging/StateLogMessages.java b/src/main/java/io/mapsmessaging/state/logging/StateLogMessages.java index 648baafb9..f2f23b47b 100644 --- a/src/main/java/io/mapsmessaging/state/logging/StateLogMessages.java +++ b/src/main/java/io/mapsmessaging/state/logging/StateLogMessages.java @@ -68,6 +68,9 @@ public enum StateLogMessages implements LogMessage { MAVLINK_STATE_CORRELATION_DATA_MISSING(LEVEL.DEBUG, SERVER_CATEGORY.PROTOCOL, "MAVLink message {} from '{}' contains no correlation data"), MAVLINK_STATE_PROCESSING_FAILED(LEVEL.ERROR, SERVER_CATEGORY.PROTOCOL, "Failed to process MAVLink state message from '{}'"), MAVLINK_STATE_TWIN_UPDATE_FAILED(LEVEL.ERROR, SERVER_CATEGORY.PROTOCOL, "Failed to update drone '{}' from MAVLink message {} received from '{}'"), + MAVLINK_BOOTSTRAP_REQUEST_SKIPPED(LEVEL.DEBUG, SERVER_CATEGORY.PROTOCOL, "Skipping MAVLink bootstrap request for twin '{}': {}"), + MAVLINK_BOOTSTRAP_REQUEST_SENT(LEVEL.DEBUG, SERVER_CATEGORY.PROTOCOL, "Sent MAVLink bootstrap request for message {} to system {} component {} for twin '{}'"), + MAVLINK_BOOTSTRAP_REQUEST_FAILED(LEVEL.WARN, SERVER_CATEGORY.PROTOCOL, "Failed to send MAVLink bootstrap request for message {} to twin '{}'"), // // diff --git a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java index 9fa2c11d5..1c6ce0797 100644 --- a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java +++ b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java @@ -40,6 +40,7 @@ import io.mapsmessaging.state.config.MavlinkTwinConfigDTO; import io.mapsmessaging.state.drone.core.TwinManager; import io.mapsmessaging.state.drone.core.TwinUpdateContext; +import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapRequestPublisher; import io.mapsmessaging.state.mavlink.listener.ListenerManager; import io.mapsmessaging.state.mavlink.packet.MavlinkPacket; import io.mapsmessaging.state.mavlink.packet.MavlinkPacketFactory; @@ -91,7 +92,7 @@ public MavlinkStateSubscriber(@NonNull @NotNull TwinManager twinManager, @NonNul this.namespaceTopicPath = mavlinkConfig.getTopic(); this.sourceRegistry = new MavlinkSourceRegistry(mavlinkConfig); this.droneRegistry = registry; - this.twinUpdater = new MavlinkTwinUpdater(twinManager, new ListenerManager(twinManager)); + this.twinUpdater = new MavlinkTwinUpdater(twinManager, new ListenerManager(twinManager), new MavlinkBootstrapRequestPublisher(twinManager, protocol)); } MavlinkStateSubscriber( diff --git a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java index c26e862af..4cf90c569 100644 --- a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java +++ b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java @@ -35,6 +35,7 @@ import io.mapsmessaging.state.drone.model.DetectionEvent; import io.mapsmessaging.state.drone.model.DroneContactManager; import io.mapsmessaging.state.mavlink.bootstrap.DroneTwinReadinessEvaluator; +import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapEventPublisher; import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapProfile; import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapStateEngine; import io.mapsmessaging.state.mavlink.listener.ListenerManager; @@ -59,9 +60,13 @@ public class MavlinkTwinUpdater implements AutoCloseable { private final AtomicBoolean closed; public MavlinkTwinUpdater(@NonNull @NotNull TwinManager twinManager, @NonNull @NotNull ListenerManager listenerManager) { + this(twinManager, listenerManager, (MavlinkBootstrapEventPublisher) null); + } + + public MavlinkTwinUpdater(@NonNull @NotNull TwinManager twinManager, @NonNull @NotNull ListenerManager listenerManager, MavlinkBootstrapEventPublisher bootstrapEventPublisher) { this.twinManager = twinManager; this.listenerManager = listenerManager; - this.droneMonitor = new MavlinkDroneMonitor(twinManager, new DroneTwinReadinessEvaluator(), new MavlinkBootstrapStateEngine(new MavlinkBootstrapProfile()), null); + this.droneMonitor = new MavlinkDroneMonitor(twinManager, new DroneTwinReadinessEvaluator(), new MavlinkBootstrapStateEngine(new MavlinkBootstrapProfile()), bootstrapEventPublisher); this.closed = new AtomicBoolean(); twinManager.addObserver(droneMonitor); } diff --git a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapProfile.java b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapProfile.java index fb1e9ec7c..e153e2d64 100644 --- a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapProfile.java +++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapProfile.java @@ -49,6 +49,11 @@ public MavlinkBootstrapProfile() { AUTOPILOT_VERSION ); + addRequestMessage( + DroneTwinMissingState.MISSING_CAPABILITIES, + AUTOPILOT_VERSION + ); + addRequestMessage( DroneTwinMissingState.MISSING_HOME_POSITION, HOME_POSITION @@ -101,4 +106,4 @@ private void addMessageInterval( ) ); } -} \ No newline at end of file +} diff --git a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestPublisher.java b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestPublisher.java new file mode 100644 index 000000000..59b280138 --- /dev/null +++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestPublisher.java @@ -0,0 +1,110 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * 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/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.mavlink.bootstrap; + +import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_BOOTSTRAP_REQUEST_FAILED; +import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_BOOTSTRAP_REQUEST_SENT; +import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_BOOTSTRAP_REQUEST_SKIPPED; +import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.AUTOPILOT_VERSION; + +import io.mapsmessaging.api.Destination; +import io.mapsmessaging.api.MessageBuilder; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.api.features.QualityOfService; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.logging.Logger; +import io.mapsmessaging.logging.LoggerFactory; +import io.mapsmessaging.state.StateLoopProtocol; +import io.mapsmessaging.state.drone.core.EntityTwin; +import io.mapsmessaging.state.drone.core.TwinManager; +import io.mapsmessaging.state.mavlink.messages.MavlinkCommandLong; +import io.mapsmessaging.state.mavlink.messages.MavlinkCommandLongFactory; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +public class MavlinkBootstrapRequestPublisher implements MavlinkBootstrapEventPublisher { + + private final Logger logger = LoggerFactory.getLogger(MavlinkBootstrapRequestPublisher.class); + private final TwinManager twinManager; + private final StateLoopProtocol protocol; + + public MavlinkBootstrapRequestPublisher(TwinManager twinManager, StateLoopProtocol protocol) { + this.twinManager = twinManager; + this.protocol = protocol; + } + + @Override + public void publish(MavlinkBootstrapEvent event) { + if (!isAutopilotVersionRequest(event)) { + return; + } + + Optional optionalTwin = twinManager.getTwin(event.getTwinId()); + if (optionalTwin.isEmpty()) { + logger.log(MAVLINK_BOOTSTRAP_REQUEST_SKIPPED, event.getTwinId(), "twin is not registered"); + return; + } + + EntityTwin twin = optionalTwin.get(); + String responseTopic = twin.getResponseTopicName(); + String correlationData = twin.getUniqueOutboundIdentifier(); + if (responseTopic == null || responseTopic.isBlank() || correlationData == null || correlationData.isBlank()) { + logger.log(MAVLINK_BOOTSTRAP_REQUEST_SKIPPED, event.getTwinId(), "MAVLink response route is unavailable"); + return; + } + + MavlinkCommandLong request = MavlinkCommandLongFactory.requestMessage(event.getTargetSystem(), event.getTargetComponent(), 0, AUTOPILOT_VERSION); + Message message = new MessageBuilder() + .setOpaqueData(request.toMavlinkJsonObject().toString().getBytes(StandardCharsets.UTF_8)) + .setContentType("application/json") + .setQoS(QualityOfService.AT_MOST_ONCE) + .setCorrelationData(correlationData) + .build(); + + if (protocol.getSession() == null) { + logger.log(MAVLINK_BOOTSTRAP_REQUEST_SKIPPED, event.getTwinId(), "MAVLink state session is unavailable"); + return; + } + + protocol.getSession().findDestination(responseTopic, DestinationType.TOPIC).whenComplete((destination, failure) -> publishToDestination(event, message, destination, failure)); + } + + private boolean isAutopilotVersionRequest(MavlinkBootstrapEvent event) { + return event != null + && event.getEventType() == MavlinkBootstrapEventType.REQUEST + && event.getRequestType() == MavlinkBootstrapRequestType.REQUEST_MESSAGE + && event.getMavlinkMessageId() == AUTOPILOT_VERSION; + } + + private void publishToDestination(MavlinkBootstrapEvent event, Message message, Destination destination, Throwable failure) { + if (failure != null || destination == null) { + logger.log(MAVLINK_BOOTSTRAP_REQUEST_FAILED, failure, event.getMavlinkMessageId(), event.getTwinId()); + return; + } + + try { + destination.storeMessage(message); + logger.log(MAVLINK_BOOTSTRAP_REQUEST_SENT, event.getMavlinkMessageId(), event.getTargetSystem(), event.getTargetComponent(), event.getTwinId()); + } catch (IOException exception) { + logger.log(MAVLINK_BOOTSTRAP_REQUEST_FAILED, exception, event.getMavlinkMessageId(), event.getTwinId()); + } + } +} diff --git a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngine.java b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngine.java index 3551268b8..01fe3c637 100644 --- a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngine.java +++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngine.java @@ -24,6 +24,7 @@ import java.time.Instant; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; @@ -146,6 +147,7 @@ private void handleMissingStates( return; } + Set emittedRequests = new HashSet<>(); for (DroneTwinMissingState missingState : readinessResult.getMissingStates()) { MavlinkBootstrapRequestDefinition requestDefinition = bootstrapProfile.getRequestDefinitions().get(missingState); @@ -174,13 +176,15 @@ private void handleMissingStates( continue; } - events.add( - createRequestEvent( - droneTwin, - missingState, - requestDefinition - ) - ); + if (emittedRequests.add(requestDefinition)) { + events.add( + createRequestEvent( + droneTwin, + missingState, + requestDefinition + ) + ); + } requestTracker.markRequested(now); } diff --git a/src/main/java/io/mapsmessaging/state/mavlink/messages/MavlinkCommandLongFactory.java b/src/main/java/io/mapsmessaging/state/mavlink/messages/MavlinkCommandLongFactory.java index 7d828cdf7..5ad2e8db9 100644 --- a/src/main/java/io/mapsmessaging/state/mavlink/messages/MavlinkCommandLongFactory.java +++ b/src/main/java/io/mapsmessaging/state/mavlink/messages/MavlinkCommandLongFactory.java @@ -30,6 +30,7 @@ public final class MavlinkCommandLongFactory { public static final int MAV_CMD_DO_SET_MISSION_CURRENT = 224; public static final int MAV_CMD_MISSION_START = 300; public static final int MAV_CMD_COMPONENT_ARM_DISARM = 400; + public static final int MAV_CMD_REQUEST_MESSAGE = 512; public static final float ARM = 1.0f; public static final float DISARM = 0.0f; @@ -128,6 +129,12 @@ public static MavlinkCommandLong missionStart( return commandLong; } + public static MavlinkCommandLong requestMessage(int targetSystem, int targetComponent, int sequence, int messageId) { + MavlinkCommandLong commandLong = command(targetSystem, targetComponent, MAV_CMD_REQUEST_MESSAGE, sequence); + commandLong.setParam1(messageId); + return commandLong; + } + public static MavlinkCommandLong setMissionCurrent( int targetSystem, int targetComponent, diff --git a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApi.java b/src/main/java/io/mapsmessaging/state/rest/twins/TwinConfigurationApi.java similarity index 99% rename from src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApi.java rename to src/main/java/io/mapsmessaging/state/rest/twins/TwinConfigurationApi.java index 3605d6d3e..8a5bde102 100644 --- a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApi.java +++ b/src/main/java/io/mapsmessaging/state/rest/twins/TwinConfigurationApi.java @@ -17,7 +17,7 @@ * limitations under the License. */ -package io.mapsmessaging.rest.api.impl.twins; +package io.mapsmessaging.state.rest.twins; import io.mapsmessaging.configuration.ConfigurationProperties; import io.mapsmessaging.dto.rest.config.protocol.impl.TakProtocolDTO; diff --git a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStore.java b/src/main/java/io/mapsmessaging/state/rest/twins/TwinConfigurationStore.java similarity index 99% rename from src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStore.java rename to src/main/java/io/mapsmessaging/state/rest/twins/TwinConfigurationStore.java index eb1303e28..00cfe66a9 100644 --- a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStore.java +++ b/src/main/java/io/mapsmessaging/state/rest/twins/TwinConfigurationStore.java @@ -17,7 +17,7 @@ * limitations under the License. */ -package io.mapsmessaging.rest.api.impl.twins; +package io.mapsmessaging.state.rest.twins; import io.mapsmessaging.configuration.ConfigurationProperties; import io.mapsmessaging.dto.rest.config.protocol.impl.TakProtocolDTO; diff --git a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinCoreConfigDTO.java b/src/main/java/io/mapsmessaging/state/rest/twins/TwinCoreConfigDTO.java similarity index 98% rename from src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinCoreConfigDTO.java rename to src/main/java/io/mapsmessaging/state/rest/twins/TwinCoreConfigDTO.java index e295ed93d..4b654dfd4 100644 --- a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinCoreConfigDTO.java +++ b/src/main/java/io/mapsmessaging/state/rest/twins/TwinCoreConfigDTO.java @@ -17,7 +17,7 @@ * limitations under the License. */ -package io.mapsmessaging.rest.api.impl.twins; +package io.mapsmessaging.state.rest.twins; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinManagementApi.java b/src/main/java/io/mapsmessaging/state/rest/twins/TwinManagementApi.java similarity index 99% rename from src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinManagementApi.java rename to src/main/java/io/mapsmessaging/state/rest/twins/TwinManagementApi.java index 1d6c77b75..8d7c1a13b 100644 --- a/src/main/java/io/mapsmessaging/rest/api/impl/twins/TwinManagementApi.java +++ b/src/main/java/io/mapsmessaging/state/rest/twins/TwinManagementApi.java @@ -17,7 +17,7 @@ * limitations under the License. */ -package io.mapsmessaging.rest.api.impl.twins; +package io.mapsmessaging.state.rest.twins; import io.mapsmessaging.MessageDaemon; import io.mapsmessaging.rest.api.impl.BaseRestApi; diff --git a/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestPublisherTest.java b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestPublisherTest.java new file mode 100644 index 000000000..94d31ff2e --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestPublisherTest.java @@ -0,0 +1,110 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * 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/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.mavlink.bootstrap; + +import static io.mapsmessaging.state.mavlink.messages.MavlinkCommandLongFactory.MAV_CMD_REQUEST_MESSAGE; +import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.AUTOPILOT_VERSION; +import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.BATTERY_STATUS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.mapsmessaging.api.Destination; +import io.mapsmessaging.api.Session; +import io.mapsmessaging.api.features.DestinationType; +import io.mapsmessaging.api.message.Message; +import io.mapsmessaging.state.StateLoopProtocol; +import io.mapsmessaging.state.drone.core.TwinManager; +import io.mapsmessaging.state.drone.drone.DroneTwin; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +class MavlinkBootstrapRequestPublisherTest { + + @Test + void autopilot_version_request_is_published_to_the_recorded_mavlink_route() throws Exception { + TwinManager twinManager = new TwinManager(); + DroneTwin droneTwin = routedTwin(); + twinManager.registerTwin(droneTwin, null); + + StateLoopProtocol protocol = mock(StateLoopProtocol.class); + Session session = mock(Session.class); + Destination destination = mock(Destination.class); + when(protocol.getSession()).thenReturn(session); + when(session.findDestination("/mavlink/out", DestinationType.TOPIC)).thenReturn(CompletableFuture.completedFuture(destination)); + + new MavlinkBootstrapRequestPublisher(twinManager, protocol).publish( + MavlinkBootstrapEvent.requestMessage(droneTwin.getTwinId(), 3, 1, DroneTwinMissingState.MISSING_AUTOPILOT_VERSION, AUTOPILOT_VERSION) + ); + + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Message.class); + verify(destination).storeMessage(messageCaptor.capture()); + Message message = messageCaptor.getValue(); + JsonObject command = JsonParser.parseString(new String(message.getOpaqueData(), StandardCharsets.UTF_8)).getAsJsonObject(); + + assertEquals("ID#42#/127.0.0.1:14550", new String(message.getCorrelationData(), StandardCharsets.UTF_8)); + assertEquals(76, command.getAsJsonObject("header").get("messageId").getAsInt()); + assertEquals(3, command.getAsJsonObject("payload").get("target_system").getAsInt()); + assertEquals(1, command.getAsJsonObject("payload").get("target_component").getAsInt()); + assertEquals(MAV_CMD_REQUEST_MESSAGE, command.getAsJsonObject("payload").get("command").getAsInt()); + assertEquals(AUTOPILOT_VERSION, command.getAsJsonObject("payload").get("param1").getAsInt()); + } + + @Test + void non_autopilot_bootstrap_requests_remain_dormant() { + TwinManager twinManager = new TwinManager(); + DroneTwin droneTwin = routedTwin(); + twinManager.registerTwin(droneTwin, null); + StateLoopProtocol protocol = mock(StateLoopProtocol.class); + + new MavlinkBootstrapRequestPublisher(twinManager, protocol).publish( + MavlinkBootstrapEvent.requestMessage(droneTwin.getTwinId(), 3, 1, DroneTwinMissingState.MISSING_BATTERY_STATE, BATTERY_STATUS) + ); + + verify(protocol, never()).getSession(); + } + + @Test + void autopilot_version_request_without_a_response_route_is_not_published() { + TwinManager twinManager = new TwinManager(); + DroneTwin droneTwin = new DroneTwin("drone-3"); + twinManager.registerTwin(droneTwin, null); + StateLoopProtocol protocol = mock(StateLoopProtocol.class); + + new MavlinkBootstrapRequestPublisher(twinManager, protocol).publish( + MavlinkBootstrapEvent.requestMessage(droneTwin.getTwinId(), 3, 1, DroneTwinMissingState.MISSING_AUTOPILOT_VERSION, AUTOPILOT_VERSION) + ); + + verify(protocol, never()).getSession(); + } + + private DroneTwin routedTwin() { + DroneTwin droneTwin = new DroneTwin("drone-3"); + droneTwin.setResponseTopicName("/mavlink/out"); + droneTwin.setUniqueOutboundIdentifier("ID#42#/127.0.0.1:14550"); + return droneTwin; + } +} diff --git a/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java index 08ee7e748..2c810898c 100644 --- a/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java +++ b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java @@ -30,6 +30,7 @@ import java.util.List; import java.util.Set; +import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.AUTOPILOT_VERSION; import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.BATTERY_STATUS; import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.GLOBAL_POSITION_INT; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -94,6 +95,35 @@ void unchanged_partial_state_waits_for_the_full_retry_interval() { assertEquals(1, requests(stateEngine.update(droneTwin, result, contextAt(2))).size()); } + @Test + void missing_autopilot_version_and_capabilities_share_one_request() { + DroneTwinReadinessResult result = result( + DroneTwinReadinessState.CAPABILITY_PARTIAL, + false, + DroneTwinMissingState.MISSING_AUTOPILOT_VERSION, + DroneTwinMissingState.MISSING_CAPABILITIES + ); + + List initialRequests = requests(stateEngine.update(droneTwin, result, contextAt(0))); + List retryRequests = requests(stateEngine.update(droneTwin, result, contextAt(2))); + + assertEquals(1, initialRequests.size()); + assertEquals(AUTOPILOT_VERSION, initialRequests.getFirst().getMavlinkMessageId()); + assertEquals(MavlinkBootstrapRequestType.REQUEST_MESSAGE, initialRequests.getFirst().getRequestType()); + assertEquals(1, retryRequests.size()); + assertEquals(AUTOPILOT_VERSION, retryRequests.getFirst().getMavlinkMessageId()); + } + + @Test + void missing_capabilities_alone_requests_autopilot_version() { + DroneTwinReadinessResult result = result(DroneTwinReadinessState.CAPABILITY_PARTIAL, false, DroneTwinMissingState.MISSING_CAPABILITIES); + + List emittedRequests = requests(stateEngine.update(droneTwin, result, contextAt(0))); + + assertEquals(1, emittedRequests.size()); + assertEquals(AUTOPILOT_VERSION, emittedRequests.getFirst().getMavlinkMessageId()); + } + @Test void retries_exhaust_then_timeout_once_without_sleeps() { DroneTwinReadinessResult result = result( diff --git a/src/test/java/io/mapsmessaging/state/mavlink/listener/AutopilotVersionListenerTest.java b/src/test/java/io/mapsmessaging/state/mavlink/listener/AutopilotVersionListenerTest.java new file mode 100644 index 000000000..2597f09d4 --- /dev/null +++ b/src/test/java/io/mapsmessaging/state/mavlink/listener/AutopilotVersionListenerTest.java @@ -0,0 +1,68 @@ +/* + * + * Copyright [ 2020 - 2024 ] Matthew Buckton + * 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/ + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.mapsmessaging.state.mavlink.listener; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.mapsmessaging.state.drone.core.TwinManager; +import io.mapsmessaging.state.drone.drone.DroneTwin; +import io.mapsmessaging.state.drone.model.autopilot.GenericAutopilotState; +import io.mapsmessaging.state.mavlink.bootstrap.DroneTwinMissingState; +import io.mapsmessaging.state.mavlink.bootstrap.DroneTwinReadinessEvaluator; +import io.mapsmessaging.state.mavlink.bootstrap.DroneTwinReadinessResult; +import io.mapsmessaging.state.mavlink.packet.AutopilotVersionPacket; +import org.junit.jupiter.api.Test; + +class AutopilotVersionListenerTest { + + @Test + void autopilot_version_response_populates_version_and_capabilities() { + TwinManager twinManager = new TwinManager(); + DroneTwin droneTwin = new DroneTwin("drone-1"); + GenericAutopilotState autopilotState = new GenericAutopilotState(); + autopilotState.setAutopilotType("ARDUPILOTMEGA"); + droneTwin.setAutopilotState(autopilotState); + twinManager.registerTwin(droneTwin, null); + + DroneTwinReadinessResult before = new DroneTwinReadinessEvaluator().evaluate(droneTwin, null); + assertTrue(before.getMissingStates().contains(DroneTwinMissingState.MISSING_AUTOPILOT_VERSION)); + assertTrue(before.getMissingStates().contains(DroneTwinMissingState.MISSING_CAPABILITIES)); + + AutopilotVersionPacket packet = mock(AutopilotVersionPacket.class); + when(packet.isValid()).thenReturn(true); + when(packet.getUid()).thenReturn(0x1234L); + when(packet.getFlightSoftwareVersion()).thenReturn(0x040500FFL); + when(packet.getMiddlewareSoftwareVersion()).thenReturn(0x010200FFL); + when(packet.getOsSoftwareVersion()).thenReturn(0x060100FFL); + when(packet.getCapabilities()).thenReturn(59_647L); + + new AutopilotVersionListener(twinManager).handle(droneTwin.getTwinId(), packet, null); + + DroneTwinReadinessResult after = new DroneTwinReadinessEvaluator().evaluate(droneTwin, null); + assertEquals(0x1234L, droneTwin.getAutopilotState().getUid()); + assertEquals(59_647L, droneTwin.getAutopilotState().getCapabilities()); + assertFalse(after.getMissingStates().contains(DroneTwinMissingState.MISSING_AUTOPILOT_VERSION)); + assertFalse(after.getMissingStates().contains(DroneTwinMissingState.MISSING_CAPABILITIES)); + } +} diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApiTest.java b/src/test/java/io/mapsmessaging/state/rest/twins/TwinConfigurationApiTest.java similarity index 99% rename from src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApiTest.java rename to src/test/java/io/mapsmessaging/state/rest/twins/TwinConfigurationApiTest.java index 7fd7dfbc6..c71e21723 100644 --- a/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationApiTest.java +++ b/src/test/java/io/mapsmessaging/state/rest/twins/TwinConfigurationApiTest.java @@ -17,7 +17,7 @@ * limitations under the License. */ -package io.mapsmessaging.rest.api.impl.twins; +package io.mapsmessaging.state.rest.twins; import io.mapsmessaging.rest.ApiTestBase; import io.restassured.http.ContentType; diff --git a/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStoreTest.java b/src/test/java/io/mapsmessaging/state/rest/twins/TwinConfigurationStoreTest.java similarity index 98% rename from src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStoreTest.java rename to src/test/java/io/mapsmessaging/state/rest/twins/TwinConfigurationStoreTest.java index 0cd021e50..1a93b6a66 100644 --- a/src/test/java/io/mapsmessaging/rest/api/impl/twins/TwinConfigurationStoreTest.java +++ b/src/test/java/io/mapsmessaging/state/rest/twins/TwinConfigurationStoreTest.java @@ -17,7 +17,7 @@ * limitations under the License. */ -package io.mapsmessaging.rest.api.impl.twins; +package io.mapsmessaging.state.rest.twins; import io.mapsmessaging.configuration.ConfigurationProperties; import io.mapsmessaging.dto.rest.config.protocol.impl.TakProtocolDTO; @@ -26,6 +26,8 @@ import io.mapsmessaging.state.config.TwinManagerConfig; import io.mapsmessaging.state.config.TwinPublishConfigDTO; import io.mapsmessaging.state.config.n2k.N2KTwinConfig; +import io.mapsmessaging.state.rest.twins.TwinConfigurationStore; +import io.mapsmessaging.state.rest.twins.TwinCoreConfigDTO; import org.junit.jupiter.api.Test; import java.io.IOException;