diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index 0d8dd4486a7..7e159a06d66 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 0b912136214..189bcb5fdc0 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 4dba916088f..f48a18bb906 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 a3b07f33030..3f00e0010cd 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) { - this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, String - .format("Interrupted update state for service %s: %s", service.getServiceName(), e.getMessage())); Thread.currentThread().interrupt(); + this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, String + .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 9fabcdff4f5..c16495c5a9b 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 8deb0578061..9575de3c955 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 00000000000..8dd4784cd39 --- /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 50a9b893568..6f5bb912cca 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 00000000000..70aa9db2ff6 --- /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 00000000000..0e216b80334 --- /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 00000000000..c6206d682a6 --- /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 d7963e58b3a..00f53544804 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 7ddee65935f..1bb020940eb 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 41dd7784d60..70a178c3578 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 1c754d6d32c..755fd51357f 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 a634f0c4c27..9b5d4216f68 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 00000000000..c45cc3e0e53 --- /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()); + } +}