Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[boschshc] Add scenario channel #15752

Merged
merged 6 commits into from
Nov 11, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions bundles/org.openhab.binding.boschshc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,10 @@ _Press and hold the Bosch Smart Home Controller Bridge button until the LED star

### Supported Channels
david-pace marked this conversation as resolved.
Show resolved Hide resolved

| 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 |
|--------------------|--------|:--------:|-------------------------------------------------------------------------|
david-pace marked this conversation as resolved.
Show resolved Hide resolved
| 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 |
david-pace marked this conversation as resolved.
Show resolved Hide resolved


## Getting the device IDs
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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());
}
}

Expand Down Expand Up @@ -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));
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -42,60 +38,57 @@ public class ScenarioHandler {

private final Logger logger = LoggerFactory.getLogger(getClass());

private final Map<String, Scenario> availableScenarios;

protected ScenarioHandler(Map<String, Scenario> 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> 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);
david-pace marked this conversation as resolved.
Show resolved Hide resolved
} 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();
david-pace marked this conversation as resolved.
Show resolved Hide resolved
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);
david-pace marked this conversation as resolved.
Show resolved Hide resolved
} 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 "";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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));
}
}
david-pace marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
<description>The Bosch Smart Home Bridge representing the Bosch Smart Home Controller.</description>

<channels>
<channel id="triggered-scenario" typeId="triggered-scenario"/>
<channel id="execute-scenario" typeId="execute-scenario"/>
<channel id="scenario-triggered" typeId="scenario-triggered"/>
<channel id="trigger-scenario" typeId="trigger-scenario"/>
</channels>

<properties>
Expand Down Expand Up @@ -529,16 +529,16 @@
</state>
</channel-type>

<channel-type id="triggered-scenario">
<channel-type id="scenario-triggered">
<item-type>String</item-type>
<label>Triggered Scenario</label>
<label>Scenario Triggered</label>
<description>Name of the triggered scenario</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="execute-scenario">
<channel-type id="trigger-scenario">
<item-type>String</item-type>
<label>Execute Scenario</label>
<description>Name of the scenario to execute</description>
<label>Trigger Scenario</label>
<description>Name of the scenario to trigger</description>
</channel-type>

</thing:thing-descriptions>
Original file line number Diff line number Diff line change
Expand Up @@ -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">
<thing-type uid="boschshc:shc">
<instruction-set targetVersion="1">
<add-channel id="triggered-scenario">
<type>boschshc:triggered-scenario</type>
<add-channel id="scenario-triggered">
<type>boschshc:scenario-triggered</type>
</add-channel>
<add-channel id="execute-scenario">
<type>boschshc:execute-scenario</type>
<add-channel id="trigger-scenario">
<type>boschshc:trigger-scenario</type>
</add-channel>
</instruction-set>
</thing-type>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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> 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 {
Expand Down
Loading