]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschshc] Add user defined states (#16028)
authorPatrick <54861416+pat-git023@users.noreply.github.com>
Sat, 30 Dec 2023 23:56:51 +0000 (00:56 +0100)
committerGitHub <noreply@github.com>
Sat, 30 Dec 2023 23:56:51 +0000 (00:56 +0100)
Signed-off-by: Patrick Gell <patgit023@gmail.com>
25 files changed:
bundles/org.openhab.binding.boschshc/README.md
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/BoschSHCHandlerFactory.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClient.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/bridge/ScenarioHandler.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/UserDefinedState.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/userdefinedstate/UserStateHandler.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/serialization/BoschServiceDataDeserializer.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/userstate/UserStateService.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/userstate/dto/UserStateServiceState.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/config/configs.xml
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/bridge/BoschHttpClientTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandlerTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/LongPollingTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/ScenarioHandlerTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/UserDefinedStateTest.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/userdefinedstate/UserStateHandlerTest.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryServiceTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceStateTest.java
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/userstate/dto/UserStateServiceStateTest.java [new file with mode: 0644]

index c17ce7ff808b240e67141c385cfdc05ac65acecf..4b75bb23c220193f8f482913dcf5f0674b087cb4 100644 (file)
@@ -19,6 +19,7 @@ Binding for the Bosch Smart Home.
     - [Intrusion Detection System](#intrusion-detection-system)
     - [Smart Bulb](#smart-bulb)
     - [Smoke Detector](#smoke-detector)
+    - [User-defined States](#user-defined-states)
   - [Limitations](#limitations)
   - [Discovery](#discovery)
   - [Bridge Configuration](#bridge-configuration)
@@ -218,6 +219,19 @@ The smoke detector warns you in case of fire.
 | smoke-check        | String               | &#9745;  | State of the smoke check. Also used to request a new smoke check.                                 |
 
 
+### User-defined States
+
+User-defined states enable automations to be better adapted to specific needs and everyday situations. 
+Individual states can be activated/deactivated and can be used as triggers, conditions and actions in automations.
+
+**Thing Type ID**: `user-defined-state`
+
+
+| Channel Type ID | Item Type | Writable | Description                                |
+|-----------------|-----------| :------: |--------------------------------------------|
+| user-state      | Switch    | &#9745;  | Switches the User-defined state on or off. |
+
+
 ## Limitations
 
 No major limitation known.
index c520241131a4b2b373a582b68ae5c1091f189e9d..b87a217a301e0166c10d63722e6fca09db0b5da2 100644 (file)
@@ -49,6 +49,8 @@ public class BoschSHCBindingConstants {
     public static final ThingTypeUID THING_TYPE_SMART_BULB = new ThingTypeUID(BINDING_ID, "smart-bulb");
     public static final ThingTypeUID THING_TYPE_SMOKE_DETECTOR = new ThingTypeUID(BINDING_ID, "smoke-detector");
 
+    public static final ThingTypeUID THING_TYPE_USER_DEFINED_STATE = new ThingTypeUID(BINDING_ID, "user-defined-state");
+
     // List of all Channel IDs
     // Auto-generated from thing-types.xml via script, don't modify
     public static final String CHANNEL_SCENARIO_TRIGGERED = "scenario-triggered";
@@ -87,6 +89,8 @@ public class BoschSHCBindingConstants {
     public static final String CHANNEL_SILENT_MODE = "silent-mode";
     public static final String CHANNEL_ILLUMINANCE = "illuminance";
 
+    public static final String CHANNEL_USER_DEFINED_STATE = "user-state";
+
     // static device/service names
     public static final String SERVICE_INTRUSION_DETECTION = "intrusionDetectionSystem";
 }
index 193cb162c0c83d44705562ac301a1371fdb135a4..010a46fc1e25974a933a85cd99ca5f063c9be666 100644 (file)
@@ -32,6 +32,7 @@ import org.openhab.binding.boschshc.internal.devices.smartbulb.SmartBulbHandler;
 import org.openhab.binding.boschshc.internal.devices.smokedetector.SmokeDetectorHandler;
 import org.openhab.binding.boschshc.internal.devices.thermostat.ThermostatHandler;
 import org.openhab.binding.boschshc.internal.devices.twinguard.TwinguardHandler;
+import org.openhab.binding.boschshc.internal.devices.userdefinedstate.UserStateHandler;
 import org.openhab.binding.boschshc.internal.devices.wallthermostat.WallThermostatHandler;
 import org.openhab.binding.boschshc.internal.devices.windowcontact.WindowContactHandler;
 import org.openhab.core.thing.Bridge;
@@ -82,7 +83,8 @@ public class BoschSHCHandlerFactory extends BaseThingHandlerFactory {
             new ThingTypeHandlerMapping(THING_TYPE_INTRUSION_DETECTION_SYSTEM, IntrusionDetectionHandler::new),
             new ThingTypeHandlerMapping(THING_TYPE_SMART_PLUG_COMPACT, PlugHandler::new),
             new ThingTypeHandlerMapping(THING_TYPE_SMART_BULB, SmartBulbHandler::new),
-            new ThingTypeHandlerMapping(THING_TYPE_SMOKE_DETECTOR, SmokeDetectorHandler::new));
+            new ThingTypeHandlerMapping(THING_TYPE_SMOKE_DETECTOR, SmokeDetectorHandler::new),
+            new ThingTypeHandlerMapping(THING_TYPE_USER_DEFINED_STATE, UserStateHandler::new));
 
     @Override
     public boolean supportsThingType(ThingTypeUID thingTypeUID) {
index 2e9e612468f788685c729da5d419c78d898cb8e3..70b0151c9f9733f46aa6c7a4d645d010041619ca 100644 (file)
@@ -38,6 +38,8 @@ import org.eclipse.jetty.http.HttpStatus;
 import org.eclipse.jetty.util.ssl.SslContextFactory;
 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
 import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
+import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
+import org.openhab.binding.boschshc.internal.services.userstate.dto.UserStateServiceState;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -131,6 +133,15 @@ public class BoschHttpClient extends HttpClient {
         return this.getBoschSmartHomeUrl(String.format("devices/%s/services/%s/state", deviceId, serviceName));
     }
 
+    public <T extends BoschSHCServiceState> String getServiceStateUrl(String serviceName, String deviceId,
+            Class<T> serviceClass) {
+        if (serviceClass.isAssignableFrom(UserStateServiceState.class)) {
+            return this.getBoschSmartHomeUrl(String.format("userdefinedstates/%s/state", deviceId));
+        } else {
+            return getServiceStateUrl(serviceName, deviceId);
+        }
+    }
+
     /**
      * Returns a URL to get general information about a service.
      * <p>
@@ -291,8 +302,13 @@ public class BoschHttpClient extends HttpClient {
                 .timeout(10, TimeUnit.SECONDS); // Set default timeout
 
         if (content != null) {
-            String body = GsonUtils.DEFAULT_GSON_INSTANCE.toJson(content);
-            logger.trace("create request for {} and content {}", url, content);
+            final String body;
+            if (content.getClass().isAssignableFrom(UserStateServiceState.class)) {
+                body = ((UserStateServiceState) content).getStateAsString();
+            } else {
+                body = GsonUtils.DEFAULT_GSON_INSTANCE.toJson(content);
+            }
+            logger.trace("create request for {} and content {}", url, body);
             request = request.content(new StringContentProvider(body));
         } else {
             logger.trace("create request for {}", url);
index 717ad89d375cbc2a9eefd5f447b5217fca9fe53d..858fb8916a1fd8a843ae4f674c36a8a2c1bb7924 100644 (file)
@@ -41,6 +41,7 @@ import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceDat
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
 import org.openhab.binding.boschshc.internal.discovery.ThingDiscoveryService;
 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
@@ -80,6 +81,8 @@ import com.google.gson.reflect.TypeToken;
 @NonNullByDefault
 public class BridgeHandler extends BaseBridgeHandler {
 
+    private static final String HTTP_CLIENT_NOT_INITIALIZED = "HttpClient not initialized";
+
     private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
 
     /**
@@ -154,11 +157,11 @@ public class BridgeHandler extends BaseBridgeHandler {
         }
 
         // Instantiate HttpClient with the SslContextFactory
-        BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
+        BoschHttpClient localHttpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
 
         // Start http client
         try {
-            httpClient.start();
+            localHttpClient.start();
         } catch (Exception e) {
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
                     String.format("Could not create http connection to controller: %s", e.getMessage()));
@@ -170,16 +173,16 @@ public class BridgeHandler extends BaseBridgeHandler {
 
         // Initialize bridge in the background.
         // Start initial access the first time
-        scheduleInitialAccess(httpClient);
+        scheduleInitialAccess(localHttpClient);
     }
 
     @Override
     public void dispose() {
         // Cancel scheduled pairing.
         @Nullable
-        ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
-        if (scheduledPairing != null) {
-            scheduledPairing.cancel(true);
+        ScheduledFuture<?> localScheduledPairing = this.scheduledPairing;
+        if (localScheduledPairing != null) {
+            localScheduledPairing.cancel(true);
             this.scheduledPairing = null;
         }
 
@@ -187,10 +190,10 @@ public class BridgeHandler extends BaseBridgeHandler {
         this.longPolling.stop();
 
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient != null) {
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient != null) {
             try {
-                httpClient.stop();
+                localHttpClient.stop();
             } catch (Exception e) {
                 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage(), e);
             }
@@ -295,16 +298,16 @@ public class BridgeHandler extends BaseBridgeHandler {
      */
     public boolean checkBridgeAccess() throws InterruptedException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
+        BoschHttpClient localHttpClient = this.httpClient;
 
-        if (httpClient == null) {
+        if (localHttpClient == null) {
             return false;
         }
 
         try {
-            logger.debug("Sending http request to BoschSHC to check access: {}", httpClient);
-            String url = httpClient.getBoschSmartHomeUrl("devices");
-            ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
+            logger.debug("Sending http request to BoschSHC to check access: {}", localHttpClient);
+            String url = localHttpClient.getBoschSmartHomeUrl("devices");
+            ContentResponse contentResponse = localHttpClient.createRequest(url, GET).send();
 
             // check HTTP status code
             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
@@ -327,15 +330,15 @@ public class BridgeHandler extends BaseBridgeHandler {
      */
     public List<Device> getDevices() throws InterruptedException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
             return Collections.emptyList();
         }
 
         try {
-            logger.trace("Sending http request to Bosch to request devices: {}", httpClient);
-            String url = httpClient.getBoschSmartHomeUrl("devices");
-            ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
+            logger.trace("Sending http request to Bosch to request devices: {}", localHttpClient);
+            String url = localHttpClient.getBoschSmartHomeUrl("devices");
+            ContentResponse contentResponse = localHttpClient.createRequest(url, GET).send();
 
             // check HTTP status code
             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
@@ -357,6 +360,39 @@ public class BridgeHandler extends BaseBridgeHandler {
         }
     }
 
+    public List<UserDefinedState> getUserStates() throws InterruptedException {
+        @Nullable
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
+            return List.of();
+        }
+
+        try {
+            logger.trace("Sending http request to Bosch to request user-defined states: {}", localHttpClient);
+            String url = localHttpClient.getBoschSmartHomeUrl("userdefinedstates");
+            ContentResponse contentResponse = localHttpClient.createRequest(url, GET).send();
+
+            // check HTTP status code
+            if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
+                logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
+                return List.of();
+            }
+
+            String content = contentResponse.getContentAsString();
+            logger.trace("Request devices completed with success: {} - status code: {}", content,
+                    contentResponse.getStatus());
+
+            Type collectionType = new TypeToken<ArrayList<UserDefinedState>>() {
+            }.getType();
+            List<UserDefinedState> nullableUserStates = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
+                    collectionType);
+            return Optional.ofNullable(nullableUserStates).orElse(Collections.emptyList());
+        } catch (TimeoutException | ExecutionException e) {
+            logger.debug("Request user-defined states failed because of {}!", e.getMessage(), e);
+            return List.of();
+        }
+    }
+
     /**
      * Get a list of rooms from the Smart-Home controller
      *
@@ -365,12 +401,12 @@ public class BridgeHandler extends BaseBridgeHandler {
     public List<Room> getRooms() throws InterruptedException {
         List<Room> emptyRooms = new ArrayList<>();
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient != null) {
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient != null) {
             try {
                 logger.trace("Sending http request to Bosch to request rooms");
-                String url = httpClient.getBoschSmartHomeUrl("rooms");
-                ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
+                String url = localHttpClient.getBoschSmartHomeUrl("rooms");
+                ContentResponse contentResponse = localHttpClient.createRequest(url, GET).send();
 
                 // check HTTP status code
                 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
@@ -426,6 +462,8 @@ public class BridgeHandler extends BaseBridgeHandler {
         for (BoschSHCServiceState serviceState : result.result) {
             if (serviceState instanceof DeviceServiceData deviceServiceData) {
                 handleDeviceServiceData(deviceServiceData);
+            } else if (serviceState instanceof UserDefinedState userDefinedState) {
+                handleUserDefinedState(userDefinedState);
             } else if (serviceState instanceof Scenario scenario) {
                 final Channel channel = this.getThing().getChannel(BoschSHCBindingConstants.CHANNEL_SCENARIO_TRIGGERED);
                 if (channel != null && isLinked(channel.getUID())) {
@@ -458,6 +496,24 @@ public class BridgeHandler extends BaseBridgeHandler {
         }
     }
 
+    private void handleUserDefinedState(@Nullable UserDefinedState userDefinedState) {
+        if (userDefinedState != null) {
+            JsonElement state = GsonUtils.DEFAULT_GSON_INSTANCE.toJsonTree(userDefinedState.isState());
+
+            logger.debug("Got update for user-defined state {} with id {}: {}", userDefinedState.getName(),
+                    userDefinedState.getId(), state);
+
+            var stateId = userDefinedState.getId();
+            if (stateId == null || state == null) {
+                return;
+            }
+
+            logger.debug("Got update for user-defined state {}", userDefinedState);
+
+            forwardStateToHandlers(userDefinedState, state, stateId);
+        }
+    }
+
     /**
      * Extracts the actual state object from the given {@link DeviceServiceData} instance.
      * <p>
@@ -482,12 +538,18 @@ public class BridgeHandler extends BaseBridgeHandler {
     /**
      * Tries to find handlers for the device with the given ID and forwards the received state to the handlers.
      *
-     * @param deviceServiceData object representing updates received in long poll results
+     * @param serviceData object representing updates received in long poll results
      * @param state the received state object as JSON element
      * @param updateDeviceId the ID of the device for which the state update was received
      */
-    private void forwardStateToHandlers(DeviceServiceData deviceServiceData, JsonElement state, String updateDeviceId) {
+    private void forwardStateToHandlers(BoschSHCServiceState serviceData, JsonElement state, String updateDeviceId) {
         boolean handled = false;
+        final String serviceId;
+        if (serviceData instanceof UserDefinedState userState) {
+            serviceId = userState.getId();
+        } else {
+            serviceId = ((DeviceServiceData) serviceData).id;
+        }
 
         Bridge bridge = this.getThing();
         for (Thing childThing : bridge.getThings()) {
@@ -502,9 +564,8 @@ public class BridgeHandler extends BaseBridgeHandler {
                 logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
 
                 if (deviceId != null && updateDeviceId.equals(deviceId)) {
-                    logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler,
-                            deviceServiceData.id, state);
-                    handler.processUpdate(deviceServiceData.id, state);
+                    logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler, serviceId, state);
+                    handler.processUpdate(serviceId, state);
                 }
             } else {
                 logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
@@ -526,8 +587,8 @@ public class BridgeHandler extends BaseBridgeHandler {
     private void handleLongPollFailure(Throwable e) {
         logger.warn("Long polling failed, will try to reconnect", e);
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
                     "@text/offline.long-polling-failed.http-client-null");
             return;
@@ -535,36 +596,68 @@ public class BridgeHandler extends BaseBridgeHandler {
 
         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
                 "@text/offline.long-polling-failed.trying-to-reconnect");
-        scheduleInitialAccess(httpClient);
+        scheduleInitialAccess(localHttpClient);
     }
 
     public Device getDeviceInfo(String deviceId)
             throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
             throw new BoschSHCException("HTTP client not initialized");
         }
 
-        String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
-        Request request = httpClient.createRequest(url, GET);
-
-        return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
-            JsonRestExceptionResponse errorResponse = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
-                    JsonRestExceptionResponse.class);
-            if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
-                if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
-                    return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
-                } else {
-                    return new BoschSHCException(
-                            String.format("Request for info of device %s failed with status code %d and error code %s",
+        String url = localHttpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
+        Request request = localHttpClient.createRequest(url, GET);
+
+        return localHttpClient.sendRequest(request, Device.class, Device::isValid,
+                (Integer statusCode, String content) -> {
+                    JsonRestExceptionResponse errorResponse = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
+                            JsonRestExceptionResponse.class);
+                    if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
+                        if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
+                            return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
+                        } else {
+                            return new BoschSHCException(String.format(
+                                    "Request for info of device %s failed with status code %d and error code %s",
                                     deviceId, errorResponse.statusCode, errorResponse.errorCode));
-                }
-            } else {
-                return new BoschSHCException(String.format("Request for info of device %s failed with status code %d",
-                        deviceId, statusCode));
-            }
-        });
+                        }
+                    } else {
+                        return new BoschSHCException(String.format(
+                                "Request for info of device %s failed with status code %d", deviceId, statusCode));
+                    }
+                });
+    }
+
+    public UserDefinedState getUserStateInfo(String stateId)
+            throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
+        @Nullable
+        BoschHttpClient locaHttpClient = this.httpClient;
+        if (locaHttpClient == null) {
+            throw new BoschSHCException("HTTP client not initialized");
+        }
+
+        String url = locaHttpClient.getBoschSmartHomeUrl(String.format("userdefinedstates/%s", stateId));
+        Request request = locaHttpClient.createRequest(url, GET);
+
+        return locaHttpClient.sendRequest(request, UserDefinedState.class, UserDefinedState::isValid,
+                (Integer statusCode, String content) -> {
+                    JsonRestExceptionResponse errorResponse = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
+                            JsonRestExceptionResponse.class);
+                    if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
+                        if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
+                            return new BoschSHCException("@text/offline.conf-error.invalid-state-id");
+                        } else {
+                            return new BoschSHCException(String.format(
+                                    "Request for info of user-defines state %s failed with status code %d and error code %s",
+                                    stateId, errorResponse.statusCode, errorResponse.errorCode));
+                        }
+                    } else {
+                        return new BoschSHCException(
+                                String.format("Request for info of user-defined state %s failed with status code %d",
+                                        stateId, statusCode));
+                    }
+                });
     }
 
     /**
@@ -588,15 +681,15 @@ public class BridgeHandler extends BaseBridgeHandler {
     public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
-            logger.warn("HttpClient not initialized");
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
+            logger.warn(HTTP_CLIENT_NOT_INITIALIZED);
             return null;
         }
 
-        String url = httpClient.getServiceStateUrl(stateName, deviceId);
+        String url = localHttpClient.getServiceStateUrl(stateName, deviceId, stateClass);
         logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
-        return getState(httpClient, url, stateClass);
+        return getState(localHttpClient, url, stateClass);
     }
 
     /**
@@ -614,15 +707,15 @@ public class BridgeHandler extends BaseBridgeHandler {
     public <T extends BoschSHCServiceState> @Nullable T getState(String endpoint, Class<T> stateClass)
             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
-            logger.warn("HttpClient not initialized");
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
+            logger.warn(HTTP_CLIENT_NOT_INITIALIZED);
             return null;
         }
 
-        String url = httpClient.getBoschSmartHomeUrl(endpoint);
+        String url = localHttpClient.getBoschSmartHomeUrl(endpoint);
         logger.debug("getState(): Requesting from Bosch: {}", url);
-        return getState(httpClient, url, stateClass);
+        return getState(localHttpClient, url, stateClass);
     }
 
     /**
@@ -684,15 +777,15 @@ public class BridgeHandler extends BaseBridgeHandler {
     public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
             throws InterruptedException, TimeoutException, ExecutionException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
-            logger.warn("HttpClient not initialized");
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
+            logger.warn(HTTP_CLIENT_NOT_INITIALIZED);
             return null;
         }
 
         // Create request
-        String url = httpClient.getServiceStateUrl(serviceName, deviceId);
-        Request request = httpClient.createRequest(url, PUT, state);
+        String url = localHttpClient.getServiceStateUrl(serviceName, deviceId, state.getClass());
+        Request request = localHttpClient.createRequest(url, PUT, state);
 
         // Send request
         return request.send();
@@ -726,28 +819,28 @@ public class BridgeHandler extends BaseBridgeHandler {
     public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
             throws InterruptedException, TimeoutException, ExecutionException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
-            logger.warn("HttpClient not initialized");
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
+            logger.warn(HTTP_CLIENT_NOT_INITIALIZED);
             return null;
         }
 
-        String url = httpClient.getBoschSmartHomeUrl(endpoint);
-        Request request = httpClient.createRequest(url, POST, requestBody);
+        String url = localHttpClient.getBoschSmartHomeUrl(endpoint);
+        Request request = localHttpClient.createRequest(url, POST, requestBody);
         return request.send();
     }
 
     public @Nullable DeviceServiceData getServiceData(String deviceId, String serviceName)
             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
         @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient == null) {
-            logger.warn("HttpClient not initialized");
+        BoschHttpClient localHttpClient = this.httpClient;
+        if (localHttpClient == null) {
+            logger.warn(HTTP_CLIENT_NOT_INITIALIZED);
             return null;
         }
 
-        String url = httpClient.getServiceUrl(serviceName, deviceId);
+        String url = localHttpClient.getServiceUrl(serviceName, deviceId);
         logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", serviceName, deviceId, url);
-        return getState(httpClient, url, DeviceServiceData.class);
+        return getState(localHttpClient, url, DeviceServiceData.class);
     }
 }
index 54a080a8cf1eaa6b341b9d5a310f8e7414256aa0..e78fd2fc59690928f29da105aa3be7dc57fb1f2a 100644 (file)
@@ -42,7 +42,6 @@ public class ScenarioHandler {
     }
 
     public void triggerScenario(final BoschHttpClient httpClient, final String scenarioName) {
-
         final Scenario[] scenarios;
         try {
             scenarios = getAvailableScenarios(httpClient);
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/UserDefinedState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/UserDefinedState.java
new file mode 100644 (file)
index 0000000..e66887a
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.boschshc.internal.devices.bridge.dto;
+
+import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
+
+/**
+ * Represents a single user-defined state defined on the Bosch Smart Home Controller.
+ *
+ * Example from Json:
+ *
+ * <pre>
+ * {
+ * "@type": "userDefinedState",
+ * "id": "23d34fa6-382a-444d-8aae-89c706e22158",
+ * "name": "atHome",
+ * "state": false
+ * }
+ * </pre>
+ *
+ * @author Patrick Gell - Initial contribution
+ */
+public class UserDefinedState extends BoschSHCServiceState {
+
+    private String id;
+    private String name;
+    private boolean state;
+
+    public UserDefinedState() {
+        super("UserDefinedState");
+    }
+
+    public String getId() {
+        return id;
+    }
+
+    public void setId(String id) {
+        this.id = id;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    public boolean isState() {
+        return state;
+    }
+
+    public void setState(boolean state) {
+        this.state = state;
+    }
+
+    @Override
+    public String toString() {
+        return "UserDefinedState{" + "id='" + id + '\'' + ", name='" + name + '\'' + ", state=" + state + ", type='"
+                + type + '\'' + '}';
+    }
+
+    public static Boolean isValid(UserDefinedState obj) {
+        return obj != null && obj.id != null;
+    }
+}
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/userdefinedstate/UserStateHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/userdefinedstate/UserStateHandler.java
new file mode 100644 (file)
index 0000000..52f5064
--- /dev/null
@@ -0,0 +1,137 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.boschshc.internal.devices.userdefinedstate;
+
+import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.CHANNEL_USER_DEFINED_STATE;
+
+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.openhab.binding.boschshc.internal.devices.BoschSHCConfiguration;
+import org.openhab.binding.boschshc.internal.devices.BoschSHCHandler;
+import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
+import org.openhab.binding.boschshc.internal.services.userstate.UserStateService;
+import org.openhab.binding.boschshc.internal.services.userstate.dto.UserStateServiceState;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.types.Command;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.JsonElement;
+
+/**
+ * Handler for user defined states
+ *
+ * @author Patrick Gell - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class UserStateHandler extends BoschSHCHandler {
+
+    private final Logger logger = LoggerFactory.getLogger(getClass());
+
+    private final UserStateService userStateService;
+    /**
+     * Bosch SHC configuration loaded from openHAB configuration.
+     */
+    private @Nullable BoschSHCConfiguration config;
+
+    public UserStateHandler(Thing thing) {
+        super(thing);
+
+        userStateService = new UserStateService();
+    }
+
+    @Override
+    public void initialize() {
+        var localConfig = this.config = getConfigAs(BoschSHCConfiguration.class);
+        String stateId = localConfig.id;
+        if (stateId == null || stateId.isBlank()) {
+            this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "@text/offline.conf-error.empty-state-id");
+            return;
+        }
+
+        // Try to get state info to make sure the state exists
+        try {
+            var bridgeHandler = this.getBridgeHandler();
+            var info = bridgeHandler.getUserStateInfo(stateId);
+            logger.trace("User-defined state initialized:\n{}", info);
+        } catch (TimeoutException | ExecutionException | BoschSHCException e) {
+            this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+            return;
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+            return;
+        }
+        super.initialize();
+    }
+
+    @Override
+    public @Nullable String getBoschID() {
+        if (config != null) {
+            return config.id;
+        }
+
+        return null;
+    }
+
+    @Override
+    protected void initializeServices() throws BoschSHCException {
+        super.initializeServices();
+
+        logger.debug("Initializing service for UserStateHandler");
+        this.registerService(userStateService, this::updateChannels, List.of(CHANNEL_USER_DEFINED_STATE), true);
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        super.handleCommand(channelUID, command);
+
+        if (channelUID.getId().equals(CHANNEL_USER_DEFINED_STATE) && (command instanceof OnOffType onOffCommand)) {
+            updateUserState(channelUID.getThingUID().getId(), onOffCommand);
+        }
+    }
+
+    private void updateUserState(String stateId, OnOffType userState) {
+        UserStateServiceState serviceState = new UserStateServiceState();
+        serviceState.setState(userState == OnOffType.ON);
+        try {
+            getBridgeHandler().putState(stateId, "", serviceState);
+        } catch (BoschSHCException | ExecutionException | TimeoutException e) {
+            this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+                    String.format("Error while putting user-defined state for %s", stateId));
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
+                    String.format("Error while putting user-defined state for %s", stateId));
+        }
+    }
+
+    private void updateChannels(UserStateServiceState userState) {
+        super.updateState(CHANNEL_USER_DEFINED_STATE, userState.toOnOffType());
+    }
+
+    @Override
+    public void processUpdate(String serviceName, @Nullable JsonElement stateData) {
+        super.processUpdate("UserDefinedState", stateData);
+    }
+}
index ae83f196ae4b3a5713063a3d8ca395fdf61ae2ca..e2e005e756af657144c453c77e72761b09469eee 100644 (file)
@@ -24,6 +24,7 @@ import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
 import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
 import org.openhab.core.config.discovery.AbstractDiscoveryService;
 import org.openhab.core.config.discovery.DiscoveryResultBuilder;
 import org.openhab.core.thing.ThingTypeUID;
@@ -164,6 +165,8 @@ public class ThingDiscoveryService extends AbstractDiscoveryService implements T
         logger.debug("SHC has {} rooms", rooms.size());
         List<Device> devices = shcBridgeHandler.getDevices();
         logger.debug("SHC has {} devices", devices.size());
+        List<UserDefinedState> userStates = shcBridgeHandler.getUserStates();
+        logger.debug("SHC has {} user-defined states", userStates.size());
 
         // Write found devices into openhab.log to support manual configuration
         for (Device d : devices) {
@@ -174,8 +177,47 @@ public class ThingDiscoveryService extends AbstractDiscoveryService implements T
                 }
             }
         }
+        for (UserDefinedState userState : userStates) {
+            logger.debug("Found user-defined state: name={} id={} state={}", userState.getName(), userState.getId(),
+                    userState.isState());
+        }
 
         addDevices(devices, rooms);
+        addUserStates(userStates);
+    }
+
+    protected void addUserStates(List<UserDefinedState> userStates) {
+        for (UserDefinedState userState : userStates) {
+            addUserState(userState);
+        }
+    }
+
+    private void addUserState(UserDefinedState userState) {
+        // see startScan for the runtime null check of shcBridgeHandler
+        assert shcBridgeHandler != null;
+
+        logger.trace("Discovering user-defined state {}", userState.getName());
+        logger.trace("- details: id {}, state {}", userState.getId(), userState.isState());
+
+        ThingTypeUID thingTypeUID = new ThingTypeUID(BoschSHCBindingConstants.BINDING_ID,
+                BoschSHCBindingConstants.THING_TYPE_USER_DEFINED_STATE.getId());
+
+        logger.trace("- got thingTypeID '{}' for user-defined state '{}'", thingTypeUID.getId(), userState.getName());
+
+        ThingUID thingUID = new ThingUID(thingTypeUID, shcBridgeHandler.getThing().getUID(),
+                userState.getId().replace(':', '_'));
+
+        logger.trace("- got thingUID '{}' for user-defined state: '{}'", thingUID, userState);
+
+        DiscoveryResultBuilder discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID)
+                .withProperty("id", userState.getId()).withLabel(userState.getName());
+
+        discoveryResult.withBridge(shcBridgeHandler.getThing().getUID());
+
+        thingDiscovered(discoveryResult.build());
+
+        logger.debug("Discovered user-defined state '{}' with thingTypeUID={}, thingUID={}, id={}, state={}",
+                userState.getName(), thingUID, thingTypeUID, userState.getId(), userState.isState());
     }
 
     protected void addDevices(List<Device> devices, List<Room> rooms) {
index c04d0bd89a70f207e50b06ff5da579366fd6b0ae..84fe4136432779d64868a7ec86f385373b3276a9 100644 (file)
@@ -18,6 +18,7 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
 import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
 
 import com.google.gson.JsonDeserializationContext;
@@ -39,7 +40,6 @@ public class BoschServiceDataDeserializer implements JsonDeserializer<BoschSHCSe
     @Override
     public BoschSHCServiceState deserialize(JsonElement jsonElement, Type type,
             JsonDeserializationContext jsonDeserializationContext) throws JsonParseException {
-
         JsonObject jsonObject = jsonElement.getAsJsonObject();
         JsonElement dataType = jsonObject.get("@type");
         switch (dataType.getAsString()) {
@@ -58,6 +58,13 @@ public class BoschServiceDataDeserializer implements JsonDeserializer<BoschSHCSe
                 scenario.lastTimeTriggered = jsonObject.get("lastTimeTriggered").getAsString();
                 return scenario;
             }
+            case "userDefinedState" -> {
+                var state = new UserDefinedState();
+                state.setId(jsonObject.get("id").getAsString());
+                state.setName(jsonObject.get("name").getAsString());
+                state.setState(jsonObject.get("state").getAsBoolean());
+                return state;
+            }
             default -> {
                 return new BoschSHCServiceState(dataType.getAsString());
             }
index 7d906c67174490e03c067ae1054f021087bf2faa..9144f8d25b12c1239f8fec4e1b443d221e2888ae 100644 (file)
@@ -14,6 +14,7 @@ package org.openhab.binding.boschshc.internal.services.dto;
 
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
+import org.openhab.binding.boschshc.internal.services.userstate.dto.UserStateServiceState;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -62,9 +63,12 @@ public class BoschSHCServiceState {
 
     public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(String json,
             Class<TState> stateClass) {
-        var state = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(json, stateClass);
+        var state = getUserDefinedStateOrNull(json, stateClass);
         if (state == null || !state.isValid()) {
-            return null;
+            state = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(json, stateClass);
+            if (state == null || !state.isValid()) {
+                return null;
+            }
         }
 
         return state;
@@ -72,11 +76,31 @@ public class BoschSHCServiceState {
 
     public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(JsonElement json,
             Class<TState> stateClass) {
-        var state = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(json, stateClass);
+        var state = getUserDefinedStateOrNull(json, stateClass);
         if (state == null || !state.isValid()) {
-            return null;
+            state = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(json, stateClass);
+            if (state == null || !state.isValid()) {
+                return null;
+            }
         }
-
         return state;
     }
+
+    private static <TState extends BoschSHCServiceState> TState getUserDefinedStateOrNull(JsonElement json,
+            Class<TState> stateClass) {
+        if (stateClass.isAssignableFrom(UserStateServiceState.class)) {
+            return BoschSHCServiceState.getUserDefinedStateOrNull(json.getAsString(), stateClass);
+        }
+        return null;
+    }
+
+    private static <TState extends BoschSHCServiceState> TState getUserDefinedStateOrNull(String json,
+            Class<TState> stateClass) {
+        if (stateClass.isAssignableFrom(UserStateServiceState.class)) {
+            var state = new UserStateServiceState();
+            state.setStateFromString(json);
+            return (TState) state;
+        }
+        return null;
+    }
 }
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/userstate/UserStateService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/userstate/UserStateService.java
new file mode 100644 (file)
index 0000000..7e05e7d
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.boschshc.internal.services.userstate;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.boschshc.internal.services.BoschSHCService;
+import org.openhab.binding.boschshc.internal.services.userstate.dto.UserStateServiceState;
+
+/**
+ * Service to get and set the state of a user-defined state.
+ * 
+ * @author Patrick Gell - Initial contribution
+ */
+@NonNullByDefault
+public class UserStateService extends BoschSHCService<UserStateServiceState> {
+
+    public UserStateService() {
+        super("UserDefinedState", UserStateServiceState.class);
+    }
+}
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/userstate/dto/UserStateServiceState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/userstate/dto/UserStateServiceState.java
new file mode 100644 (file)
index 0000000..338d815
--- /dev/null
@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.boschshc.internal.services.userstate.dto;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
+import org.openhab.core.library.types.OnOffType;
+
+/**
+ * Represents the state of a user-defined state
+ *
+ * @author Patrick Gell - Initial contribution
+ */
+public class UserStateServiceState extends BoschSHCServiceState {
+
+    public UserStateServiceState() {
+        super("userdefinedstates");
+    }
+
+    /**
+     * Current state
+     */
+    private boolean state;
+
+    public boolean isState() {
+        return state;
+    }
+
+    public void setState(boolean state) {
+        this.state = state;
+    }
+
+    public void setStateFromString(final String stateStr) {
+        this.state = Boolean.parseBoolean(stateStr);
+    }
+
+    public String getStateAsString() {
+        return Boolean.toString(state);
+    }
+
+    public @NonNull OnOffType toOnOffType() {
+        return OnOffType.from(state);
+    }
+
+    @Override
+    public String toString() {
+        return "UserStateServiceState{" + "state=" + state + ", type='" + type + '\'' + '}';
+    }
+}
index 0dbe7d94a305b73e141dc711071e3c129699219c..dbfa963a8fb8f639956a2de3be1b02ae0c322682 100644 (file)
                        <description>Unique ID of the device.</description>
                </parameter>
        </config-description>
+       <config-description uri="thing-type:boschshc:user-defined-state">
+               <parameter name="id" type="text" required="true">
+                       <label>State ID</label>
+                       <description>Unique ID of the state.</description>
+               </parameter>
+       </config-description>
 </config-description:config-descriptions>
index f2eb06fccffc68e7c1732882d09c545d9a3abaed..12375ea375b083640b57bbcc695c5131cb12c779 100644 (file)
@@ -31,6 +31,8 @@ thing-type.boschshc.thermostat.label = Thermostat
 thing-type.boschshc.thermostat.description = Radiator thermostat
 thing-type.boschshc.twinguard.label = Twinguard
 thing-type.boschshc.twinguard.description = The Twinguard smoke detector warns you in case of fire and constantly monitors the air.
+thing-type.boschshc.user-defined-state.label = User-defined State
+thing-type.boschshc.user-defined-state.description = A User-defined state.
 thing-type.boschshc.wall-thermostat.label = Wall Thermostat
 thing-type.boschshc.wall-thermostat.description = Display of the current room temperature as well as the relative humidity in the room.
 thing-type.boschshc.window-contact.label = Door/Window Contact
@@ -44,6 +46,8 @@ thing-type.config.boschshc.bridge.password.label = System Password
 thing-type.config.boschshc.bridge.password.description = The system password of the Bosch Smart Home Controller necessary for pairing.
 thing-type.config.boschshc.device.id.label = Device ID
 thing-type.config.boschshc.device.id.description = Unique ID of the device.
+thing-type.config.boschshc.user-defined-state.id.label = State ID
+thing-type.config.boschshc.user-defined-state.id.description = Unique ID of the state.
 
 # channel types
 
@@ -130,6 +134,8 @@ channel-type.boschshc.temperature.label = Temperature
 channel-type.boschshc.temperature.description = Current measured temperature.
 channel-type.boschshc.trigger-scenario.label = Trigger Scenario
 channel-type.boschshc.trigger-scenario.description = Name of the scenario to trigger
+channel-type.boschshc.user-state.label = State
+channel-type.boschshc.user-state.description = State of user-defined state
 channel-type.boschshc.valve-tappet-position.label = Valve Tappet Position
 channel-type.boschshc.valve-tappet-position.description = Current open ratio (0 to 100).
 
@@ -146,3 +152,5 @@ offline.long-polling-failed.trying-to-reconnect = Long polling failed, will try
 offline.interrupted = Connection to Bosch Smart Home Controller was interrupted.
 offline.conf-error.empty-device-id = No device ID set.
 offline.conf-error.invalid-device-id = Device ID is invalid.
+offline.conf-error.empty-state-id = No ID set.
+offline.conf-error.invalid-state-id = ID is invalid.
index 0f880173c3de1f7a350baf75dc6d03517df85f62..83e69c05e58df20d022a00101f96c544d9b0a898 100644 (file)
                <config-description-ref uri="thing-type:boschshc:device"/>
        </thing-type>
 
+       <thing-type id="user-defined-state">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="shc"/>
+               </supported-bridge-type-refs>
+
+               <label>User-defined State</label>
+               <description>A User-defined state.</description>
+
+               <channels>
+                       <channel id="user-state" typeId="user-state"/>
+               </channels>
+
+               <config-description-ref uri="thing-type:boschshc:user-defined-state"/>
+       </thing-type>
+
        <!-- Channels -->
 
        <channel-type id="system-availability">
                <description>Name of the scenario to trigger</description>
        </channel-type>
 
+       <channel-type id="user-state">
+               <item-type>Switch</item-type>
+               <label>State</label>
+               <description>State of user-defined state</description>
+       </channel-type>
+
 </thing:thing-descriptions>
index 390651f554af058179e5797baefc79f1201cc109..1ef36e2b98a37e5eb902ab060291c1880e8a55b4 100644 (file)
@@ -37,6 +37,7 @@ import org.openhab.binding.boschshc.internal.devices.bridge.dto.SubscribeResult;
 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
 import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
 import org.openhab.binding.boschshc.internal.services.binaryswitch.dto.BinarySwitchServiceState;
+import org.openhab.binding.boschshc.internal.services.userstate.dto.UserStateServiceState;
 import org.slf4j.Logger;
 
 /**
@@ -94,6 +95,12 @@ class BoschHttpClientTest {
                 httpClient.getServiceStateUrl("testService", "testDevice"));
     }
 
+    @Test
+    void getServiceStateUrlForUserState() {
+        assertEquals("https://127.0.0.1:8444/smarthome/userdefinedstates/testDevice/state",
+                httpClient.getServiceStateUrl("testService", "testDevice", UserStateServiceState.class));
+    }
+
     @Test
     void isAccessPossible() throws InterruptedException {
         assertFalse(httpClient.isAccessPossible());
@@ -165,6 +172,15 @@ class BoschHttpClientTest {
 
     @Test
     void createRequestWithObject() {
+        UserStateServiceState userState = new UserStateServiceState();
+        userState.setState(true);
+        Request request = httpClient.createRequest("https://127.0.0.1", HttpMethod.GET, userState);
+        assertNotNull(request);
+        assertEquals("true", StandardCharsets.UTF_8.decode(request.getContent().iterator().next()).toString());
+    }
+
+    @Test
+    void createRequestForUserDefinedState() {
         BinarySwitchServiceState binarySwitchState = new BinarySwitchServiceState();
         binarySwitchState.on = true;
         Request request = httpClient.createRequest("https://127.0.0.1", HttpMethod.GET, binarySwitchState);
index bd71f83b376fcd6585023da984037e4fe8a3f8cb..592bfa8b14094c2fe80620b31665167e89cecc92 100644 (file)
@@ -21,7 +21,9 @@ import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.UUID;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeoutException;
 import java.util.function.BiFunction;
@@ -41,7 +43,10 @@ import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceDat
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceTest;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Faults;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.SubscribeResult;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedStateTest;
 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
+import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
 import org.openhab.binding.boschshc.internal.services.binaryswitch.dto.BinarySwitchServiceState;
 import org.openhab.binding.boschshc.internal.services.intrusion.actions.arm.dto.ArmActionRequest;
 import org.openhab.binding.boschshc.internal.services.intrusion.dto.AlarmState;
@@ -243,6 +248,7 @@ class BridgeHandlerTest {
     void getDeviceState() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
         when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
+        when(httpClient.getServiceStateUrl(anyString(), anyString(), any())).thenCallRealMethod();
         when(httpClient.getServiceStateUrl(anyString(), anyString())).thenCallRealMethod();
 
         Request request = mock(Request.class);
@@ -405,6 +411,7 @@ class BridgeHandlerTest {
         when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
         when(httpClient.getServiceStateUrl(anyString(), anyString())).thenCallRealMethod();
+        when(httpClient.getServiceStateUrl(anyString(), anyString(), any())).thenCallRealMethod();
 
         Request request = mock(Request.class);
         when(request.header(anyString(), anyString())).thenReturn(request);
@@ -419,6 +426,78 @@ class BridgeHandlerTest {
         fixture.putState("hdm:ZigBee:f0d1b80000f2a3e9", "BinarySwitch", binarySwitchState);
     }
 
+    @Test
+    void getUserStateInfo() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
+        when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
+        when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
+        String stateId = UUID.randomUUID().toString();
+
+        Request request = mock(Request.class);
+        when(request.header(anyString(), anyString())).thenReturn(request);
+        ContentResponse response = mock(ContentResponse.class);
+        when(response.getStatus()).thenReturn(200);
+        when(request.send()).thenReturn(response);
+        when(httpClient.createRequest(anyString(), same(HttpMethod.GET))).thenReturn(request);
+        when(httpClient.sendRequest(same(request), same(UserDefinedState.class), any(), any()))
+                .thenReturn(UserDefinedStateTest.createTestState(stateId));
+
+        UserDefinedState userState = fixture.getUserStateInfo(stateId);
+        assertEquals(stateId, userState.getId());
+    }
+
+    @Test
+    void getUserStates() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
+        when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
+        when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
+        String stateId = UUID.randomUUID().toString();
+
+        Request request = mock(Request.class);
+        when(request.header(anyString(), anyString())).thenReturn(request);
+        ContentResponse response = mock(ContentResponse.class);
+        when(response.getStatus()).thenReturn(200);
+        when(request.send()).thenReturn(response);
+        when(httpClient.createRequest(anyString(), same(HttpMethod.GET))).thenReturn(request);
+        when(response.getContentAsString()).thenReturn(
+                GsonUtils.DEFAULT_GSON_INSTANCE.toJson(List.of(UserDefinedStateTest.createTestState(stateId))));
+
+        List<UserDefinedState> userStates = fixture.getUserStates();
+        assertEquals(1, userStates.size());
+    }
+
+    @Test
+    void getUserStatesReturnsEmptyListIfRequestNotSuccessful()
+            throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
+        when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
+        when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
+
+        Request request = mock(Request.class);
+        when(request.header(anyString(), anyString())).thenReturn(request);
+        ContentResponse response = mock(ContentResponse.class);
+        when(response.getStatus()).thenReturn(401);
+        when(request.send()).thenReturn(response);
+        when(httpClient.createRequest(anyString(), same(HttpMethod.GET))).thenReturn(request);
+
+        List<UserDefinedState> userStates = fixture.getUserStates();
+        assertTrue(userStates.isEmpty());
+    }
+
+    @Test
+    void getUserStatesReturnsEmptyListIfExceptionHappened()
+            throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
+        when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
+        when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
+
+        Request request = mock(Request.class);
+        when(request.header(anyString(), anyString())).thenReturn(request);
+        ContentResponse response = mock(ContentResponse.class);
+        when(response.getStatus()).thenReturn(401);
+        when(request.send()).thenThrow(new TimeoutException("text exception"));
+        when(httpClient.createRequest(anyString(), same(HttpMethod.GET))).thenReturn(request);
+
+        List<UserDefinedState> userStates = fixture.getUserStates();
+        assertTrue(userStates.isEmpty());
+    }
+
     @AfterEach
     void afterEach() throws Exception {
         fixture.dispose();
index 2b28b8d068b482fa734f3df994afcb6775e2c0fa..628607ca9ba1ce53492904064dbb3267e299a0ce 100644 (file)
@@ -50,6 +50,7 @@ import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceDat
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Scenario;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.SubscribeResult;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
 
@@ -249,9 +250,8 @@ class LongPollingTest {
     }
 
     @Test
-    void startLongPolling_receiveScenario()
+    void startLongPollingReceiveScenario()
             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
-        // when(httpClient.getBoschSmartHomeUrl(anyString())).thenCallRealMethod();
         when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
 
         Request subscribeRequest = mock(Request.class);
@@ -290,6 +290,47 @@ class LongPollingTest {
         assertEquals("1693758693032", longPollResultItem.lastTimeTriggered);
     }
 
+    @Test
+    void startLongPollingReceiveUserDefinedState()
+            throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
+        when(httpClient.getBoschShcUrl(anyString())).thenCallRealMethod();
+
+        Request subscribeRequest = mock(Request.class);
+        when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
+                argThat((JsonRpcRequest r) -> "RE/subscribe".equals(r.method)))).thenReturn(subscribeRequest);
+        SubscribeResult subscribeResult = new SubscribeResult();
+        when(httpClient.sendRequest(any(), same(SubscribeResult.class), any(), any())).thenReturn(subscribeResult);
+
+        Request longPollRequest = mock(Request.class);
+        when(httpClient.createRequest(anyString(), same(HttpMethod.POST),
+                argThat((JsonRpcRequest r) -> "RE/longPoll".equals(r.method)))).thenReturn(longPollRequest);
+
+        fixture.start(httpClient);
+
+        ArgumentCaptor<CompleteListener> completeListener = ArgumentCaptor.forClass(CompleteListener.class);
+        verify(longPollRequest).send(completeListener.capture());
+
+        BufferingResponseListener bufferingResponseListener = (BufferingResponseListener) completeListener.getValue();
+
+        String longPollResultJSON = "{\"result\":[{\"deleted\":false,\"@type\":\"userDefinedState\",\"name\":\"My User state\",\"id\":\"23d34fa6-382a-444d-8aae-89c706e22155\",\"state\":true}],\"jsonrpc\":\"2.0\"}\n";
+        Response response = mock(Response.class);
+        bufferingResponseListener.onContent(response,
+                ByteBuffer.wrap(longPollResultJSON.getBytes(StandardCharsets.UTF_8)));
+
+        Result result = mock(Result.class);
+        bufferingResponseListener.onComplete(result);
+
+        ArgumentCaptor<LongPollResult> longPollResultCaptor = ArgumentCaptor.forClass(LongPollResult.class);
+        verify(longPollHandler).accept(longPollResultCaptor.capture());
+        LongPollResult longPollResult = longPollResultCaptor.getValue();
+        assertEquals(1, longPollResult.result.size());
+        assertEquals(longPollResult.result.get(0).getClass(), UserDefinedState.class);
+        UserDefinedState longPollResultItem = (UserDefinedState) longPollResult.result.get(0);
+        assertEquals("23d34fa6-382a-444d-8aae-89c706e22155", longPollResultItem.getId());
+        assertEquals("My User state", longPollResultItem.getName());
+        assertTrue(longPollResultItem.isState());
+    }
+
     @Test
     void startSubscriptionFailure()
             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
index 3035a6f1023496562fa8aadfe4b6bb2ccf3c3f61..9a0ea10639520008c0054676adbc1b0bcf5b7d9c 100644 (file)
@@ -62,7 +62,7 @@ class ScenarioHandlerTest {
     }
 
     @Test
-    void triggerScenario_ShouldSendPOST_ToBoschAPI() throws Exception {
+    void triggerScenarioShouldSendPOSTToBoschAPI() throws Exception {
         // GIVEN
         final var httpClient = mock(BoschHttpClient.class);
         final var request = mock(Request.class);
@@ -85,7 +85,7 @@ class ScenarioHandlerTest {
     }
 
     @Test
-    void triggerScenario_ShouldNoSendPOST_ToScenarioNameDoesNotExist() throws Exception {
+    void triggerScenarioShouldNoSendPOSTToScenarioNameDoesNotExist() throws Exception {
         // GIVEN
         final var httpClient = mock(BoschHttpClient.class);
         final var request = mock(Request.class);
@@ -106,7 +106,7 @@ class ScenarioHandlerTest {
 
     @ParameterizedTest
     @MethodSource("exceptionData")
-    void triggerScenario_ShouldNotPanic_IfBoschAPIThrowsException(final Exception exception) throws Exception {
+    void triggerScenarioShouldNotPanicIfBoschAPIThrowsException(final Exception exception) throws Exception {
         // GIVEN
         final var httpClient = mock(BoschHttpClient.class);
         final var request = mock(Request.class);
@@ -126,7 +126,7 @@ class ScenarioHandlerTest {
     }
 
     @Test
-    void triggerScenario_ShouldNotPanic_IfPOSTIsNotSuccessful() throws Exception {
+    void triggerScenarioShouldNotPanicIfPOSTIsNotSuccessful() throws Exception {
         // GIVEN
         final var httpClient = mock(BoschHttpClient.class);
         final var request = mock(Request.class);
@@ -150,7 +150,7 @@ class ScenarioHandlerTest {
 
     @ParameterizedTest
     @MethodSource("httpExceptionData")
-    void triggerScenario_ShouldNotPanic_IfPOSTThrowsException(final Exception exception) throws Exception {
+    void triggerScenarioShouldNotPanicIfPOSTThrowsException(final Exception exception) throws Exception {
         // GIVEN
         final var httpClient = mock(BoschHttpClient.class);
         final var request = mock(Request.class);
diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/UserDefinedStateTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/UserDefinedStateTest.java
new file mode 100644 (file)
index 0000000..5a204a1
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.boschshc.internal.devices.bridge.dto;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.UUID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for UserDefinedStateTest
+ *
+ * @author Patrick Gell - Initial contribution
+ */
+@NonNullByDefault
+public class UserDefinedStateTest {
+
+    public static UserDefinedState createTestState(final String id) {
+        UserDefinedState userState = new UserDefinedState();
+        userState.setId(id);
+        userState.setState(true);
+        userState.setName("test user state");
+        return userState;
+    }
+
+    private @NonNullByDefault({}) UserDefinedState fixture;
+    private final String testId = UUID.randomUUID().toString();
+
+    @BeforeEach
+    void beforeEach() {
+        fixture = createTestState(testId);
+    }
+
+    @Test
+    void testIsValid() {
+        assertTrue(UserDefinedState.isValid(fixture));
+    }
+
+    @Test
+    void testToString() {
+        assertEquals(
+                String.format("UserDefinedState{id='%s', name='test user state', state=true, type='UserDefinedState'}",
+                        testId),
+                fixture.toString());
+    }
+}
diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/userdefinedstate/UserStateHandlerTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/userdefinedstate/UserStateHandlerTest.java
new file mode 100644 (file)
index 0000000..e8f3e2b
--- /dev/null
@@ -0,0 +1,118 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.boschshc.internal.devices.userdefinedstate;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import 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.reset;
+import static org.mockito.Mockito.verify;
+
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.mockito.ArgumentCaptor;
+import org.openhab.binding.boschshc.internal.devices.AbstractBoschSHCHandlerTest;
+import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
+import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
+import org.openhab.binding.boschshc.internal.services.userstate.dto.UserStateServiceState;
+import org.openhab.core.config.core.Configuration;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+
+/**
+ * Unit tests for UserStateHandlerTest
+ *
+ * @author Patrick Gell - Initial contribution
+ */
+@NonNullByDefault
+class UserStateHandlerTest extends AbstractBoschSHCHandlerTest<UserStateHandler> {
+
+    private final Configuration config = new Configuration(Map.of("id", UUID.randomUUID().toString()));
+
+    @Override
+    protected UserStateHandler createFixture() {
+        return new UserStateHandler(getThing());
+    }
+
+    @Override
+    protected ThingTypeUID getThingTypeUID() {
+        return BoschSHCBindingConstants.THING_TYPE_USER_DEFINED_STATE;
+    }
+
+    @Override
+    protected Configuration getConfiguration() {
+        return config;
+    }
+
+    @Test
+    void testHandleCommandSetState()
+            throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
+        var channel = new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_USER_DEFINED_STATE);
+        getFixture().handleCommand(channel, OnOffType.ON);
+
+        ArgumentCaptor<String> deviceId = ArgumentCaptor.forClass(String.class);
+        ArgumentCaptor<UserStateServiceState> stateClass = ArgumentCaptor.forClass(UserStateServiceState.class);
+
+        verify(getBridgeHandler()).getUserStateInfo(config.get("id").toString());
+        verify(getBridgeHandler()).getState(anyString(), anyString(), any());
+        verify(getBridgeHandler()).putState(deviceId.capture(), anyString(), stateClass.capture());
+
+        assertNotNull(deviceId.getValue());
+        assertEquals(channel.getThingUID().getId(), deviceId.getValue());
+
+        assertNotNull(stateClass.getValue());
+        assertTrue(stateClass.getValue().isState());
+    }
+
+    @ParameterizedTest()
+    @MethodSource("provideExceptions")
+    void testHandleCommandSetStateUpdatesThingStatusOnException(Exception mockException)
+            throws InterruptedException, TimeoutException, ExecutionException {
+        reset(getCallback());
+        lenient().when(getBridgeHandler().putState(anyString(), anyString(), any(UserStateServiceState.class)))
+                .thenThrow(mockException);
+        var channel = new ChannelUID(getThing().getUID(), BoschSHCBindingConstants.CHANNEL_USER_DEFINED_STATE);
+        getFixture().handleCommand(channel, OnOffType.ON);
+
+        verify(getCallback()).getBridge(any(ThingUID.class));
+
+        ThingStatusInfo expectedStatusInfo = new ThingStatusInfo(ThingStatus.OFFLINE,
+                ThingStatusDetail.COMMUNICATION_ERROR,
+                String.format("Error while putting user-defined state for %s", channel.getThingUID().getId()));
+        verify(getCallback()).statusUpdated(same(getThing()), eq(expectedStatusInfo));
+    }
+
+    private static Stream<Arguments> provideExceptions() {
+        return Stream.of(Arguments.of(new TimeoutException("test exception")),
+                Arguments.of(new InterruptedException("test exception")));
+    }
+}
index 204dbd89ec6b4489927b0598dfc885926ac4b7bc..d114e99a23604fa366b8ae0aeb62311d136e400f 100644 (file)
@@ -19,6 +19,7 @@ import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.*;
 
 import java.util.ArrayList;
+import java.util.UUID;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.junit.jupiter.api.BeforeEach;
@@ -32,6 +33,7 @@ import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
 import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.UserDefinedState;
 import org.openhab.core.config.discovery.DiscoveryListener;
 import org.openhab.core.config.discovery.DiscoveryResult;
 import org.openhab.core.config.discovery.DiscoveryService;
@@ -234,4 +236,29 @@ class ThingDiscoveryServiceTest {
         device.deviceModel = "TWINGUARD";
         assertThat(fixture.getThingTypeUID(device), is(BoschSHCBindingConstants.THING_TYPE_TWINGUARD));
     }
+
+    @Test
+    void testAddUserDefinedStates() {
+        mockBridgeCalls();
+
+        ArrayList<UserDefinedState> userStates = new ArrayList<>();
+
+        UserDefinedState userState1 = new UserDefinedState();
+        userState1.setId(UUID.randomUUID().toString());
+        userState1.setName("first defined state");
+        userState1.setState(true);
+        UserDefinedState userState2 = new UserDefinedState();
+        userState2.setId(UUID.randomUUID().toString());
+        userState2.setName("another defined state");
+        userState2.setState(false);
+        userStates.add(userState1);
+        userStates.add(userState2);
+
+        verify(discoveryListener, never()).thingDiscovered(any(), any());
+
+        fixture.addUserStates(userStates);
+
+        // two calls for the two devices expected
+        verify(discoveryListener, times(2)).thingDiscovered(any(), any());
+    }
 }
index 36b5361830d903ee5434bc7aa134178637e21900..2c3fc0f86e8049fd6a3e4d29fdb7668d6b030ea9 100644 (file)
@@ -17,8 +17,10 @@ import static org.junit.jupiter.api.Assertions.*;
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.junit.jupiter.api.Test;
 import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
+import org.openhab.binding.boschshc.internal.services.userstate.dto.UserStateServiceState;
 
 import com.google.gson.JsonObject;
+import com.google.gson.JsonPrimitive;
 
 /**
  * Test class
@@ -79,4 +81,12 @@ class BoschSHCServiceStateTest {
                 TestState2.class);
         assertNotEquals(null, state2);
     }
+
+    @Test
+    void fromJsonReturnsUserStateServiceStateForValidJson() {
+        var state = BoschSHCServiceState.fromJson(new JsonPrimitive("false"), UserStateServiceState.class);
+        assertNotEquals(null, state);
+        assertTrue(state.getClass().isAssignableFrom(UserStateServiceState.class));
+        assertFalse(state.isState());
+    }
 }
diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/userstate/dto/UserStateServiceStateTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/services/userstate/dto/UserStateServiceStateTest.java
new file mode 100644 (file)
index 0000000..405f21a
--- /dev/null
@@ -0,0 +1,74 @@
+/**
+ * Copyright (c) 2010-2023 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.boschshc.internal.services.userstate.dto;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.openhab.core.library.types.OnOffType;
+
+/**
+ * Unit tests for UserStateServiceStateTest
+ *
+ * @author Patrick Gell - Initial contribution
+ */
+class UserStateServiceStateTest {
+
+    UserStateServiceState subject;
+
+    @BeforeEach
+    void setUp() {
+        subject = new UserStateServiceState();
+    }
+
+    @ParameterizedTest
+    @MethodSource("provideStringsForIsBlank")
+    void setStateFromStringUpdatesTheState(String inputState, boolean expectedState) {
+        subject.setStateFromString(inputState);
+
+        assertEquals(expectedState, subject.isState());
+    }
+
+    private static Stream<Arguments> provideStringsForIsBlank() {
+        return Stream.of(Arguments.of("true", true), Arguments.of("false", false), Arguments.of("True", true),
+                Arguments.of("False", false), Arguments.of("TRUE", true), Arguments.of("FALSE", false),
+                Arguments.of(null, false), Arguments.of("", false), Arguments.of("  ", false),
+                Arguments.of("not blank", false));
+    }
+
+    @Test
+    void getStateAsStringReturnsState() {
+        subject.setState(false);
+
+        assertEquals("false", subject.getStateAsString());
+
+        subject.setState(true);
+        assertEquals("true", subject.getStateAsString());
+    }
+
+    @Test
+    void toOnOffTypeReturnsType() {
+        subject.setState(false);
+
+        assertEquals(OnOffType.OFF, subject.toOnOffType());
+
+        subject.setState(true);
+        assertEquals(OnOffType.ON, subject.toOnOffType());
+    }
+}