From 64b1b33798fa1089b869843682433162bbdea6ce Mon Sep 17 00:00:00 2001
From: Matthew Buckton
Date: Mon, 10 Aug 2026 09:52:25 +1000
Subject: [PATCH] fix(mavlink): harden twin state management
---
.../state/mavlink/MavlinkDroneMonitor.java | 124 +++++--
.../state/mavlink/MavlinkStateSubscriber.java | 120 ++++++-
.../state/mavlink/MavlinkTwinUpdater.java | 111 ++++---
.../DroneTwinReadinessEvaluator.java | 18 +-
.../MavlinkBootstrapStateEngine.java | 16 +-
.../checks/BatteryReadinessCheck.java | 24 +-
.../checks/GlobalPositionReadinessCheck.java | 23 +-
.../bootstrap/checks/GpsReadinessCheck.java | 17 +-
.../state/mavlink/packet/MavlinkPacket.java | 28 ++
.../mavlink/MavlinkDroneMonitorTest.java | 190 +++++++++++
.../mavlink/MavlinkSourceRegistryTest.java | 96 ++++++
.../mavlink/MavlinkStateSubscriberTest.java | 150 +++++++++
.../state/mavlink/MavlinkTwinUpdaterTest.java | 282 ++++++++++++++++
.../DroneTwinReadinessEvaluatorTest.java | 303 ++++++++++++++++++
.../MavlinkBootstrapRequestTrackerTest.java | 114 +++++++
.../MavlinkBootstrapStateEngineTest.java | 303 ++++++++++++++++++
.../mavlink/packet/MavlinkPacketTest.java | 23 ++
17 files changed, 1863 insertions(+), 79 deletions(-)
create mode 100644 src/test/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitorTest.java
create mode 100644 src/test/java/io/mapsmessaging/state/mavlink/MavlinkSourceRegistryTest.java
create mode 100644 src/test/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriberTest.java
create mode 100644 src/test/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdaterTest.java
create mode 100644 src/test/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluatorTest.java
create mode 100644 src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestTrackerTest.java
create mode 100644 src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitor.java b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitor.java
index b956bdfb4..03f684623 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitor.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitor.java
@@ -19,12 +19,26 @@
package io.mapsmessaging.state.mavlink;
-import io.mapsmessaging.state.drone.core.*;
+import io.mapsmessaging.state.drone.core.EntityTwin;
+import io.mapsmessaging.state.drone.core.TwinLifecycleStatus;
+import io.mapsmessaging.state.drone.core.TwinManager;
+import io.mapsmessaging.state.drone.core.TwinObserver;
+import io.mapsmessaging.state.drone.core.TwinRelationship;
+import io.mapsmessaging.state.drone.core.TwinUpdateContext;
import io.mapsmessaging.state.drone.drone.DroneTwin;
-import io.mapsmessaging.state.mavlink.bootstrap.*;
+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.bootstrap.MavlinkBootstrapEvent;
+import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapEventPublisher;
+import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapStateEngine;
import java.util.List;
+import java.util.Map;
import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Observes MAVLink-backed drone twins and drives MAVLink bootstrap/readiness evaluation.
@@ -33,12 +47,15 @@
* updates readiness fields on the twin, and publishes bootstrap events for another
* component to translate into MAVLink commands.
*/
-public class MavlinkDroneMonitor implements TwinObserver {
+public class MavlinkDroneMonitor implements TwinObserver, AutoCloseable {
private final TwinManager twinManager;
private final DroneTwinReadinessEvaluator readinessEvaluator;
private final MavlinkBootstrapStateEngine bootstrapStateEngine;
private final MavlinkBootstrapEventPublisher bootstrapEventPublisher;
+ private final Set readinessUpdates;
+ private final Map deferredUpdates;
+ private final AtomicBoolean closed;
public MavlinkDroneMonitor(
TwinManager twinManager,
@@ -50,6 +67,9 @@ public MavlinkDroneMonitor(
this.readinessEvaluator = readinessEvaluator;
this.bootstrapStateEngine = bootstrapStateEngine;
this.bootstrapEventPublisher = bootstrapEventPublisher;
+ this.readinessUpdates = ConcurrentHashMap.newKeySet();
+ this.deferredUpdates = new ConcurrentHashMap<>();
+ this.closed = new AtomicBoolean();
}
@Override
@@ -64,8 +84,15 @@ public void onTwinUpdated(String twinId, EntityTwin current, TwinUpdateContext c
@Override
public void onTwinRemoved(EntityTwin removed, TwinUpdateContext context) {
+ if (closed.get()) {
+ return;
+ }
+
if (removed != null && removed.getTwinId() != null) {
- bootstrapStateEngine.remove(removed.getTwinId());
+ String twinId = removed.getTwinId();
+ deferredUpdates.remove(twinId);
+ readinessUpdates.remove(twinId);
+ bootstrapStateEngine.remove(twinId);
}
}
@@ -98,7 +125,55 @@ public void onRelationshipRemoved(
// no-op
}
+ @Override
+ public void close() {
+ if (closed.compareAndSet(false, true)) {
+ twinManager.removeObserver(this);
+ readinessUpdates.clear();
+ deferredUpdates.clear();
+ }
+ }
+
+ void beginTwinUpdate(String twinId) {
+ if (closed.get() || twinId == null) {
+ return;
+ }
+
+ deferredUpdates.compute(twinId, (key, depth) -> {
+ if (depth == null) {
+ return new AtomicInteger(1);
+ }
+ depth.incrementAndGet();
+ return depth;
+ });
+ }
+
+ void endTwinUpdate(String twinId, TwinUpdateContext context) {
+ if (twinId == null) {
+ return;
+ }
+
+ AtomicBoolean evaluate = new AtomicBoolean();
+ deferredUpdates.computeIfPresent(twinId, (key, depth) -> {
+ if (depth.decrementAndGet() <= 0) {
+ evaluate.set(true);
+ return null;
+ }
+ return depth;
+ });
+
+ if (!evaluate.get() || closed.get()) {
+ return;
+ }
+
+ twinManager.getTwin(twinId).ifPresent(twin -> evaluateTwin(twin, context));
+ }
+
private void evaluateTwin(EntityTwin twin, TwinUpdateContext context) {
+ if (closed.get()) {
+ return;
+ }
+
if (!(twin instanceof DroneTwin droneTwin)) {
return;
}
@@ -107,10 +182,16 @@ private void evaluateTwin(EntityTwin twin, TwinUpdateContext context) {
return;
}
+ String twinId = droneTwin.getTwinId();
+ if (twinId != null
+ && (readinessUpdates.contains(twinId) || deferredUpdates.containsKey(twinId))) {
+ return;
+ }
+
DroneTwinReadinessResult readinessResult = readinessEvaluator.evaluate(droneTwin, context);
updateReadinessIfChanged(droneTwin, readinessResult, context);
List events = bootstrapStateEngine.update(droneTwin, readinessResult, context);
- if(bootstrapEventPublisher != null) {
+ if (bootstrapEventPublisher != null && !closed.get()) {
for (MavlinkBootstrapEvent event : events) {
bootstrapEventPublisher.publish(event);
}
@@ -134,19 +215,26 @@ private void updateReadinessIfChanged(
}
String twinId = droneTwin.getTwinId();
+ if (twinId == null || !readinessUpdates.add(twinId)) {
+ return;
+ }
- twinManager.updateTwin(twinId, twin -> {
- DroneTwin updatedDroneTwin = (DroneTwin) twin;
-
- updatedDroneTwin.setReadinessState(readinessResult.getReadinessState().name());
- updatedDroneTwin.setRegistrationReady(readinessResult.isRegistrationReady());
- updatedDroneTwin.setCommandReady(readinessResult.isCommandReady());
- updatedDroneTwin.setMissingReadinessItems(toNames(readinessResult.getMissingStates()));
- updatedDroneTwin.setDegradedReadinessItems(toNames(readinessResult.getDegradedStates()));
- updatedDroneTwin.setBlockingReadinessItems(toNames(readinessResult.getBlockingStates()));
- updatedDroneTwin.setReadinessUpdatedAt(readinessResult.getEvaluatedAt());
-
- }, context);
+ try {
+ twinManager.updateTwin(twinId, twin -> {
+ DroneTwin updatedDroneTwin = (DroneTwin) twin;
+
+ updatedDroneTwin.setReadinessState(readinessResult.getReadinessState().name());
+ updatedDroneTwin.setRegistrationReady(readinessResult.isRegistrationReady());
+ updatedDroneTwin.setCommandReady(readinessResult.isCommandReady());
+ updatedDroneTwin.setMissingReadinessItems(toNames(readinessResult.getMissingStates()));
+ updatedDroneTwin.setDegradedReadinessItems(toNames(readinessResult.getDegradedStates()));
+ updatedDroneTwin.setBlockingReadinessItems(toNames(readinessResult.getBlockingStates()));
+ updatedDroneTwin.setReadinessUpdatedAt(readinessResult.getEvaluatedAt());
+
+ }, context);
+ } finally {
+ readinessUpdates.remove(twinId);
+ }
}
private boolean hasReadinessChanged(
@@ -207,4 +295,4 @@ private boolean equalsNullable(Object left, Object right) {
return left.equals(right);
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java
index 3356bdeb5..9fa2c11d5 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriber.java
@@ -53,11 +53,27 @@
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.Objects;
import java.util.UUID;
-import static io.mapsmessaging.state.logging.StateLogMessages.*;
-
-public class MavlinkStateSubscriber implements MessageHandler {
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_CORRELATION_DATA_MISSING;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_DRONE_NOT_CONFIGURED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_EMPTY_MESSAGE_IGNORED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_JSON_PARSE_FAILED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_MAVLINK_OBJECT_MISSING;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_PAYLOAD_OBJECT_MISSING;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_PROCESSING_FAILED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_SOURCE_NOT_CONFIGURED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_SUBSCRIBER_START_FAILED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_SUBSCRIBER_STARTED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_SUBSCRIBER_STARTING;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_SUBSCRIBER_STOP_FAILED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_SUBSCRIBER_STOPPED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_SUBSCRIBER_STOPPING;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_TWIN_UPDATE_FAILED;
+import static io.mapsmessaging.state.logging.StateLogMessages.MAVLINK_STATE_UNSUPPORTED_PACKET_IGNORED;
+
+public class MavlinkStateSubscriber implements MessageHandler, AutoCloseable {
private final Logger logger = LoggerFactory.getLogger(MavlinkStateSubscriber.class);
@@ -67,6 +83,9 @@ public class MavlinkStateSubscriber implements MessageHandler {
private final DroneInfoRegistry droneRegistry;
private final MavlinkTwinUpdater twinUpdater;
+ private volatile boolean started;
+ private volatile boolean closed;
+
public MavlinkStateSubscriber(@NonNull @NotNull TwinManager twinManager, @NonNull @NotNull MavlinkTwinConfigDTO mavlinkConfig, @NonNull @NotNull DroneInfoRegistry registry) {
this.protocol = SessionHelper.createLoopbackProtocol(this);
this.namespaceTopicPath = mavlinkConfig.getTopic();
@@ -75,34 +94,115 @@ public MavlinkStateSubscriber(@NonNull @NotNull TwinManager twinManager, @NonNul
this.twinUpdater = new MavlinkTwinUpdater(twinManager, new ListenerManager(twinManager));
}
- public void start() throws IOException {
+ MavlinkStateSubscriber(
+ StateLoopProtocol protocol,
+ String namespaceTopicPath,
+ MavlinkSourceRegistry sourceRegistry,
+ DroneInfoRegistry droneRegistry,
+ MavlinkTwinUpdater twinUpdater
+ ) {
+ this.protocol = Objects.requireNonNull(protocol, "protocol must not be null");
+ this.namespaceTopicPath = Objects.requireNonNull(namespaceTopicPath, "namespaceTopicPath must not be null");
+ this.sourceRegistry = Objects.requireNonNull(sourceRegistry, "sourceRegistry must not be null");
+ this.droneRegistry = Objects.requireNonNull(droneRegistry, "droneRegistry must not be null");
+ this.twinUpdater = Objects.requireNonNull(twinUpdater, "twinUpdater must not be null");
+ }
+
+ public synchronized void start() throws IOException {
+ if (closed) {
+ throw new IllegalStateException("MAVLink state subscriber is closed");
+ }
+
+ if (started) {
+ return;
+ }
+
logger.log(MAVLINK_STATE_SUBSCRIBER_STARTING, namespaceTopicPath);
try {
protocol.connect(UUID.randomUUID().toString(), "anonymous", "anonymous");
protocol.subscribeLocal(namespaceTopicPath, namespaceTopicPath, QualityOfService.AT_MOST_ONCE, null, null, null, null, null);
+ started = true;
logger.log(MAVLINK_STATE_SUBSCRIBER_STARTED, namespaceTopicPath);
} catch (IOException exception) {
+ closed = true;
+ try {
+ protocol.close();
+ } catch (IOException closeException) {
+ exception.addSuppressed(closeException);
+ }
+ try {
+ twinUpdater.close();
+ } catch (RuntimeException closeException) {
+ exception.addSuppressed(closeException);
+ }
logger.log(MAVLINK_STATE_SUBSCRIBER_START_FAILED, exception, namespaceTopicPath);
throw exception;
}
}
- public void stop() throws IOException {
+ public synchronized void stop() throws IOException {
+ if (closed) {
+ return;
+ }
+
+ closed = true;
logger.log(MAVLINK_STATE_SUBSCRIBER_STOPPING, namespaceTopicPath);
+ Throwable failure = null;
+ if (started) {
+ try {
+ protocol.unsubscribeLocal(namespaceTopicPath);
+ } catch (RuntimeException exception) {
+ failure = exception;
+ }
+ }
+
try {
- protocol.unsubscribeLocal(namespaceTopicPath);
protocol.close();
- logger.log(MAVLINK_STATE_SUBSCRIBER_STOPPED, namespaceTopicPath);
} catch (IOException exception) {
- logger.log(MAVLINK_STATE_SUBSCRIBER_STOP_FAILED, exception, namespaceTopicPath);
- throw exception;
+ if (failure == null) {
+ failure = exception;
+ } else {
+ failure.addSuppressed(exception);
+ }
}
+
+ started = false;
+
+ try {
+ twinUpdater.close();
+ } catch (RuntimeException exception) {
+ if (failure == null) {
+ failure = exception;
+ } else {
+ failure.addSuppressed(exception);
+ }
+ }
+
+ if (failure != null) {
+ logger.log(MAVLINK_STATE_SUBSCRIBER_STOP_FAILED, failure, namespaceTopicPath);
+ if (failure instanceof IOException ioException) {
+ throw ioException;
+ }
+ throw (RuntimeException) failure;
+ }
+
+ logger.log(MAVLINK_STATE_SUBSCRIBER_STOPPED, namespaceTopicPath);
+ }
+
+ @Override
+ public void close() throws IOException {
+ stop();
}
@Override
public void handle(@NonNull @NotNull MessageEvent messageEvent) {
+ if (closed) {
+ messageEvent.getCompletionTask().run();
+ return;
+ }
+
String sourceName = messageEvent.getDestinationName();
Integer messageId = null;
String droneName = null;
@@ -224,4 +324,4 @@ private ProcessedFrame parseJson(byte[] opaqueData, String sourceName) {
return null;
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java
index 3803ab608..c26e862af 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdater.java
@@ -32,61 +32,105 @@
import io.mapsmessaging.state.drone.core.TwinManager;
import io.mapsmessaging.state.drone.core.TwinUpdateContext;
import io.mapsmessaging.state.drone.drone.DroneTwin;
-import io.mapsmessaging.state.drone.model.Contact;
import io.mapsmessaging.state.drone.model.DetectionEvent;
import io.mapsmessaging.state.drone.model.DroneContactManager;
-import io.mapsmessaging.state.drone.model.GeoPosition;
import io.mapsmessaging.state.mavlink.bootstrap.DroneTwinReadinessEvaluator;
import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapProfile;
import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapStateEngine;
import io.mapsmessaging.state.mavlink.listener.ListenerManager;
import io.mapsmessaging.state.mavlink.model.ModelManager;
import io.mapsmessaging.state.mavlink.model.UxvModel;
+import io.mapsmessaging.state.mavlink.packet.BatteryStatusPacket;
import io.mapsmessaging.state.mavlink.packet.MavlinkPacket;
import io.mapsmessaging.state.mavlink.sender.MavlinkEventListSender;
+import java.util.Objects;
import java.util.Optional;
+import java.util.concurrent.atomic.AtomicBoolean;
import lombok.NonNull;
import org.jetbrains.annotations.NotNull;
-public class MavlinkTwinUpdater {
+public class MavlinkTwinUpdater implements AutoCloseable {
private final Logger logger = LoggerFactory.getLogger(MavlinkTwinUpdater.class);
private final TwinManager twinManager;
private final ListenerManager listenerManager;
private final MavlinkDroneMonitor droneMonitor;
+ private final AtomicBoolean closed;
public MavlinkTwinUpdater(@NonNull @NotNull TwinManager twinManager, @NonNull @NotNull ListenerManager listenerManager) {
this.twinManager = twinManager;
this.listenerManager = listenerManager;
this.droneMonitor = new MavlinkDroneMonitor(twinManager, new DroneTwinReadinessEvaluator(), new MavlinkBootstrapStateEngine(new MavlinkBootstrapProfile()), null);
+ this.closed = new AtomicBoolean();
+ twinManager.addObserver(droneMonitor);
+ }
+
+ MavlinkTwinUpdater(
+ TwinManager twinManager,
+ ListenerManager listenerManager,
+ MavlinkDroneMonitor droneMonitor
+ ) {
+ this.twinManager = Objects.requireNonNull(twinManager, "twinManager must not be null");
+ this.listenerManager = Objects.requireNonNull(listenerManager, "listenerManager must not be null");
+ this.droneMonitor = Objects.requireNonNull(droneMonitor, "droneMonitor must not be null");
+ this.closed = new AtomicBoolean();
twinManager.addObserver(droneMonitor);
}
public void updateTwinState(@NonNull @NotNull ProcessedFrame env, @NonNull @NotNull MavlinkPacket packet, @NonNull @NotNull TwinUpdateContext context, @NonNull @NotNull MavlinkKnownSourceDTO knownSource, DroneInfoDTO droneInfo) {
+ if (closed.get()) {
+ return;
+ }
+
String twinId = buildTwinId(env, knownSource);
- EntityTwin entityTwin = twinManager.getTwin(twinId).orElseGet(() -> createTwin(twinId, env, context, knownSource, droneInfo));
- twinManager.updateTwin(
- twinId,
- twinToUpdate -> {
- if (twinToUpdate instanceof DroneTwin drone) {
- drone.setSystemId(env.getFrame().getSystemId());
- drone.setComponentId(env.getFrame().getComponentId());
- updateTwinResponseTopic(twinToUpdate, context.getResponseTopic());
- drone.setUniqueOutboundIdentifier(context.getUniqueOutboundIdentifier());
- }
- },
- context
- );
+ droneMonitor.beginTwinUpdate(twinId);
+ try {
+ EntityTwin entityTwin = twinManager.getTwin(twinId).orElseGet(() -> createTwin(twinId, env, context, knownSource, droneInfo));
+ twinManager.updateTwin(
+ twinId,
+ twinToUpdate -> {
+ if (twinToUpdate instanceof DroneTwin drone) {
+ drone.setSystemId(env.getFrame().getSystemId());
+ drone.setComponentId(env.getFrame().getComponentId());
+ updateTwinResponseTopic(twinToUpdate, context.getResponseTopic());
+ drone.setUniqueOutboundIdentifier(context.getUniqueOutboundIdentifier());
+ updateMessageFreshness(drone, packet, context);
+ }
+ },
+ context
+ );
+
+ listenerManager.handle(env.getFrame().getMessageId(), twinId, packet, context);
+
+ if (entityTwin instanceof DroneTwin droneTwin) {
+ MavlinkEventListSender sender = droneTwin.getActiveMavlinkSender();
+ if (sender != null) {
+ sender.onMavlinkMessage(packet);
+ }
+ applyModelDetectionEvent(droneTwin, packet);
+ }
+ } finally {
+ droneMonitor.endTwinUpdate(twinId, context);
+ }
+ }
- listenerManager.handle(env.getFrame().getMessageId(), twinId, packet, context);
+ @Override
+ public void close() {
+ if (closed.compareAndSet(false, true)) {
+ droneMonitor.close();
+ }
+ }
- if (entityTwin instanceof DroneTwin droneTwin) {
- MavlinkEventListSender sender = droneTwin.getActiveMavlinkSender();
- if(sender != null){
- sender.onMavlinkMessage(packet);
- }
- applyModelDetectionEvent(droneTwin, packet);
+ private void updateMessageFreshness(
+ DroneTwin droneTwin,
+ MavlinkPacket packet,
+ TwinUpdateContext context
+ ) {
+ if (packet instanceof BatteryStatusPacket batteryStatusPacket
+ && batteryStatusPacket.isValid()
+ && context.getReceivedTime() != null) {
+ droneTwin.setPowerUpdatedAt(context.getReceivedTime());
}
}
@@ -98,7 +142,7 @@ private void applyModelDetectionEvent(DroneTwin droneTwin, MavlinkPacket packet)
try {
UxvModel uxvModel = ModelManager.getInstance().getRequiredModel(modelName);
- if(uxvModel != null) {
+ if (uxvModel != null) {
Optional detectionEvent = uxvModel.interpretDetection(droneTwin, packet);
detectionEvent.ifPresent(event -> applyDetectionEvent(droneTwin, event));
}
@@ -141,15 +185,7 @@ private void upsertContact(DroneContactManager contactManager, DetectionEvent ev
return;
}
- GeoPosition position = event.getPosition();
- String name = event.getName();
-
- if (contactManager.hasContact(event.getContactId())) {
- contactManager.updateContact(event.getContactId(), name, position, ttlMillis);
- } else {
- Contact contact = new Contact(name, position, ttlMillis);
- contactManager.addContact(contact);
- }
+ contactManager.updateContact(event.getContactId(), event.getName(), event.getPosition(), ttlMillis);
}
private void removeContact(DroneContactManager contactManager, DetectionEvent event) {
@@ -168,10 +204,9 @@ private EntityTwin createTwin(String twinId, ProcessedFrame env, TwinUpdateConte
droneTwin.setComponentId(env.getFrame().getComponentId());
droneTwin.setModelName(droneInfo.getModelName());
droneTwin.setSurveyRadiusMeters(droneInfo.getSurveyRadiusMeters());
- if(droneInfo.getStopAction() != null) {
+ if (droneInfo.getStopAction() != null) {
droneTwin.setStopAction(droneInfo.getStopAction());
- }
- else{
+ } else {
droneTwin.setStopAction(StopActionEnum.STOP);
}
if (droneInfo.getCapabilities() != null) {
@@ -185,7 +220,7 @@ private EntityTwin createTwin(String twinId, ProcessedFrame env, TwinUpdateConte
}
- twinManager.registerTwin(droneTwin, context);
+ EntityTwin registeredTwin = twinManager.registerTwin(droneTwin, context);
logger.log(
MAVLINK_STATE_TWIN_CREATED,
@@ -194,7 +229,7 @@ private EntityTwin createTwin(String twinId, ProcessedFrame env, TwinUpdateConte
env.getFrame().getComponentId()
);
- return droneTwin;
+ return registeredTwin;
}
private VehicleClass resolveVehicleClass(MavlinkKnownSourceDTO knownSource) {
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluator.java b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluator.java
index ada26b3ed..3d4063e35 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluator.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluator.java
@@ -22,7 +22,17 @@
import io.mapsmessaging.state.drone.core.TwinLifecycleStatus;
import io.mapsmessaging.state.drone.core.TwinUpdateContext;
import io.mapsmessaging.state.drone.drone.DroneTwin;
-import io.mapsmessaging.state.mavlink.bootstrap.checks.*;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.AutopilotReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.BatteryReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.CapabilitiesReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.ConnectivityReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.DroneTwinReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.GlobalPositionReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.GpsReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.HomePositionReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.IdentityReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.LifecycleReadinessCheck;
+import io.mapsmessaging.state.mavlink.bootstrap.checks.SystemStateReadinessCheck;
import java.time.Duration;
import java.time.Instant;
@@ -159,6 +169,10 @@ private boolean isCommandReady(
return false;
}
+ if (missingStates.contains(DroneTwinMissingState.STALE_POWER)) {
+ return false;
+ }
+
return !missingStates.contains(DroneTwinMissingState.MISSING_CAPABILITIES);
}
@@ -223,4 +237,4 @@ private Instant getNow(TwinUpdateContext context) {
return Instant.now();
}
-}
\ No newline at end of file
+}
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 4516e2362..3551268b8 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngine.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngine.java
@@ -26,6 +26,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class MavlinkBootstrapStateEngine {
@@ -52,6 +53,7 @@ public synchronized List update(DroneTwin droneTwin, Dron
MavlinkBootstrapState bootstrapState = bootstrapStates.computeIfAbsent(twinId, MavlinkBootstrapState::new);
Instant now = getNow(context);
+ resetResolvedTrackers(bootstrapState, readinessResult.getMissingStates());
emitReadinessChangeIfRequired(bootstrapState, readinessResult, events);
bootstrapState.setUpdatedAt(now);
@@ -78,6 +80,18 @@ public void remove(String twinId) {
}
}
+ private void resetResolvedTrackers(
+ MavlinkBootstrapState bootstrapState,
+ Set missingStates
+ ) {
+ Map requestTrackers = bootstrapState.getRequestTrackers();
+ if (requestTrackers == null || requestTrackers.isEmpty() || missingStates == null) {
+ return;
+ }
+
+ requestTrackers.keySet().removeIf(missingState -> !missingStates.contains(missingState));
+ }
+
private void emitReadinessChangeIfRequired(
MavlinkBootstrapState bootstrapState,
DroneTwinReadinessResult readinessResult,
@@ -231,4 +245,4 @@ private Instant getNow(TwinUpdateContext context) {
}
return Instant.now();
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/BatteryReadinessCheck.java b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/BatteryReadinessCheck.java
index b6581e7b4..9b48e2887 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/BatteryReadinessCheck.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/BatteryReadinessCheck.java
@@ -47,9 +47,7 @@ public void evaluate(
return;
}
- if (batteryState.getPercentage() == null
- && batteryState.getVoltageVolts() == null
- && batteryState.getCurrentAmps() == null) {
+ if (!hasUsableMeasurement(batteryState)) {
evaluation.degraded(DroneTwinMissingState.MISSING_BATTERY_STATE);
}
@@ -58,6 +56,24 @@ public void evaluate(
}
}
+ private boolean hasUsableMeasurement(BatteryState batteryState) {
+ return isValidPercentage(batteryState.getPercentage())
+ || isValidVoltage(batteryState.getVoltageVolts())
+ || isFinite(batteryState.getCurrentAmps());
+ }
+
+ private boolean isValidPercentage(Double percentage) {
+ return isFinite(percentage) && percentage >= 0.0 && percentage <= 100.0;
+ }
+
+ private boolean isValidVoltage(Double voltage) {
+ return isFinite(voltage) && voltage > 0.0;
+ }
+
+ private boolean isFinite(Double value) {
+ return value != null && Double.isFinite(value);
+ }
+
private boolean isStale(Instant timestamp, Instant now) {
if (timestamp == null) {
return true;
@@ -65,4 +81,4 @@ private boolean isStale(Instant timestamp, Instant now) {
return Duration.between(timestamp, now).compareTo(powerFreshness) > 0;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GlobalPositionReadinessCheck.java b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GlobalPositionReadinessCheck.java
index 15c017b99..825b706a2 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GlobalPositionReadinessCheck.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GlobalPositionReadinessCheck.java
@@ -42,9 +42,7 @@ public void evaluate(
) {
GeoPosition geoPosition = droneTwin.getGeoPosition();
- if (geoPosition == null
- || geoPosition.getLatitude() == null
- || geoPosition.getLongitude() == null) {
+ if (!isValidPosition(geoPosition)) {
evaluation.blocking(DroneTwinMissingState.MISSING_GLOBAL_POSITION);
return;
}
@@ -54,6 +52,23 @@ public void evaluate(
}
}
+ private boolean isValidPosition(GeoPosition geoPosition) {
+ if (geoPosition == null) {
+ return false;
+ }
+
+ Double latitude = geoPosition.getLatitude();
+ Double longitude = geoPosition.getLongitude();
+ return latitude != null
+ && longitude != null
+ && Double.isFinite(latitude)
+ && Double.isFinite(longitude)
+ && latitude >= -90.0
+ && latitude <= 90.0
+ && longitude >= -180.0
+ && longitude <= 180.0;
+ }
+
private boolean isStale(Instant timestamp, Instant now) {
if (timestamp == null) {
return true;
@@ -61,4 +76,4 @@ private boolean isStale(Instant timestamp, Instant now) {
return Duration.between(timestamp, now).compareTo(positionFreshness) > 0;
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GpsReadinessCheck.java b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GpsReadinessCheck.java
index 65ae08b76..be302d191 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GpsReadinessCheck.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/bootstrap/checks/GpsReadinessCheck.java
@@ -24,6 +24,8 @@
import io.mapsmessaging.state.mavlink.bootstrap.DroneTwinMissingState;
import io.mapsmessaging.state.mavlink.bootstrap.DroneTwinReadinessEvaluation;
+import java.util.Locale;
+
public class GpsReadinessCheck implements DroneTwinReadinessCheck {
@Override
@@ -37,8 +39,19 @@ public void evaluate(
}
FixInfo fixInfo = droneTwin.getFixInfo();
- if (fixInfo == null || fixInfo.getFixType() == null) {
+ if (fixInfo == null || !isValidFixType(fixInfo.getFixType())) {
evaluation.blocking(DroneTwinMissingState.MISSING_GPS_FIX);
}
}
-}
\ No newline at end of file
+
+ private boolean isValidFixType(String fixType) {
+ if (fixType == null || fixType.isBlank()) {
+ return false;
+ }
+
+ return switch (fixType.trim().toUpperCase(Locale.ROOT)) {
+ case "NO_GPS", "NO_FIX", "UNKNOWN" -> false;
+ default -> true;
+ };
+ }
+}
diff --git a/src/main/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacket.java b/src/main/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacket.java
index c708065d4..3ff0a888e 100644
--- a/src/main/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacket.java
+++ b/src/main/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacket.java
@@ -19,6 +19,7 @@
package io.mapsmessaging.state.mavlink.packet;
+import java.util.List;
import java.util.Map;
public abstract class MavlinkPacket {
@@ -49,6 +50,18 @@ protected int[] getIntArray(Map fields, String key) {
return result;
}
+ if (value instanceof List> data) {
+ int[] result = new int[data.size()];
+ for (int index = 0; index < data.size(); index++) {
+ Object item = data.get(index);
+ if (!(item instanceof Number number)) {
+ return new int[0];
+ }
+ result[index] = number.intValue();
+ }
+ return result;
+ }
+
if (value instanceof Number number) {
return new int[] {number.intValue()};
}
@@ -123,6 +136,21 @@ protected String getString(Map fields, String key) {
return stringBuilder.toString();
}
+ if (value instanceof List> data) {
+ StringBuilder stringBuilder = new StringBuilder();
+ for (Object item : data) {
+ if (!(item instanceof Number number)) {
+ return String.valueOf(value);
+ }
+ int datum = number.intValue();
+ if (datum == 0) {
+ break;
+ }
+ stringBuilder.append((char) (datum & 0xFF));
+ }
+ return stringBuilder.toString();
+ }
+
return String.valueOf(value);
}
}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitorTest.java b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitorTest.java
new file mode 100644
index 000000000..03da3f72f
--- /dev/null
+++ b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkDroneMonitorTest.java
@@ -0,0 +1,190 @@
+/*
+ *
+ * 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;
+
+import io.mapsmessaging.state.drone.core.TwinManager;
+import io.mapsmessaging.state.drone.core.TwinUpdateContext;
+import io.mapsmessaging.state.drone.drone.DroneTwin;
+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.bootstrap.DroneTwinReadinessState;
+import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapEvent;
+import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapEventPublisher;
+import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapStateEngine;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+
+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.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MavlinkDroneMonitorTest {
+
+ private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z");
+
+ @Test
+ void repeated_registration_and_readiness_write_trigger_one_bootstrap_evaluation() {
+ TwinManager twinManager = new TwinManager(false, 10_000L, 5_000L, 120_000L, null);
+ DroneTwinReadinessEvaluator evaluator = mock(DroneTwinReadinessEvaluator.class);
+ MavlinkBootstrapStateEngine stateEngine = mock(MavlinkBootstrapStateEngine.class);
+ MavlinkBootstrapEventPublisher publisher = mock(MavlinkBootstrapEventPublisher.class);
+ MavlinkDroneMonitor monitor = new MavlinkDroneMonitor(twinManager, evaluator, stateEngine, publisher);
+ TwinUpdateContext context = context();
+ DroneTwinReadinessResult result = result(DroneTwinReadinessState.REGISTRATION_READY, true, false);
+ MavlinkBootstrapEvent event = MavlinkBootstrapEvent.readinessChanged(
+ "drone-1",
+ DroneTwinReadinessState.DISCOVERED,
+ DroneTwinReadinessState.REGISTRATION_READY
+ );
+ when(evaluator.evaluate(any(DroneTwin.class), same(context))).thenReturn(result);
+ when(stateEngine.update(any(DroneTwin.class), same(result), same(context))).thenReturn(List.of(event));
+
+ twinManager.addObserver(monitor);
+ twinManager.addObserver(monitor);
+ DroneTwin twin = mavlinkTwin();
+ twinManager.registerTwin(twin, context);
+
+ verify(evaluator, times(1)).evaluate(same(twin), same(context));
+ verify(stateEngine, times(1)).update(same(twin), same(result), same(context));
+ verify(publisher, times(1)).publish(same(event));
+ assertEquals(DroneTwinReadinessState.REGISTRATION_READY.name(), twin.getReadinessState());
+ assertTrue(twin.getRegistrationReady());
+ assertFalse(twin.getCommandReady());
+ }
+
+ @Test
+ void unchanged_readiness_does_not_write_twin_but_still_advances_bootstrap() {
+ TwinManager twinManager = mock(TwinManager.class);
+ DroneTwinReadinessEvaluator evaluator = mock(DroneTwinReadinessEvaluator.class);
+ MavlinkBootstrapStateEngine stateEngine = mock(MavlinkBootstrapStateEngine.class);
+ MavlinkBootstrapEventPublisher publisher = mock(MavlinkBootstrapEventPublisher.class);
+ MavlinkDroneMonitor monitor = new MavlinkDroneMonitor(twinManager, evaluator, stateEngine, publisher);
+ TwinUpdateContext context = context();
+ DroneTwin twin = mavlinkTwin();
+ DroneTwinReadinessResult result = result(DroneTwinReadinessState.COMMAND_READY, true, true);
+ twin.setReadinessState(DroneTwinReadinessState.COMMAND_READY.name());
+ twin.setRegistrationReady(true);
+ twin.setCommandReady(true);
+ twin.setMissingReadinessItems(List.of());
+ twin.setDegradedReadinessItems(List.of());
+ twin.setBlockingReadinessItems(List.of());
+ when(evaluator.evaluate(twin, context)).thenReturn(result);
+ when(stateEngine.update(twin, result, context)).thenReturn(List.of());
+
+ monitor.onTwinUpdated(twin.getTwinId(), twin, context);
+
+ verify(twinManager, never()).updateTwin(anyString(), any(), any());
+ verify(stateEngine).update(twin, result, context);
+ }
+
+ @Test
+ void removal_cancels_bootstrap_state() {
+ MavlinkBootstrapStateEngine stateEngine = mock(MavlinkBootstrapStateEngine.class);
+ MavlinkDroneMonitor monitor = new MavlinkDroneMonitor(
+ mock(TwinManager.class),
+ mock(DroneTwinReadinessEvaluator.class),
+ stateEngine,
+ null
+ );
+
+ monitor.onTwinRemoved(mavlinkTwin(), context());
+
+ verify(stateEngine).remove("drone-1");
+ }
+
+ @Test
+ void close_is_idempotent_and_late_callbacks_are_ignored() {
+ TwinManager twinManager = mock(TwinManager.class);
+ DroneTwinReadinessEvaluator evaluator = mock(DroneTwinReadinessEvaluator.class);
+ MavlinkBootstrapStateEngine stateEngine = mock(MavlinkBootstrapStateEngine.class);
+ MavlinkBootstrapEventPublisher publisher = mock(MavlinkBootstrapEventPublisher.class);
+ MavlinkDroneMonitor monitor = new MavlinkDroneMonitor(twinManager, evaluator, stateEngine, publisher);
+ DroneTwin twin = mavlinkTwin();
+
+ monitor.close();
+ monitor.close();
+ clearInvocations(evaluator, stateEngine, publisher);
+ monitor.onTwinUpdated(twin.getTwinId(), twin, context());
+ monitor.onTwinRemoved(twin, context());
+
+ verify(twinManager, times(1)).removeObserver(monitor);
+ verifyNoInteractions(evaluator, stateEngine, publisher);
+ }
+
+ @Test
+ void non_mavlink_twin_is_ignored() {
+ DroneTwinReadinessEvaluator evaluator = mock(DroneTwinReadinessEvaluator.class);
+ MavlinkBootstrapStateEngine stateEngine = mock(MavlinkBootstrapStateEngine.class);
+ MavlinkDroneMonitor monitor = new MavlinkDroneMonitor(
+ mock(TwinManager.class),
+ evaluator,
+ stateEngine,
+ null
+ );
+ DroneTwin twin = new DroneTwin("unidentified");
+
+ monitor.onTwinAdded(twin, context());
+
+ verifyNoInteractions(evaluator, stateEngine);
+ }
+
+ private DroneTwin mavlinkTwin() {
+ DroneTwin twin = new DroneTwin("drone-1");
+ twin.setSystemId(17);
+ twin.setComponentId(42);
+ return twin;
+ }
+
+ private TwinUpdateContext context() {
+ TwinUpdateContext context = new TwinUpdateContext();
+ context.setReceivedTime(NOW);
+ return context;
+ }
+
+ private DroneTwinReadinessResult result(
+ DroneTwinReadinessState state,
+ boolean registrationReady,
+ boolean commandReady
+ ) {
+ DroneTwinReadinessResult result = new DroneTwinReadinessResult("drone-1");
+ result.setReadinessState(state);
+ result.setRegistrationReady(registrationReady);
+ result.setCommandReady(commandReady);
+ result.setMissingStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setDegradedStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setBlockingStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setEvaluatedAt(NOW);
+ return result;
+ }
+}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/MavlinkSourceRegistryTest.java b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkSourceRegistryTest.java
new file mode 100644
index 000000000..9f7f61cd5
--- /dev/null
+++ b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkSourceRegistryTest.java
@@ -0,0 +1,96 @@
+/*
+ *
+ * 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;
+
+import io.mapsmessaging.dto.rest.config.protocol.impl.MavlinkKnownSourceDTO;
+import io.mapsmessaging.mavlink.ProcessedFrame;
+import io.mapsmessaging.state.config.MavlinkTwinConfigDTO;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class MavlinkSourceRegistryTest {
+
+ @Test
+ void null_or_empty_configuration_matches_no_sources() {
+ MavlinkTwinConfigDTO nullConfig = new MavlinkTwinConfigDTO();
+ nullConfig.setKnownSources(null);
+ assertNull(new MavlinkSourceRegistry(nullConfig).getKnownSource(frame(1, 1)));
+
+ MavlinkTwinConfigDTO emptyConfig = new MavlinkTwinConfigDTO();
+ assertNull(new MavlinkSourceRegistry(emptyConfig).getKnownSource(frame(1, 1)));
+ }
+
+ @Test
+ void exact_system_and_component_pair_matches_configured_source() {
+ MavlinkKnownSourceDTO knownSource = source("drone-1", 17, 42);
+ MavlinkTwinConfigDTO config = config(knownSource);
+
+ MavlinkKnownSourceDTO result = new MavlinkSourceRegistry(config).getKnownSource(frame(17, 42));
+
+ assertSame(knownSource, result);
+ }
+
+ @Test
+ void different_system_or_component_is_not_related() {
+ MavlinkKnownSourceDTO knownSource = source("drone-1", 17, 42);
+ MavlinkSourceRegistry registry = new MavlinkSourceRegistry(config(knownSource));
+
+ assertNull(registry.getKnownSource(frame(18, 42)));
+ assertNull(registry.getKnownSource(frame(17, 43)));
+ }
+
+ @Test
+ void duplicate_source_key_uses_last_configuration_entry() {
+ MavlinkKnownSourceDTO first = source("first", 17, 42);
+ MavlinkKnownSourceDTO second = source("second", 17, 42);
+
+ MavlinkKnownSourceDTO result = new MavlinkSourceRegistry(config(first, second)).getKnownSource(frame(17, 42));
+
+ assertSame(second, result);
+ }
+
+ private MavlinkTwinConfigDTO config(MavlinkKnownSourceDTO... sources) {
+ MavlinkTwinConfigDTO config = new MavlinkTwinConfigDTO();
+ config.setKnownSources(List.of(sources));
+ return config;
+ }
+
+ private MavlinkKnownSourceDTO source(String name, int systemId, int componentId) {
+ MavlinkKnownSourceDTO source = new MavlinkKnownSourceDTO();
+ source.setName(name);
+ source.setSystemId(systemId);
+ source.setComponentId(componentId);
+ return source;
+ }
+
+ private ProcessedFrame frame(int systemId, int componentId) {
+ ProcessedFrame processedFrame = mock(ProcessedFrame.class, RETURNS_DEEP_STUBS);
+ when(processedFrame.getFrame().getSystemId()).thenReturn(systemId);
+ when(processedFrame.getFrame().getComponentId()).thenReturn(componentId);
+ return processedFrame;
+ }
+}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriberTest.java b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriberTest.java
new file mode 100644
index 000000000..c432dda23
--- /dev/null
+++ b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkStateSubscriberTest.java
@@ -0,0 +1,150 @@
+/*
+ *
+ * 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;
+
+import io.mapsmessaging.api.MessageEvent;
+import io.mapsmessaging.state.StateLoopProtocol;
+import io.mapsmessaging.state.config.DroneInfoRegistry;
+import org.junit.jupiter.api.Test;
+import org.mockito.invocation.Invocation;
+
+import java.io.IOException;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockingDetails;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class MavlinkStateSubscriberTest {
+
+ @Test
+ void repeated_start_and_stop_are_idempotent() throws Exception {
+ Fixture fixture = fixture();
+
+ fixture.subscriber.start();
+ fixture.subscriber.start();
+
+ assertEquals(1, invocationCount(fixture.protocol, "connect"));
+ assertEquals(1, invocationCount(fixture.protocol, "subscribeLocal"));
+
+ fixture.subscriber.stop();
+ fixture.subscriber.stop();
+
+ assertEquals(1, invocationCount(fixture.protocol, "unsubscribeLocal"));
+ assertEquals(1, invocationCount(fixture.protocol, "close"));
+ verify(fixture.twinUpdater, times(1)).close();
+ }
+
+ @Test
+ void stop_closes_protocol_and_monitor_when_unsubscribe_fails() throws Exception {
+ Fixture fixture = fixture();
+ fixture.subscriber.start();
+ RuntimeException failure = new IllegalStateException("unsubscribe failed");
+ doThrow(failure).when(fixture.protocol).unsubscribeLocal("mavlink/state");
+
+ RuntimeException thrown = assertThrows(RuntimeException.class, fixture.subscriber::stop);
+
+ assertEquals(failure, thrown);
+ assertEquals(1, invocationCount(fixture.protocol, "close"));
+ verify(fixture.twinUpdater).close();
+ }
+
+ @Test
+ void startFailureClosesProtocolAndTwinUpdater() throws Exception {
+ Fixture fixture = fixture();
+ IOException failure = new IOException("connect failed");
+ doThrow(failure).when(fixture.protocol).connect(
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString(),
+ org.mockito.ArgumentMatchers.anyString()
+ );
+
+ assertEquals(failure, assertThrows(IOException.class, fixture.subscriber::start));
+ assertEquals(1, invocationCount(fixture.protocol, "close"));
+ verify(fixture.twinUpdater).close();
+ assertThrows(IllegalStateException.class, fixture.subscriber::start);
+ }
+
+ @Test
+ void start_after_stop_is_rejected() throws Exception {
+ Fixture fixture = fixture();
+ fixture.subscriber.stop();
+
+ assertThrows(IllegalStateException.class, fixture.subscriber::start);
+ assertEquals(0, invocationCount(fixture.protocol, "connect"));
+ assertEquals(1, invocationCount(fixture.protocol, "close"));
+ }
+
+ @Test
+ void late_message_after_stop_only_runs_completion() throws Exception {
+ Fixture fixture = fixture();
+ MessageEvent messageEvent = mock(MessageEvent.class);
+ Runnable completionTask = mock(Runnable.class);
+ when(messageEvent.getCompletionTask()).thenReturn(completionTask);
+ fixture.subscriber.stop();
+
+ fixture.subscriber.handle(messageEvent);
+
+ verify(completionTask).run();
+ verify(messageEvent, never()).getDestinationName();
+ verify(messageEvent, never()).getMessage();
+ verify(fixture.twinUpdater, never()).updateTwinState(
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any(),
+ org.mockito.ArgumentMatchers.any()
+ );
+ }
+
+ private long invocationCount(Object mock, String methodName) {
+ return mockingDetails(mock).getInvocations().stream()
+ .map(Invocation::getMethod)
+ .filter(method -> method.getName().equals(methodName))
+ .count();
+ }
+
+ private Fixture fixture() {
+ StateLoopProtocol protocol = mock(StateLoopProtocol.class);
+ MavlinkSourceRegistry sourceRegistry = mock(MavlinkSourceRegistry.class);
+ DroneInfoRegistry droneRegistry = mock(DroneInfoRegistry.class);
+ MavlinkTwinUpdater twinUpdater = mock(MavlinkTwinUpdater.class);
+ MavlinkStateSubscriber subscriber = new MavlinkStateSubscriber(
+ protocol,
+ "mavlink/state",
+ sourceRegistry,
+ droneRegistry,
+ twinUpdater
+ );
+ return new Fixture(protocol, twinUpdater, subscriber);
+ }
+
+ private record Fixture(
+ StateLoopProtocol protocol,
+ MavlinkTwinUpdater twinUpdater,
+ MavlinkStateSubscriber subscriber
+ ) {
+ }
+}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdaterTest.java b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdaterTest.java
new file mode 100644
index 000000000..961d4d771
--- /dev/null
+++ b/src/test/java/io/mapsmessaging/state/mavlink/MavlinkTwinUpdaterTest.java
@@ -0,0 +1,282 @@
+/*
+ *
+ * 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;
+
+import io.mapsmessaging.dto.rest.config.protocol.impl.MavlinkKnownSourceDTO;
+import io.mapsmessaging.mavlink.ProcessedFrame;
+import io.mapsmessaging.state.config.DroneInfoDTO;
+import io.mapsmessaging.state.config.VehicleClass;
+import io.mapsmessaging.state.drone.core.TwinManager;
+import io.mapsmessaging.state.drone.core.TwinUpdateContext;
+import io.mapsmessaging.state.drone.drone.DroneTwin;
+import io.mapsmessaging.state.drone.model.BatteryState;
+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.bootstrap.DroneTwinReadinessState;
+import io.mapsmessaging.state.mavlink.bootstrap.MavlinkBootstrapStateEngine;
+import io.mapsmessaging.state.mavlink.listener.ListenerManager;
+import io.mapsmessaging.state.mavlink.packet.BatteryStatusPacket;
+import io.mapsmessaging.state.mavlink.packet.MavlinkPacket;
+import io.mapsmessaging.state.mavlink.packet.NamedValueFloatPacket;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.BATTERY_STATUS;
+import static io.mapsmessaging.state.mavlink.packet.MavlinkMessageIds.NAMED_VALUE_FLOAT;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
+import static org.mockito.Mockito.clearInvocations;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+class MavlinkTwinUpdaterTest {
+
+ private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z");
+
+ @Test
+ void valid_battery_packet_refreshes_power_and_preserves_existing_response_topic() {
+ TwinManager twinManager = twinManager();
+ DroneTwin twin = new DroneTwin("drone-1");
+ twin.setResponseTopicName("mavlink/original");
+ twinManager.registerTwin(twin, context(null, null));
+ ListenerManager listenerManager = mock(ListenerManager.class);
+ MavlinkTwinUpdater updater = new MavlinkTwinUpdater(twinManager, listenerManager);
+ BatteryStatusPacket packet = mock(BatteryStatusPacket.class);
+ when(packet.isValid()).thenReturn(true);
+ ProcessedFrame frame = frame(17, 42, BATTERY_STATUS);
+ TwinUpdateContext context = context("mavlink/replacement", "outbound-1");
+
+ updater.updateTwinState(frame, packet, context, knownSource(), new DroneInfoDTO());
+
+ assertEquals(17, twin.getSystemId());
+ assertEquals(42, twin.getComponentId());
+ assertEquals(NOW, twin.getPowerUpdatedAt());
+ assertEquals("mavlink/original", twin.getResponseTopicName());
+ assertEquals("outbound-1", twin.getUniqueOutboundIdentifier());
+ verify(listenerManager).handle(BATTERY_STATUS, "drone-1", packet, context);
+ updater.close();
+ }
+
+ @Test
+ void invalid_battery_packet_does_not_refresh_power_timestamp() {
+ TwinManager twinManager = twinManager();
+ DroneTwin twin = new DroneTwin("drone-1");
+ twinManager.registerTwin(twin, context(null, null));
+ ListenerManager listenerManager = mock(ListenerManager.class);
+ MavlinkTwinUpdater updater = new MavlinkTwinUpdater(twinManager, listenerManager);
+ BatteryStatusPacket packet = mock(BatteryStatusPacket.class);
+ when(packet.isValid()).thenReturn(false);
+
+ updater.updateTwinState(
+ frame(17, 42, BATTERY_STATUS),
+ packet,
+ context(null, null),
+ knownSource(),
+ new DroneInfoDTO()
+ );
+
+ assertNull(twin.getPowerUpdatedAt());
+ updater.close();
+ }
+
+ @Test
+ void readiness_is_evaluated_once_after_listener_updates_complete() {
+ TwinManager twinManager = twinManager();
+ ListenerManager listenerManager = mock(ListenerManager.class);
+ DroneTwinReadinessEvaluator readinessEvaluator = mock(DroneTwinReadinessEvaluator.class);
+ MavlinkBootstrapStateEngine stateEngine = mock(MavlinkBootstrapStateEngine.class);
+ MavlinkDroneMonitor monitor = new MavlinkDroneMonitor(twinManager, readinessEvaluator, stateEngine, null);
+ MavlinkTwinUpdater updater = new MavlinkTwinUpdater(twinManager, listenerManager, monitor);
+ TwinUpdateContext context = context(null, null);
+ BatteryStatusPacket packet = mock(BatteryStatusPacket.class);
+ when(packet.isValid()).thenReturn(true);
+ BatteryState batteryState = new BatteryState();
+ batteryState.setPercentage(80.0);
+ DroneTwinReadinessResult readinessResult = readinessResult();
+
+ doAnswer(invocation -> {
+ twinManager.updateTwin(
+ "drone-1",
+ twin -> ((DroneTwin) twin).setBatteryState(batteryState),
+ context
+ );
+ return true;
+ }).when(listenerManager).handle(eq(BATTERY_STATUS), eq("drone-1"), same(packet), same(context));
+
+ when(readinessEvaluator.evaluate(any(DroneTwin.class), same(context))).thenAnswer(invocation -> {
+ DroneTwin evaluatedTwin = invocation.getArgument(0);
+ assertSame(batteryState, evaluatedTwin.getBatteryState());
+ assertEquals(NOW, evaluatedTwin.getPowerUpdatedAt());
+ return readinessResult;
+ });
+ when(stateEngine.update(any(DroneTwin.class), same(readinessResult), same(context))).thenReturn(List.of());
+
+ updater.updateTwinState(
+ frame(17, 42, BATTERY_STATUS),
+ packet,
+ context,
+ knownSource(),
+ new DroneInfoDTO()
+ );
+
+ verify(readinessEvaluator, times(1)).evaluate(any(DroneTwin.class), same(context));
+ verify(stateEngine, times(1)).update(any(DroneTwin.class), same(readinessResult), same(context));
+ updater.close();
+ }
+
+ @Test
+ void new_twin_uses_known_source_and_drone_configuration() {
+ TwinManager twinManager = twinManager();
+ ListenerManager listenerManager = mock(ListenerManager.class);
+ MavlinkTwinUpdater updater = new MavlinkTwinUpdater(twinManager, listenerManager);
+ MavlinkKnownSourceDTO knownSource = knownSource();
+ knownSource.setDescription("Survey aircraft");
+ knownSource.setVehicleClass(VehicleClass.UAV);
+ DroneInfoDTO droneInfo = new DroneInfoDTO();
+ UUID uuid = UUID.fromString("d972348c-8496-45de-b130-9c003d7bf245");
+ droneInfo.setUuid(uuid);
+ droneInfo.setBatteryCapacityHours(4.5);
+ droneInfo.setDescription(Map.of("role", "survey"));
+ MavlinkPacket packet = mock(MavlinkPacket.class);
+ ProcessedFrame frame = frame(17, 42, 999);
+ TwinUpdateContext context = context("mavlink/outbound", "outbound-2");
+
+ updater.updateTwinState(frame, packet, context, knownSource, droneInfo);
+
+ DroneTwin twin = (DroneTwin) twinManager.getTwin("drone-1").orElseThrow();
+ assertEquals(uuid, twin.getUuid());
+ assertEquals("Survey aircraft", twin.getDisplayName());
+ assertEquals("Survey aircraft", twin.getDescriptionString());
+ assertEquals("drone-1", twin.getCallSign());
+ assertEquals(4.5, twin.getBatteryCapacityHours());
+ assertEquals("mavlink/outbound", twin.getResponseTopicName());
+ assertEquals("outbound-2", twin.getUniqueOutboundIdentifier());
+ assertEquals("survey", twin.getDescription().get("role"));
+ updater.close();
+ }
+
+ @Test
+ void close_is_idempotent_and_late_updates_are_ignored() {
+ TwinManager twinManager = mock(TwinManager.class);
+ ListenerManager listenerManager = mock(ListenerManager.class);
+ MavlinkTwinUpdater updater = new MavlinkTwinUpdater(twinManager, listenerManager);
+
+ updater.close();
+ updater.close();
+
+ verify(twinManager, times(1)).removeObserver(org.mockito.ArgumentMatchers.any(MavlinkDroneMonitor.class));
+ clearInvocations(twinManager, listenerManager);
+
+ updater.updateTwinState(
+ frame(17, 42, 999),
+ mock(MavlinkPacket.class),
+ context(null, null),
+ knownSource(),
+ new DroneInfoDTO()
+ );
+
+ verifyNoInteractions(twinManager, listenerManager);
+ }
+
+ @Test
+ void detectionEventsPreserveContactIdForAddAndLost() {
+ TwinManager twinManager = twinManager();
+ DroneTwin twin = new DroneTwin("drone-1");
+ twin.setModelName("stickleback-ardupilot-usv");
+ twinManager.registerTwin(twin, context(null, null));
+ MavlinkTwinUpdater updater = new MavlinkTwinUpdater(twinManager, mock(ListenerManager.class));
+ NamedValueFloatPacket packet = mock(NamedValueFloatPacket.class);
+ when(packet.isValid()).thenReturn(true);
+ when(packet.hasName()).thenReturn(true);
+ when(packet.hasValue()).thenReturn(true);
+ when(packet.getName()).thenReturn("target-1");
+ when(packet.getValue()).thenReturn(1.0d);
+ UUID contactId = UUID.nameUUIDFromBytes("target-1".getBytes(StandardCharsets.UTF_8));
+
+ updater.updateTwinState(frame(17, 42, NAMED_VALUE_FLOAT), packet, context(null, null), knownSource(), new DroneInfoDTO());
+
+ assertTrue(twin.getContactManager().hasContact(contactId));
+ assertEquals(contactId, twin.getContactManager().getContactList().getFirst().getId());
+
+ when(packet.getValue()).thenReturn(0.0d);
+ updater.updateTwinState(frame(17, 42, NAMED_VALUE_FLOAT), packet, context(null, null), knownSource(), new DroneInfoDTO());
+
+ assertFalse(twin.getContactManager().hasContact(contactId));
+ updater.close();
+ }
+
+ private TwinManager twinManager() {
+ return new TwinManager(false, 10_000L, 5_000L, 120_000L, null);
+ }
+
+ private DroneTwinReadinessResult readinessResult() {
+ DroneTwinReadinessResult result = new DroneTwinReadinessResult("drone-1");
+ result.setReadinessState(DroneTwinReadinessState.DISCOVERED);
+ result.setRegistrationReady(false);
+ result.setCommandReady(false);
+ result.setMissingStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setDegradedStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setBlockingStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setEvaluatedAt(NOW);
+ return result;
+ }
+
+ private MavlinkKnownSourceDTO knownSource() {
+ MavlinkKnownSourceDTO knownSource = new MavlinkKnownSourceDTO();
+ knownSource.setName("drone-1");
+ knownSource.setSystemId(17);
+ knownSource.setComponentId(42);
+ return knownSource;
+ }
+
+ private TwinUpdateContext context(String responseTopic, String outboundIdentifier) {
+ TwinUpdateContext context = new TwinUpdateContext();
+ context.setReceivedTime(NOW);
+ context.setResponseTopic(responseTopic);
+ context.setUniqueOutboundIdentifier(outboundIdentifier);
+ return context;
+ }
+
+ private ProcessedFrame frame(int systemId, int componentId, int messageId) {
+ ProcessedFrame processedFrame = mock(ProcessedFrame.class, RETURNS_DEEP_STUBS);
+ when(processedFrame.getFrame().getSystemId()).thenReturn(systemId);
+ when(processedFrame.getFrame().getComponentId()).thenReturn(componentId);
+ when(processedFrame.getFrame().getMessageId()).thenReturn(messageId);
+ return processedFrame;
+ }
+}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluatorTest.java b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluatorTest.java
new file mode 100644
index 000000000..344f03735
--- /dev/null
+++ b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/DroneTwinReadinessEvaluatorTest.java
@@ -0,0 +1,303 @@
+/*
+ *
+ * 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 io.mapsmessaging.state.config.VehicleClass;
+import io.mapsmessaging.state.drone.core.TwinLifecycleStatus;
+import io.mapsmessaging.state.drone.core.TwinUpdateContext;
+import io.mapsmessaging.state.drone.drone.DroneTwin;
+import io.mapsmessaging.state.drone.model.BatteryState;
+import io.mapsmessaging.state.drone.model.FixInfo;
+import io.mapsmessaging.state.drone.model.GeoPosition;
+import io.mapsmessaging.state.drone.model.LinkState;
+import io.mapsmessaging.state.drone.model.SystemState;
+import io.mapsmessaging.state.drone.model.autopilot.AutopilotState;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class DroneTwinReadinessEvaluatorTest {
+
+ private static final Instant NOW = Instant.parse("2026-07-28T00:00:00Z");
+
+ private final DroneTwinReadinessEvaluator evaluator = new DroneTwinReadinessEvaluator();
+
+ @Test
+ void fully_populated_fresh_twin_is_command_ready() {
+ DroneTwinReadinessResult result = evaluate(readyTwin());
+
+ assertEquals(DroneTwinReadinessState.COMMAND_READY, result.getReadinessState());
+ assertTrue(result.isRegistrationReady());
+ assertTrue(result.isCommandReady());
+ assertTrue(result.getMissingStates().isEmpty());
+ }
+
+ @Test
+ void null_twin_is_unknown_and_not_ready() {
+ DroneTwinReadinessResult result = evaluator.evaluate(null, contextAt(NOW));
+
+ assertEquals(DroneTwinReadinessState.UNKNOWN, result.getReadinessState());
+ assertFalse(result.isRegistrationReady());
+ assertFalse(result.isCommandReady());
+ }
+
+ @Test
+ void missing_identity_fields_block_registration() {
+ DroneTwin twin = readyTwin();
+ twin.setSystemId(null);
+ twin.setComponentId(null);
+ twin.setVehicleClass(null);
+
+ DroneTwinReadinessResult result = evaluate(twin);
+
+ assertFalse(result.isRegistrationReady());
+ assertFalse(result.isCommandReady());
+ assertTrue(result.getBlockingStates().containsAll(List.of(
+ DroneTwinMissingState.MISSING_SYSTEM_ID,
+ DroneTwinMissingState.MISSING_COMPONENT_ID,
+ DroneTwinMissingState.MISSING_VEHICLE_CLASS
+ )));
+ }
+
+ @Test
+ void missing_autopilot_type_blocks_registration_even_with_valid_position() {
+ DroneTwin twin = readyTwin();
+ twin.getAutopilotState().setAutopilotType(null);
+
+ DroneTwinReadinessResult result = evaluate(twin);
+
+ assertFalse(result.isRegistrationReady());
+ assertFalse(result.isCommandReady());
+ assertTrue(result.getMissingStates().contains(DroneTwinMissingState.MISSING_AUTOPILOT_TYPE));
+ assertEquals(DroneTwinReadinessState.POSITIONED, result.getReadinessState());
+ }
+
+ @Test
+ void disconnected_or_stale_heartbeat_reports_stale() {
+ DroneTwin disconnected = readyTwin();
+ disconnected.getLinkState().setConnected(false);
+
+ DroneTwinReadinessResult disconnectedResult = evaluate(disconnected);
+ assertEquals(DroneTwinReadinessState.STALE, disconnectedResult.getReadinessState());
+ assertTrue(disconnectedResult.getBlockingStates().contains(DroneTwinMissingState.STALE_HEARTBEAT));
+
+ DroneTwin stale = readyTwin();
+ stale.setConnectivityUpdatedAt(NOW.minusSeconds(11));
+
+ DroneTwinReadinessResult staleResult = evaluate(stale);
+ assertEquals(DroneTwinReadinessState.STALE, staleResult.getReadinessState());
+ assertFalse(staleResult.isRegistrationReady());
+ }
+
+ @Test
+ void missing_or_stale_position_blocks_commands_but_not_registration() {
+ DroneTwin missing = readyTwin();
+ missing.setGeoPosition(null);
+
+ DroneTwinReadinessResult missingResult = evaluate(missing);
+ assertTrue(missingResult.isRegistrationReady());
+ assertFalse(missingResult.isCommandReady());
+ assertEquals(DroneTwinReadinessState.POSITION_PARTIAL, missingResult.getReadinessState());
+ assertTrue(missingResult.getBlockingStates().contains(DroneTwinMissingState.MISSING_GLOBAL_POSITION));
+
+ DroneTwin stale = readyTwin();
+ stale.setNavigationUpdatedAt(NOW.minusSeconds(11));
+
+ DroneTwinReadinessResult staleResult = evaluate(stale);
+ assertTrue(staleResult.isRegistrationReady());
+ assertFalse(staleResult.isCommandReady());
+ assertTrue(staleResult.getBlockingStates().contains(DroneTwinMissingState.STALE_POSITION));
+ }
+
+ @Test
+ void non_finite_or_out_of_range_position_never_becomes_ready() {
+ List invalidPositions = List.of(
+ new GeoPosition(Double.NaN, 151.2, 10.0, null),
+ new GeoPosition(-33.8, Double.POSITIVE_INFINITY, 10.0, null),
+ new GeoPosition(90.0001, 151.2, 10.0, null),
+ new GeoPosition(-33.8, -180.0001, 10.0, null)
+ );
+
+ for (GeoPosition invalidPosition : invalidPositions) {
+ DroneTwin twin = readyTwin();
+ twin.setGeoPosition(invalidPosition);
+
+ DroneTwinReadinessResult result = evaluate(twin);
+
+ assertFalse(result.isCommandReady(), invalidPosition.toString());
+ assertTrue(result.getBlockingStates().contains(DroneTwinMissingState.MISSING_GLOBAL_POSITION), invalidPosition.toString());
+ }
+ }
+
+ @Test
+ void gps_flag_and_fix_type_must_both_represent_a_valid_fix() {
+ DroneTwin invalidFlag = readyTwin();
+ invalidFlag.setGpsValid(false);
+ assertFalse(evaluate(invalidFlag).isCommandReady());
+
+ for (String invalidFixType : List.of("NO_GPS", "NO_FIX", "UNKNOWN", " ")) {
+ DroneTwin twin = readyTwin();
+ twin.getFixInfo().setFixType(invalidFixType);
+
+ DroneTwinReadinessResult result = evaluate(twin);
+
+ assertFalse(result.isCommandReady(), invalidFixType);
+ assertTrue(result.getBlockingStates().contains(DroneTwinMissingState.MISSING_GPS_FIX), invalidFixType);
+ }
+ }
+
+ @Test
+ void missing_stale_or_invalid_battery_state_is_health_partial() {
+ DroneTwin missing = readyTwin();
+ missing.setBatteryState(null);
+ assertHealthPartial(evaluate(missing), DroneTwinMissingState.MISSING_BATTERY_STATE);
+
+ DroneTwin stale = readyTwin();
+ stale.setPowerUpdatedAt(NOW.minusSeconds(31));
+ assertHealthPartial(evaluate(stale), DroneTwinMissingState.STALE_POWER);
+
+ DroneTwin invalid = readyTwin();
+ BatteryState batteryState = new BatteryState();
+ batteryState.setPercentage(Double.NaN);
+ batteryState.setVoltageVolts(Double.POSITIVE_INFINITY);
+ batteryState.setCurrentAmps(Double.NaN);
+ invalid.setBatteryState(batteryState);
+ assertHealthPartial(evaluate(invalid), DroneTwinMissingState.MISSING_BATTERY_STATE);
+ }
+
+ @Test
+ void missing_capabilities_is_capability_partial() {
+ DroneTwin twin = readyTwin();
+ twin.getAutopilotState().setCapabilities(null);
+
+ DroneTwinReadinessResult result = evaluate(twin);
+
+ assertTrue(result.isRegistrationReady());
+ assertFalse(result.isCommandReady());
+ assertEquals(DroneTwinReadinessState.CAPABILITY_PARTIAL, result.getReadinessState());
+ assertTrue(result.getMissingStates().contains(DroneTwinMissingState.MISSING_CAPABILITIES));
+ }
+
+ @Test
+ void missing_home_and_system_health_are_degraded_but_not_command_blocking() {
+ DroneTwin twin = readyTwin();
+ twin.setHomePosition(null);
+ twin.setSystemState(null);
+
+ DroneTwinReadinessResult result = evaluate(twin);
+
+ assertTrue(result.isCommandReady());
+ assertEquals(DroneTwinReadinessState.COMMAND_READY, result.getReadinessState());
+ assertTrue(result.getDegradedStates().contains(DroneTwinMissingState.MISSING_HOME_POSITION));
+ assertTrue(result.getDegradedStates().contains(DroneTwinMissingState.MISSING_SYSTEM_STATE));
+ }
+
+ @Test
+ void freshness_thresholds_are_inclusive() {
+ DroneTwin twin = readyTwin();
+ twin.setConnectivityUpdatedAt(NOW.minusSeconds(10));
+ twin.setNavigationUpdatedAt(NOW.minusSeconds(10));
+ twin.setPowerUpdatedAt(NOW.minusSeconds(30));
+
+ assertTrue(evaluate(twin).isCommandReady());
+
+ twin.setPowerUpdatedAt(NOW.minusSeconds(30).minusMillis(1));
+ assertFalse(evaluate(twin).isCommandReady());
+ }
+
+ @Test
+ void stale_lifecycle_blocks_readiness_even_with_fresh_link_timestamp() {
+ DroneTwin twin = readyTwin();
+ twin.setLifecycleStatus(TwinLifecycleStatus.STALE);
+
+ DroneTwinReadinessResult result = evaluate(twin);
+
+ assertEquals(DroneTwinReadinessState.STALE, result.getReadinessState());
+ assertFalse(result.isRegistrationReady());
+ assertFalse(result.isCommandReady());
+ }
+
+ private void assertHealthPartial(
+ DroneTwinReadinessResult result,
+ DroneTwinMissingState expectedMissingState
+ ) {
+ assertTrue(result.isRegistrationReady());
+ assertFalse(result.isCommandReady());
+ assertEquals(DroneTwinReadinessState.HEALTH_PARTIAL, result.getReadinessState());
+ assertTrue(result.getMissingStates().contains(expectedMissingState));
+ }
+
+ private DroneTwinReadinessResult evaluate(DroneTwin twin) {
+ return evaluator.evaluate(twin, contextAt(NOW));
+ }
+
+ private TwinUpdateContext contextAt(Instant instant) {
+ TwinUpdateContext context = new TwinUpdateContext();
+ context.setReceivedTime(instant);
+ return context;
+ }
+
+ private DroneTwin readyTwin() {
+ DroneTwin twin = new DroneTwin("drone-1");
+ twin.setSystemId(1);
+ twin.setComponentId(1);
+ twin.setVehicleClass(VehicleClass.UAV);
+
+ TestAutopilotState autopilotState = new TestAutopilotState();
+ autopilotState.setAutopilotType("PX4");
+ autopilotState.setUid(1L);
+ autopilotState.setCapabilities(1L);
+ twin.setAutopilotState(autopilotState);
+
+ LinkState linkState = new LinkState();
+ linkState.setConnected(true);
+ linkState.setState("CONNECTED");
+ twin.setLinkState(linkState);
+ twin.setConnectivityUpdatedAt(NOW);
+
+ twin.setGeoPosition(new GeoPosition(-33.8688, 151.2093, 30.0, null));
+ twin.setNavigationUpdatedAt(NOW);
+
+ FixInfo fixInfo = new FixInfo();
+ fixInfo.setFixType("3D");
+ fixInfo.setSatelliteCount(12);
+ twin.setFixInfo(fixInfo);
+ twin.setGpsValid(true);
+
+ twin.setHomePosition(new GeoPosition(-33.8688, 151.2093, 20.0, null));
+
+ BatteryState batteryState = new BatteryState();
+ batteryState.setPercentage(75.0);
+ twin.setBatteryState(batteryState);
+ twin.setPowerUpdatedAt(NOW);
+
+ twin.setSystemState(new SystemState());
+ twin.setLifecycleStatus(TwinLifecycleStatus.ACTIVE);
+ return twin;
+ }
+
+ private static final class TestAutopilotState extends AutopilotState {
+ }
+}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestTrackerTest.java b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestTrackerTest.java
new file mode 100644
index 000000000..94af1a674
--- /dev/null
+++ b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapRequestTrackerTest.java
@@ -0,0 +1,114 @@
+/*
+ *
+ * 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 org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MavlinkBootstrapRequestTrackerTest {
+
+ private static final Instant START = Instant.parse("2026-07-28T00:00:00Z");
+ private static final Duration RETRY_INTERVAL = Duration.ofSeconds(2);
+ private static final Duration TIMEOUT = Duration.ofSeconds(15);
+
+ @Test
+ void new_tracker_can_request_immediately() {
+ MavlinkBootstrapRequestTracker tracker = tracker();
+
+ assertTrue(tracker.canRetry(START, RETRY_INTERVAL, 3));
+ assertFalse(tracker.hasTimedOut(START, TIMEOUT));
+ }
+
+ @Test
+ void mark_requested_preserves_first_time_and_advances_last_time_and_count() {
+ MavlinkBootstrapRequestTracker tracker = tracker();
+
+ tracker.markRequested(START);
+ tracker.markRequested(START.plusSeconds(2));
+
+ assertEquals(2, tracker.getRequestCount());
+ assertEquals(START, tracker.getFirstRequestedAt());
+ assertEquals(START.plusSeconds(2), tracker.getLastRequestedAt());
+ }
+
+ @Test
+ void retry_requires_full_interval_and_rejects_out_of_order_time() {
+ MavlinkBootstrapRequestTracker tracker = tracker();
+ tracker.markRequested(START.plusSeconds(10));
+
+ assertFalse(tracker.canRetry(START.plusSeconds(9), RETRY_INTERVAL, 3));
+ assertFalse(tracker.canRetry(START.plusSeconds(11), RETRY_INTERVAL, 3));
+ assertTrue(tracker.canRetry(START.plusSeconds(12), RETRY_INTERVAL, 3));
+ }
+
+ @Test
+ void maximum_retry_count_is_inclusive_and_zero_disables_requests() {
+ MavlinkBootstrapRequestTracker tracker = tracker();
+
+ assertFalse(tracker.canRetry(START, RETRY_INTERVAL, 0));
+
+ tracker.markRequested(START);
+ tracker.markRequested(START.plusSeconds(2));
+ tracker.markRequested(START.plusSeconds(4));
+
+ assertFalse(tracker.canRetry(START.plusSeconds(6), RETRY_INTERVAL, 3));
+ }
+
+ @Test
+ void timeout_is_measured_from_first_request_and_is_inclusive() {
+ MavlinkBootstrapRequestTracker tracker = tracker();
+ tracker.markRequested(START);
+ tracker.markRequested(START.plusSeconds(4));
+
+ assertFalse(tracker.hasTimedOut(START.minusSeconds(1), TIMEOUT));
+ assertFalse(tracker.hasTimedOut(START.plusSeconds(14), TIMEOUT));
+ assertTrue(tracker.hasTimedOut(START.plusSeconds(15), TIMEOUT));
+ }
+
+ @Test
+ void timed_out_tracker_never_retries_and_remains_timed_out() {
+ MavlinkBootstrapRequestTracker tracker = tracker();
+ tracker.markRequested(START);
+ tracker.setTimedOut(true);
+
+ assertTrue(tracker.hasTimedOut(START.plusSeconds(1), TIMEOUT));
+ assertFalse(tracker.canRetry(START.plusSeconds(100), RETRY_INTERVAL, 100));
+ }
+
+ @Test
+ void unrequested_tracker_has_no_request_timestamps() {
+ MavlinkBootstrapRequestTracker tracker = tracker();
+
+ assertNull(tracker.getFirstRequestedAt());
+ assertNull(tracker.getLastRequestedAt());
+ assertEquals(0, tracker.getRequestCount());
+ }
+
+ private MavlinkBootstrapRequestTracker tracker() {
+ return new MavlinkBootstrapRequestTracker(DroneTwinMissingState.MISSING_BATTERY_STATE);
+ }
+}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java
new file mode 100644
index 000000000..08ee7e748
--- /dev/null
+++ b/src/test/java/io/mapsmessaging/state/mavlink/bootstrap/MavlinkBootstrapStateEngineTest.java
@@ -0,0 +1,303 @@
+/*
+ *
+ * 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 io.mapsmessaging.state.drone.core.TwinUpdateContext;
+import io.mapsmessaging.state.drone.drone.DroneTwin;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+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;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class MavlinkBootstrapStateEngineTest {
+
+ private static final Instant START = Instant.parse("2026-07-28T00:00:00Z");
+
+ private MavlinkBootstrapStateEngine stateEngine;
+ private DroneTwin droneTwin;
+
+ @BeforeEach
+ void setUp() {
+ stateEngine = new MavlinkBootstrapStateEngine(new MavlinkBootstrapProfile());
+ droneTwin = new DroneTwin("drone-1");
+ droneTwin.setSystemId(17);
+ droneTwin.setComponentId(42);
+ }
+
+ @Test
+ void initial_partial_state_emits_readiness_change_and_actionable_requests() {
+ DroneTwinReadinessResult result = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_GLOBAL_POSITION,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+
+ List events = stateEngine.update(droneTwin, result, contextAt(0));
+
+ assertEquals(3, events.size());
+ assertEquals(MavlinkBootstrapEventType.READINESS_CHANGED, events.get(0).getEventType());
+ assertEquals(DroneTwinReadinessState.UNKNOWN, events.get(0).getPreviousReadinessState());
+ assertEquals(DroneTwinReadinessState.HEALTH_PARTIAL, events.get(0).getCurrentReadinessState());
+
+ MavlinkBootstrapEvent positionRequest = requestFor(events, DroneTwinMissingState.MISSING_GLOBAL_POSITION);
+ assertEquals(MavlinkBootstrapRequestType.SET_MESSAGE_INTERVAL, positionRequest.getRequestType());
+ assertEquals(GLOBAL_POSITION_INT, positionRequest.getMavlinkMessageId());
+ assertEquals(500_000, positionRequest.getIntervalMicroseconds());
+
+ MavlinkBootstrapEvent batteryRequest = requestFor(events, DroneTwinMissingState.MISSING_BATTERY_STATE);
+ assertEquals(MavlinkBootstrapRequestType.REQUEST_MESSAGE, batteryRequest.getRequestType());
+ assertEquals(BATTERY_STATUS, batteryRequest.getMavlinkMessageId());
+ assertEquals(17, batteryRequest.getTargetSystem());
+ assertEquals(42, batteryRequest.getTargetComponent());
+ }
+
+ @Test
+ void unchanged_partial_state_waits_for_the_full_retry_interval() {
+ DroneTwinReadinessResult result = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+
+ stateEngine.update(droneTwin, result, contextAt(0));
+
+ assertTrue(stateEngine.update(droneTwin, result, contextAt(1)).isEmpty());
+ assertEquals(1, requests(stateEngine.update(droneTwin, result, contextAt(2))).size());
+ }
+
+ @Test
+ void retries_exhaust_then_timeout_once_without_sleeps() {
+ DroneTwinReadinessResult result = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+
+ assertEquals(1, requests(stateEngine.update(droneTwin, result, contextAt(0))).size());
+ assertEquals(1, requests(stateEngine.update(droneTwin, result, contextAt(2))).size());
+ assertEquals(1, requests(stateEngine.update(droneTwin, result, contextAt(4))).size());
+ assertTrue(stateEngine.update(droneTwin, result, contextAt(6)).isEmpty());
+
+ List timedOut = stateEngine.update(droneTwin, result, contextAt(15));
+ assertEquals(1, timedOut.size());
+ assertEquals(MavlinkBootstrapEventType.BOOTSTRAP_TIMED_OUT, timedOut.get(0).getEventType());
+ assertEquals(DroneTwinMissingState.MISSING_BATTERY_STATE, timedOut.get(0).getMissingState());
+ assertTrue(timedOut.get(0).getReason().contains("MISSING_BATTERY_STATE"));
+
+ assertTrue(stateEngine.update(droneTwin, result, contextAt(16)).isEmpty());
+ }
+
+ @Test
+ void resolved_item_resets_exhausted_tracker_before_it_becomes_missing_again() {
+ DroneTwinReadinessResult batteryMissing = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+
+ stateEngine.update(droneTwin, batteryMissing, contextAt(0));
+ stateEngine.update(droneTwin, batteryMissing, contextAt(2));
+ stateEngine.update(droneTwin, batteryMissing, contextAt(4));
+ assertTrue(stateEngine.update(droneTwin, batteryMissing, contextAt(6)).isEmpty());
+
+ DroneTwinReadinessResult batteryRecovered = result(
+ DroneTwinReadinessState.CAPABILITY_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_CAPABILITIES
+ );
+ stateEngine.update(droneTwin, batteryRecovered, contextAt(7));
+
+ DroneTwinReadinessResult batteryMissingAgain = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE,
+ DroneTwinMissingState.MISSING_CAPABILITIES
+ );
+ List events = stateEngine.update(droneTwin, batteryMissingAgain, contextAt(8));
+
+ assertEquals(1, requests(events).size());
+ assertEquals(DroneTwinMissingState.MISSING_BATTERY_STATE, requests(events).get(0).getMissingState());
+ }
+
+ @Test
+ void remove_cancels_progress_and_next_update_starts_fresh() {
+ DroneTwinReadinessResult result = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+
+ stateEngine.update(droneTwin, result, contextAt(0));
+ stateEngine.remove(droneTwin.getTwinId());
+
+ List events = stateEngine.update(droneTwin, result, contextAt(1));
+
+ assertEquals(1, requests(events).size());
+ assertEquals(MavlinkBootstrapEventType.READINESS_CHANGED, events.get(0).getEventType());
+ }
+
+ @Test
+ void command_ready_completes_once_and_late_regression_does_not_reopen_requests() {
+ DroneTwinReadinessResult ready = result(DroneTwinReadinessState.COMMAND_READY, true);
+
+ List initialEvents = stateEngine.update(droneTwin, ready, contextAt(0));
+ assertEquals(2, initialEvents.size());
+ assertEquals(MavlinkBootstrapEventType.READINESS_CHANGED, initialEvents.get(0).getEventType());
+ assertEquals(MavlinkBootstrapEventType.BOOTSTRAP_COMPLETED, initialEvents.get(1).getEventType());
+
+ assertTrue(stateEngine.update(droneTwin, ready, contextAt(1)).isEmpty());
+
+ DroneTwinReadinessResult latePartial = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+ List lateEvents = stateEngine.update(droneTwin, latePartial, contextAt(2));
+
+ assertEquals(1, lateEvents.size());
+ assertEquals(MavlinkBootstrapEventType.READINESS_CHANGED, lateEvents.get(0).getEventType());
+ assertTrue(requests(lateEvents).isEmpty());
+ assertFalse(lateEvents.stream().anyMatch(event -> event.getEventType() == MavlinkBootstrapEventType.BOOTSTRAP_COMPLETED));
+ }
+
+ @Test
+ void missing_target_identity_suppresses_requests_until_ids_are_available() {
+ droneTwin.setSystemId(null);
+ DroneTwinReadinessResult result = result(
+ DroneTwinReadinessState.DISCOVERED,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+
+ List events = stateEngine.update(droneTwin, result, contextAt(0));
+
+ assertTrue(requests(events).isEmpty());
+ assertEquals(1, events.size());
+ assertEquals(MavlinkBootstrapEventType.READINESS_CHANGED, events.get(0).getEventType());
+ }
+
+ @Test
+ void unrelated_missing_state_does_not_consume_an_actionable_request_budget() {
+ DroneTwinReadinessResult unrelated = result(
+ DroneTwinReadinessState.IDENTIFIED,
+ false,
+ DroneTwinMissingState.MISSING_AUTOPILOT_TYPE
+ );
+ assertTrue(requests(stateEngine.update(droneTwin, unrelated, contextAt(0))).isEmpty());
+
+ DroneTwinReadinessResult batteryMissing = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_AUTOPILOT_TYPE,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+ assertEquals(1, requests(stateEngine.update(droneTwin, batteryMissing, contextAt(0))).size());
+ }
+
+ @Test
+ void one_timed_out_request_does_not_block_a_new_independent_request() {
+ DroneTwinReadinessResult batteryMissing = result(
+ DroneTwinReadinessState.HEALTH_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+ stateEngine.update(droneTwin, batteryMissing, contextAt(0));
+ stateEngine.update(droneTwin, batteryMissing, contextAt(2));
+ stateEngine.update(droneTwin, batteryMissing, contextAt(4));
+ stateEngine.update(droneTwin, batteryMissing, contextAt(15));
+
+ DroneTwinReadinessResult batteryAndPositionMissing = result(
+ DroneTwinReadinessState.POSITION_PARTIAL,
+ false,
+ DroneTwinMissingState.MISSING_GLOBAL_POSITION,
+ DroneTwinMissingState.MISSING_BATTERY_STATE
+ );
+ List events = stateEngine.update(droneTwin, batteryAndPositionMissing, contextAt(16));
+
+ assertEquals(1, requests(events).size());
+ assertEquals(DroneTwinMissingState.MISSING_GLOBAL_POSITION, requests(events).get(0).getMissingState());
+ }
+
+ @Test
+ void null_inputs_and_null_twin_id_are_ignored() {
+ DroneTwinReadinessResult result = result(DroneTwinReadinessState.DISCOVERED, false);
+
+ assertTrue(stateEngine.update(null, result, contextAt(0)).isEmpty());
+ assertTrue(stateEngine.update(droneTwin, null, contextAt(0)).isEmpty());
+
+ droneTwin.setTwinId(null);
+ assertTrue(stateEngine.update(droneTwin, result, contextAt(0)).isEmpty());
+ }
+
+ private DroneTwinReadinessResult result(
+ DroneTwinReadinessState readinessState,
+ boolean commandReady,
+ DroneTwinMissingState... missingStates
+ ) {
+ DroneTwinReadinessResult result = new DroneTwinReadinessResult(droneTwin.getTwinId());
+ Set missing = missingStates.length == 0
+ ? EnumSet.noneOf(DroneTwinMissingState.class)
+ : EnumSet.copyOf(Arrays.asList(missingStates));
+ result.setReadinessState(readinessState);
+ result.setRegistrationReady(readinessState != DroneTwinReadinessState.UNKNOWN && readinessState != DroneTwinReadinessState.DISCOVERED);
+ result.setCommandReady(commandReady);
+ result.setMissingStates(missing);
+ result.setDegradedStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setBlockingStates(EnumSet.noneOf(DroneTwinMissingState.class));
+ result.setEvaluatedAt(START);
+ return result;
+ }
+
+ private TwinUpdateContext contextAt(long seconds) {
+ TwinUpdateContext context = new TwinUpdateContext();
+ context.setReceivedTime(START.plusSeconds(seconds));
+ return context;
+ }
+
+ private List requests(List events) {
+ return events.stream()
+ .filter(event -> event.getEventType() == MavlinkBootstrapEventType.REQUEST)
+ .toList();
+ }
+
+ private MavlinkBootstrapEvent requestFor(
+ List events,
+ DroneTwinMissingState missingState
+ ) {
+ MavlinkBootstrapEvent event = requests(events).stream()
+ .filter(candidate -> candidate.getMissingState() == missingState)
+ .findFirst()
+ .orElse(null);
+ assertNotNull(event);
+ return event;
+ }
+}
diff --git a/src/test/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacketTest.java b/src/test/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacketTest.java
index b43e9d132..dea1c6ccf 100644
--- a/src/test/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacketTest.java
+++ b/src/test/java/io/mapsmessaging/state/mavlink/packet/MavlinkPacketTest.java
@@ -26,6 +26,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.HashMap;
+import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -62,6 +63,17 @@ void getIntArray_missingOrUnsupportedValue_returnsEmptyArray() {
assertArrayEquals(new int[0], packet.intArray(Map.of("value", "unsupported"), "value"));
}
+ @Test
+ void getIntArray_convertsJsonNumberListsAndRejectsMixedLists() {
+ Map fields = Map.of(
+ "numbers", List.of(1.0d, 65535L, 42),
+ "mixed", List.of(1, "two", 3)
+ );
+
+ assertArrayEquals(new int[] {1, 65535, 42}, packet.intArray(fields, "numbers"));
+ assertArrayEquals(new int[0], packet.intArray(fields, "mixed"));
+ }
+
@Test
void numericHelpers_missingValues_returnSentinels() {
Map fields = Map.of();
@@ -91,6 +103,17 @@ void getString_decodesUnsignedArraysAndStopsAtNullTerminator() {
assertNull(packet.string(fields, "missing"));
}
+ @Test
+ void getString_decodesJsonNumberListsAndPreservesUnsupportedValues() {
+ Map fields = Map.of(
+ "characters", List.of(65.0d, 0x142, 0, 90),
+ "mixed", List.of(65, "B")
+ );
+
+ assertEquals("AB", packet.string(fields, "characters"));
+ assertEquals("[65, B]", packet.string(fields, "mixed"));
+ }
+
private static final class TestPacket extends MavlinkPacket {
int[] intArray(Map fields, String key) {