From c5945f0257c155478b1425b3f6751a85bfcf6a43 Mon Sep 17 00:00:00 2001 From: Patrick <54861416+pat-git023@users.noreply.github.com> Date: Sat, 11 Nov 2023 14:22:52 +0100 Subject: [PATCH] [boschshc] Add scenario channel (#15752) Signed-off-by: Patrick Gell --- .../org.openhab.binding.boschshc/README.md | 11 ++ .../devices/BoschSHCBindingConstants.java | 2 + .../devices/bridge/BridgeHandler.java | 24 ++- .../devices/bridge/ScenarioHandler.java | 110 +++++++++++ .../devices/bridge/dto/LongPollResult.java | 4 +- .../internal/devices/bridge/dto/Scenario.java | 64 +++++++ .../BoschServiceDataDeserializer.java | 66 +++++++ .../internal/serialization/GsonUtils.java | 2 + .../services/dto/BoschSHCServiceState.java | 2 +- .../resources/OH-INF/i18n/boschshc.properties | 4 + .../resources/OH-INF/thing/thing-types.xml | 21 +++ .../main/resources/OH-INF/update/binding.xml | 15 ++ .../devices/bridge/LongPollingTest.java | 46 ++++- .../devices/bridge/ScenarioHandlerTest.java | 172 ++++++++++++++++++ .../BoschServiceDataDeserializerTest.java | 71 ++++++++ 15 files changed, 609 insertions(+), 5 deletions(-) create mode 100644 bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandler.java create mode 100644 bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Scenario.java create mode 100644 bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializer.java create mode 100644 bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml create mode 100644 bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandlerTest.java create mode 100644 bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializerTest.java diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index 6be38759eab06..3448c644c8f41 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -4,6 +4,7 @@ Binding for the Bosch Smart Home. - [Bosch Smart Home Binding](#bosch-smart-home-binding) - [Supported Things](#supported-things) + - [Smart Home Controller](#smart-home-controller) - [In-Wall Switch](#in-wall-switch) - [Compact Smart Plug](#compact-smart-plug) - [Twinguard Smoke Detector](#twinguard-smoke-detector) @@ -27,6 +28,16 @@ Binding for the Bosch Smart Home. ## Supported Things +### Smart Home Controller +The Smart Home Controller is the central hub that allows you to monitor and control your smart home devices from one place. + +**Bridge Type ID**: ``shc`` + +| Channel Type ID | Item Type | Writable | Description | +|--------------------|-----------|:--------:|-------------------------------------------------------------------------| +| scenario-triggered | String | ☐ | Name of the triggered scenario (e.g. by the Universal Switch Flex) | +| trigger-scenario | String | ☑ | Name of a scenario to be triggered on the Bosch Smart Home Controller. | + ### In-Wall Switch A simple light control. diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java index 8b70ce0a49987..d99006889148d 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java @@ -51,6 +51,8 @@ public class BoschSHCBindingConstants { // List of all Channel IDs // Auto-generated from thing-types.xml via script, don't modify + public static final String CHANNEL_SCENARIO_TRIGGERED = "scenario-triggered"; + public static final String CHANNEL_TRIGGER_SCENARIO = "trigger-scenario"; public static final String CHANNEL_POWER_SWITCH = "power-switch"; public static final String CHANNEL_TEMPERATURE = "temperature"; public static final String CHANNEL_TEMPERATURE_RATING = "temperature-rating"; diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java index 54e14d749d335..717ad89d375cb 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java @@ -34,11 +34,13 @@ import org.eclipse.jetty.client.api.Response; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.util.ssl.SslContextFactory; +import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants; import org.openhab.binding.boschshc.internal.devices.BoschSHCHandler; import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device; import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData; import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult; import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario; import org.openhab.binding.boschshc.internal.discovery.ThingDiscoveryService; import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException; @@ -46,7 +48,9 @@ import org.openhab.binding.boschshc.internal.serialization.GsonUtils; import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; import org.openhab.binding.boschshc.internal.services.dto.JsonRestExceptionResponse; +import org.openhab.core.library.types.StringType; import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Channel; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; @@ -55,6 +59,7 @@ import org.openhab.core.thing.binding.ThingHandler; import org.openhab.core.thing.binding.ThingHandlerService; import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; import org.osgi.framework.Bundle; import org.osgi.framework.FrameworkUtil; import org.slf4j.Logger; @@ -99,8 +104,11 @@ public class BridgeHandler extends BaseBridgeHandler { */ private @Nullable ThingDiscoveryService thingDiscoveryService; + private final ScenarioHandler scenarioHandler; + public BridgeHandler(Bridge bridge) { super(bridge); + scenarioHandler = new ScenarioHandler(); this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure); } @@ -195,6 +203,11 @@ public void dispose() { @Override public void handleCommand(ChannelUID channelUID, Command command) { // commands are handled by individual device handlers + BoschHttpClient localHttpClient = httpClient; + if (BoschSHCBindingConstants.CHANNEL_TRIGGER_SCENARIO.equals(channelUID.getId()) + && !RefreshType.REFRESH.equals(command) && localHttpClient != null) { + scenarioHandler.triggerScenario(localHttpClient, command.toString()); + } } /** @@ -410,8 +423,15 @@ public boolean unregisterDiscoveryListener() { * @param result Results from Long Polling */ private void handleLongPollResult(LongPollResult result) { - for (DeviceServiceData deviceServiceData : result.result) { - handleDeviceServiceData(deviceServiceData); + for (BoschSHCServiceState serviceState : result.result) { + if (serviceState instanceof DeviceServiceData deviceServiceData) { + handleDeviceServiceData(deviceServiceData); + } else if (serviceState instanceof Scenario scenario) { + final Channel channel = this.getThing().getChannel(BoschSHCBindingConstants.CHANNEL_SCENARIO_TRIGGERED); + if (channel != null && isLinked(channel.getUID())) { + updateState(channel.getUID(), new StringType(scenario.name)); + } + } } } diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandler.java new file mode 100644 index 0000000000000..54a080a8cf1ea --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandler.java @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2010-2023 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.boschshc.internal.devices.bridge; + +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario; +import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handler for executing a scenario. + * + * @author Patrick Gell - Initial contribution + * + */ +@NonNullByDefault +public class ScenarioHandler { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + protected ScenarioHandler() { + } + + public void triggerScenario(final BoschHttpClient httpClient, final String scenarioName) { + + final Scenario[] scenarios; + try { + scenarios = getAvailableScenarios(httpClient); + } catch (BoschSHCException e) { + logger.debug("unable to read the available scenarios from Bosch Smart Home Conteroller", e); + return; + } + final Optional scenario = Arrays.stream(scenarios).filter(s -> s.name.equals(scenarioName)) + .findFirst(); + if (scenario.isPresent()) { + sendPOSTRequest(httpClient.getBoschSmartHomeUrl(String.format("scenarios/%s/triggers", scenario.get().id)), + httpClient); + } else { + if (logger.isDebugEnabled()) { + logger.debug("Scenario '{}' was not found in the list of available scenarios {}", scenarioName, + prettyLogScenarios(scenarios)); + } + } + } + + private Scenario[] getAvailableScenarios(final BoschHttpClient httpClient) throws BoschSHCException { + final Request request = httpClient.createRequest(httpClient.getBoschSmartHomeUrl("scenarios"), HttpMethod.GET); + try { + return httpClient.sendRequest(request, Scenario[].class, Scenario::isValid, null); + } catch (InterruptedException e) { + logger.debug("Scenario call was interrupted", e); + Thread.currentThread().interrupt(); + } catch (TimeoutException e) { + logger.debug("Scenario call timed out", e); + } catch (ExecutionException e) { + logger.debug("Exception occurred during scenario call", e); + } + + return new Scenario[] {}; + } + + private void sendPOSTRequest(final String url, final BoschHttpClient httpClient) { + try { + final Request request = httpClient.createRequest(url, HttpMethod.POST); + final ContentResponse response = request.send(); + if (HttpStatus.ACCEPTED_202 != response.getStatus()) { + logger.debug("{} - {} failed with {}: {}", HttpMethod.POST, url, response.getStatus(), + response.getContentAsString()); + } + } catch (InterruptedException e) { + logger.debug("Scenario call was interrupted", e); + Thread.currentThread().interrupt(); + } catch (TimeoutException e) { + logger.debug("Scenario call timed out", e); + } catch (ExecutionException e) { + logger.debug("Exception occurred during scenario call", e); + } + } + + private String prettyLogScenarios(final Scenario[] scenarios) { + final StringBuilder builder = new StringBuilder(); + builder.append("["); + for (Scenario scenario : scenarios) { + builder.append("\n "); + builder.append(scenario); + } + builder.append("\n]"); + return builder.toString(); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/LongPollResult.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/LongPollResult.java index 1838c96b445f5..70b8cbade032a 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/LongPollResult.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/LongPollResult.java @@ -14,6 +14,8 @@ import java.util.ArrayList; +import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; + /** * Response of the Controller for a Long Poll API call. * @@ -35,6 +37,6 @@ public class LongPollResult { * ],"jsonrpc":"2.0"} */ - public ArrayList result; + public ArrayList result; public String jsonrpc; } diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Scenario.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Scenario.java new file mode 100644 index 0000000000000..4440d8ff8ce5e --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Scenario.java @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2010-2023 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.boschshc.internal.devices.bridge.dto; + +import java.util.Arrays; + +import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; + +/** + * A scenario as represented by the controller. + * + * Json example: + * { + * "@type": "scenarioTriggered", + * "name": "My scenario", + * "id": "509bd737-eed0-40b7-8caa-e8686a714399", + * "lastTimeTriggered": "1693758693032" + * } + * + * @author Patrick Gell - Initial contribution + */ +public class Scenario extends BoschSHCServiceState { + + public String name; + public String id; + public String lastTimeTriggered; + + public Scenario() { + super("scenarioTriggered"); + } + + public static Scenario createScenario(final String id, final String name, final String lastTimeTriggered) { + final Scenario scenario = new Scenario(); + + scenario.id = id; + scenario.name = name; + scenario.lastTimeTriggered = lastTimeTriggered; + return scenario; + } + + public static Boolean isValid(Scenario[] scenarios) { + return Arrays.stream(scenarios).allMatch(scenario -> (scenario.id != null)); + } + + @Override + public String toString() { + final StringBuilder sb = new StringBuilder("Scenario{"); + sb.append("name='").append(name).append("'"); + sb.append(", id='").append(id).append("'"); + sb.append(", lastTimeTriggered='").append(lastTimeTriggered).append("'"); + sb.append('}'); + return sb.toString(); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializer.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializer.java new file mode 100644 index 0000000000000..c04d0bd89a70f --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializer.java @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2010-2023 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.boschshc.internal.serialization; + +import java.lang.reflect.Type; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario; +import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +/** + * Utility class for JSON deserialization of device data and triggered scenarios using Google Gson. + * + * @author Patrick Gell - Initial contribution + * + */ +@NonNullByDefault +public class BoschServiceDataDeserializer implements JsonDeserializer { + + @Nullable + @Override + public BoschSHCServiceState deserialize(JsonElement jsonElement, Type type, + JsonDeserializationContext jsonDeserializationContext) throws JsonParseException { + + JsonObject jsonObject = jsonElement.getAsJsonObject(); + JsonElement dataType = jsonObject.get("@type"); + switch (dataType.getAsString()) { + case "DeviceServiceData" -> { + var deviceServiceData = new DeviceServiceData(); + deviceServiceData.deviceId = jsonObject.get("deviceId").getAsString(); + deviceServiceData.state = jsonObject.get("state"); + deviceServiceData.id = jsonObject.get("id").getAsString(); + deviceServiceData.path = jsonObject.get("path").getAsString(); + return deviceServiceData; + } + case "scenarioTriggered" -> { + var scenario = new Scenario(); + scenario.id = jsonObject.get("id").getAsString(); + scenario.name = jsonObject.get("name").getAsString(); + scenario.lastTimeTriggered = jsonObject.get("lastTimeTriggered").getAsString(); + return scenario; + } + default -> { + return new BoschSHCServiceState(dataType.getAsString()); + } + } + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/GsonUtils.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/GsonUtils.java index efa652a50656c..0360370a0c4ba 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/GsonUtils.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/serialization/GsonUtils.java @@ -13,6 +13,7 @@ package org.openhab.binding.boschshc.internal.serialization; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -35,6 +36,7 @@ private GsonUtils() { * This instance does not serialize or deserialize fields named logger. */ public static final Gson DEFAULT_GSON_INSTANCE = new GsonBuilder() + .registerTypeAdapter(BoschSHCServiceState.class, new BoschServiceDataDeserializer()) .addSerializationExclusionStrategy(new LoggerExclusionStrategy()) .addDeserializationExclusionStrategy(new LoggerExclusionStrategy()).create(); } diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java index 488356b30534f..7d906c6717449 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java @@ -37,7 +37,7 @@ public class BoschSHCServiceState { @SerializedName("@type") public final String type; - protected BoschSHCServiceState(String type) { + public BoschSHCServiceState(String type) { this.type = type; if (stateType == null) { diff --git a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties index 2371ad36a11b5..f2eb06fccffc6 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties @@ -105,6 +105,8 @@ channel-type.boschshc.purity-rating.label = Purity Rating channel-type.boschshc.purity-rating.description = Rating of the air purity. channel-type.boschshc.purity.label = Purity channel-type.boschshc.purity.description = Purity of the air. A higher value indicates a higher pollution. +channel-type.boschshc.scenario-triggered.label = Scenario Triggered +channel-type.boschshc.scenario-triggered.description = Name of the triggered scenario channel-type.boschshc.setpoint-temperature.label = Setpoint Temperature channel-type.boschshc.setpoint-temperature.description = Desired temperature. channel-type.boschshc.silent-mode.label = Silent Mode @@ -126,6 +128,8 @@ channel-type.boschshc.temperature-rating.state.option.MEDIUM = Medium Temperatur channel-type.boschshc.temperature-rating.state.option.BAD = Bad Temperature channel-type.boschshc.temperature.label = Temperature channel-type.boschshc.temperature.description = Current measured temperature. +channel-type.boschshc.trigger-scenario.label = Trigger Scenario +channel-type.boschshc.trigger-scenario.description = Name of the scenario to trigger channel-type.boschshc.valve-tappet-position.label = Valve Tappet Position channel-type.boschshc.valve-tappet-position.description = Current open ratio (0 to 100). diff --git a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml index 03354abe14322..1ff71e37d1bd8 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml @@ -9,6 +9,15 @@ The Bosch Smart Home Bridge representing the Bosch Smart Home Controller. + + + + + + + 1 + + @@ -520,4 +529,16 @@ + + String + + Name of the triggered scenario + + + + String + + Name of the scenario to trigger + + diff --git a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml new file mode 100644 index 0000000000000..814f6b8da8b3f --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml @@ -0,0 +1,15 @@ + + + + + + boschshc:scenario-triggered + + + boschshc:trigger-scenario + + + + diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/LongPollingTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/LongPollingTest.java index 33035a95b08af..2b28b8d068b48 100644 --- a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/LongPollingTest.java +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/LongPollingTest.java @@ -48,6 +48,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData; import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario; import org.openhab.binding.boschshc.internal.devices.bridge.dto.SubscribeResult; import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException; @@ -237,7 +238,8 @@ void start() throws InterruptedException, TimeoutException, ExecutionException, verify(longPollHandler).accept(longPollResultCaptor.capture()); LongPollResult longPollResult = longPollResultCaptor.getValue(); assertEquals(1, longPollResult.result.size()); - DeviceServiceData longPollResultItem = longPollResult.result.get(0); + assertEquals(longPollResult.result.get(0).getClass(), DeviceServiceData.class); + DeviceServiceData longPollResultItem = (DeviceServiceData) longPollResult.result.get(0); assertEquals("hdm:HomeMaticIP:3014F711A0001916D859A8A9", longPollResultItem.deviceId); assertEquals("/devices/hdm:HomeMaticIP:3014F711A0001916D859A8A9/services/PowerSwitch", longPollResultItem.path); assertEquals("PowerSwitch", longPollResultItem.id); @@ -246,6 +248,48 @@ void start() throws InterruptedException, TimeoutException, ExecutionException, assertEquals("ON", stateObject.get("switchState").getAsString()); } + @Test + void startLongPolling_receiveScenario() + throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { + // when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod(); + when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod(); + + Request subscribeRequest = mock(Request.class); + when(httpClient.createRequest(anyString(), same(HttpMethod.POST), + argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest); + SubscribeResult subscribeResult = new SubscribeResult(); + when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult); + + Request longPollRequest = mock(Request.class); + when(httpClient.createRequest(anyString(), same(HttpMethod.POST), + argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest); + + fixture.start(httpClient); + + ArgumentCaptor completeListener = ArgumentCaptor.forClass(CompleteListener.class); + verify(longPollRequest).send(completeListener.capture()); + + BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue(); + + String longPollResultJSON = "{\"result\":[{\"@type\": \"scenarioTriggered\",\"name\": \"My scenario\",\"id\": \"509bd737-eed0-40b7-8caa-e8686a714399\",\"lastTimeTriggered\": \"1693758693032\"}],\"jsonrpc\":\"2.0\"}\n"; + Response response = mock(Response.class); + bufferingResponseListener.onContent(response, + ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8))); + + Result result = mock(Result.class); + bufferingResponseListener.onComplete(result); + + ArgumentCaptor longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class); + verify(longPollHandler).accept(longPollResultCaptor.capture()); + LongPollResult longPollResult = longPollResultCaptor.getValue(); + assertEquals(1, longPollResult.result.size()); + assertEquals(longPollResult.result.get(0).getClass(), Scenario.class); + Scenario longPollResultItem = (Scenario) longPollResult.result.get(0); + assertEquals("509bd737-eed0-40b7-8caa-e8686a714399", longPollResultItem.id); + assertEquals("My scenario", longPollResultItem.name); + assertEquals("1693758693032", longPollResultItem.lastTimeTriggered); + } + @Test void startSubscriptionFailure() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandlerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandlerTest.java new file mode 100644 index 0000000000000..3035a6f102349 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandlerTest.java @@ -0,0 +1,172 @@ +/** + * Copyright (c) 2010-2023 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.boschshc.internal.devices.bridge; + +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario; +import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; + +/** + * Unit tests for {@link ScenarioHandler}. + * + * @author Patrick Gell - Initial contribution + * + */ +@NonNullByDefault +@ExtendWith(MockitoExtension.class) +class ScenarioHandlerTest { + + private final Scenario[] existingScenarios = List.of( + Scenario.createScenario(UUID.randomUUID().toString(), "Scenario 1", + String.valueOf(System.currentTimeMillis())), + Scenario.createScenario(UUID.randomUUID().toString(), "Scenario 2", + String.valueOf(System.currentTimeMillis())) + + ).toArray(Scenario[]::new); + + protected static Exception[] exceptionData() { + return List.of(new BoschSHCException(), new InterruptedException(), new TimeoutException(), + new ExecutionException(new BoschSHCException())).toArray(Exception[]::new); + } + + protected static Exception[] httpExceptionData() { + return List + .of(new InterruptedException(), new TimeoutException(), new ExecutionException(new BoschSHCException())) + .toArray(Exception[]::new); + } + + @Test + void triggerScenario_ShouldSendPOST_ToBoschAPI() throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + final var contentResponse = mock(ContentResponse.class); + when(httpClient.getBoschSmartHomeUrl(anyString())).thenReturn("http://localhost/smartHome/scenarios") + .thenReturn("http://localhost/smartHome/scenarios/1234/triggers"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request).thenReturn(request); + when(httpClient.sendRequest(any(Request.class), any(), any(), any())).thenReturn(existingScenarios); + when(request.send()).thenReturn(contentResponse); + when(contentResponse.getStatus()).thenReturn(HttpStatus.OK_200); + + final var handler = new ScenarioHandler(); + + // WHEN + handler.triggerScenario(httpClient, "Scenario 1"); + + // THEN + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request).send(); + } + + @Test + void triggerScenario_ShouldNoSendPOST_ToScenarioNameDoesNotExist() throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + when(httpClient.getBoschSmartHomeUrl(anyString())).thenReturn("http://localhost/smartHome/scenarios") + .thenReturn("http://localhost/smartHome/scenarios/1234/triggers"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request).thenReturn(request); + when(httpClient.sendRequest(any(Request.class), any(), any(), any())).thenReturn(existingScenarios); + + final var handler = new ScenarioHandler(); + + // WHEN + handler.triggerScenario(httpClient, "not existing Scenario"); + + // THEN + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request, times(0)).send(); + } + + @ParameterizedTest + @MethodSource("exceptionData") + void triggerScenario_ShouldNotPanic_IfBoschAPIThrowsException(final Exception exception) throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + when(httpClient.getBoschSmartHomeUrl(anyString())).thenReturn("http://localhost/smartHome/scenarios") + .thenReturn("http://localhost/smartHome/scenarios/1234/triggers"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); + when(httpClient.sendRequest(any(Request.class), any(), any(), any())).thenThrow(exception); + + final var handler = new ScenarioHandler(); + + // WHEN + handler.triggerScenario(httpClient, "Scenario 1"); + + // THEN + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request, times(0)).send(); + } + + @Test + void triggerScenario_ShouldNotPanic_IfPOSTIsNotSuccessful() throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + final var contentResponse = mock(ContentResponse.class); + when(httpClient.getBoschSmartHomeUrl(anyString())).thenReturn("http://localhost/smartHome/scenarios") + .thenReturn("http://localhost/smartHome/scenarios/1234/triggers"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request).thenReturn(request); + when(httpClient.sendRequest(any(Request.class), any(), any(), any())).thenReturn(existingScenarios); + when(request.send()).thenReturn(contentResponse); + when(contentResponse.getStatus()).thenReturn(HttpStatus.METHOD_NOT_ALLOWED_405); + + final var handler = new ScenarioHandler(); + + // WHEN + handler.triggerScenario(httpClient, "Scenario 1"); + + // THEN + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request).send(); + } + + @ParameterizedTest + @MethodSource("httpExceptionData") + void triggerScenario_ShouldNotPanic_IfPOSTThrowsException(final Exception exception) throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + when(httpClient.getBoschSmartHomeUrl(anyString())).thenReturn("http://localhost/smartHome/scenarios") + .thenReturn("http://localhost/smartHome/scenarios/1234/triggers"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request).thenReturn(request); + when(httpClient.sendRequest(any(Request.class), any(), any(), any())).thenReturn(existingScenarios); + when(request.send()).thenThrow(exception); + + final var handler = new ScenarioHandler(); + + // WHEN + handler.triggerScenario(httpClient, "Scenario 1"); + + // THEN + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request).send(); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializerTest.java new file mode 100644 index 0000000000000..c06753d797cc9 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializerTest.java @@ -0,0 +1,71 @@ +/** + * Copyright (c) 2010-2023 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.boschshc.internal.serialization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario; + +/** + * Unit tests for {@link BoschServiceDataDeserializer}. + * + * @author Patrick Gell - Initial contribution + * + */ +@NonNullByDefault +class BoschServiceDataDeserializerTest { + + @Test + void deserializationOfLongPollingResult() { + var resultJson = """ + { + "result": [ + { + "@type": "scenarioTriggered", + "name": "MyTriggeredScenario", + "id": "509bd737-eed0-40b7-8caa-e8686a714399", + "lastTimeTriggered": "1689417526720" + }, + { + "path":"/devices/hdm:HomeMaticIP:3014F711A0001916D859A8A9/services/PowerSwitch", + "@type":"DeviceServiceData", + "id":"PowerSwitch", + "state":{ + "@type":"powerSwitchState", + "switchState":"ON" + }, + "deviceId":"hdm:HomeMaticIP:3014F711A0001916D859A8A9" + } + ], + "jsonrpc": "2.0" + } + """; + + var longPollResult = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(resultJson, LongPollResult.class); + assertNotNull(longPollResult); + assertEquals(2, longPollResult.result.size()); + + var resultClasses = new HashSet<>(longPollResult.result.stream().map(e -> e.getClass().getName()).toList()); + assertEquals(2, resultClasses.size()); + assertTrue(resultClasses.contains(DeviceServiceData.class.getName())); + assertTrue(resultClasses.contains(Scenario.class.getName())); + } +}