]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschshc] Support for Smart Relay (#17026)
authorDavid Pace <dev@davidpace.de>
Mon, 22 Jul 2024 19:10:29 +0000 (21:10 +0200)
committerGitHub <noreply@github.com>
Mon, 22 Jul 2024 19:10:29 +0000 (21:10 +0200)
* [boschshc] Support for Smart Relay

Signed-off-by: David Pace <dev@davidpace.de>
17 files changed:
bundles/org.openhab.binding.boschshc/README.md
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/console/BoschShcCommandExtension.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCBindingConstants.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandlerFactory.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/AbstractStatelessBoschSHCDeviceService.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/ImpulseSwitchService.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/impulseswitch/dto/ImpulseSwitchServiceState.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties
bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCDeviceHandlerTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractBoschSHCHandlerTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/AbstractPowerSwitchHandlerTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/relay/RelayHandlerTest.java [new file with mode: 0644]

index 0d8dd4486a7d7cb184b49389ff8025d3658f1429..7e159a06d66a8069bd6e37b62c1555d346adbe16 100644 (file)
@@ -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               | &#9744;  | 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               | &#9744;  | 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      | &#9744;  | Communication quality between the device and the Smart Home Controller. Possible values range between 0 (unknown) and 4 (best signal strength). |
+| child-protection        | Switch      | &#9745;  | Indicates whether the child protection is active.                                  |
+| power-switch            | Switch      | &#9745;  | Switches the relay on or off. Only available if the relay is in power switch mode. |
+| impulse-switch          | Switch      | &#9745;  | 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 | &#9745;  | 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    | &#9744;  | 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.
index 0b91213621482991c19d4d80a9177f2dcfaf640a..189bcb5fdc0bae8bde9c7bad06af699fb567d4ad 100644 (file)
@@ -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.
+ * <p>
+ * 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".
+     * <p>
+     * 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
+     * <code>src/main/java/org/openhab/binding/boschshc/internal/services</code>.
      */
     List<String> 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
index 4dba916088faa8b29b74e1a1e973bc9f1ac60e1f..f48a18bb90606ec21555f621362063f71cb7a1aa 100644 (file)
@@ -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
index a3b07f3303078b45e8febe9c9ca4932a408afd46..3f00e0010cd0aa4c085e0d704e8a9391c55476dd 100644 (file)
@@ -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 <TService extends AbstractStatelessBoschSHCDeviceService<TState>, 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()));
         }
     }
 
index 9fabcdff4f5076ec13acf40294e085850312dc78..c16495c5a9b327ffff69d8821cbbbf6ed3f20920 100644 (file)
@@ -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) {
index 8deb0578061c55e8d1ee05dc858788e26e990e7c..9575de3c9556c7ba703180a9303927abe7dd130e 100644 (file)
@@ -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 <code>PUT</code> 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 <T> 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 <T extends BoschSHCServiceState> @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 <code>POST</code> request.
+     * 
+     * @param <T> 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 <T extends BoschSHCServiceState> @Nullable Response postState(String deviceId, String serviceName, T state)
+            throws InterruptedException, TimeoutException, ExecutionException {
+        return sendState(deviceId, serviceName, state, POST);
+    }
+
+    private <T extends BoschSHCServiceState> @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 (file)
index 0000000..8dd4784
--- /dev/null
@@ -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.
+ * <p>
+ * Relays are in one of two possible modes:
+ * <ul>
+ * <li>Power switch mode: a switch is used to toggle the relay on / off</li>
+ * <li>Impulse switch: the relay is triggered by an impulse and automatically
+ * switches off after a configured period of time</li>
+ * </ul>
+ * <p>
+ * Every time the thing is initialized, we detect dynamically which mode was
+ * configured for the relay and reconfigure the channels accordingly, if
+ * required.
+ * <p>
+ * 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
+     * <code>false</code>, the relay is in the default power switch (toggle) mode
+     */
+    private boolean isInImpulseSwitchMode;
+
+    /**
+     * A provider for the current date/time.
+     * <p>
+     * It is exchanged in unit tests in order to be able to assert that a certain
+     * date is contained in the result.
+     */
+    private Provider<Instant> 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.
+     * <p>
+     * Two configurations are possible:
+     * 
+     * <ul>
+     * <li>Power Switch Mode (relay stays on indefinitely when switched on)</li>
+     * <li>Impulse Switch Mode (relay stays on for a configured amount of time and
+     * then switches off automatically)</li>
+     * </ul>
+     */
+    private void configureChannels() {
+        if (isInImpulseSwitchMode) {
+            configureImpulseSwitchModeChannels();
+        } else {
+            configurePowerSwitchModeChannels();
+        }
+    }
+
+    private void configureImpulseSwitchModeChannels() {
+        List<String> channelsToBePresent = List.of(CHANNEL_IMPULSE_SWITCH, CHANNEL_IMPULSE_LENGTH,
+                CHANNEL_INSTANT_OF_LAST_IMPULSE);
+        List<String> channelsToBeAbsent = List.of(CHANNEL_POWER_SWITCH);
+        configureChannels(channelsToBePresent, channelsToBeAbsent);
+    }
+
+    private void configurePowerSwitchModeChannels() {
+        List<String> channelsToBePresent = List.of(CHANNEL_POWER_SWITCH);
+        List<String> 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<String> channelsToBePresent, List<String> channelsToBeAbsent) {
+        List<String> channelsToAdd = channelsToBePresent.stream().filter(c -> getThing().getChannel(c) == null)
+                .toList();
+        List<Channel> 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<String> 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<String> 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<Instant> currentDateTimeProvider) {
+        this.currentDateTimeProvider = currentDateTimeProvider;
+    }
+}
index 50a9b89356849d6f95c535e194d6fa9eda9168e8..6f5bb912cca2c293bf6ab229b5aefe98522b0476 100644 (file)
@@ -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 (file)
index 0000000..70aa9db
--- /dev/null
@@ -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<TState extends BoschSHCServiceState>
+        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 (file)
index 0000000..0e216b8
--- /dev/null
@@ -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<ImpulseSwitchServiceState> {
+
+    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 (file)
index 0000000..c6206d6
--- /dev/null
@@ -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.
+ * <p>
+ * Example JSON:
+ * 
+ * <pre>
+ * {
+ *   "@type": "ImpulseSwitchState",
+ *   "impulseState": true,
+ *   "impulseLength": 100,
+ *   "instantOfLastImpulse": "2024-04-14T15:52:31.677366Z"
+ * }
+ * </pre>
+ * 
+ * The <code>impulseLength</code> 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;
+}
index d7963e58b3afd8218d00d45877e3b1fef9cc24d4..00f535448047aa423e641c718297f3ce5ed8aff6 100644 (file)
@@ -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
index 7ddee65935fd2e83e0e4eca4cd426735e7f93c5d..1bb020940eb8b6c5c176b4aba93a6798e717fd95 100644 (file)
                <config-description-ref uri="thing-type:boschshc:device"/>
        </thing-type>
 
+       <thing-type id="relay">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="shc"/>
+               </supported-bridge-type-refs>
+
+               <label>Relay</label>
+               <description>The smart switching relay is your universal all-rounder for smart switching.</description>
+
+               <channels>
+                       <channel id="signal-strength" typeId="system.signal-strength"/>
+                       <channel id="child-protection" typeId="child-protection"/>
+                       <channel id="power-switch" typeId="system.power"/>
+                       <channel id="impulse-switch" typeId="impulse-switch"/>
+                       <channel id="impulse-length" typeId="impulse-length"/>
+                       <channel id="instant-of-last-impulse" typeId="instant-of-last-impulse"/>
+               </channels>
+
+               <config-description-ref uri="thing-type:boschshc:device"/>
+       </thing-type>
+
        <!-- Channels -->
 
        <channel-type id="system-availability">
                </tags>
        </channel-type>
 
+       <channel-type id="impulse-switch">
+               <item-type>Switch</item-type>
+               <label>Impulse Switch</label>
+               <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`.</description>
+       </channel-type>
+
+       <channel-type id="impulse-length">
+               <item-type unitHint="ds">Number:Time</item-type>
+               <label>Impulse Length</label>
+               <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.</description>
+       </channel-type>
+
+       <channel-type id="instant-of-last-impulse">
+               <item-type>DateTime</item-type>
+               <label>Instant of Last Impulse</label>
+               <description>Timestamp indicating when the last impulse was triggered.</description>
+               <category>Time</category>
+               <state readOnly="true"/>
+       </channel-type>
+
 </thing:thing-descriptions>
index 41dd7784d60c5558d7ddcb33995d7dae6f92d58a..70a178c35784f0a68752c9542174bdef1723a29c 100644 (file)
@@ -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<T extends BoschSHCDevice
         getFixture().getThing().getConfiguration().remove("id");
         getFixture().initialize();
 
-        verify(getCallback()).statusUpdated(eq(getThing()),
+        verify(getCallback()).statusUpdated(any(Thing.class),
                 argThat(status -> status.getStatus().equals(ThingStatus.OFFLINE)
                         && status.getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)));
     }
@@ -75,7 +76,7 @@ public abstract class AbstractBoschSHCDeviceHandlerTest<T extends BoschSHCDevice
 
         getFixture().initialize();
 
-        verify(getCallback()).statusUpdated(eq(getThing()),
+        verify(getCallback()).statusUpdated(any(Thing.class),
                 argThat(status -> status.getStatus().equals(ThingStatus.OFFLINE)
                         && status.getStatusDetail().equals(ThingStatusDetail.CONFIGURATION_ERROR)));
     }
index 1c754d6d32cb187de1433f6b36d70a9a294b0814..755fd51357f6f9c85a50b3bdab97312c4de4c8e9 100644 (file)
@@ -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<T extends BoschSHCHandler> {
     @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
index a634f0c4c275ef591b33d441c8d2bb0cb682ef4f..9b5d4216f680fddddac680657c33fc5bbed15576 100644 (file)
@@ -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<T extends AbstractPowerSwit
 
         getFixture().handleCommand(getChannelUID(BoschSHCBindingConstants.CHANNEL_POWER_SWITCH), OnOffType.ON);
 
-        verify(getCallback()).statusUpdated(same(getThing()),
+        verify(getCallback()).statusUpdated(any(Thing.class),
                 argThat(status -> status.getStatus().equals(ThingStatus.OFFLINE)
                         && status.getStatusDetail().equals(ThingStatusDetail.COMMUNICATION_ERROR)));
     }
@@ -132,7 +133,7 @@ public abstract class AbstractPowerSwitchHandlerTest<T extends AbstractPowerSwit
 
         getFixture().handleCommand(getChannelUID(BoschSHCBindingConstants.CHANNEL_POWER_SWITCH), RefreshType.REFRESH);
 
-        verify(getCallback()).statusUpdated(same(getThing()),
+        verify(getCallback()).statusUpdated(any(Thing.class),
                 argThat(status -> 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 (file)
index 0000000..c45cc3e
--- /dev/null
@@ -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<RelayHandler> {
+
+    private @Captor @NonNullByDefault({}) ArgumentCaptor<ChildProtectionServiceState> childProtectionServiceStateCaptor;
+
+    private @Captor @NonNullByDefault({}) ArgumentCaptor<ImpulseSwitchServiceState> 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());
+    }
+}