- [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)
## 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.
// 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";
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;
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;
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;
*/
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);
}
@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());
+ }
}
/**
* @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));
+ }
+ }
}
}
--- /dev/null
+/**
+ * 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> 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();
+ }
+}
import java.util.ArrayList;
+import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
+
/**
* Response of the Controller for a Long Poll API call.
*
* ],"jsonrpc":"2.0"}
*/
- public ArrayList<DeviceServiceData> result;
+ public ArrayList<BoschSHCServiceState> result;
public String jsonrpc;
}
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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<BoschSHCServiceState> {
+
+ @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());
+ }
+ }
+ }
+}
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;
* This instance does not serialize or deserialize fields named <code>logger</code>.
*/
public static final Gson DEFAULT_GSON_INSTANCE = new GsonBuilder()
+ .registerTypeAdapter(BoschSHCServiceState.class, new BoschServiceDataDeserializer())
.addSerializationExclusionStrategy(new LoggerExclusionStrategy())
.addDeserializationExclusionStrategy(new LoggerExclusionStrategy()).create();
}
@SerializedName("@type")
public final String type;
- protected BoschSHCServiceState(String type) {
+ public BoschSHCServiceState(String type) {
this.type = type;
if (stateType == null) {
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
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).
<label>Smart Home Controller</label>
<description>The Bosch Smart Home Bridge representing the Bosch Smart Home Controller.</description>
+ <channels>
+ <channel id="scenario-triggered" typeId="scenario-triggered"/>
+ <channel id="trigger-scenario" typeId="trigger-scenario"/>
+ </channels>
+
+ <properties>
+ <property name="thingTypeVersion">1</property>
+ </properties>
+
<config-description-ref uri="thing-type:boschshc:bridge"/>
</bridge-type>
</state>
</channel-type>
+ <channel-type id="scenario-triggered">
+ <item-type>String</item-type>
+ <label>Scenario Triggered</label>
+ <description>Name of the triggered scenario</description>
+ <state readOnly="true"/>
+ </channel-type>
+ <channel-type id="trigger-scenario">
+ <item-type>String</item-type>
+ <label>Trigger Scenario</label>
+ <description>Name of the scenario to trigger</description>
+ </channel-type>
+
</thing:thing-descriptions>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<update:update-descriptions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:update="https://openhab.org/schemas/update-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/update-description/v1.0.0 https://openhab.org/schemas/update-description-1.0.0.xsd">
+ <thing-type uid="boschshc:shc">
+ <instruction-set targetVersion="1">
+ <add-channel id="scenario-triggered">
+ <type>boschshc:scenario-triggered</type>
+ </add-channel>
+ <add-channel id="trigger-scenario">
+ <type>boschshc:trigger-scenario</type>
+ </add-channel>
+ </instruction-set>
+ </thing-type>
+</update:update-descriptions>
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;
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);
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> 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<LongPollResult> 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 {
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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()));
+ }
+}