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) {