diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/AbstractMieleThingHandler.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/AbstractMieleThingHandler.java index 937d6b069..384185d1d 100644 --- a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/AbstractMieleThingHandler.java +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/AbstractMieleThingHandler.java @@ -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 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; diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/channel/ChannelTypeUtil.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/channel/ChannelTypeUtil.java index 754b789fb..462b11702 100644 --- a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/channel/ChannelTypeUtil.java +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/handler/channel/ChannelTypeUtil.java @@ -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 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 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 index 4d5bf570b..000000000 --- a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcher.java +++ /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 lastDeviceState = Optional.empty(); - private final Supplier 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 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()); - } - } -} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebservice.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebservice.java index 0f309bb5a..602bfb86e 100644 --- a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebservice.java +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebservice.java @@ -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 index 000000000..ca985b07b --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollection.java @@ -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>() { + }.getType(); + + private final Map actions; + + ActionsCollection(Map 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 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 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 + "]"; + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/sse/ServerSentEvent.java b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/sse/ServerSentEvent.java index 57bc3900d..5cc91f133 100644 --- a/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/sse/ServerSentEvent.java +++ b/bundles/org.openhab.binding.mielecloud/src/main/java/org/openhab/binding/mielecloud/internal/webservice/sse/ServerSentEvent.java @@ -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 index 7dfd22600..000000000 --- a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/ActionStateFetcherTest.java +++ /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. 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()); - } -} diff --git a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebserviceTest.java b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebserviceTest.java index 28023aa3b..812cb68d0 100644 --- a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebserviceTest.java +++ b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/DefaultMieleWebserviceTest.java @@ -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. * diff --git a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/ActionsStateTest.java b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/ActionsStateTest.java index a7fe5c92e..ee15602c8 100644 --- a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/ActionsStateTest.java +++ b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/ActionsStateTest.java @@ -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 index 000000000..bfd65db6f --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsCollectionTest.java @@ -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()); + } +} diff --git a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsTest.java b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsTest.java index 18d8124a3..f2ff3193b 100644 --- a/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsTest.java +++ b/bundles/org.openhab.binding.mielecloud/src/test/java/org/openhab/binding/mielecloud/internal/webservice/api/json/ActionsTest.java @@ -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 index 000000000..5e02946fa --- /dev/null +++ b/bundles/org.openhab.binding.mielecloud/src/test/resources/org/openhab/binding/mielecloud/internal/webservice/api/json/actionsCollection.json @@ -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