From 9855ced6aae6002ae164dc1a7e139806fccf9a4b Mon Sep 17 00:00:00 2001 From: Patrick Gell Date: Sat, 14 Oct 2023 14:39:52 +0200 Subject: [PATCH 1/6] feat: support Bosch SHC Scenarios Signed-off-by: Patrick Gell --- .../org.openhab.binding.boschshc/README.md | 7 + .../devices/BoschSHCBindingConstants.java | 2 + .../devices/bridge/BridgeHandler.java | 23 ++- .../devices/bridge/ScenarioHandler.java | 101 ++++++++++ .../devices/bridge/dto/LongPollResult.java | 4 +- .../internal/devices/bridge/dto/Scenario.java | 39 ++++ .../BoschServiceDataDeserializer.java | 66 +++++++ .../internal/serialization/GsonUtils.java | 2 + .../services/dto/BoschSHCServiceState.java | 2 +- .../resources/OH-INF/i18n/boschshc.properties | 2 + .../resources/OH-INF/thing/thing-types.xml | 21 ++ .../main/resources/OH-INF/update/binding.xml | 15 ++ .../devices/bridge/LongPollingTest.java | 2 +- .../devices/bridge/ScenarioHandlerTest.java | 187 ++++++++++++++++++ .../BoschServiceDataDeserializerTest.java | 72 +++++++ .../scenarios/GET_scenarios_result.json | 47 +++++ 16 files changed, 587 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 create mode 100644 bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index 6be38759eab06..b31ff616a9a7e 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -228,6 +228,13 @@ This certificate is used for pairing between the Bridge and the Bosch Smart Home _Press and hold the Bosch Smart Home Controller Bridge button until the LED starts blinking after you save your settings for pairing_. +### Supported Channels + +| Channel Type ID | Item Type | Writable | Description | +|--------------------| -------------------- |:--------:|--------------------------------------------------------------------| +| triggered-scenario | String | ☐ | Name of the triggered scenario (e.g. by the Universal Switch Flex) | + + ## Getting the device IDs Bosch IDs for found devices are displayed in the openHAB log on bootup (`OPENHAB_FOLDER/userdata/logs/openhab.log`) 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..9203f27aa2870 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"; + public static final String CHANNEL_EXECUTE_SCENARIO = "execute-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..9e090a495c3ab 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 @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -34,11 +35,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 +49,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; @@ -99,8 +104,11 @@ public class BridgeHandler extends BaseBridgeHandler { */ private @Nullable ThingDiscoveryService thingDiscoveryService; + private final @NonNullByDefault ScenarioHandler scenarioHandler; + public BridgeHandler(Bridge bridge) { super(bridge); + scenarioHandler = new ScenarioHandler(new HashMap<>()); this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure); } @@ -195,6 +203,10 @@ public void dispose() { @Override public void handleCommand(ChannelUID channelUID, Command command) { // commands are handled by individual device handlers + if (channelUID.getId().equals(BoschSHCBindingConstants.CHANNEL_EXECUTE_SCENARIO) + && !command.toString().equals("REFRESH") && this.httpClient != null) { + this.scenarioHandler.executeScenario(this.httpClient, command.toString()); + } } /** @@ -410,8 +422,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 (DeviceServiceData.class == serviceState.getClass()) { + handleDeviceServiceData((DeviceServiceData) serviceState); + } else if (Scenario.class == serviceState.getClass()) { + final Channel channel = this.getThing().getChannel(BoschSHCBindingConstants.CHANNEL_SCENARIO); + if (channel != null && isLinked(channel.getUID())) { + updateState(channel.getUID(), new StringType(((Scenario) serviceState).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..a1d16b5a3c1a6 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandler.java @@ -0,0 +1,101 @@ +/** + * 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.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +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.serialization.GsonUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonSyntaxException; + +/** + * Handler for executing a scenario. + * + * @author Patrick Gell - Initial contribution + * + */ +@NonNullByDefault +public class ScenarioHandler { + + private final Logger logger = LoggerFactory.getLogger(getClass()); + + private final Map availableScenarios; + + protected ScenarioHandler(Map availableScenarios) { + this.availableScenarios = Objects.requireNonNullElseGet(availableScenarios, HashMap::new); + } + + public void executeScenario(final @Nullable BoschHttpClient httpClient, final String scenarioName) { + assert httpClient != null; + if (!availableScenarios.containsKey(scenarioName)) { + updateScenarios(httpClient); + } + final Scenario scenario = this.availableScenarios.get(scenarioName); + if (scenario != null) { + sendRequest(HttpMethod.POST, + httpClient.getBoschSmartHomeUrl(String.format("scenarios/%s/triggers", scenario.id)), httpClient); + } else { + logger.debug("scenario '{}' not found on the Bosch Controller", scenarioName); + } + } + + private void updateScenarios(final @Nullable BoschHttpClient httpClient) { + if (httpClient != null) { + final String result = sendRequest(HttpMethod.GET, httpClient.getBoschSmartHomeUrl("scenarios"), httpClient); + try { + Scenario[] scenarios = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(result, Scenario[].class); + if (scenarios != null) { + for (Scenario scenario : scenarios) { + availableScenarios.put(scenario.name, scenario); + } + } + } catch (JsonSyntaxException e) { + logger.debug("response from SHC could not be parsed: {}", result, e); + } + } + } + + private String sendRequest(final HttpMethod method, final String url, final BoschHttpClient httpClient) { + try { + final Request request = httpClient.createRequest(url, method); + final ContentResponse response = request.send(); + switch (HttpStatus.getCode(response.getStatus())) { + case OK -> { + return response.getContentAsString(); + } + case NOT_FOUND, METHOD_NOT_ALLOWED -> logger.debug("{} - {} failed with {}: {}", method, url, + response.getStatus(), response.getContentAsString()); + } + } catch (InterruptedException e) { + logger.debug("scenario call was interrupted", e); + } catch (TimeoutException e) { + logger.debug("scenarion call timed out", e); + } catch (ExecutionException e) { + logger.debug("exception occurred during scenario call", e); + } + return ""; + } +} 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..ddaba8545c4d3 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Scenario.java @@ -0,0 +1,39 @@ +/** + * 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 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"); + } +} 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..e0f5e87c368bf 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.label = Triggered Scenario +channel-type.boschshc.scenario.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 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..c71a8db4ff2b5 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 execute + + 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..61477ff9f5ee5 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/update/binding.xml @@ -0,0 +1,15 @@ + + + + + + boschshc:triggered-scenario + + + boschshc:execute-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..2092ae155e71e 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 @@ -237,7 +237,7 @@ 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); + 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); 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..f54bb618eac98 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandlerTest.java @@ -0,0 +1,187 @@ +/** + * 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.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.*; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.stream.Stream; + +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.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario; + +/** + * Unit tests for {@link ScenarioHandler}. + * + * @author Patrick Gell - Initial contribution + * + */ +@NonNullByDefault +@ExtendWith(MockitoExtension.class) +public class ScenarioHandlerTest { + + @Test + public void executeScenario_ShouldLoadAllScenarios_IfAvailableScenariosAreEmpty() throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + final var response = mock(ContentResponse.class); + when(httpClient.getBoschSmartHomeUrl("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); + when(request.send()).thenReturn(response); + when(response.getStatus()).thenReturn(200); + when(response.getContentAsString()).thenReturn(getJsonStringFromFile()); + + final Map availableScenarios = new HashMap<>(); + final var handler = new ScenarioHandler(availableScenarios); + + // WHEN + handler.executeScenario(httpClient, "fooBar"); + + // THEN + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + assertEquals(3, availableScenarios.size()); + } + + @Test + public void executeScenario_ShouldMakePostCall_IfScenarioExists() throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + final var response = mock(ContentResponse.class); + final Map availableScenarios = new HashMap<>(); + final var testScenario = new Scenario(); + testScenario.id = UUID.randomUUID().toString(); + testScenario.name = "fooBar"; + availableScenarios.put(testScenario.name, testScenario); + final var endpoint = String.format("scenarios/%s/triggers", testScenario.id); + when(httpClient.getBoschSmartHomeUrl(endpoint)).thenReturn(endpoint); + when(httpClient.createRequest(endpoint, HttpMethod.POST)).thenReturn(request); + when(request.send()).thenReturn(response); + when(response.getStatus()).thenReturn(200); + when(response.getContentAsString()).thenReturn(""); + final var handler = new ScenarioHandler(availableScenarios); + + // WHEN + handler.executeScenario(httpClient, testScenario.name); + + // THEN + verify(request, times(1)).send(); + } + + private static Stream provideExceptionsForTest() { + return Stream.of(Arguments.of(new InterruptedException("call interrupted")), + Arguments.of(new TimeoutException("call timed out")), + Arguments.of(new ExecutionException(new Exception()))); + } + + @ParameterizedTest + @MethodSource("provideExceptionsForTest") + public void executeScenario_ShouldNotThrowException_IfApiCallsHaveException(final Exception exception) + throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + when(httpClient.getBoschSmartHomeUrl("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); + when(request.send()).thenThrow(exception); + final Map availableScenarios = new HashMap<>(); + final var handler = new ScenarioHandler(availableScenarios); + + // WHEN + handler.executeScenario(httpClient, "fooBar"); + + // THEN + assertTrue(availableScenarios.isEmpty()); + } + + @ParameterizedTest + @ValueSource(ints = { 404, 405 }) + public void executeScenario_ShouldNotThrowException_IfApiCallReturnsError(final int statusCode) throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + final var response = mock(ContentResponse.class); + when(httpClient.getBoschSmartHomeUrl("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); + when(request.send()).thenReturn(response); + when(response.getStatus()).thenReturn(statusCode); + final Map availableScenarios = new HashMap<>(); + final var handler = new ScenarioHandler(availableScenarios); + + // WHEN + handler.executeScenario(httpClient, "fooBar"); + + // THEN + assertTrue(availableScenarios.isEmpty()); + } + + @Test + public void executeScenario_ShouldNotThrowException_IfResponseIsNoJson() throws Exception { + // GIVEN + final var httpClient = mock(BoschHttpClient.class); + final var request = mock(Request.class); + final var response = mock(ContentResponse.class); + when(httpClient.getBoschSmartHomeUrl("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); + when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); + when(request.send()).thenReturn(response); + when(response.getStatus()).thenReturn(200); + when(response.getContentAsString()).thenReturn("this is not a valid json"); + + final Map availableScenarios = new HashMap<>(); + final var handler = new ScenarioHandler(availableScenarios); + + // WHEN + handler.executeScenario(httpClient, "fooBar"); + + // THEN + assertTrue(availableScenarios.isEmpty()); + } + + private String getJsonStringFromFile() throws IOException { + try (InputStream input = this.getClass().getClassLoader() + .getResourceAsStream("scenarios/GET_scenarios_result.json")) { + if (input == null) { + return ""; + } + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) { + StringBuilder stringBuilder = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + stringBuilder.append(line); + } + return stringBuilder.toString(); + } + } + } +} 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..e4ecf1cbaa674 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/serialization/BoschServiceDataDeserializerTest.java @@ -0,0 +1,72 @@ +/** + * 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 java.util.ArrayList; +import java.util.List; + +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 +public class BoschServiceDataDeserializerTest { + + @Test + public 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 ArrayList<>(longPollResult.result.stream().map(e -> e.getClass().getName()).toList()); + for (var className : List.of(DeviceServiceData.class.getName(), Scenario.class.getName())) { + resultClasses.remove(className); + } + assertEquals(0, resultClasses.size()); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json b/bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json new file mode 100644 index 0000000000000..bb2310d0047b7 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json @@ -0,0 +1,47 @@ +[ + { + "@type": "scenario", + "id": "c6547ee8-db0d-490a-8860-2cb90ebe59c8", + "name": "Scenario 1", + "iconId": "icon_scenario_good_night", + "actions": [ + { + "deviceId": "hdm:ZigBee:cc86coffee0ad42", + "deviceServiceId": "PowerSwitch", + "targetState": { + "@type": "powerSwitchState", + "switchState": "OFF" + } + }, + { + "deviceId": "hdm:HomeMaticIP:3014F711A007999878593483", + "deviceServiceId": "PowerSwitch", + "targetState": { + "@type": "powerSwitchState", + "switchState": "OFF" + } + } + ] + }, + { + "@type": "scenario", + "id": "509bd737-eed0-40b7-8caa-e8686a714399", + "name": "Scenario without actions", + "iconId": "icon_scenario_own_scenario", + "actions": [] + }, + { + "@type": "scenario", + "id": "74fcf9d6-802a-4bed-87cd-a069670f5b2a", + "name": "duplicate scenario", + "iconId": "icon_scenario_good_night", + "actions": [] + }, + { + "@type": "scenario", + "id": "8352bd9c-e97e-45f3-b6bb-bdf25d1ce158", + "name": "duplicate scenario", + "iconId": "icon_scenario_good_night", + "actions": [] + } +] \ No newline at end of file From 7291ca9d41ccf3a2f7ccfc2cfdbcf6c8f90a518d Mon Sep 17 00:00:00 2001 From: Patrick Gell Date: Sat, 4 Nov 2023 07:26:13 +0100 Subject: [PATCH 2/6] chore: adapt review comments Signed-off-by: Patrick Gell --- .../org.openhab.binding.boschshc/README.md | 7 +- .../devices/BoschSHCBindingConstants.java | 4 +- .../devices/bridge/BridgeHandler.java | 23 +-- .../devices/bridge/ScenarioHandler.java | 83 ++++---- .../internal/devices/bridge/dto/Scenario.java | 15 ++ .../resources/OH-INF/i18n/boschshc.properties | 6 +- .../resources/OH-INF/thing/thing-types.xml | 14 +- .../main/resources/OH-INF/update/binding.xml | 8 +- .../devices/bridge/LongPollingTest.java | 44 +++++ .../devices/bridge/ScenarioHandlerTest.java | 178 ++++++++---------- .../scenarios/GET_scenarios_result.json | 47 ----- 11 files changed, 213 insertions(+), 216 deletions(-) delete mode 100644 bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index b31ff616a9a7e..efe040b000776 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -230,9 +230,10 @@ _Press and hold the Bosch Smart Home Controller Bridge button until the LED star ### Supported Channels -| Channel Type ID | Item Type | Writable | Description | -|--------------------| -------------------- |:--------:|--------------------------------------------------------------------| -| triggered-scenario | String | ☐ | Name of the triggered scenario (e.g. by the Universal Switch Flex) | +| Channel ID | Item Type | Writable | Description | +|--------------------|--------|:--------:|-------------------------------------------------------------------------| +| scenario-triggered | String | ☐ | Name of the triggered scenario (e.g. by the Universal Switch Flex) | +| trigger-scenario | String | ☐ | Scenario name that will be triggered on the Bosch Smart Home Controller | ## Getting the device IDs 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 9203f27aa2870..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,8 +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"; - public static final String CHANNEL_EXECUTE_SCENARIO = "execute-scenario"; + 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 9e090a495c3ab..8de8901d258a8 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 @@ -18,7 +18,6 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -60,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; @@ -104,11 +104,11 @@ public class BridgeHandler extends BaseBridgeHandler { */ private @Nullable ThingDiscoveryService thingDiscoveryService; - private final @NonNullByDefault ScenarioHandler scenarioHandler; + private final ScenarioHandler scenarioHandler; public BridgeHandler(Bridge bridge) { super(bridge); - scenarioHandler = new ScenarioHandler(new HashMap<>()); + scenarioHandler = new ScenarioHandler(); this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure); } @@ -203,9 +203,10 @@ public void dispose() { @Override public void handleCommand(ChannelUID channelUID, Command command) { // commands are handled by individual device handlers - if (channelUID.getId().equals(BoschSHCBindingConstants.CHANNEL_EXECUTE_SCENARIO) - && !command.toString().equals("REFRESH") && this.httpClient != null) { - this.scenarioHandler.executeScenario(this.httpClient, command.toString()); + BoschHttpClient httpClient = this.httpClient; + if (BoschSHCBindingConstants.CHANNEL_TRIGGER_SCENARIO.equals(channelUID.getId()) + && !RefreshType.REFRESH.equals(command) && httpClient != null) { + scenarioHandler.triggerScenario(httpClient, command.toString()); } } @@ -423,12 +424,12 @@ public boolean unregisterDiscoveryListener() { */ private void handleLongPollResult(LongPollResult result) { for (BoschSHCServiceState serviceState : result.result) { - if (DeviceServiceData.class == serviceState.getClass()) { - handleDeviceServiceData((DeviceServiceData) serviceState); - } else if (Scenario.class == serviceState.getClass()) { - final Channel channel = this.getThing().getChannel(BoschSHCBindingConstants.CHANNEL_SCENARIO); + 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) serviceState).name)); + 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 index a1d16b5a3c1a6..a7b752d036543 100644 --- 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 @@ -12,25 +12,21 @@ */ package org.openhab.binding.boschshc.internal.devices.bridge; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; +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.jdt.annotation.Nullable; 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.serialization.GsonUtils; +import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.JsonSyntaxException; - /** * Handler for executing a scenario. * @@ -42,60 +38,57 @@ public class ScenarioHandler { private final Logger logger = LoggerFactory.getLogger(getClass()); - private final Map availableScenarios; - - protected ScenarioHandler(Map availableScenarios) { - this.availableScenarios = Objects.requireNonNullElseGet(availableScenarios, HashMap::new); + protected ScenarioHandler() { } - public void executeScenario(final @Nullable BoschHttpClient httpClient, final String scenarioName) { - assert httpClient != null; - if (!availableScenarios.containsKey(scenarioName)) { - updateScenarios(httpClient); + 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 Scenario scenario = this.availableScenarios.get(scenarioName); - if (scenario != null) { - sendRequest(HttpMethod.POST, - httpClient.getBoschSmartHomeUrl(String.format("scenarios/%s/triggers", scenario.id)), httpClient); + 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 { - logger.debug("scenario '{}' not found on the Bosch Controller", scenarioName); + logger.debug("Scenario '{}' could not be found on the Bosch Smart Home Controller.", scenarioName); } } - private void updateScenarios(final @Nullable BoschHttpClient httpClient) { - if (httpClient != null) { - final String result = sendRequest(HttpMethod.GET, httpClient.getBoschSmartHomeUrl("scenarios"), httpClient); - try { - Scenario[] scenarios = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(result, Scenario[].class); - if (scenarios != null) { - for (Scenario scenario : scenarios) { - availableScenarios.put(scenario.name, scenario); - } - } - } catch (JsonSyntaxException e) { - logger.debug("response from SHC could not be parsed: {}", result, e); - } + 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); + } 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 String sendRequest(final HttpMethod method, final String url, final BoschHttpClient httpClient) { + private void sendPOSTRequest(final String url, final BoschHttpClient httpClient) { try { - final Request request = httpClient.createRequest(url, method); + final Request request = httpClient.createRequest(url, HttpMethod.POST); final ContentResponse response = request.send(); - switch (HttpStatus.getCode(response.getStatus())) { - case OK -> { - return response.getContentAsString(); - } - case NOT_FOUND, METHOD_NOT_ALLOWED -> logger.debug("{} - {} failed with {}: {}", method, url, - response.getStatus(), response.getContentAsString()); + 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); + logger.debug("Scenario call was interrupted", e); } catch (TimeoutException e) { - logger.debug("scenarion call timed out", e); + logger.debug("Scenario call timed out", e); } catch (ExecutionException e) { - logger.debug("exception occurred during scenario call", e); + logger.debug("Exception occurred during scenario call", e); } - return ""; } } 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 index ddaba8545c4d3..298ff425bf475 100644 --- 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 @@ -12,6 +12,8 @@ */ package org.openhab.binding.boschshc.internal.devices.bridge.dto; +import java.util.Arrays; + import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; /** @@ -36,4 +38,17 @@ public class Scenario extends BoschSHCServiceState { 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)); + } } 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 e0f5e87c368bf..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,8 +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.label = Triggered Scenario -channel-type.boschshc.scenario.description = Name of the triggered scenario +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 @@ -128,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 c71a8db4ff2b5..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 @@ -10,8 +10,8 @@ The Bosch Smart Home Bridge representing the Bosch Smart Home Controller. - - + + @@ -529,16 +529,16 @@ - + String - + Name of the triggered scenario - + String - - Name of the scenario to execute + + 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 index 61477ff9f5ee5..814f6b8da8b3f 100644 --- 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 @@ -4,11 +4,11 @@ xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd"> - - boschshc:triggered-scenario + + boschshc:scenario-triggered - - boschshc:execute-scenario + + 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 2092ae155e71e..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,6 +238,7 @@ void start() throws InterruptedException, TimeoutException, ExecutionException, verify(longPollHandler).accept(longPollResultCaptor.capture()); LongPollResult longPollResult = longPollResultCaptor.getValue(); assertEquals(1, longPollResult.result.size()); + 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); @@ -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 index f54bb618eac98..6306243d7c84c 100644 --- 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 @@ -12,33 +12,25 @@ */ package org.openhab.binding.boschshc.internal.devices.bridge; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.*; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.HashMap; -import java.util.Map; +import java.util.List; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; -import java.util.stream.Stream; 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.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; 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}. @@ -48,140 +40,136 @@ */ @NonNullByDefault @ExtendWith(MockitoExtension.class) -public class ScenarioHandlerTest { +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 - public void executeScenario_ShouldLoadAllScenarios_IfAvailableScenariosAreEmpty() throws Exception { + void triggerScenario_ShouldSendPOST_ToBoschAPI() throws Exception { // GIVEN final var httpClient = mock(BoschHttpClient.class); final var request = mock(Request.class); - final var response = mock(ContentResponse.class); - when(httpClient.getBoschSmartHomeUrl("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); - when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); - when(request.send()).thenReturn(response); - when(response.getStatus()).thenReturn(200); - when(response.getContentAsString()).thenReturn(getJsonStringFromFile()); + 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 Map availableScenarios = new HashMap<>(); - final var handler = new ScenarioHandler(availableScenarios); + final var handler = new ScenarioHandler(); // WHEN - handler.executeScenario(httpClient, "fooBar"); + handler.triggerScenario(httpClient, "Scenario 1"); // THEN verify(httpClient).getBoschSmartHomeUrl("scenarios"); - assertEquals(3, availableScenarios.size()); + verify(request).send(); } @Test - public void executeScenario_ShouldMakePostCall_IfScenarioExists() throws Exception { + void triggerScenario_ShouldNoSendPOST_ToScenarioNameDoesNotExist() throws Exception { // GIVEN final var httpClient = mock(BoschHttpClient.class); final var request = mock(Request.class); - final var response = mock(ContentResponse.class); - final Map availableScenarios = new HashMap<>(); - final var testScenario = new Scenario(); - testScenario.id = UUID.randomUUID().toString(); - testScenario.name = "fooBar"; - availableScenarios.put(testScenario.name, testScenario); - final var endpoint = String.format("scenarios/%s/triggers", testScenario.id); - when(httpClient.getBoschSmartHomeUrl(endpoint)).thenReturn(endpoint); - when(httpClient.createRequest(endpoint, HttpMethod.POST)).thenReturn(request); - when(request.send()).thenReturn(response); - when(response.getStatus()).thenReturn(200); - when(response.getContentAsString()).thenReturn(""); - final var handler = new ScenarioHandler(availableScenarios); + 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); + + final var handler = new ScenarioHandler(); // WHEN - handler.executeScenario(httpClient, testScenario.name); + handler.triggerScenario(httpClient, "not existing Scenario"); // THEN - verify(request, times(1)).send(); - } - - private static Stream provideExceptionsForTest() { - return Stream.of(Arguments.of(new InterruptedException("call interrupted")), - Arguments.of(new TimeoutException("call timed out")), - Arguments.of(new ExecutionException(new Exception()))); + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request, times(0)).send(); } @ParameterizedTest - @MethodSource("provideExceptionsForTest") - public void executeScenario_ShouldNotThrowException_IfApiCallsHaveException(final Exception exception) - throws Exception { + @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("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); + 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); - when(request.send()).thenThrow(exception); - final Map availableScenarios = new HashMap<>(); - final var handler = new ScenarioHandler(availableScenarios); + when(httpClient.sendRequest(any(Request.class), any(), any(), any())).thenThrow(exception); + + final var handler = new ScenarioHandler(); // WHEN - handler.executeScenario(httpClient, "fooBar"); + handler.triggerScenario(httpClient, "Scenario 1"); // THEN - assertTrue(availableScenarios.isEmpty()); + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request, times(0)).send(); } - @ParameterizedTest - @ValueSource(ints = { 404, 405 }) - public void executeScenario_ShouldNotThrowException_IfApiCallReturnsError(final int statusCode) throws Exception { + @Test + void triggerScenario_ShouldNotPanic_IfPOSTIsNotSuccessful() throws Exception { // GIVEN final var httpClient = mock(BoschHttpClient.class); final var request = mock(Request.class); - final var response = mock(ContentResponse.class); - when(httpClient.getBoschSmartHomeUrl("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); - when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); - when(request.send()).thenReturn(response); - when(response.getStatus()).thenReturn(statusCode); - final Map availableScenarios = new HashMap<>(); - final var handler = new ScenarioHandler(availableScenarios); + 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.executeScenario(httpClient, "fooBar"); + handler.triggerScenario(httpClient, "Scenario 1"); // THEN - assertTrue(availableScenarios.isEmpty()); + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request).send(); } - @Test - public void executeScenario_ShouldNotThrowException_IfResponseIsNoJson() throws Exception { + @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); - final var response = mock(ContentResponse.class); - when(httpClient.getBoschSmartHomeUrl("scenarios")).thenReturn("http://localhost/smartHome/scenarios"); - when(httpClient.createRequest(anyString(), any(HttpMethod.class))).thenReturn(request); - when(request.send()).thenReturn(response); - when(response.getStatus()).thenReturn(200); - when(response.getContentAsString()).thenReturn("this is not a valid json"); + 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()).thenThrow(exception); - final Map availableScenarios = new HashMap<>(); - final var handler = new ScenarioHandler(availableScenarios); + final var handler = new ScenarioHandler(); // WHEN - handler.executeScenario(httpClient, "fooBar"); + handler.triggerScenario(httpClient, "Scenario 1"); // THEN - assertTrue(availableScenarios.isEmpty()); - } - - private String getJsonStringFromFile() throws IOException { - try (InputStream input = this.getClass().getClassLoader() - .getResourceAsStream("scenarios/GET_scenarios_result.json")) { - if (input == null) { - return ""; - } - try (BufferedReader reader = new BufferedReader(new InputStreamReader(input))) { - StringBuilder stringBuilder = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - stringBuilder.append(line); - } - return stringBuilder.toString(); - } - } + verify(httpClient).getBoschSmartHomeUrl("scenarios"); + verify(request).send(); } } diff --git a/bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json b/bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json deleted file mode 100644 index bb2310d0047b7..0000000000000 --- a/bundles/org.openhab.binding.boschshc/src/test/resources/scenarios/GET_scenarios_result.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "@type": "scenario", - "id": "c6547ee8-db0d-490a-8860-2cb90ebe59c8", - "name": "Scenario 1", - "iconId": "icon_scenario_good_night", - "actions": [ - { - "deviceId": "hdm:ZigBee:cc86coffee0ad42", - "deviceServiceId": "PowerSwitch", - "targetState": { - "@type": "powerSwitchState", - "switchState": "OFF" - } - }, - { - "deviceId": "hdm:HomeMaticIP:3014F711A007999878593483", - "deviceServiceId": "PowerSwitch", - "targetState": { - "@type": "powerSwitchState", - "switchState": "OFF" - } - } - ] - }, - { - "@type": "scenario", - "id": "509bd737-eed0-40b7-8caa-e8686a714399", - "name": "Scenario without actions", - "iconId": "icon_scenario_own_scenario", - "actions": [] - }, - { - "@type": "scenario", - "id": "74fcf9d6-802a-4bed-87cd-a069670f5b2a", - "name": "duplicate scenario", - "iconId": "icon_scenario_good_night", - "actions": [] - }, - { - "@type": "scenario", - "id": "8352bd9c-e97e-45f3-b6bb-bdf25d1ce158", - "name": "duplicate scenario", - "iconId": "icon_scenario_good_night", - "actions": [] - } -] \ No newline at end of file From ddcead60df5fd798f8715c7d19ccd073bfe27950 Mon Sep 17 00:00:00 2001 From: Patrick Gell Date: Sun, 5 Nov 2023 07:37:50 +0100 Subject: [PATCH 3/6] chore: adapt review comments Signed-off-by: Patrick Gell --- bundles/org.openhab.binding.boschshc/README.md | 6 +++--- .../internal/devices/bridge/BridgeHandler.java | 6 +++--- .../devices/bridge/ScenarioHandler.java | 2 ++ .../devices/bridge/ScenarioHandlerTest.java | 3 --- .../BoschServiceDataDeserializerTest.java | 17 ++++++++--------- 5 files changed, 16 insertions(+), 18 deletions(-) diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index efe040b000776..f2e683033dc4f 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -231,9 +231,9 @@ _Press and hold the Bosch Smart Home Controller Bridge button until the LED star ### Supported Channels | Channel ID | Item Type | Writable | Description | -|--------------------|--------|:--------:|-------------------------------------------------------------------------| -| scenario-triggered | String | ☐ | Name of the triggered scenario (e.g. by the Universal Switch Flex) | -| trigger-scenario | String | ☐ | Scenario name that will be triggered on the Bosch Smart Home Controller | +|--------------------|-----------|:--------:|-------------------------------------------------------------------------| +| 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. | ## Getting the device IDs 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 8de8901d258a8..6355a62a71cfa 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 @@ -203,10 +203,10 @@ public void dispose() { @Override public void handleCommand(ChannelUID channelUID, Command command) { // commands are handled by individual device handlers - BoschHttpClient httpClient = this.httpClient; + BoschHttpClient localHttpClient = this.httpClient; if (BoschSHCBindingConstants.CHANNEL_TRIGGER_SCENARIO.equals(channelUID.getId()) - && !RefreshType.REFRESH.equals(command) && httpClient != null) { - scenarioHandler.triggerScenario(httpClient, command.toString()); + && !RefreshType.REFRESH.equals(command) && localHttpClient != null) { + scenarioHandler.triggerScenario(localHttpClient, command.toString()); } } 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 index a7b752d036543..1f919163e67ba 100644 --- 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 @@ -66,6 +66,7 @@ private Scenario[] getAvailableScenarios(final BoschHttpClient httpClient) throw 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) { @@ -85,6 +86,7 @@ private void sendPOSTRequest(final String url, final BoschHttpClient httpClient) } } 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) { 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 index 6306243d7c84c..3035a6f102349 100644 --- 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 @@ -89,7 +89,6 @@ void triggerScenario_ShouldNoSendPOST_ToScenarioNameDoesNotExist() throws Except // 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); @@ -111,7 +110,6 @@ void triggerScenario_ShouldNotPanic_IfBoschAPIThrowsException(final Exception ex // 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); @@ -156,7 +154,6 @@ void triggerScenario_ShouldNotPanic_IfPOSTThrowsException(final Exception except // 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); 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 index e4ecf1cbaa674..c06753d797cc9 100644 --- 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 @@ -14,9 +14,9 @@ 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.ArrayList; -import java.util.List; +import java.util.HashSet; import org.eclipse.jdt.annotation.NonNullByDefault; import org.junit.jupiter.api.Test; @@ -31,10 +31,10 @@ * */ @NonNullByDefault -public class BoschServiceDataDeserializerTest { +class BoschServiceDataDeserializerTest { @Test - public void deserializationOfLongPollingResult() { + void deserializationOfLongPollingResult() { var resultJson = """ { "result": [ @@ -63,10 +63,9 @@ public void deserializationOfLongPollingResult() { assertNotNull(longPollResult); assertEquals(2, longPollResult.result.size()); - var resultClasses = new ArrayList<>(longPollResult.result.stream().map(e -> e.getClass().getName()).toList()); - for (var className : List.of(DeviceServiceData.class.getName(), Scenario.class.getName())) { - resultClasses.remove(className); - } - assertEquals(0, resultClasses.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())); } } From e56184e32acb58b8749f7c18774484937355cd41 Mon Sep 17 00:00:00 2001 From: Patrick Gell Date: Mon, 6 Nov 2023 06:52:50 +0100 Subject: [PATCH 4/6] chore: adapt README Signed-off-by: Patrick Gell --- .../org.openhab.binding.boschshc/README.md | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index f2e683033dc4f..9330d094f44ae 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,17 @@ 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. @@ -228,14 +240,6 @@ This certificate is used for pairing between the Bridge and the Bosch Smart Home _Press and hold the Bosch Smart Home Controller Bridge button until the LED starts blinking after you save your settings for pairing_. -### Supported Channels - -| Channel 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. | - - ## Getting the device IDs Bosch IDs for found devices are displayed in the openHAB log on bootup (`OPENHAB_FOLDER/userdata/logs/openhab.log`) From 5fc5883c7f0a938f86fa16ee009cd2b7f43fa588 Mon Sep 17 00:00:00 2001 From: Patrick Gell Date: Tue, 7 Nov 2023 06:36:35 +0100 Subject: [PATCH 5/6] chore: adapt review comments Signed-off-by: Patrick Gell --- bundles/org.openhab.binding.boschshc/README.md | 1 - .../binding/boschshc/internal/devices/bridge/BridgeHandler.java | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index 9330d094f44ae..3448c644c8f41 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -38,7 +38,6 @@ The Smart Home Controller is the central hub that allows you to monitor and cont | 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/bridge/BridgeHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java index 6355a62a71cfa..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 @@ -203,7 +203,7 @@ public void dispose() { @Override public void handleCommand(ChannelUID channelUID, Command command) { // commands are handled by individual device handlers - BoschHttpClient localHttpClient = this.httpClient; + BoschHttpClient localHttpClient = httpClient; if (BoschSHCBindingConstants.CHANNEL_TRIGGER_SCENARIO.equals(channelUID.getId()) && !RefreshType.REFRESH.equals(command) && localHttpClient != null) { scenarioHandler.triggerScenario(localHttpClient, command.toString()); From b6c8bbc4cca4460c48ed56f0521b0718e2ce341d Mon Sep 17 00:00:00 2001 From: Patrick Gell Date: Sat, 11 Nov 2023 06:22:25 +0100 Subject: [PATCH 6/6] chore: adapt log statement Signed-off-by: Patrick Gell --- .../internal/devices/bridge/ScenarioHandler.java | 16 +++++++++++++++- .../internal/devices/bridge/dto/Scenario.java | 10 ++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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 index 1f919163e67ba..54a080a8cf1ea 100644 --- 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 @@ -56,7 +56,10 @@ public void triggerScenario(final BoschHttpClient httpClient, final String scena sendPOSTRequest(httpClient.getBoschSmartHomeUrl(String.format("scenarios/%s/triggers", scenario.get().id)), httpClient); } else { - logger.debug("Scenario '{}' could not be found on the Bosch Smart Home Controller.", scenarioName); + if (logger.isDebugEnabled()) { + logger.debug("Scenario '{}' was not found in the list of available scenarios {}", scenarioName, + prettyLogScenarios(scenarios)); + } } } @@ -93,4 +96,15 @@ private void sendPOSTRequest(final String url, final BoschHttpClient httpClient) 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/Scenario.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/Scenario.java index 298ff425bf475..4440d8ff8ce5e 100644 --- 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 @@ -51,4 +51,14 @@ public static Scenario createScenario(final String id, final String name, final 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(); + } }