From 64fc6bcd1f4c8f4e2f19afbb45f9f2ce89462d7b Mon Sep 17 00:00:00 2001 From: David Pace Date: Mon, 22 Jul 2024 21:10:29 +0200 Subject: [PATCH] [boschshc] Support for Smart Relay (#17026) * [boschshc] Support for Smart Relay Signed-off-by: David Pace --- .../org.openhab.binding.boschshc/README.md | 16 + .../console/BoschShcCommandExtension.java | 23 +- .../devices/BoschSHCBindingConstants.java | 5 +- .../internal/devices/BoschSHCHandler.java | 19 +- .../devices/BoschSHCHandlerFactory.java | 5 +- .../devices/bridge/BridgeHandler.java | 41 ++- .../internal/devices/relay/RelayHandler.java | 319 ++++++++++++++++++ .../discovery/ThingDiscoveryService.java | 3 +- ...bstractStatelessBoschSHCDeviceService.java | 60 ++++ .../impulseswitch/ImpulseSwitchService.java | 33 ++ .../dto/ImpulseSwitchServiceState.java | 47 +++ .../resources/OH-INF/i18n/boschshc.properties | 8 + .../resources/OH-INF/thing/thing-types.xml | 42 +++ .../AbstractBoschSHCDeviceHandlerTest.java | 7 +- .../devices/AbstractBoschSHCHandlerTest.java | 3 +- .../AbstractPowerSwitchHandlerTest.java | 5 +- .../devices/relay/RelayHandlerTest.java | 258 ++++++++++++++ 17 files changed, 866 insertions(+), 28 deletions(-) create mode 100644 bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandler.java create mode 100644 bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/AbstractStatelessBoschSHCDeviceService.java create mode 100644 bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/ImpulseSwitchService.java create mode 100644 bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/dto/ImpulseSwitchServiceState.java create mode 100644 bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandlerTest.java diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index 0d8dd4486a..7e159a06d6 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -17,6 +17,7 @@ Binding for Bosch Smart Home devices. - [Thermostat](#thermostat) - [Climate Control](#climate-control) - [Wall Thermostat](#wall-thermostat) + - [Relay](#relay) - [Security Camera 360](#security-camera-360) - [Security Camera Eyes](#security-camera-eyes) - [Intrusion Detection System](#intrusion-detection-system) @@ -222,6 +223,21 @@ Display of the current room temperature as well as the relative humidity in the | battery-level | Number | ☐ | Current battery level percentage as integer number. Bosch-specific battery levels are mapped to numbers as follows: `OK`: 100, `LOW_BATTERY`: 10, `CRITICAL_LOW`: 1, `CRITICALLY_LOW_BATTERY`: 1, `NOT_AVAILABLE`: `UNDEF`. | | low-battery | Switch | ☐ | Indicates whether the battery is low (`ON`) or OK (`OFF`). | +### Relay + +The smart switching relay is your universal all-rounder for smart switching. + +**Thing Type ID**: `relay` + +| Channel Type ID | Item Type | Writable | Description | +| ----------------------- | ----------- | :------: | ---------------------------------------------------------------------------------- | +| signal-strength | Number | ☐ | Communication quality between the device and the Smart Home Controller. Possible values range between 0 (unknown) and 4 (best signal strength). | +| child-protection | Switch | ☑ | Indicates whether the child protection is active. | +| power-switch | Switch | ☑ | Switches the relay on or off. Only available if the relay is in power switch mode. | +| impulse-switch | Switch | ☑ | Channel to send impulses by means of `ON` events. After the time specified by `impulse-length`, the relay will switch off automatically and the state will be reset to `OFF`. Only available if the relay is in impulse switch mode. | +| impulse-length | Number:Time | ☑ | Channel to configure how long the relay will stay on after receiving an impulse switch event. The time is specified in tenth seconds (deciseconds), e.g. 15 means 1.5 seconds. Only available if the relay is in impulse switch mode. | +| instant-of-last-impulse | DateTime | ☐ | Timestamp indicating when the last impulse was triggered. Only available if the relay is in impulse switch mode. | + ### Security Camera 360 Indoor security camera with 360° view and motion detection. diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java index 0b91213621..189bcb5fdc 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java @@ -44,9 +44,10 @@ import org.osgi.service.component.annotations.Reference; /** * Console command to list Bosch SHC devices and openhab support. - * Use the SHC API to get all SHC devices and SHC services - * and tries to lookup openhab devices and implemented service classes. - * Prints each name and looked-up implementation on console. + *

+ * Uses the SHC API to get all SHC devices and SHC services and tries to lookup + * openHAB devices and implemented service classes. Prints each name and + * looked-up implementation on console. * * @author Gerd Zanker - Initial contribution */ @@ -73,17 +74,19 @@ public class BoschShcCommandExtension extends AbstractConsoleCommandExtension im /** * Returns all implemented services of this Bosch SHC binding. - * This list shall contain all available services and needs to be extended when a new service is added. - * A unit tests checks if this list matches with the existing subfolders in - * "src/main/java/org/openhab/binding/boschshc/internal/services". + *

+ * This list shall contain all available services and needs to be extended when + * a new service is added. A unit tests checks if this list matches with the + * existing subfolders in + * src/main/java/org/openhab/binding/boschshc/internal/services. */ List getAllBoschShcServices() { return List.of("airqualitylevel", "batterylevel", "binaryswitch", "bypass", "cameranotification", "childlock", "childprotection", "communicationquality", "hsbcoloractuator", "humiditylevel", "illuminance", - "intrusion", "keypad", "latestmotion", "multilevelswitch", "powermeter", "powerswitch", "privacymode", - "roomclimatecontrol", "shuttercontact", "shuttercontrol", "silentmode", "smokedetectorcheck", - "temperaturelevel", "userstate", "valvetappet", "waterleakagesensor", "waterleakagesensorcheck", - "waterleakagesensortilt"); + "impulseswitch", "intrusion", "keypad", "latestmotion", "multilevelswitch", "powermeter", "powerswitch", + "privacymode", "roomclimatecontrol", "shuttercontact", "shuttercontrol", "silentmode", + "smokedetectorcheck", "temperaturelevel", "userstate", "valvetappet", "waterleakagesensor", + "waterleakagesensorcheck", "waterleakagesensortilt"); } @Override 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 4dba916088..f48a18bb90 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 @@ -60,6 +60,7 @@ public class BoschSHCBindingConstants { public static final ThingTypeUID THING_TYPE_LIGHT_CONTROL_2 = new ThingTypeUID(BINDING_ID, "light-control-2"); public static final ThingTypeUID THING_TYPE_DIMMER = new ThingTypeUID(BINDING_ID, "dimmer"); public static final ThingTypeUID THING_TYPE_WATER_DETECTOR = new ThingTypeUID(BINDING_ID, "water-detector"); + public static final ThingTypeUID THING_TYPE_RELAY = new ThingTypeUID(BINDING_ID, "relay"); public static final ThingTypeUID THING_TYPE_USER_DEFINED_STATE = new ThingTypeUID(BINDING_ID, "user-defined-state"); @@ -112,7 +113,9 @@ public class BoschSHCBindingConstants { public static final String CHANNEL_ACOUSTIC_SIGNALS_ON_MOVE = "acoustic-signals-on-move"; public static final String CHANNEL_WATER_LEAKAGE_SENSOR_CHECK = "water-leakage-sensor-check"; public static final String CHANNEL_SENSOR_MOVED = "sensor-moved"; - + public static final String CHANNEL_IMPULSE_SWITCH = "impulse-switch"; + public static final String CHANNEL_IMPULSE_LENGTH = "impulse-length"; + public static final String CHANNEL_INSTANT_OF_LAST_IMPULSE = "instant-of-last-impulse"; // numbered channels // the rationale for introducing numbered channels was discussed in // https://github.com/openhab/openhab-addons/pull/16400 diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java index a3b07f3303..3f00e0010c 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java @@ -26,6 +26,7 @@ import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler; import org.openhab.binding.boschshc.internal.devices.bridge.dto.Message; import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; import org.openhab.binding.boschshc.internal.services.AbstractBoschSHCService; +import org.openhab.binding.boschshc.internal.services.AbstractStatelessBoschSHCDeviceService; import org.openhab.binding.boschshc.internal.services.AbstractStatelessBoschSHCService; import org.openhab.binding.boschshc.internal.services.AbstractStatelessBoschSHCServiceWithRequestBody; import org.openhab.binding.boschshc.internal.services.BoschSHCService; @@ -402,11 +403,25 @@ public abstract class BoschSHCHandler extends BaseThingHandler { service.setState(state); } catch (TimeoutException | ExecutionException e) { this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, String.format( - "Error when trying to update state for service %s: %s", service.getServiceName(), e.getMessage())); + "Error while trying to update state for service %s: %s", service.getServiceName(), e.getMessage())); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, String - .format("Interrupted update state for service %s: %s", service.getServiceName(), e.getMessage())); + .format("Interrupted state update for service %s: %s", service.getServiceName(), e.getMessage())); + } + } + + protected , TState extends BoschSHCServiceState> void postState( + TService service, TState state) { + try { + service.setState(state); + } catch (TimeoutException | ExecutionException e) { + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, String.format( + "Error while trying to post state for service %s: %s", service.getServiceName(), e.getMessage())); + } catch (InterruptedException e) { Thread.currentThread().interrupt(); + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, String + .format("Interrupted state update for service %s: %s", service.getServiceName(), e.getMessage())); } } diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandlerFactory.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandlerFactory.java index 9fabcdff4f..c16495c5a9 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandlerFactory.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandlerFactory.java @@ -20,6 +20,7 @@ import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConst import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.THING_TYPE_INWALL_SWITCH; import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.THING_TYPE_LIGHT_CONTROL_2; import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.THING_TYPE_MOTION_DETECTOR; +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.THING_TYPE_RELAY; import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.THING_TYPE_SHC; import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL; import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL_2; @@ -52,6 +53,7 @@ import org.openhab.binding.boschshc.internal.devices.lightcontrol.LightControl2H import org.openhab.binding.boschshc.internal.devices.lightcontrol.LightControlHandler; import org.openhab.binding.boschshc.internal.devices.motiondetector.MotionDetectorHandler; import org.openhab.binding.boschshc.internal.devices.plug.PlugHandler; +import org.openhab.binding.boschshc.internal.devices.relay.RelayHandler; import org.openhab.binding.boschshc.internal.devices.shuttercontrol.ShutterControl2Handler; import org.openhab.binding.boschshc.internal.devices.shuttercontrol.ShutterControlHandler; import org.openhab.binding.boschshc.internal.devices.smartbulb.SmartBulbHandler; @@ -135,7 +137,8 @@ public class BoschSHCHandlerFactory extends BaseThingHandlerFactory { new ThingTypeHandlerMapping(THING_TYPE_SMOKE_DETECTOR_2, SmokeDetector2Handler::new), new ThingTypeHandlerMapping(THING_TYPE_LIGHT_CONTROL_2, LightControl2Handler::new), new ThingTypeHandlerMapping(THING_TYPE_DIMMER, DimmerHandler::new), - new ThingTypeHandlerMapping(THING_TYPE_WATER_DETECTOR, WaterLeakageSensorHandler::new)); + new ThingTypeHandlerMapping(THING_TYPE_WATER_DETECTOR, WaterLeakageSensorHandler::new), + new ThingTypeHandlerMapping(THING_TYPE_RELAY, RelayHandler::new)); @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { 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 8deb057806..9575de3c95 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 @@ -33,6 +33,7 @@ import org.eclipse.jdt.annotation.Nullable; import org.eclipse.jetty.client.api.ContentResponse; import org.eclipse.jetty.client.api.Request; import org.eclipse.jetty.client.api.Response; +import org.eclipse.jetty.http.HttpMethod; import org.eclipse.jetty.http.HttpStatus; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.openhab.binding.boschshc.internal.devices.BoschDeviceIdUtils; @@ -872,19 +873,47 @@ public class BridgeHandler extends BaseBridgeHandler { } /** - * Sends a state change for a device to the controller + * Sends a state change for a device to the controller using a HTTP PUT request. * - * @param deviceId Id of device to change state for - * @param serviceName Name of service of device to change state for - * @param state New state data to set for service + * @param type of the state object + * + * @param deviceId the ID of the device for which the state should be updated + * @param serviceName the name of the service for which the state should be updated + * @param state object representing the new state data * - * @return Response of request + * @return the HTTP response of the Bosch Smart Home Controller + * * @throws InterruptedException * @throws ExecutionException * @throws TimeoutException */ public @Nullable Response putState(String deviceId, String serviceName, T state) throws InterruptedException, TimeoutException, ExecutionException { + return sendState(deviceId, serviceName, state, PUT); + } + + /** + * Sends a state change for a device to the controller using a HTTP POST request. + * + * @param type of the state object + * + * @param deviceId the ID of the device for which the state should be updated + * @param serviceName the name of the service for which the state should be updated + * @param state object representing the new state data + * + * @return the HTTP response of the Bosch Smart Home Controller + * + * @throws InterruptedException + * @throws TimeoutException + * @throws ExecutionException + */ + public @Nullable Response postState(String deviceId, String serviceName, T state) + throws InterruptedException, TimeoutException, ExecutionException { + return sendState(deviceId, serviceName, state, POST); + } + + private @Nullable Response sendState(String deviceId, String serviceName, T state, + HttpMethod method) throws InterruptedException, TimeoutException, ExecutionException { @Nullable BoschHttpClient localHttpClient = this.httpClient; if (localHttpClient == null) { @@ -894,7 +923,7 @@ public class BridgeHandler extends BaseBridgeHandler { // Create request String url = localHttpClient.getServiceStateUrl(serviceName, deviceId, state.getClass()); - Request request = localHttpClient.createRequest(url, PUT, state); + Request request = localHttpClient.createRequest(url, method, state); // Send request return request.send(); diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandler.java new file mode 100644 index 0000000000..8dd4784cd3 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandler.java @@ -0,0 +1,319 @@ +/** + * Copyright (c) 2010-2024 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.relay; + +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.BINDING_ID; +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.CHANNEL_CHILD_PROTECTION; +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.CHANNEL_IMPULSE_LENGTH; +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.CHANNEL_IMPULSE_SWITCH; +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.CHANNEL_INSTANT_OF_LAST_IMPULSE; +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.CHANNEL_POWER_SWITCH; +import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.CHANNEL_SIGNAL_STRENGTH; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +import javax.inject.Provider; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.boschshc.internal.devices.AbstractPowerSwitchHandler; +import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device; +import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; +import org.openhab.binding.boschshc.internal.services.childprotection.ChildProtectionService; +import org.openhab.binding.boschshc.internal.services.childprotection.dto.ChildProtectionServiceState; +import org.openhab.binding.boschshc.internal.services.communicationquality.CommunicationQualityService; +import org.openhab.binding.boschshc.internal.services.communicationquality.dto.CommunicationQualityServiceState; +import org.openhab.binding.boschshc.internal.services.impulseswitch.ImpulseSwitchService; +import org.openhab.binding.boschshc.internal.services.impulseswitch.dto.ImpulseSwitchServiceState; +import org.openhab.core.library.CoreItemFactory; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.Channel; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.DefaultSystemChannelTypeProvider; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.binding.builder.ChannelBuilder; +import org.openhab.core.thing.binding.builder.ThingBuilder; +import org.openhab.core.thing.type.ChannelTypeUID; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Handler for smart relays. + *

+ * Relays are in one of two possible modes: + *

+ *

+ * Every time the thing is initialized, we detect dynamically which mode was + * configured for the relay and reconfigure the channels accordingly, if + * required. + *

+ * In common usage scenarios, this will be the case upon the very first + * initialization only, or if the device is re-purposed. + * + * @author David Pace - Initial contribution + * + */ +@NonNullByDefault +public class RelayHandler extends AbstractPowerSwitchHandler { + + private final Logger logger = LoggerFactory.getLogger(RelayHandler.class); + + private ChildProtectionService childProtectionService; + private ImpulseSwitchService impulseSwitchService; + + /** + * Indicates whether the relay is configured in impulse switch mode. If this is + * false, the relay is in the default power switch (toggle) mode + */ + private boolean isInImpulseSwitchMode; + + /** + * A provider for the current date/time. + *

+ * It is exchanged in unit tests in order to be able to assert that a certain + * date is contained in the result. + */ + private Provider currentDateTimeProvider = Instant::now; + + @Nullable + private ImpulseSwitchServiceState currentImpulseSwitchServiceState; + + public RelayHandler(Thing thing) { + super(thing); + this.childProtectionService = new ChildProtectionService(); + this.impulseSwitchService = new ImpulseSwitchService(); + } + + @Override + protected boolean processDeviceInfo(Device deviceInfo) { + this.isInImpulseSwitchMode = isRelayInImpulseSwitchMode(deviceInfo); + configureChannels(); + return super.processDeviceInfo(deviceInfo); + } + + /** + * Dynamically configures the channels according to the device mode. + *

+ * Two configurations are possible: + * + *

+ */ + private void configureChannels() { + if (isInImpulseSwitchMode) { + configureImpulseSwitchModeChannels(); + } else { + configurePowerSwitchModeChannels(); + } + } + + private void configureImpulseSwitchModeChannels() { + List channelsToBePresent = List.of(CHANNEL_IMPULSE_SWITCH, CHANNEL_IMPULSE_LENGTH, + CHANNEL_INSTANT_OF_LAST_IMPULSE); + List channelsToBeAbsent = List.of(CHANNEL_POWER_SWITCH); + configureChannels(channelsToBePresent, channelsToBeAbsent); + } + + private void configurePowerSwitchModeChannels() { + List channelsToBePresent = List.of(CHANNEL_POWER_SWITCH); + List channelsToBeAbsent = List.of(CHANNEL_IMPULSE_SWITCH, CHANNEL_IMPULSE_LENGTH, + CHANNEL_INSTANT_OF_LAST_IMPULSE); + configureChannels(channelsToBePresent, channelsToBeAbsent); + } + + /** + * Re-configures the channels of the associated thing, if applicable. + * + * @param channelsToBePresent channels to be added, if not present already + * @param channelsToBeAbsent channels to be removed, if present + */ + private void configureChannels(List channelsToBePresent, List channelsToBeAbsent) { + List channelsToAdd = channelsToBePresent.stream().filter(c -> getThing().getChannel(c) == null) + .toList(); + List channelsToRemove = channelsToBeAbsent.stream().map(c -> getThing().getChannel(c)) + .filter(Objects::nonNull).map(Objects::requireNonNull).toList(); + + if (channelsToAdd.isEmpty() && channelsToRemove.isEmpty()) { + return; + } + + ThingBuilder thingBuilder = editThing(); + if (!channelsToAdd.isEmpty()) { + addChannels(channelsToAdd, thingBuilder); + } + if (!channelsToRemove.isEmpty()) { + thingBuilder.withoutChannels(channelsToRemove); + } + + updateThing(thingBuilder.build()); + } + + private void addChannels(List channelsToAdd, ThingBuilder thingBuilder) { + for (String channelToAdd : channelsToAdd) { + Channel channel = createChannel(channelToAdd); + thingBuilder.withChannel(channel); + } + } + + private Channel createChannel(String channelId) { + ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId); + ChannelTypeUID channelTypeUID = getChannelTypeUID(channelId); + @Nullable + String itemType = getItemType(channelId); + return ChannelBuilder.create(channelUID, itemType).withType(channelTypeUID).build(); + } + + private ChannelTypeUID getChannelTypeUID(String channelId) { + switch (channelId) { + case CHANNEL_IMPULSE_SWITCH, CHANNEL_IMPULSE_LENGTH, CHANNEL_INSTANT_OF_LAST_IMPULSE: + return new ChannelTypeUID(BINDING_ID, channelId); + case CHANNEL_POWER_SWITCH: + return DefaultSystemChannelTypeProvider.SYSTEM_CHANNEL_TYPE_UID_POWER; + default: + throw new UnsupportedOperationException( + "Cannot determine channel type UID to create channel " + channelId + " dynamically."); + } + } + + private @Nullable String getItemType(String channelId) { + switch (channelId) { + case CHANNEL_POWER_SWITCH, CHANNEL_IMPULSE_SWITCH: + return CoreItemFactory.SWITCH; + case CHANNEL_IMPULSE_LENGTH: + return CoreItemFactory.NUMBER + ":Time"; + case CHANNEL_INSTANT_OF_LAST_IMPULSE: + return CoreItemFactory.DATETIME; + default: + throw new UnsupportedOperationException( + "Cannot determine item type to create channel " + channelId + " dynamically."); + } + } + + private boolean isRelayInImpulseSwitchMode(Device deviceInfo) { + List serviceIds = deviceInfo.deviceServiceIds; + return serviceIds != null && serviceIds.contains(ImpulseSwitchService.IMPULSE_SWITCH_SERVICE_NAME); + } + + @Override + protected void initializeServices() throws BoschSHCException { + if (!isInImpulseSwitchMode) { + // initialize PowerSwitch service only if the relay is not configured as impulse + // switch + super.initializeServices(); + } else { + // initialize impulse switch service only if the relay is configured as impulse + // switch + registerService(impulseSwitchService, this::updateChannels, + List.of(CHANNEL_IMPULSE_SWITCH, CHANNEL_IMPULSE_LENGTH, CHANNEL_INSTANT_OF_LAST_IMPULSE), true); + } + + createService(CommunicationQualityService::new, this::updateChannels, List.of(CHANNEL_SIGNAL_STRENGTH), true); + registerService(childProtectionService, this::updateChannels, List.of(CHANNEL_CHILD_PROTECTION), true); + } + + private void updateChannels(CommunicationQualityServiceState communicationQualityServiceState) { + updateState(CHANNEL_SIGNAL_STRENGTH, communicationQualityServiceState.quality.toSystemSignalStrength()); + } + + private void updateChannels(ChildProtectionServiceState childProtectionServiceState) { + updateState(CHANNEL_CHILD_PROTECTION, OnOffType.from(childProtectionServiceState.childLockActive)); + } + + private void updateChannels(ImpulseSwitchServiceState impulseSwitchServiceState) { + this.currentImpulseSwitchServiceState = impulseSwitchServiceState; + + updateState(CHANNEL_IMPULSE_SWITCH, OnOffType.from(impulseSwitchServiceState.impulseState)); + updateState(CHANNEL_IMPULSE_LENGTH, new DecimalType(impulseSwitchServiceState.impulseLength)); + + State newInstantOfLastImpulse = impulseSwitchServiceState.instantOfLastImpulse != null + ? new DateTimeType(impulseSwitchServiceState.instantOfLastImpulse) + : UnDefType.NULL; + updateState(CHANNEL_INSTANT_OF_LAST_IMPULSE, newInstantOfLastImpulse); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + super.handleCommand(channelUID, command); + + if (CHANNEL_CHILD_PROTECTION.equals(channelUID.getId()) && (command instanceof OnOffType onOffCommand)) { + updateChildProtectionState(onOffCommand); + } else if (CHANNEL_IMPULSE_SWITCH.equals(channelUID.getId()) && command instanceof OnOffType onOffCommand) { + triggerImpulse(onOffCommand); + } else if (CHANNEL_IMPULSE_LENGTH.equals(channelUID.getId()) && command instanceof DecimalType number) { + updateImpulseLength(number); + } + } + + private void updateChildProtectionState(OnOffType onOffCommand) { + ChildProtectionServiceState childProtectionServiceState = new ChildProtectionServiceState(); + childProtectionServiceState.childLockActive = onOffCommand == OnOffType.ON; + updateServiceState(childProtectionService, childProtectionServiceState); + } + + private void triggerImpulse(OnOffType onOffCommand) { + if (onOffCommand != OnOffType.ON) { + return; + } + + ImpulseSwitchServiceState newState = cloneCurrentImpulseSwitchServiceState(); + if (newState != null) { + newState.impulseState = true; + newState.instantOfLastImpulse = currentDateTimeProvider.get().toString(); + this.currentImpulseSwitchServiceState = newState; + updateServiceState(impulseSwitchService, newState); + } + } + + private void updateImpulseLength(DecimalType number) { + ImpulseSwitchServiceState newState = cloneCurrentImpulseSwitchServiceState(); + if (newState != null) { + newState.impulseLength = number.intValue(); + this.currentImpulseSwitchServiceState = newState; + logger.debug("New impulse length setting for relay: {} deciseconds", newState.impulseLength); + + updateServiceState(impulseSwitchService, newState); + logger.debug("Successfully sent state with new impulse length to controller."); + } + } + + private @Nullable ImpulseSwitchServiceState cloneCurrentImpulseSwitchServiceState() { + if (currentImpulseSwitchServiceState != null) { + ImpulseSwitchServiceState clonedState = new ImpulseSwitchServiceState(); + clonedState.impulseState = currentImpulseSwitchServiceState.impulseState; + clonedState.impulseLength = currentImpulseSwitchServiceState.impulseLength; + clonedState.instantOfLastImpulse = currentImpulseSwitchServiceState.instantOfLastImpulse; + return clonedState; + } else { + logger.warn("Could not obtain current impulse switch state, command will not be processed."); + } + return null; + } + + void setCurrentDateTimeProvider(Provider currentDateTimeProvider) { + this.currentDateTimeProvider = currentDateTimeProvider; + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java index 50a9b89356..6f5bb912cc 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java @@ -100,7 +100,8 @@ public class ThingDiscoveryService extends AbstractThingHandlerDiscoveryService< new AbstractMap.SimpleEntry<>("MICROMODULE_AWNING", BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL_2), new AbstractMap.SimpleEntry<>("MICROMODULE_LIGHT_CONTROL", BoschSHCBindingConstants.THING_TYPE_LIGHT_CONTROL_2), new AbstractMap.SimpleEntry<>("MICROMODULE_DIMMER", BoschSHCBindingConstants.THING_TYPE_DIMMER), - new AbstractMap.SimpleEntry<>("WLS", BoschSHCBindingConstants.THING_TYPE_WATER_DETECTOR) + new AbstractMap.SimpleEntry<>("WLS", BoschSHCBindingConstants.THING_TYPE_WATER_DETECTOR), + new AbstractMap.SimpleEntry<>("MICROMODULE_RELAY", BoschSHCBindingConstants.THING_TYPE_RELAY) // Future Extension: map deviceModel names to BoschSHC Thing Types when they are supported // new AbstractMap.SimpleEntry<>("SMOKE_DETECTION_SYSTEM", BoschSHCBindingConstants.), // new AbstractMap.SimpleEntry<>("PRESENCE_SIMULATION_SERVICE", BoschSHCBindingConstants.), diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/AbstractStatelessBoschSHCDeviceService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/AbstractStatelessBoschSHCDeviceService.java new file mode 100644 index 0000000000..70aa9db2ff --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/AbstractStatelessBoschSHCDeviceService.java @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2010-2024 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.services; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler; +import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; + +/** + * Abstract implementation for device services that only allow setting states + * via HTTP POST requests. State-less services can not receive any states from + * the bridge. + * + * @author David Pace - Initial contribution + * + */ +@NonNullByDefault +public class AbstractStatelessBoschSHCDeviceService + extends AbstractBoschSHCService { + + protected AbstractStatelessBoschSHCDeviceService(String serviceName) { + super(serviceName); + } + + /** + * Sends a HTTP POST request to the bridge with a state payload. + * + * @param state the state object to be sent to the bridge + * + * @throws InterruptedException + * @throws ExecutionException + * @throws TimeoutException + */ + public void setState(TState state) throws InterruptedException, TimeoutException, ExecutionException { + String deviceId = getDeviceId(); + if (deviceId == null) { + return; + } + + BridgeHandler bridgeHandler = getBridgeHandler(); + if (bridgeHandler == null) { + return; + } + + bridgeHandler.postState(deviceId, getServiceName(), state); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/ImpulseSwitchService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/ImpulseSwitchService.java new file mode 100644 index 0000000000..0e216b8033 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/ImpulseSwitchService.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2010-2024 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.services.impulseswitch; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.boschshc.internal.services.BoschSHCService; +import org.openhab.binding.boschshc.internal.services.impulseswitch.dto.ImpulseSwitchServiceState; + +/** + * Service to send impulses to relays. + * + * @author David Pace - Initial contribution + * + */ +@NonNullByDefault +public class ImpulseSwitchService extends BoschSHCService { + + public static final String IMPULSE_SWITCH_SERVICE_NAME = "ImpulseSwitch"; + + public ImpulseSwitchService() { + super(IMPULSE_SWITCH_SERVICE_NAME, ImpulseSwitchServiceState.class); + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/dto/ImpulseSwitchServiceState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/dto/ImpulseSwitchServiceState.java new file mode 100644 index 0000000000..c6206d682a --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/dto/ImpulseSwitchServiceState.java @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2010-2024 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.services.impulseswitch.dto; + +import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState; + +/** + * Data transfer object for impulses to be sent to relays. + *

+ * Example JSON: + * + *

+ * {
+ *   "@type": "ImpulseSwitchState",
+ *   "impulseState": true,
+ *   "impulseLength": 100,
+ *   "instantOfLastImpulse": "2024-04-14T15:52:31.677366Z"
+ * }
+ * 
+ * + * The impulseLength specifies the time (in tenth seconds) until the relay switches off again. + * + * @author David Pace - Initial contribution + * + */ +public class ImpulseSwitchServiceState extends BoschSHCServiceState { + + public ImpulseSwitchServiceState() { + super("ImpulseSwitchState"); + } + + public boolean impulseState; + + public int impulseLength; + + public String instantOfLastImpulse; +} 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 d7963e58b3..00f5354480 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 @@ -17,6 +17,8 @@ thing-type.boschshc.light-control-2.label = Light Control II thing-type.boschshc.light-control-2.description = Advanced light control with two switch circuits. thing-type.boschshc.motion-detector.label = Motion Detector thing-type.boschshc.motion-detector.description = Detects every movement through an intelligent combination of passive infra-red technology and an additional temperature sensor. +thing-type.boschshc.relay.label = Relay +thing-type.boschshc.relay.description = The smart switching relay is your universal all-rounder for smart switching. thing-type.boschshc.security-camera-360.label = Security Camera 360 thing-type.boschshc.security-camera-360.description = Indoor security camera with 360° view and motion detection. thing-type.boschshc.security-camera-eyes.label = Security Camera Eyes @@ -121,6 +123,12 @@ channel-type.boschshc.humidity.label = Humidity channel-type.boschshc.humidity.description = Current measured humidity. channel-type.boschshc.illuminance.label = Illuminance channel-type.boschshc.illuminance.description = The illuminance level measured by the sensor (0 to 1000). +channel-type.boschshc.impulse-length.label = Impulse Length +channel-type.boschshc.impulse-length.description = Channel to configure how long the relay will stay on after receiving an impulse switch event. The time is specified in tenth seconds (deciseconds), e.g. 15 means 1.5 seconds. +channel-type.boschshc.impulse-switch.label = Impulse Switch +channel-type.boschshc.impulse-switch.description = Channel to send impulses by means of `ON` events. After the time specified by impulse-length, the relay will switch off automatically and the state will be reset to `OFF`. +channel-type.boschshc.instant-of-last-impulse.label = Instant of Last Impulse +channel-type.boschshc.instant-of-last-impulse.description = Timestamp indicating when the last impulse was triggered. channel-type.boschshc.key-code.label = Key Code channel-type.boschshc.key-code.description = Integer code of the key that was pressed. channel-type.boschshc.key-event-timestamp.label = Key Event Timestamp 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 7ddee65935..1bb020940e 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 @@ -461,6 +461,26 @@ + + + + + + + The smart switching relay is your universal all-rounder for smart switching. + + + + + + + + + + + + + @@ -845,4 +865,26 @@ + + Switch + + Channel to send impulses by means of `ON` events. After the time specified by impulse-length, the relay + will switch off automatically and the state will be reset to `OFF`. + + + + Number:Time + + Channel to configure how long the relay will stay on after receiving an impulse switch event. The time is + specified in tenth seconds (deciseconds), e.g. 15 means 1.5 seconds. + + + + DateTime + + Timestamp indicating when the last impulse was triggered. + Time + + + diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCDeviceHandlerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCDeviceHandlerTest.java index 41dd7784d6..70a178c357 100644 --- a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCDeviceHandlerTest.java +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCDeviceHandlerTest.java @@ -12,8 +12,8 @@ */ package org.openhab.binding.boschshc.internal.devices; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -27,6 +27,7 @@ import org.junit.jupiter.params.provider.MethodSource; import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device; import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; import org.openhab.core.config.core.Configuration; +import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; @@ -62,7 +63,7 @@ public abstract class AbstractBoschSHCDeviceHandlerTest status.getStatus().equals(ThingStatus.OFFLINE) && status.getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR))); } @@ -75,7 +76,7 @@ public abstract class AbstractBoschSHCDeviceHandlerTest status.getStatus().equals(ThingStatus.OFFLINE) && status.getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR))); } diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCHandlerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCHandlerTest.java index 1c754d6d32..755fd51357 100644 --- a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCHandlerTest.java +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCHandlerTest.java @@ -18,7 +18,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -133,7 +132,7 @@ public abstract class AbstractBoschSHCHandlerTest { @Test void testInitialize() { ThingStatusInfo expectedStatusInfo = new ThingStatusInfo(ThingStatus.ONLINE, ThingStatusDetail.NONE, null); - verify(callback).statusUpdated(same(thing), eq(expectedStatusInfo)); + verify(callback).statusUpdated(any(Thing.class), eq(expectedStatusInfo)); } @Test diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractPowerSwitchHandlerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractPowerSwitchHandlerTest.java index a634f0c4c2..9b5d4216f6 100644 --- a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractPowerSwitchHandlerTest.java +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractPowerSwitchHandlerTest.java @@ -36,6 +36,7 @@ import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; import org.openhab.binding.boschshc.internal.services.powerswitch.PowerSwitchState; import org.openhab.binding.boschshc.internal.services.powerswitch.dto.PowerSwitchServiceState; import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.Thing; import org.openhab.core.thing.ThingStatus; import org.openhab.core.thing.ThingStatusDetail; import org.openhab.core.types.RefreshType; @@ -90,7 +91,7 @@ public abstract class AbstractPowerSwitchHandlerTest status.getStatus().equals(ThingStatus.OFFLINE) && status.getStatusDetail().equals(ThingStatusDetail.COMMUNICATION_ERROR))); } @@ -132,7 +133,7 @@ public abstract class AbstractPowerSwitchHandlerTest status.getStatus().equals(ThingStatus.OFFLINE) && status.getStatusDetail().equals(ThingStatusDetail.COMMUNICATION_ERROR))); } diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandlerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandlerTest.java new file mode 100644 index 0000000000..c45cc3e0e5 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandlerTest.java @@ -0,0 +1,258 @@ +/** + * Copyright (c) 2010-2024 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.relay; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.openhab.binding.boschshc.internal.devices.AbstractPowerSwitchHandlerTest; +import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants; +import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException; +import org.openhab.binding.boschshc.internal.services.childprotection.dto.ChildProtectionServiceState; +import org.openhab.binding.boschshc.internal.services.impulseswitch.ImpulseSwitchService; +import org.openhab.binding.boschshc.internal.services.impulseswitch.dto.ImpulseSwitchServiceState; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.types.UnDefType; + +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; + +/** + * Unit tests for {@link RelayHandler}. + * + * @author David Pace - Initial contributions + * + */ +@NonNullByDefault +class RelayHandlerTest extends AbstractPowerSwitchHandlerTest { + + private @Captor @NonNullByDefault({}) ArgumentCaptor childProtectionServiceStateCaptor; + + private @Captor @NonNullByDefault({}) ArgumentCaptor impulseSwitchServiceStateCaptor; + + @Override + protected RelayHandler createFixture() { + return new RelayHandler(getThing()); + } + + @Override + protected ThingTypeUID getThingTypeUID() { + return BoschSHCBindingConstants.THING_TYPE_RELAY; + } + + @Override + protected String getDeviceID() { + return "hdm:ZigBee:30XXXXXXXXXXXXXX"; + } + + @Test + void testUpdateChannelsCommunicationQualityService() { + String json = """ + { + "@type": "communicationQualityState", + "quality": "UNKNOWN" + } + """; + JsonElement jsonObject = JsonParser.parseString(json); + + getFixture().processUpdate("CommunicationQuality", jsonObject); + verify(getCallback()).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_SIGNAL_STRENGTH), + new DecimalType(0)); + + json = """ + { + "@type": "communicationQualityState", + "quality": "GOOD" + } + """; + jsonObject = JsonParser.parseString(json); + + getFixture().processUpdate("CommunicationQuality", jsonObject); + verify(getCallback()).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_SIGNAL_STRENGTH), + new DecimalType(4)); + } + + @Test + void testUpdateChannelsChildProtectionService() { + String json = """ + { + "@type": "ChildProtectionState", + "childLockActive": true + } + """; + JsonElement jsonObject = JsonParser.parseString(json); + + getFixture().processUpdate("ChildProtection", jsonObject); + verify(getCallback()).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_CHILD_PROTECTION), OnOffType.ON); + } + + @Test + void testUpdateChannelsImpulseSwitchService() + throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException { + configureImpulseSwitchMode(); + String json = """ + { + "@type": "ImpulseSwitchState", + "impulseState": true, + "impulseLength": 100, + "instantOfLastImpulse": "2024-04-14T15:52:31.677366Z" + } + """; + JsonElement jsonObject = JsonParser.parseString(json); + + getFixture().processUpdate("ImpulseSwitch", jsonObject); + verify(getCallback()).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_IMPULSE_SWITCH), OnOffType.ON); + verify(getCallback(), times(2)).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_IMPULSE_LENGTH), + new DecimalType(100)); + verify(getCallback(), times(2)).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_INSTANT_OF_LAST_IMPULSE), + new DateTimeType("2024-04-14T15:52:31.677366Z")); + } + + @Test + void testUpdateChannelsImpulseSwitchServiceNoInstantOfLastImpulse() + throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException { + configureImpulseSwitchMode(); + String json = """ + { + "@type": "ImpulseSwitchState", + "impulseState": true, + "impulseLength": 100 + } + """; + JsonElement jsonObject = JsonParser.parseString(json); + + getFixture().processUpdate("ImpulseSwitch", jsonObject); + verify(getCallback()).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_IMPULSE_SWITCH), OnOffType.ON); + verify(getCallback(), times(2)).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_IMPULSE_LENGTH), + new DecimalType(100)); + verify(getCallback()).stateUpdated( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_INSTANT_OF_LAST_IMPULSE), + UnDefType.NULL); + } + + private void configureImpulseSwitchMode() + throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException { + getDevice().deviceServiceIds = List.of(ImpulseSwitchService.IMPULSE_SWITCH_SERVICE_NAME); + getFixture().initialize(); + + assertThat(getFixture().getThing().getChannel(BoschSHCBindingConstants.CHANNEL_IMPULSE_SWITCH), + is(notNullValue())); + assertThat(getFixture().getThing().getChannel(BoschSHCBindingConstants.CHANNEL_IMPULSE_LENGTH), + is(notNullValue())); + assertThat(getFixture().getThing().getChannel(BoschSHCBindingConstants.CHANNEL_INSTANT_OF_LAST_IMPULSE), + is(notNullValue())); + + @Nullable + JsonElement impulseSwitchServiceState = JsonParser.parseString(""" + { + "@type": "ImpulseSwitchState", + "impulseState": false, + "impulseLength": 100, + "instantOfLastImpulse": "2024-04-14T15:52:31.677366Z" + } + """); + getFixture().processUpdate(ImpulseSwitchService.IMPULSE_SWITCH_SERVICE_NAME, impulseSwitchServiceState); + } + + @Test + void testHandleCommandChildProtection() + throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { + getFixture().handleCommand( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_CHILD_PROTECTION), OnOffType.ON); + verify(getBridgeHandler()).putState(eq(getDeviceID()), eq("ChildProtection"), + childProtectionServiceStateCaptor.capture()); + ChildProtectionServiceState state = childProtectionServiceStateCaptor.getValue(); + assertThat(state.childLockActive, is(true)); + } + + @Test + void testHandleCommandChildProtectionInvalidCommand() + throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { + getFixture().handleCommand( + new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_CHILD_PROTECTION), + DecimalType.ZERO); + verify(getBridgeHandler(), times(0)).putState(eq(getDeviceID()), eq("ChildProtection"), any()); + } + + @Test + void testHandleCommandImpulseStateOn() + throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { + configureImpulseSwitchMode(); + + Instant testDate = Instant.now(); + getFixture().setCurrentDateTimeProvider(() -> testDate); + + getFixture().handleCommand(new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_IMPULSE_SWITCH), + OnOffType.ON); + verify(getBridgeHandler()).putState(eq(getDeviceID()), eq(ImpulseSwitchService.IMPULSE_SWITCH_SERVICE_NAME), + impulseSwitchServiceStateCaptor.capture()); + ImpulseSwitchServiceState state = impulseSwitchServiceStateCaptor.getValue(); + assertThat(state.impulseState, is(true)); + assertThat(state.impulseLength, is(100)); + assertThat(state.instantOfLastImpulse, is(testDate.toString())); + } + + @Test + void testHandleCommandImpulseLength() + throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { + configureImpulseSwitchMode(); + + Instant testDate = Instant.now(); + getFixture().setCurrentDateTimeProvider(() -> testDate); + + getFixture().handleCommand(new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_IMPULSE_LENGTH), + new DecimalType(15)); + verify(getBridgeHandler()).putState(eq(getDeviceID()), eq(ImpulseSwitchService.IMPULSE_SWITCH_SERVICE_NAME), + impulseSwitchServiceStateCaptor.capture()); + ImpulseSwitchServiceState state = impulseSwitchServiceStateCaptor.getValue(); + assertThat(state.impulseState, is(false)); + assertThat(state.impulseLength, is(15)); + assertThat(state.instantOfLastImpulse, is("2024-04-14T15:52:31.677366Z")); + } + + @Test + void testHandleCommandImpulseStateOff() + throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { + getFixture().handleCommand(new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_IMPULSE_SWITCH), + OnOffType.OFF); + verify(getBridgeHandler(), times(0)).postState(eq(getDeviceID()), + eq(ImpulseSwitchService.IMPULSE_SWITCH_SERVICE_NAME), any()); + } +} -- 2.47.3