]> git.basschouten.com Git - openhab-addons.git/commitdiff
[mielecloud] Fix washing machine can be started channel is not updated (#12583)
authorBjörn Lange <bjoern.lange@udo.edu>
Mon, 25 Apr 2022 18:13:47 +0000 (20:13 +0200)
committerGitHub <noreply@github.com>
Mon, 25 Apr 2022 18:13:47 +0000 (20:13 +0200)
* Add tests to ensure that parsing works correctly
* Fetch /actions on server sent event
* Refactor onServerSentEvent
* Remove ActionStateFetcher
* Manually construct BigDecimal

Signed-off-by: Björn Lange <bjoern.lange@itemis.de>
12 files changed:
bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/AbstractMieleThingHandler.java
bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/channel/ChannelTypeUtil.java
bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcher.java [deleted file]
bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebservice.java
bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollection.java [new file with mode: 0644]
bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/sse/ServerSentEvent.java
bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcherTest.java [deleted file]
bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebserviceTest.java
bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/ActionsStateTest.java
bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollectionTest.java [new file with mode: 0644]
bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsTest.java
bundles/org.openhab.binding.mielecloud/src/test/resources/org/openhab/binding/mielecloud/internal/webservice/api/json/actionsCollection.json [new file with mode: 0644]

index 937d6b069a06dbe2732acc9b6b1d02a16d7a026b..384185d1dda1fd5bcaa52afb73954d1f513c4565 100644 (file)
@@ -27,7 +27,6 @@ import org.openhab.binding.mielecloud.internal.discovery.ThingInformationExtract
 import org.openhab.binding.mielecloud.internal.handler.channel.ActionsChannelState;
 import org.openhab.binding.mielecloud.internal.handler.channel.DeviceChannelState;
 import org.openhab.binding.mielecloud.internal.handler.channel.TransitionChannelState;
-import org.openhab.binding.mielecloud.internal.webservice.ActionStateFetcher;
 import org.openhab.binding.mielecloud.internal.webservice.MieleWebservice;
 import org.openhab.binding.mielecloud.internal.webservice.UnavailableMieleWebservice;
 import org.openhab.binding.mielecloud.internal.webservice.api.ActionsState;
@@ -59,7 +58,6 @@ import org.slf4j.LoggerFactory;
  */
 @NonNullByDefault
 public abstract class AbstractMieleThingHandler extends BaseThingHandler {
-    protected final ActionStateFetcher actionFetcher;
     protected DeviceState latestDeviceState = new DeviceState(getDeviceId(), null);
     protected TransitionState latestTransitionState = new TransitionState(null, latestDeviceState);
     protected ActionsState latestActionsState = new ActionsState(getDeviceId(), null);
@@ -73,7 +71,6 @@ public abstract class AbstractMieleThingHandler extends BaseThingHandler {
      */
     public AbstractMieleThingHandler(Thing thing) {
         super(thing);
-        this.actionFetcher = new ActionStateFetcher(this::getWebservice, scheduler);
     }
 
     private Optional<MieleBridgeHandler> getMieleBridgeHandler() {
@@ -170,8 +167,6 @@ public abstract class AbstractMieleThingHandler extends BaseThingHandler {
      * Invoked when a device state update for the device managed by this handler is received from the Miele cloud.
      */
     public final void onDeviceStateUpdated(DeviceState deviceState) {
-        actionFetcher.onDeviceStateUpdated(deviceState);
-
         latestTransitionState = new TransitionState(latestTransitionState, deviceState);
         latestDeviceState = deviceState;
 
index 754b789fbad90eaf81ba191005fc9f2159976844..462b117023c588b803ec748a3532ff3b94d5b170 100644 (file)
@@ -12,6 +12,7 @@
  */
 package org.openhab.binding.mielecloud.internal.handler.channel;
 
+import java.math.BigDecimal;
 import java.util.Optional;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
@@ -53,14 +54,14 @@ public final class ChannelTypeUtil {
      * Converts an {@link Optional} of {@link Integer} to {@link State}.
      */
     public static State intToState(Optional<Integer> value) {
-        return value.map(v -> (State) new DecimalType(v)).orElse(UnDefType.UNDEF);
+        return value.map(v -> (State) new DecimalType(new BigDecimal(v))).orElse(UnDefType.UNDEF);
     }
 
     /**
      * Converts an {@link Optional} of {@link Long} to {@link State}.
      */
     public static State longToState(Optional<Long> value) {
-        return value.map(v -> (State) new DecimalType(v)).orElse(UnDefType.UNDEF);
+        return value.map(v -> (State) new DecimalType(new BigDecimal(v))).orElse(UnDefType.UNDEF);
     }
 
     /**
diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcher.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcher.java
deleted file mode 100644 (file)
index 4d5bf57..0000000
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Copyright (c) 2010-2022 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.mielecloud.internal.webservice;
-
-import java.util.Optional;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.function.Supplier;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.openhab.binding.mielecloud.internal.webservice.api.DeviceState;
-import org.openhab.binding.mielecloud.internal.webservice.exception.AuthorizationFailedException;
-import org.openhab.binding.mielecloud.internal.webservice.exception.MieleWebserviceException;
-import org.openhab.binding.mielecloud.internal.webservice.exception.TooManyRequestsException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/**
- * The {@link ActionStateFetcher} fetches the updated actions state for a device from the {@link MieleWebservice} if
- * the state of that device changed.
- *
- * Note that an instance of this class is required for each device.
- *
- * @author Roland Edelhoff - Initial contribution
- * @author Björn Lange - Make calls to webservice asynchronous
- */
-@NonNullByDefault
-public class ActionStateFetcher {
-    private Optional<DeviceState> lastDeviceState = Optional.empty();
-    private final Supplier<MieleWebservice> webserviceSupplier;
-    private final ScheduledExecutorService scheduler;
-
-    private final Logger logger = LoggerFactory.getLogger(ActionStateFetcher.class);
-
-    /**
-     * Creates a new {@link ActionStateFetcher}.
-     *
-     * @param webserviceSupplier Getter function for access to the {@link MieleWebservice}.
-     * @param scheduler System-wide scheduler.
-     */
-    public ActionStateFetcher(Supplier<MieleWebservice> webserviceSupplier, ScheduledExecutorService scheduler) {
-        this.webserviceSupplier = webserviceSupplier;
-        this.scheduler = scheduler;
-    }
-
-    /**
-     * Invoked when the state of a device was updated.
-     */
-    public void onDeviceStateUpdated(DeviceState deviceState) {
-        if (hasDeviceStatusChanged(deviceState)) {
-            scheduler.submit(() -> fetchActions(deviceState));
-        }
-        lastDeviceState = Optional.of(deviceState);
-    }
-
-    private boolean hasDeviceStatusChanged(DeviceState newDeviceState) {
-        return lastDeviceState.map(DeviceState::getStateType)
-                .map(rawStatus -> !newDeviceState.getStateType().equals(rawStatus)).orElse(true);
-    }
-
-    private void fetchActions(DeviceState deviceState) {
-        try {
-            webserviceSupplier.get().fetchActions(deviceState.getDeviceIdentifier());
-        } catch (MieleWebserviceException e) {
-            logger.warn("Failed to fetch action state for device {}: {} - {}", deviceState.getDeviceIdentifier(),
-                    e.getConnectionError(), e.getMessage());
-        } catch (AuthorizationFailedException | TooManyRequestsException e) {
-            logger.warn("Failed to fetch action state for device {}: {}", deviceState.getDeviceIdentifier(),
-                    e.getMessage());
-        }
-    }
-}
index 0f309bb5ae1d173ff0c267723a6b180232ad037f..602bfb86ed93b046730b11ae906c8904f3c0af40 100644 (file)
@@ -25,6 +25,7 @@ import org.eclipse.jdt.annotation.Nullable;
 import org.eclipse.jetty.client.api.ContentResponse;
 import org.eclipse.jetty.client.api.Request;
 import org.openhab.binding.mielecloud.internal.webservice.api.json.Actions;
+import org.openhab.binding.mielecloud.internal.webservice.api.json.ActionsCollection;
 import org.openhab.binding.mielecloud.internal.webservice.api.json.DeviceCollection;
 import org.openhab.binding.mielecloud.internal.webservice.api.json.Light;
 import org.openhab.binding.mielecloud.internal.webservice.api.json.MieleSyntaxException;
@@ -64,6 +65,7 @@ public final class DefaultMieleWebservice implements MieleWebservice, SseListene
     private static final String ENDPOINT_ALL_SSE_EVENTS = ENDPOINT_DEVICES + "all/events";
 
     private static final String SSE_EVENT_TYPE_DEVICES = "devices";
+    public static final String SSE_EVENT_TYPE_ACTIONS = "actions";
 
     private static final Gson GSON = new Gson();
 
@@ -142,12 +144,37 @@ public final class DefaultMieleWebservice implements MieleWebservice, SseListene
     public void onServerSentEvent(ServerSentEvent event) {
         fireConnectionAlive();
 
-        if (!SSE_EVENT_TYPE_DEVICES.equals(event.getEvent())) {
-            return;
-        }
-
         try {
-            deviceStateDispatcher.dispatchDeviceStateUpdates(DeviceCollection.fromJson(event.getData()));
+            switch (event.getEvent()) {
+                case SSE_EVENT_TYPE_ACTIONS:
+                    // We could use the actions payload here directly BUT as of March 2022 there is a bug in the cloud
+                    // that makes the payload differ from the actual values. The /actions endpoint delivers the correct
+                    // data. Thus, receiving an actions update via SSE is used as a trigger to fetch the actions state
+                    // from the /actions endpoint as a workaround. See
+                    // https://github.com/openhab/openhab-addons/issues/12500
+                    for (String deviceIdentifier : ActionsCollection.fromJson(event.getData()).getDeviceIdentifiers()) {
+                        try {
+                            fetchActions(deviceIdentifier);
+                        } catch (MieleWebserviceException e) {
+                            logger.warn("Failed to fetch action state for device {}: {} - {}", deviceIdentifier,
+                                    e.getConnectionError(), e.getMessage());
+                        } catch (AuthorizationFailedException e) {
+                            logger.warn("Failed to fetch action state for device {}: {}", deviceIdentifier,
+                                    e.getMessage());
+                            onConnectionError(ConnectionError.AUTHORIZATION_FAILED, 0);
+                            break;
+                        } catch (TooManyRequestsException e) {
+                            logger.warn("Failed to fetch action state for device {}: {}", deviceIdentifier,
+                                    e.getMessage());
+                            break;
+                        }
+                    }
+                    break;
+
+                case SSE_EVENT_TYPE_DEVICES:
+                    deviceStateDispatcher.dispatchDeviceStateUpdates(DeviceCollection.fromJson(event.getData()));
+                    break;
+            }
         } catch (MieleSyntaxException e) {
             logger.warn("SSE payload is not valid Json: {}", event.getData());
         }
diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollection.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollection.java
new file mode 100644 (file)
index 0000000..ca985b0
--- /dev/null
@@ -0,0 +1,97 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mielecloud.internal.webservice.api.json;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.Gson;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.reflect.TypeToken;
+
+/**
+ * Immutable POJO representing a collection of actions queried from the Miele REST API.
+ *
+ * @author Björn Lange - Initial contribution
+ */
+@NonNullByDefault
+public class ActionsCollection {
+    private static final java.lang.reflect.Type STRING_ACTIONS_MAP_TYPE = new TypeToken<Map<String, Actions>>() {
+    }.getType();
+
+    private final Map<String, Actions> actions;
+
+    ActionsCollection(Map<String, Actions> actions) {
+        this.actions = actions;
+    }
+
+    /**
+     * Creates a new {@link ActionsCollection} from the given Json text.
+     *
+     * @param json The Json text.
+     * @return The created {@link ActionsCollection}.
+     * @throws MieleSyntaxException if parsing the data from {@code json} fails.
+     */
+    public static ActionsCollection fromJson(String json) {
+        try {
+            Map<String, Actions> actions = new Gson().fromJson(json, STRING_ACTIONS_MAP_TYPE);
+            if (actions == null) {
+                throw new MieleSyntaxException("Failed to parse Json.");
+            }
+            return new ActionsCollection(actions);
+        } catch (JsonSyntaxException e) {
+            throw new MieleSyntaxException("Failed to parse Json.", e);
+        }
+    }
+
+    public Set<String> getDeviceIdentifiers() {
+        return actions.keySet();
+    }
+
+    public Actions getActions(String identifier) {
+        Actions actions = this.actions.get(identifier);
+        if (actions == null) {
+            throw new IllegalArgumentException("There are no actions for identifier " + identifier);
+        }
+        return actions;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(actions);
+    }
+
+    @Override
+    public boolean equals(@Nullable Object obj) {
+        if (this == obj) {
+            return true;
+        }
+        if (obj == null) {
+            return false;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+        ActionsCollection other = (ActionsCollection) obj;
+        return Objects.equals(actions, other.actions);
+    }
+
+    @Override
+    public String toString() {
+        return "ActionsCollection [actions=" + actions + "]";
+    }
+}
index 57bc3900d7de5c44f15122489482804875196716..5cc91f133201cc30f34ed3e0ff237d53faffb843 100644 (file)
@@ -27,7 +27,7 @@ public final class ServerSentEvent {
     private final String event;
     private final String data;
 
-    ServerSentEvent(String event, String data) {
+    public ServerSentEvent(String event, String data) {
         this.event = event;
         this.data = data;
     }
diff --git a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcherTest.java b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcherTest.java
deleted file mode 100644 (file)
index 7dfd226..0000000
+++ /dev/null
@@ -1,178 +0,0 @@
-/**
- * Copyright (c) 2010-2022 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.mielecloud.internal.webservice;
-
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
-
-import java.util.Optional;
-import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
-
-import org.eclipse.jdt.annotation.NonNullByDefault;
-import org.eclipse.jdt.annotation.Nullable;
-import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentMatchers;
-import org.mockito.invocation.InvocationOnMock;
-import org.mockito.stubbing.Answer;
-import org.openhab.binding.mielecloud.internal.util.MockUtil;
-import org.openhab.binding.mielecloud.internal.webservice.api.DeviceState;
-import org.openhab.binding.mielecloud.internal.webservice.api.json.StateType;
-import org.openhab.binding.mielecloud.internal.webservice.exception.AuthorizationFailedException;
-import org.openhab.binding.mielecloud.internal.webservice.exception.MieleWebserviceException;
-import org.openhab.binding.mielecloud.internal.webservice.exception.TooManyRequestsException;
-
-/**
- * @author Björn Lange - Initial Contribution
- */
-@NonNullByDefault
-public class ActionStateFetcherTest {
-    private ScheduledExecutorService mockImmediatelyExecutingExecutorService() {
-        ScheduledExecutorService scheduler = mock(ScheduledExecutorService.class);
-        when(scheduler.submit(ArgumentMatchers.<Runnable> any()))
-                .thenAnswer(new Answer<@Nullable ScheduledFuture<?>>() {
-                    @Override
-                    @Nullable
-                    public ScheduledFuture<?> answer(@Nullable InvocationOnMock invocation) throws Throwable {
-                        ((Runnable) MockUtil.requireNonNull(invocation).getArgument(0)).run();
-                        return null;
-                    }
-                });
-        return scheduler;
-    }
-
-    @Test
-    public void testFetchActionsIsInvokedWhenInitialDeviceStateIsSet() {
-        // given:
-        ScheduledExecutorService scheduler = mockImmediatelyExecutingExecutorService();
-
-        MieleWebservice webservice = mock(MieleWebservice.class);
-        DeviceState deviceState = mock(DeviceState.class);
-        DeviceState newDeviceState = mock(DeviceState.class);
-        ActionStateFetcher actionsfetcher = new ActionStateFetcher(() -> webservice, scheduler);
-
-        when(deviceState.getStateType()).thenReturn(Optional.of(StateType.RUNNING));
-        when(newDeviceState.getStateType()).thenReturn(Optional.of(StateType.END_PROGRAMMED));
-
-        // when:
-        actionsfetcher.onDeviceStateUpdated(deviceState);
-
-        // then:
-        verify(webservice).fetchActions(any());
-    }
-
-    @Test
-    public void testFetchActionsIsInvokedOnStateTransition() {
-        // given:
-        ScheduledExecutorService scheduler = mockImmediatelyExecutingExecutorService();
-
-        MieleWebservice webservice = mock(MieleWebservice.class);
-        DeviceState deviceState = mock(DeviceState.class);
-        DeviceState newDeviceState = mock(DeviceState.class);
-        ActionStateFetcher actionsfetcher = new ActionStateFetcher(() -> webservice, scheduler);
-
-        when(deviceState.getStateType()).thenReturn(Optional.of(StateType.RUNNING));
-        when(newDeviceState.getStateType()).thenReturn(Optional.of(StateType.END_PROGRAMMED));
-
-        actionsfetcher.onDeviceStateUpdated(deviceState);
-
-        // when:
-        actionsfetcher.onDeviceStateUpdated(newDeviceState);
-
-        // then:
-        verify(webservice, times(2)).fetchActions(any());
-    }
-
-    @Test
-    public void testFetchActionsIsNotInvokedWhenNoStateTransitionOccurrs() {
-        // given:
-        ScheduledExecutorService scheduler = mockImmediatelyExecutingExecutorService();
-
-        MieleWebservice webservice = mock(MieleWebservice.class);
-        DeviceState deviceState = mock(DeviceState.class);
-        DeviceState newDeviceState = mock(DeviceState.class);
-        ActionStateFetcher actionsfetcher = new ActionStateFetcher(() -> webservice, scheduler);
-
-        when(deviceState.getStateType()).thenReturn(Optional.of(StateType.RUNNING));
-        when(newDeviceState.getStateType()).thenReturn(Optional.of(StateType.RUNNING));
-
-        actionsfetcher.onDeviceStateUpdated(deviceState);
-
-        // when:
-        actionsfetcher.onDeviceStateUpdated(newDeviceState);
-
-        // then:
-        verify(webservice, times(1)).fetchActions(any());
-    }
-
-    @Test
-    public void whenFetchActionsFailsWithAMieleWebserviceExceptionThenNoExceptionIsThrown() {
-        // given:
-        ScheduledExecutorService scheduler = mockImmediatelyExecutingExecutorService();
-
-        MieleWebservice webservice = mock(MieleWebservice.class);
-        doThrow(new MieleWebserviceException("It went wrong", ConnectionError.REQUEST_EXECUTION_FAILED))
-                .when(webservice).fetchActions(any());
-
-        DeviceState deviceState = mock(DeviceState.class);
-        when(deviceState.getStateType()).thenReturn(Optional.of(StateType.RUNNING));
-
-        ActionStateFetcher actionsfetcher = new ActionStateFetcher(() -> webservice, scheduler);
-
-        // when:
-        actionsfetcher.onDeviceStateUpdated(deviceState);
-
-        // then:
-        verify(webservice, times(1)).fetchActions(any());
-    }
-
-    @Test
-    public void whenFetchActionsFailsWithAnAuthorizationFailedExceptionThenNoExceptionIsThrown() {
-        // given:
-        ScheduledExecutorService scheduler = mockImmediatelyExecutingExecutorService();
-
-        MieleWebservice webservice = mock(MieleWebservice.class);
-        doThrow(new AuthorizationFailedException("Authorization failed")).when(webservice).fetchActions(any());
-
-        DeviceState deviceState = mock(DeviceState.class);
-        when(deviceState.getStateType()).thenReturn(Optional.of(StateType.RUNNING));
-
-        ActionStateFetcher actionsfetcher = new ActionStateFetcher(() -> webservice, scheduler);
-
-        // when:
-        actionsfetcher.onDeviceStateUpdated(deviceState);
-
-        // then:
-        verify(webservice, times(1)).fetchActions(any());
-    }
-
-    @Test
-    public void whenFetchActionsFailsWithATooManyRequestsExceptionThenNoExceptionIsThrown() {
-        // given:
-        ScheduledExecutorService scheduler = mockImmediatelyExecutingExecutorService();
-
-        MieleWebservice webservice = mock(MieleWebservice.class);
-        doThrow(new TooManyRequestsException("Too many requests", null)).when(webservice).fetchActions(any());
-
-        DeviceState deviceState = mock(DeviceState.class);
-        when(deviceState.getStateType()).thenReturn(Optional.of(StateType.RUNNING));
-
-        ActionStateFetcher actionsfetcher = new ActionStateFetcher(() -> webservice, scheduler);
-
-        // when:
-        actionsfetcher.onDeviceStateUpdated(deviceState);
-
-        // then:
-        verify(webservice, times(1)).fetchActions(any());
-    }
-}
index 28023aa3becef52dc70314f54f582f26ce043acf..812cb68d0b7fc09f26b2a357fbdc67d2674cad1b 100644 (file)
@@ -43,6 +43,7 @@ import org.openhab.binding.mielecloud.internal.webservice.retry.AuthorizationFai
 import org.openhab.binding.mielecloud.internal.webservice.retry.NTimesRetryStrategy;
 import org.openhab.binding.mielecloud.internal.webservice.retry.RetryStrategy;
 import org.openhab.binding.mielecloud.internal.webservice.retry.RetryStrategyCombiner;
+import org.openhab.binding.mielecloud.internal.webservice.sse.ServerSentEvent;
 import org.openhab.core.io.net.http.HttpClientFactory;
 
 /**
@@ -58,7 +59,8 @@ public class DefaultMieleWebserviceTest {
 
     private static final String SERVER_ADDRESS = "https://api.mcs3.miele.com";
     private static final String ENDPOINT_DEVICES = SERVER_ADDRESS + "/v1/devices/";
-    private static final String ENDPOINT_ACTIONS = ENDPOINT_DEVICES + DEVICE_IDENTIFIER + "/actions";
+    private static final String ENDPOINT_EXTENSION_ACTIONS = "/actions";
+    private static final String ENDPOINT_ACTIONS = ENDPOINT_DEVICES + DEVICE_IDENTIFIER + ENDPOINT_EXTENSION_ACTIONS;
     private static final String ENDPOINT_LOGOUT = SERVER_ADDRESS + "/thirdparty/logout";
 
     private static final String ACCESS_TOKEN = "DE_0123456789abcdef0123456789abcdef";
@@ -721,6 +723,214 @@ public class DefaultMieleWebserviceTest {
         }
     }
 
+    @Test
+    public void receivingSseActionsEventNotifiesConnectionAlive() throws Exception {
+        // given:
+        var requestFactory = mock(RequestFactory.class);
+        var dispatcher = mock(DeviceStateDispatcher.class);
+        var scheduler = mock(ScheduledExecutorService.class);
+
+        var connectionStatusListener = mock(ConnectionStatusListener.class);
+
+        try (var webservice = new DefaultMieleWebservice(requestFactory, new NTimesRetryStrategy(0), dispatcher,
+                scheduler)) {
+            webservice.addConnectionStatusListener(connectionStatusListener);
+
+            var actionsEvent = new ServerSentEvent(DefaultMieleWebservice.SSE_EVENT_TYPE_ACTIONS, "{}");
+
+            // when:
+            webservice.onServerSentEvent(actionsEvent);
+
+            // then:
+            verify(connectionStatusListener).onConnectionAlive();
+        }
+    }
+
+    @Test
+    public void receivingSseActionsEventWithNonJsonPayloadDoesNothing() throws Exception {
+        // given:
+        var requestFactory = mock(RequestFactory.class);
+        var dispatcher = mock(DeviceStateDispatcher.class);
+        var scheduler = mock(ScheduledExecutorService.class);
+
+        try (var webservice = new DefaultMieleWebservice(requestFactory, new NTimesRetryStrategy(0), dispatcher,
+                scheduler)) {
+            webservice.setAccessToken(ACCESS_TOKEN);
+
+            var actionsEvent = new ServerSentEvent(DefaultMieleWebservice.SSE_EVENT_TYPE_ACTIONS,
+                    "{\"" + DEVICE_IDENTIFIER + "\": {}");
+
+            // when:
+            webservice.onServerSentEvent(actionsEvent);
+
+            // then:
+            verifyNoMoreInteractions(dispatcher);
+        }
+    }
+
+    @Test
+    public void receivingSseActionsEventFetchesActionsForADevice() throws Exception {
+        // given:
+        var requestFactory = mock(RequestFactory.class);
+        when(requestFactory.createGetRequest(ENDPOINT_ACTIONS, ACCESS_TOKEN)).thenReturn(request);
+
+        var response = createContentResponseMock(200, "{}");
+        when(request.send()).thenReturn(response);
+
+        var dispatcher = mock(DeviceStateDispatcher.class);
+        var scheduler = mock(ScheduledExecutorService.class);
+
+        try (var webservice = new DefaultMieleWebservice(requestFactory, new NTimesRetryStrategy(0), dispatcher,
+                scheduler)) {
+            webservice.setAccessToken(ACCESS_TOKEN);
+
+            var actionsEvent = new ServerSentEvent(DefaultMieleWebservice.SSE_EVENT_TYPE_ACTIONS,
+                    "{\"" + DEVICE_IDENTIFIER + "\": {}}");
+
+            // when:
+            webservice.onServerSentEvent(actionsEvent);
+
+            // then:
+            verify(dispatcher).dispatchActionStateUpdates(eq(DEVICE_IDENTIFIER), any());
+            verifyNoMoreInteractions(dispatcher);
+        }
+    }
+
+    @Test
+    public void receivingSseActionsEventFetchesActionsForMultipleDevices() throws Exception {
+        // given:
+        var otherRequest = mock(Request.class);
+        var otherDeviceIdentifier = "000124430017";
+
+        var requestFactory = mock(RequestFactory.class);
+        when(requestFactory.createGetRequest(ENDPOINT_ACTIONS, ACCESS_TOKEN)).thenReturn(request);
+        when(requestFactory.createGetRequest(ENDPOINT_DEVICES + otherDeviceIdentifier + ENDPOINT_EXTENSION_ACTIONS,
+                ACCESS_TOKEN)).thenReturn(otherRequest);
+
+        var response = createContentResponseMock(200, "{}");
+        when(request.send()).thenReturn(response);
+        when(otherRequest.send()).thenReturn(response);
+
+        var dispatcher = mock(DeviceStateDispatcher.class);
+        var scheduler = mock(ScheduledExecutorService.class);
+
+        try (var webservice = new DefaultMieleWebservice(requestFactory, new NTimesRetryStrategy(0), dispatcher,
+                scheduler)) {
+            webservice.setAccessToken(ACCESS_TOKEN);
+
+            var actionsEvent = new ServerSentEvent(DefaultMieleWebservice.SSE_EVENT_TYPE_ACTIONS,
+                    "{\"" + DEVICE_IDENTIFIER + "\": {}, \"" + otherDeviceIdentifier + "\": {}}");
+
+            // when:
+            webservice.onServerSentEvent(actionsEvent);
+
+            // then:
+            verify(dispatcher).dispatchActionStateUpdates(eq(DEVICE_IDENTIFIER), any());
+            verify(dispatcher).dispatchActionStateUpdates(eq(otherDeviceIdentifier), any());
+            verifyNoMoreInteractions(dispatcher);
+        }
+    }
+
+    @Test
+    public void whenFetchingActionsAfterReceivingSseActionsEventFailsForADeviceThenNothingHappensForThisDevice()
+            throws Exception {
+        // given:
+        var otherRequest = mock(Request.class);
+        var otherDeviceIdentifier = "000124430017";
+
+        var requestFactory = mock(RequestFactory.class);
+        when(requestFactory.createGetRequest(ENDPOINT_ACTIONS, ACCESS_TOKEN)).thenReturn(request);
+        when(requestFactory.createGetRequest(ENDPOINT_DEVICES + otherDeviceIdentifier + ENDPOINT_EXTENSION_ACTIONS,
+                ACCESS_TOKEN)).thenReturn(otherRequest);
+
+        var response = createContentResponseMock(200, "{}");
+        when(request.send()).thenReturn(response);
+        var otherResponse = createContentResponseMock(405, "{\"message\": \"HTTP 405 Method Not Allowed\"}");
+        when(otherRequest.send()).thenReturn(otherResponse);
+
+        var dispatcher = mock(DeviceStateDispatcher.class);
+        var scheduler = mock(ScheduledExecutorService.class);
+
+        try (var webservice = new DefaultMieleWebservice(requestFactory, new NTimesRetryStrategy(0), dispatcher,
+                scheduler)) {
+            webservice.setAccessToken(ACCESS_TOKEN);
+
+            var actionsEvent = new ServerSentEvent(DefaultMieleWebservice.SSE_EVENT_TYPE_ACTIONS,
+                    "{\"" + DEVICE_IDENTIFIER + "\": {}, \"" + otherDeviceIdentifier + "\": {}}");
+
+            // when:
+            webservice.onServerSentEvent(actionsEvent);
+
+            // then:
+            verify(dispatcher).dispatchActionStateUpdates(eq(DEVICE_IDENTIFIER), any());
+            verifyNoMoreInteractions(dispatcher);
+        }
+    }
+
+    @Test
+    public void whenFetchingActionsAfterReceivingSseActionsEventFailsBecauseOfTooManyRequestsThenNothingHappens()
+            throws Exception {
+        // given:
+        var requestFactory = mock(RequestFactory.class);
+        when(requestFactory.createGetRequest(ENDPOINT_ACTIONS, ACCESS_TOKEN)).thenReturn(request);
+
+        var response = createContentResponseMock(429, "{\"message\": \"Too Many Requests\"}");
+        when(request.send()).thenReturn(response);
+
+        var headerFields = mock(HttpFields.class);
+        when(headerFields.containsKey(anyString())).thenReturn(false);
+        when(response.getHeaders()).thenReturn(headerFields);
+
+        var dispatcher = mock(DeviceStateDispatcher.class);
+        var scheduler = mock(ScheduledExecutorService.class);
+
+        try (var webservice = new DefaultMieleWebservice(requestFactory, new NTimesRetryStrategy(0), dispatcher,
+                scheduler)) {
+            webservice.setAccessToken(ACCESS_TOKEN);
+
+            var actionsEvent = new ServerSentEvent(DefaultMieleWebservice.SSE_EVENT_TYPE_ACTIONS,
+                    "{\"" + DEVICE_IDENTIFIER + "\": {}}");
+
+            // when:
+            webservice.onServerSentEvent(actionsEvent);
+
+            // then:
+            verifyNoMoreInteractions(dispatcher);
+        }
+    }
+
+    @Test
+    public void whenFetchingActionsAfterReceivingSseActionsEventFailsBecauseOfAuthorizationFailedThenThisIsNotifiedToListeners()
+            throws Exception {
+        // given:
+        var requestFactory = mock(RequestFactory.class);
+        when(requestFactory.createGetRequest(ENDPOINT_ACTIONS, ACCESS_TOKEN)).thenReturn(request);
+
+        var response = createContentResponseMock(401, "{\"message\": \"Unauthorized\"}");
+        when(request.send()).thenReturn(response);
+
+        var dispatcher = mock(DeviceStateDispatcher.class);
+        var scheduler = mock(ScheduledExecutorService.class);
+
+        var connectionStatusListener = mock(ConnectionStatusListener.class);
+
+        try (var webservice = new DefaultMieleWebservice(requestFactory, new NTimesRetryStrategy(0), dispatcher,
+                scheduler)) {
+            webservice.addConnectionStatusListener(connectionStatusListener);
+            webservice.setAccessToken(ACCESS_TOKEN);
+
+            var actionsEvent = new ServerSentEvent(DefaultMieleWebservice.SSE_EVENT_TYPE_ACTIONS,
+                    "{\"" + DEVICE_IDENTIFIER + "\": {}}");
+
+            // when:
+            webservice.onServerSentEvent(actionsEvent);
+
+            // then:
+            verifyNoMoreInteractions(dispatcher);
+            verify(connectionStatusListener).onConnectionError(ConnectionError.AUTHORIZATION_FAILED, 0);
+        }
+    }
+
     /**
      * {@link RetryStrategy} for testing purposes. No exceptions will be catched.
      *
index a7fe5c92ee896456dbcf3faaf207f1f13ebeb78c..ee15602c82a0b89fd5217244c31338e293697b21 100644 (file)
@@ -18,6 +18,7 @@ import static org.mockito.Mockito.*;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.LinkedList;
+import java.util.List;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.junit.jupiter.api.Test;
@@ -128,9 +129,11 @@ public class ActionsStateTest {
 
         // when:
         boolean canBeStarted = actionState.canBeStarted();
+        boolean canBeStopped = actionState.canBeStopped();
 
         // then:
         assertTrue(canBeStarted);
+        assertFalse(canBeStopped);
     }
 
     @Test
@@ -141,9 +144,27 @@ public class ActionsStateTest {
         when(actions.getProcessAction()).thenReturn(Collections.singletonList(ProcessAction.STOP));
 
         // when:
+        boolean canBeStarted = actionState.canBeStarted();
+        boolean canBeStopped = actionState.canBeStopped();
+
+        // then:
+        assertFalse(canBeStarted);
+        assertTrue(canBeStopped);
+    }
+
+    @Test
+    public void testReturnValueWhenProcessActionStartAndStopAreAvailable() {
+        // given:
+        Actions actions = mock(Actions.class);
+        ActionsState actionState = new ActionsState(DEVICE_IDENTIFIER, actions);
+        when(actions.getProcessAction()).thenReturn(List.of(ProcessAction.START, ProcessAction.STOP));
+
+        // when:
+        boolean canBeStarted = actionState.canBeStarted();
         boolean canBeStopped = actionState.canBeStopped();
 
         // then:
+        assertTrue(canBeStarted);
         assertTrue(canBeStopped);
     }
 
diff --git a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollectionTest.java b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollectionTest.java
new file mode 100644 (file)
index 0000000..bfd65db
--- /dev/null
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010-2022 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.mielecloud.internal.webservice.api.json;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.openhab.binding.mielecloud.internal.util.ResourceUtil.getResourceAsString;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+
+/**
+ * @author Björn Lange - Initial contribution
+ */
+@NonNullByDefault
+public class ActionsCollectionTest {
+    @Test
+    public void canCreateActionsCollection() throws IOException {
+        // given:
+        String json = getResourceAsString(
+                "/org/openhab/binding/mielecloud/internal/webservice/api/json/actionsCollection.json");
+
+        // when:
+        ActionsCollection collection = ActionsCollection.fromJson(json);
+
+        // then:
+        assertEquals(Collections.singleton("000123456789"), collection.getDeviceIdentifiers());
+        Actions actions = collection.getActions("000123456789");
+
+        assertEquals(List.of(ProcessAction.START, ProcessAction.STOP), actions.getProcessAction());
+        assertEquals(Collections.singletonList(Light.DISABLE), actions.getLight());
+        assertEquals(Optional.empty(), actions.getStartTime());
+        assertEquals(Collections.singletonList(123), actions.getProgramId());
+        assertEquals(Optional.of(true), actions.getPowerOn());
+        assertEquals(Optional.of(false), actions.getPowerOff());
+    }
+
+    @Test
+    public void creatingActionsCollectionFromInvalidJsonThrowsMieleSyntaxException() {
+        // given:
+        String invalidJson = "{\":{}}";
+
+        // when:
+        assertThrows(MieleSyntaxException.class, () -> {
+            ActionsCollection.fromJson(invalidJson);
+        });
+    }
+
+    @Test
+    public void canCreateActionsCollectionWithLargeProgramID() throws IOException {
+        // given:
+        String json = "{\"mac-00124B000AE539D6\": {}}";
+
+        // when:
+        DeviceCollection collection = DeviceCollection.fromJson(json);
+
+        // then:
+        assertEquals(Collections.singleton("mac-00124B000AE539D6"), collection.getDeviceIdentifiers());
+    }
+}
index 18d8124a32ab648875674b3edb4a5332d1ea0539..f2ff3193bd59e8d68df9516f0367ee597885d31f 100644 (file)
@@ -16,6 +16,8 @@ import static org.junit.jupiter.api.Assertions.*;
 
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.junit.jupiter.api.Test;
@@ -100,4 +102,28 @@ public class ActionsTest {
         // then:
         assertEquals(Arrays.asList(1, 2, 3, 4), actions.getProgramId());
     }
+
+    @Test
+    public void processActionContainsSingleEntryWhenThereIsOneProcessAction() {
+        // given:
+        String json = "{ \"processAction\": [1] }";
+
+        // when:
+        Actions actions = new Gson().fromJson(json, Actions.class);
+
+        // then:
+        assertEquals(Collections.singletonList(ProcessAction.START), actions.getProcessAction());
+    }
+
+    @Test
+    public void processActionContainsTwoEntriesWhenThereAreTwoProcessActions() {
+        // given:
+        String json = "{ \"processAction\": [1,2] }";
+
+        // when:
+        Actions actions = new Gson().fromJson(json, Actions.class);
+
+        // then:
+        assertEquals(List.of(ProcessAction.START, ProcessAction.STOP), actions.getProcessAction());
+    }
 }
diff --git a/bundles/org.openhab.binding.mielecloud/src/test/resources/org/openhab/binding/mielecloud/internal/webservice/api/json/actionsCollection.json b/bundles/org.openhab.binding.mielecloud/src/test/resources/org/openhab/binding/mielecloud/internal/webservice/api/json/actionsCollection.json
new file mode 100644 (file)
index 0000000..5e02946
--- /dev/null
@@ -0,0 +1,16 @@
+{
+    "000123456789": {
+        "processAction": [1, 2],
+        "light": [2],
+        "ambientLight": [],
+        "startTime": null,
+        "ventilationStep": [],
+        "programId": [123],
+        "targetTemperature": [],
+        "deviceName": false,
+        "powerOn": true,
+        "powerOff": false,
+        "colors": [],
+        "modes": []
+    }
+}
\ No newline at end of file