]> git.basschouten.com Git - openhab-addons.git/commitdiff
[boschshc] Bridge and Device Discovery (#14197)
authorGerd Zanker <gerd.zanker@web.de>
Fri, 24 Mar 2023 23:26:24 +0000 (00:26 +0100)
committerGitHub <noreply@github.com>
Fri, 24 Mar 2023 23:26:24 +0000 (00:26 +0100)
* #14195 Bridge and Device Discovery

Bridge discovery is implemented via mDNS, local IP addresses are checked.
If a GET returns the public SHC information,
then this shcIpAddress is reported as a discovered bridge.

Devices are always discovered after successful pairing, but a manual scan is also possible.

Added unit tests for Bridge and Device Discovery.

Signed-off-by: Gerd Zanker <gerd.zanker@web.de>
16 files changed:
bundles/org.openhab.binding.boschshc/README.md
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCDeviceHandler.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/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/dto/PublicInformation.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/BridgeDiscoveryParticipant.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCSystemService.java
bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/batterylevel/BatteryLevelService.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/smokedetectorcheck/SmokeDetectorCheckState.java
bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties
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/discovery/BridgeDiscoveryParticipantTest.java [new file with mode: 0644]
bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryServiceTest.java [new file with mode: 0644]

index cd2fe0b83844e380ae892aeba76f6585f2dfe9a4..f7241a0c6816e95cbfcf922a951d7927abfb7df7 100644 (file)
@@ -206,12 +206,15 @@ The smoke detector warns you in case of fire.
 
 ## Limitations
 
-- Discovery of Things
-- Discovery of Bridge
+No major limitation known.
+Check list of [openhab issues with "boshshc"](https://github.com/openhab/openhab-addons/issues?q=is%3Aissue+boschshc+) 
 
 ## Discovery
 
-Configuration via configuration files or UI (see below).
+Bridge discovery is supported via mDNS.
+Things discovery is started after successful pairing.
+
+Configuration via configuration files or UI supported too (see below).
 
 ## Bridge Configuration
 
@@ -239,19 +242,10 @@ Alternatively, the log can be viewed using the OpenHab Log Viewer (frontail) via
 Example:
 
 ```bash
-2020-08-11 12:42:49.490 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Heizung id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX
-2020-08-11 12:42:49.495 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=-RoomClimateControl- id=roomClimateControl_hz_1
-2020-08-11 12:42:49.497 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=-VentilationService- id=ventilationService
-2020-08-11 12:42:49.498 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Großes Fenster id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX
-2020-08-11 12:42:49.501 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=-IntrusionDetectionSystem- id=intrusionDetectionSystem
-2020-08-11 12:42:49.502 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Rollladen id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX
-2020-08-11 12:42:49.502 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Heizung id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX
-2020-08-11 12:42:49.503 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Heizung Haus id=hdm:ICom:819410185:HC1
-2020-08-11 12:42:49.503 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=-RoomClimateControl- id=roomClimateControl_hz_6
-2020-08-11 12:42:49.504 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=PhilipsHueBridgeManager id=hdm:PhilipsHueBridge:PhilipsHueBridgeManager
-2020-08-11 12:42:49.505 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Rollladen id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX
-2020-08-11 12:42:49.506 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Rollladen id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX
-2020-08-11 12:42:49.507 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Central Heating id=hdm:ICom:819410185
+2023-03-20 20:30:48.026 [INFO ] [g.discovery.internal.PersistentInbox] - Added new thing 'boschshc:security-camera-eyes:yourBridgeName:hdm_Cameras_XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX' to inbox.
+2023-03-20 20:30:48.026 [INFO ] [g.discovery.internal.PersistentInbox] - Added new thing 'boschshc:smoke-detector:yourBridgeName:hdm_HomeMaticIP_XXXXXXXXXXXXXXXXXXXXXXXX' to inbox.
+2023-03-20 20:30:48.027 [INFO ] [g.discovery.internal.PersistentInbox] - Added new thing 'boschshc:twinguard:yourBridgeName:hdm_ZigBee_XXXXXXXXXXXXXXXX' to inbox.
+2023-03-20 20:30:48.028 [INFO ] [g.discovery.internal.PersistentInbox] - Added new thing 'boschshc:smart-bulb:yourBridgeName:hdm_PhilipsHueBridge_HueLight_XXXXXXXXXXXXXXXX-XX_XXXXXXXXXXXX' to inbox.
 ```
 
 ## Thing Configuration
index ffeaf49d7f105f17735e083c2d22a6b0acfc87cf..2168e1223f1141c7c15bddd99e6a9cabca3df7a1 100644 (file)
@@ -53,7 +53,6 @@ public abstract class BoschSHCDeviceHandler extends BoschSHCHandler {
 
     @Override
     public void initialize() {
-
         var config = this.config = getConfigAs(BoschSHCConfiguration.class);
 
         String deviceId = config.id;
index 0ddb7ca420608f143bb6d1371929aeec4dada0c9..13f764f9386a565fb0438b60f2aba0aac4fff0b7 100644 (file)
@@ -124,7 +124,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
      */
     @Override
     public void initialize() {
-
         // Initialize device services
         try {
             this.initializeServices();
@@ -304,7 +303,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
     protected <TService extends BoschSHCService<TState>, TState extends BoschSHCServiceState> void registerService(
             TService service, Consumer<TState> stateUpdateListener, Collection<String> affectedChannels,
             boolean shouldFetchInitialState) throws BoschSHCException {
-
         String deviceId = verifyBoschID();
         service.initialize(getBridgeHandler(), deviceId, stateUpdateListener);
         this.registerService(service, affectedChannels);
@@ -325,7 +323,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
      */
     private <TService extends BoschSHCService<TState>, TState extends BoschSHCServiceState> void fetchInitialState(
             TService service, Consumer<TState> stateUpdateListener) {
-
         try {
             @Nullable
             TState serviceState = service.getState();
@@ -353,7 +350,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
      */
     protected <TService extends AbstractBoschSHCService> void registerStatelessService(TService service)
             throws BoschSHCException {
-
         String deviceId = verifyBoschID();
         service.initialize(getBridgeHandler(), deviceId);
         // do not register in service list because the service can not receive state updates
index 211481a7b6613f1fbd20a43843caade1c4861ac1..a07f9f5be3b993acb195ba198922dbb1b402eab2 100644 (file)
@@ -65,13 +65,22 @@ public class BoschHttpClient extends HttpClient {
     }
 
     /**
-     * Returns the public information URL for the Bosch SHC clients, using port 8446.
+     * Returns the public information URL for the Bosch SHC client addressed with the given IP address, using port 8446
      * See https://github.com/BoschSmartHome/bosch-shc-api-docs/blob/master/postman/README.md
      *
      * @return URL for public information
      */
+    public static String getPublicInformationUrl(String ipAddress) {
+        return String.format("https://%s:8446/smarthome/public/information", ipAddress);
+    }
+
+    /**
+     * Returns the public information URL for the current Bosch SHC client.
+     *
+     * @return URL for public information
+     */
     public String getPublicInformationUrl() {
-        return String.format("https://%s:8446/smarthome/public/information", this.ipAddress);
+        return getPublicInformationUrl(this.ipAddress);
     }
 
     /**
@@ -316,11 +325,12 @@ public class BoschHttpClient extends HttpClient {
             if (errorResponseHandler != null) {
                 throw errorResponseHandler.apply(statusCode, textContent);
             } else {
-                throw new ExecutionException(String.format("Request failed with status code %s", statusCode), null);
+                throw new ExecutionException(String.format("Send request failed with status code %s", statusCode),
+                        null);
             }
         }
 
-        logger.debug("Received response: {} - status: {}", textContent, statusCode);
+        logger.debug("Send request completed with success: {} - status code: {}", textContent, statusCode);
 
         try {
             @Nullable
index 5856316b45c0bd46671c0965052bff9c582f28f9..6c0752c78d6b3487b1739d816c0cc0a32149fb1f 100644 (file)
@@ -16,6 +16,11 @@ import static org.eclipse.jetty.http.HttpMethod.*;
 
 import java.lang.reflect.Type;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -33,6 +38,7 @@ import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
+import org.openhab.binding.boschshc.internal.discovery.ThingDiscoveryService;
 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
 import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
@@ -45,6 +51,7 @@ import org.openhab.core.thing.ThingStatus;
 import org.openhab.core.thing.ThingStatusDetail;
 import org.openhab.core.thing.binding.BaseBridgeHandler;
 import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
 import org.openhab.core.types.Command;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.FrameworkUtil;
@@ -62,6 +69,7 @@ import com.google.gson.reflect.TypeToken;
  * @author Gerd Zanker - added HttpClient with pairing support
  * @author Christian Oeing - refactorings of e.g. server registration
  * @author David Pace - Added support for custom endpoints and HTTP POST requests
+ * @author Gerd Zanker - added thing discovery
  */
 @NonNullByDefault
 public class BridgeHandler extends BaseBridgeHandler {
@@ -88,12 +96,24 @@ public class BridgeHandler extends BaseBridgeHandler {
 
     private @Nullable ScheduledFuture<?> scheduledPairing;
 
+    /**
+     * SHC thing/device discovery service instance.
+     * Registered and unregistered if service is actived/deactived.
+     * Used to scan for things after bridge is paired with SHC.
+     */
+    private @Nullable ThingDiscoveryService thingDiscoveryService;
+
     public BridgeHandler(Bridge bridge) {
         super(bridge);
 
         this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
     }
 
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Collections.singleton(ThingDiscoveryService.class);
+    }
+
     @Override
     public void initialize() {
         Bundle bundle = FrameworkUtil.getBundle(getClass());
@@ -225,12 +245,8 @@ public class BridgeHandler extends BaseBridgeHandler {
                 return;
             }
 
-            // SHC is online and access is possible
-            // print rooms and devices
-            boolean thingReachable = true;
-            thingReachable &= this.getRooms();
-            thingReachable &= this.getDevices();
-            if (!thingReachable) {
+            // SHC is online and access should possible
+            if (!checkBridgeAccess()) {
                 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
                         "@text/offline.not-reachable");
                 // restart initial access
@@ -238,6 +254,12 @@ public class BridgeHandler extends BaseBridgeHandler {
                 return;
             }
 
+            // do thing discovery after pairing
+            final ThingDiscoveryService discovery = thingDiscoveryService;
+            if (discovery != null) {
+                discovery.doScan();
+            }
+
             // start long polling loop
             this.updateStatus(ThingStatus.ONLINE);
             try {
@@ -252,54 +274,130 @@ public class BridgeHandler extends BaseBridgeHandler {
         }
     }
 
+    /**
+     * Check the bridge access by sending an HTTP request.
+     * Does not throw any exception in case the request fails.
+     */
+    public boolean checkBridgeAccess() throws InterruptedException {
+        @Nullable
+        BoschHttpClient httpClient = this.httpClient;
+
+        if (httpClient == 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();
+
+            // check HTTP status code
+            if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
+                logger.debug("Access check failed with status code: {}", contentResponse.getStatus());
+                return false;
+            }
+
+            // Access OK
+            return true;
+        } catch (TimeoutException | ExecutionException e) {
+            logger.warn("Access check failed because of {}!", e.getMessage());
+            return false;
+        }
+    }
+
     /**
      * Get a list of connected devices from the Smart-Home Controller
      *
      * @throws InterruptedException in case bridge is stopped
      */
-    private boolean getDevices() throws InterruptedException {
+    public List<Device> getDevices() throws InterruptedException {
         @Nullable
         BoschHttpClient httpClient = this.httpClient;
         if (httpClient == null) {
-            return false;
+            return Collections.emptyList();
         }
 
         try {
-            logger.debug("Sending http request to Bosch to request devices: {}", httpClient);
+            logger.trace("Sending http request to Bosch to request devices: {}", httpClient);
             String url = httpClient.getBoschSmartHomeUrl("devices");
             ContentResponse contentResponse = httpClient.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 false;
+                return Collections.emptyList();
             }
 
             String content = contentResponse.getContentAsString();
-            logger.debug("Request devices completed with success: {} - status code: {}", content,
+            logger.trace("Request devices completed with success: {} - status code: {}", content,
                     contentResponse.getStatus());
 
             Type collectionType = new TypeToken<ArrayList<Device>>() {
             }.getType();
-            ArrayList<Device> devices = gson.fromJson(content, collectionType);
-
-            if (devices != null) {
-                for (Device d : devices) {
-                    // Write found devices into openhab.log until we have implemented auto discovery
-                    logger.info("Found device: name={} id={}", d.name, d.id);
-                    if (d.deviceServiceIds != null) {
-                        for (String s : d.deviceServiceIds) {
-                            logger.info(".... service: {}", s);
-                        }
-                    }
+            @Nullable
+            List<Device> nullableDevices = gson.fromJson(content, collectionType);
+            return Optional.ofNullable(nullableDevices).orElse(Collections.emptyList());
+        } catch (TimeoutException | ExecutionException e) {
+            logger.debug("Request devices failed because of {}!", e.getMessage());
+            return Collections.emptyList();
+        }
+    }
+
+    /**
+     * Get a list of rooms from the Smart-Home controller
+     *
+     * @throws InterruptedException in case bridge is stopped
+     */
+    public List<Room> getRooms() throws InterruptedException {
+        List<Room> emptyRooms = new ArrayList<>();
+        @Nullable
+        BoschHttpClient httpClient = this.httpClient;
+        if (httpClient != null) {
+            try {
+                logger.trace("Sending http request to Bosch to request rooms");
+                String url = httpClient.getBoschSmartHomeUrl("rooms");
+                ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
+
+                // check HTTP status code
+                if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
+                    logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
+                    return emptyRooms;
                 }
+
+                String content = contentResponse.getContentAsString();
+                logger.trace("Request rooms completed with success: {} - status code: {}", content,
+                        contentResponse.getStatus());
+
+                Type collectionType = new TypeToken<ArrayList<Room>>() {
+                }.getType();
+
+                ArrayList<Room> rooms = gson.fromJson(content, collectionType);
+                return Objects.requireNonNullElse(rooms, emptyRooms);
+            } catch (TimeoutException | ExecutionException e) {
+                logger.debug("Request rooms failed because of {}!", e.getMessage());
+                return emptyRooms;
             }
-        } catch (TimeoutException | ExecutionException e) {
-            logger.warn("Request devices failed because of {}!", e.getMessage());
-            return false;
+        } else {
+            return emptyRooms;
+        }
+    }
+
+    public boolean registerDiscoveryListener(ThingDiscoveryService listener) {
+        if (thingDiscoveryService == null) {
+            thingDiscoveryService = listener;
+            return true;
         }
 
-        return true;
+        return false;
+    }
+
+    public boolean unregisterDiscoveryListener() {
+        if (thingDiscoveryService != null) {
+            thingDiscoveryService = null;
+            return true;
+        }
+
+        return false;
     }
 
     /**
@@ -420,51 +518,6 @@ public class BridgeHandler extends BaseBridgeHandler {
         scheduleInitialAccess(httpClient);
     }
 
-    /**
-     * Get a list of rooms from the Smart-Home controller
-     *
-     * @throws InterruptedException in case bridge is stopped
-     */
-    private boolean getRooms() throws InterruptedException {
-        @Nullable
-        BoschHttpClient httpClient = this.httpClient;
-        if (httpClient != null) {
-            try {
-                logger.debug("Sending http request to Bosch to request rooms");
-                String url = httpClient.getBoschSmartHomeUrl("rooms");
-                ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
-
-                // check HTTP status code
-                if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
-                    logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
-                    return false;
-                }
-
-                String content = contentResponse.getContentAsString();
-                logger.debug("Request rooms completed with success: {} - status code: {}", content,
-                        contentResponse.getStatus());
-
-                Type collectionType = new TypeToken<ArrayList<Room>>() {
-                }.getType();
-
-                ArrayList<Room> rooms = gson.fromJson(content, collectionType);
-
-                if (rooms != null) {
-                    for (Room r : rooms) {
-                        logger.info("Found room: {}", r.name);
-                    }
-                }
-
-                return true;
-            } catch (TimeoutException | ExecutionException e) {
-                logger.warn("Request rooms failed because of {}!", e.getMessage());
-                return false;
-            }
-        } else {
-            return false;
-        }
-    }
-
     public Device getDeviceInfo(String deviceId)
             throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
         @Nullable
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/PublicInformation.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/PublicInformation.java
new file mode 100644 (file)
index 0000000..24be8fd
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * 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 java.util.List;
+
+/**
+ * Public Information of the controller.
+ * <p>
+ *
+ * Currently, only the ipAddress is used for discovery. More fields can be added on demand.
+ * <p>
+ * Json example:
+ *
+ * <pre>
+ * {
+ * "apiVersions":["1.2","2.1"],
+ * ...
+ * "shcIpAddress":"192.168.1.2",
+ * ...
+ * }
+ * </pre>
+ *
+ * @author Gerd Zanker - Initial contribution
+ */
+public class PublicInformation {
+    public PublicInformation() {
+        this.shcIpAddress = "";
+        this.shcGeneration = "";
+    }
+
+    public List<String> apiVersions;
+    public String shcIpAddress;
+    public String shcGeneration;
+}
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/BridgeDiscoveryParticipant.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/BridgeDiscoveryParticipant.java
new file mode 100644 (file)
index 0000000..e0b8ac7
--- /dev/null
@@ -0,0 +1,161 @@
+/**
+ * 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.discovery;
+
+import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.BINDING_ID;
+
+import java.time.Duration;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+import javax.jmdns.ServiceInfo;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+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.BoschSHCBindingConstants;
+import org.openhab.binding.boschshc.internal.devices.bridge.BoschHttpClient;
+import org.openhab.binding.boschshc.internal.devices.bridge.dto.PublicInformation;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
+import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Activate;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.Gson;
+
+/**
+ * The {@link BridgeDiscoveryParticipant} is responsible discovering the
+ * Bosch Smart Home Controller as a Bridge with the mDNS services.
+ *
+ * @author Gerd Zanker - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "discovery.boschsmarthomebridge")
+public class BridgeDiscoveryParticipant implements MDNSDiscoveryParticipant {
+    private static final long TTL_SECONDS = Duration.ofHours(1).toSeconds();
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(BoschSHCBindingConstants.THING_TYPE_SHC);
+
+    private final Logger logger = LoggerFactory.getLogger(BridgeDiscoveryParticipant.class);
+    private final HttpClient httpClient;
+    private final Gson gson = new Gson();
+
+    /// SHC Bridge Information, read via public REST API if bridge is detected. Otherwise, strings are empty.
+    private PublicInformation bridgeInformation = new PublicInformation();
+
+    @Activate
+    public BridgeDiscoveryParticipant(@Reference HttpClientFactory httpClientFactory) {
+        // create http client upfront to later request public information from SHC
+        SslContextFactory sslContextFactory = new SslContextFactory.Client.Client(true); // Accept all certificates
+        sslContextFactory.setTrustAll(true);
+        sslContextFactory.setValidateCerts(false);
+        sslContextFactory.setValidatePeerCerts(false);
+        sslContextFactory.setEndpointIdentificationAlgorithm(null);
+        httpClient = httpClientFactory.createHttpClient(BINDING_ID, sslContextFactory);
+    }
+
+    protected BridgeDiscoveryParticipant(HttpClient customHttpClient) {
+        httpClient = customHttpClient;
+    }
+
+    @Override
+    public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
+        return SUPPORTED_THING_TYPES_UIDS;
+    }
+
+    @Override
+    public String getServiceType() {
+        return "_http._tcp.local.";
+    }
+
+    @Override
+    public @Nullable DiscoveryResult createResult(ServiceInfo serviceInfo) {
+        logger.trace("Bridge Discovery started for {}", serviceInfo);
+
+        @Nullable
+        final ThingUID uid = getThingUID(serviceInfo);
+        if (uid == null) {
+            return null;
+        }
+
+        logger.trace("Discovered Bosch Smart Home Controller at {}", bridgeInformation.shcIpAddress);
+
+        return DiscoveryResultBuilder.create(uid)
+                .withLabel("Bosch Smart Home Controller (" + bridgeInformation.shcIpAddress + ")")
+                .withProperty("ipAddress", bridgeInformation.shcIpAddress)
+                .withProperty("shcGeneration", bridgeInformation.shcGeneration)
+                .withProperty("apiVersions", bridgeInformation.apiVersions).withTTL(TTL_SECONDS).build();
+    }
+
+    @Override
+    public @Nullable ThingUID getThingUID(ServiceInfo serviceInfo) {
+        String ipAddress = discoverBridge(serviceInfo).shcIpAddress;
+        if (!ipAddress.isBlank()) {
+            return new ThingUID(BoschSHCBindingConstants.THING_TYPE_SHC, ipAddress.replace('.', '-'));
+        }
+        return null;
+    }
+
+    protected PublicInformation discoverBridge(ServiceInfo serviceInfo) {
+        logger.trace("Discovering serviceInfo {}", serviceInfo);
+
+        if (serviceInfo.getHostAddresses() != null && serviceInfo.getHostAddresses().length > 0
+                && !serviceInfo.getHostAddresses()[0].isEmpty()) {
+            String address = serviceInfo.getHostAddresses()[0];
+            logger.trace("Discovering InetAddress {}", address);
+            // store all information for later access
+            bridgeInformation = getPublicInformationFromPossibleBridgeAddress(address);
+        }
+
+        return bridgeInformation;
+    }
+
+    protected PublicInformation getPublicInformationFromPossibleBridgeAddress(String ipAddress) {
+        String url = BoschHttpClient.getPublicInformationUrl(ipAddress);
+        logger.trace("Discovering ipAddress {}", url);
+        try {
+            httpClient.start();
+            ContentResponse contentResponse = httpClient.newRequest(url).method(HttpMethod.GET).send();
+            // check HTTP status code
+            if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
+                logger.debug("Discovering failed with status code: {}", contentResponse.getStatus());
+                return new PublicInformation();
+            }
+            // get content from response
+            String content = contentResponse.getContentAsString();
+            logger.trace("Discovered SHC - public info {}", content);
+            PublicInformation bridgeInfo = gson.fromJson(content, PublicInformation.class);
+            if (bridgeInfo.shcIpAddress != null) {
+                return bridgeInfo;
+            }
+        } catch (TimeoutException | ExecutionException e) {
+            logger.debug("Discovering failed with exception {}", e.getMessage());
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        } catch (Exception e) {
+            logger.debug("Discovering failed during http client request {}", e.getMessage());
+        }
+        return new PublicInformation();
+    }
+}
diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java
new file mode 100644 (file)
index 0000000..2759c55
--- /dev/null
@@ -0,0 +1,253 @@
+/**
+ * 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.discovery;
+
+import java.util.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+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.core.config.discovery.AbstractDiscoveryService;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link ThingDiscoveryService} is responsible to discover Bosch Smart Home things.
+ * The paired SHC BridgeHandler is required to get the lists of rooms and devices.
+ * With this data the openhab things are discovered.
+ *
+ * The order to make this work is
+ * 1. SHC bridge is created, e.v via openhab UI
+ * 2. Service is instantiated setBridgeHandler of this service is called
+ * 3. Service is activated
+ * 4. Service registers itself as discoveryLister at the bridge
+ * 5. bridge calls startScan after bridge is paired and things can be discovered
+ *
+ * @author Gerd Zanker - Initial contribution
+ */
+@NonNullByDefault
+public class ThingDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
+    private static final int SEARCH_TIME = 1;
+
+    private final Logger logger = LoggerFactory.getLogger(ThingDiscoveryService.class);
+    private @Nullable BridgeHandler shcBridgeHandler;
+
+    protected static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(
+            BoschSHCBindingConstants.THING_TYPE_INWALL_SWITCH, BoschSHCBindingConstants.THING_TYPE_TWINGUARD,
+            BoschSHCBindingConstants.THING_TYPE_WINDOW_CONTACT, BoschSHCBindingConstants.THING_TYPE_MOTION_DETECTOR,
+            BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL, BoschSHCBindingConstants.THING_TYPE_THERMOSTAT,
+            BoschSHCBindingConstants.THING_TYPE_CLIMATE_CONTROL, BoschSHCBindingConstants.THING_TYPE_WALL_THERMOSTAT,
+            BoschSHCBindingConstants.THING_TYPE_CAMERA_360, BoschSHCBindingConstants.THING_TYPE_CAMERA_EYES,
+            BoschSHCBindingConstants.THING_TYPE_INTRUSION_DETECTION_SYSTEM,
+            BoschSHCBindingConstants.THING_TYPE_SMART_PLUG_COMPACT, BoschSHCBindingConstants.THING_TYPE_SMART_BULB,
+            BoschSHCBindingConstants.THING_TYPE_SMOKE_DETECTOR);
+
+    // @formatter:off
+    protected static final Map<String, ThingTypeUID> DEVICEMODEL_TO_THINGTYPE_MAP = Map.ofEntries(
+            new AbstractMap.SimpleEntry<>("BBL", BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL),
+            new AbstractMap.SimpleEntry<>("TWINGUARD", BoschSHCBindingConstants.THING_TYPE_TWINGUARD),
+            new AbstractMap.SimpleEntry<>("PSM", BoschSHCBindingConstants.THING_TYPE_INWALL_SWITCH),
+            new AbstractMap.SimpleEntry<>("PLUG_COMPACT", BoschSHCBindingConstants.THING_TYPE_SMART_PLUG_COMPACT),
+            new AbstractMap.SimpleEntry<>("CAMERA_360", BoschSHCBindingConstants.THING_TYPE_CAMERA_360),
+            new AbstractMap.SimpleEntry<>("CAMERA_EYES", BoschSHCBindingConstants.THING_TYPE_CAMERA_EYES),
+            new AbstractMap.SimpleEntry<>("BWTH", BoschSHCBindingConstants.THING_TYPE_THERMOSTAT), // wall thermostat
+            new AbstractMap.SimpleEntry<>("THB", BoschSHCBindingConstants.THING_TYPE_WALL_THERMOSTAT), // wall thermostat with batteries
+            new AbstractMap.SimpleEntry<>("SD", BoschSHCBindingConstants.THING_TYPE_SMOKE_DETECTOR),
+            new AbstractMap.SimpleEntry<>("MD", BoschSHCBindingConstants.THING_TYPE_MOTION_DETECTOR),
+            new AbstractMap.SimpleEntry<>("ROOM_CLIMATE_CONTROL", BoschSHCBindingConstants.THING_TYPE_CLIMATE_CONTROL),
+            new AbstractMap.SimpleEntry<>("INTRUSION_DETECTION_SYSTEM", BoschSHCBindingConstants.THING_TYPE_INTRUSION_DETECTION_SYSTEM),
+            new AbstractMap.SimpleEntry<>("HUE_LIGHT", BoschSHCBindingConstants.THING_TYPE_SMART_BULB),
+            new AbstractMap.SimpleEntry<>("WRC2", BoschSHCBindingConstants.THING_TYPE_WINDOW_CONTACT)
+// 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.),
+//            new AbstractMap.SimpleEntry<>("VENTILATION_SERVICE", BoschSHCBindingConstants.),
+//            new AbstractMap.SimpleEntry<>("HUE_BRIDGE", BoschSHCBindingConstants.)
+//            new AbstractMap.SimpleEntry<>("HUE_BRIDGE_MANAGER*", BoschSHCBindingConstants.)
+//            new AbstractMap.SimpleEntry<>("HUE_LIGHT_ROOM_CONTROL", BoschSHCBindingConstants.)
+            );
+    // @formatter:on
+
+    public ThingDiscoveryService() {
+        super(SUPPORTED_THING_TYPES, SEARCH_TIME);
+    }
+
+    @Override
+    public void activate() {
+        logger.trace("activate");
+        final BridgeHandler handler = shcBridgeHandler;
+        if (handler != null) {
+            handler.registerDiscoveryListener(this);
+        }
+    }
+
+    @Override
+    public void deactivate() {
+        logger.trace("deactivate");
+        final BridgeHandler handler = shcBridgeHandler;
+        if (handler != null) {
+            removeOlderResults(new Date().getTime(), handler.getThing().getUID());
+            handler.unregisterDiscoveryListener();
+        }
+
+        super.deactivate();
+    }
+
+    @Override
+    protected void startScan() {
+        if (shcBridgeHandler == null) {
+            logger.debug("The shcBridgeHandler is empty, no manual scan is currently possible");
+            return;
+        }
+
+        try {
+            doScan();
+        } catch (InterruptedException e) {
+            // Restore interrupted state...
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    @Override
+    protected synchronized void stopScan() {
+        logger.debug("Stop manual scan on bridge {}",
+                shcBridgeHandler != null ? shcBridgeHandler.getThing().getUID() : "?");
+        super.stopScan();
+        final BridgeHandler handler = shcBridgeHandler;
+        if (handler != null) {
+            removeOlderResults(getTimestampOfLastScan(), handler.getThing().getUID());
+        }
+    }
+
+    @Override
+    public void setThingHandler(@Nullable ThingHandler handler) {
+        if (handler instanceof BridgeHandler) {
+            logger.trace("Set bridge handler {}", handler);
+            shcBridgeHandler = (BridgeHandler) handler;
+        }
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return shcBridgeHandler;
+    }
+
+    public void doScan() throws InterruptedException {
+        logger.debug("Start manual scan on bridge {}", shcBridgeHandler.getThing().getUID());
+        // use shcBridgeHandler to getDevices()
+        List<Room> rooms = shcBridgeHandler.getRooms();
+        logger.debug("SHC has {} rooms", rooms.size());
+        List<Device> devices = shcBridgeHandler.getDevices();
+        logger.debug("SHC has {} devices", devices.size());
+
+        // Write found devices into openhab.log to support manual configuration
+        for (Device d : devices) {
+            logger.debug("Found device: name={} id={}", d.name, d.id);
+            if (d.deviceServiceIds != null) {
+                for (String s : d.deviceServiceIds) {
+                    logger.debug(".... service: {}", s);
+                }
+            }
+        }
+
+        addDevices(devices, rooms);
+    }
+
+    protected void addDevices(List<Device> devices, List<Room> rooms) {
+        for (Device device : devices) {
+            addDevice(device, getRoomNameForDevice(device, rooms));
+        }
+    }
+
+    protected String getRoomNameForDevice(Device device, List<Room> rooms) {
+        return rooms.stream().filter(room -> room.id.equals(device.roomId)).findAny().map(r -> r.name).orElse("");
+    }
+
+    protected void addDevice(Device device, String roomName) {
+        // see startScan for the runtime null check of shcBridgeHandler
+        assert shcBridgeHandler != null;
+
+        logger.trace("Discovering device {}", device.name);
+        logger.trace("- details: id {}, roomId {}, deviceModel {}", device.id, device.roomId, device.deviceModel);
+
+        ThingTypeUID thingTypeUID = getThingTypeUID(device);
+        if (thingTypeUID == null) {
+            return;
+        }
+
+        logger.trace("- got thingTypeID '{}' for deviceModel '{}'", thingTypeUID.getId(), device.deviceModel);
+
+        ThingUID thingUID = new ThingUID(thingTypeUID, shcBridgeHandler.getThing().getUID(),
+                device.id.replace(':', '_'));
+
+        logger.trace("- got thingUID '{}' for device: '{}'", thingUID, device);
+
+        DiscoveryResultBuilder discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID)
+                .withProperty("id", device.id).withLabel(getNiceName(device.name, roomName));
+        if (null != shcBridgeHandler) {
+            discoveryResult.withBridge(shcBridgeHandler.getThing().getUID());
+        }
+        if (!roomName.isEmpty()) {
+            discoveryResult.withProperty("Location", roomName);
+        }
+        thingDiscovered(discoveryResult.build());
+
+        logger.debug("Discovered device '{}' with thingTypeUID={}, thingUID={}, id={}, deviceModel={}", device.name,
+                thingUID, thingTypeUID, device.id, device.deviceModel);
+    }
+
+    private String getNiceName(String name, String roomName) {
+        if (!name.startsWith("-"))
+            return name;
+
+        // convert "-IntrusionDetectionSystem-" into "Intrusion Detection System"
+        // convert "-RoomClimateControl-" into "Room Climate Control myRoomName"
+        final char[] chars = name.toCharArray();
+        StringBuilder niceNameBuilder = new StringBuilder(32);
+        for (int pos = 0; pos < chars.length; pos++) {
+            // skip "-"
+            if (chars[pos] == '-') {
+                continue;
+            }
+            // convert "CamelCase" into "Camel Case", skipping the first Uppercase after the "-"
+            if (pos > 1 && Character.getType(chars[pos]) == Character.UPPERCASE_LETTER) {
+                niceNameBuilder.append(" ");
+            }
+            niceNameBuilder.append(chars[pos]);
+        }
+        // append roomName for "Room Climate Control", because it appears for each room with a thermostat
+        if (!roomName.isEmpty() && niceNameBuilder.toString().startsWith("Room Climate Control")) {
+            niceNameBuilder.append(" ").append(roomName);
+        }
+        return niceNameBuilder.toString();
+    }
+
+    protected @Nullable ThingTypeUID getThingTypeUID(Device device) {
+        @Nullable
+        ThingTypeUID thingTypeId = DEVICEMODEL_TO_THINGTYPE_MAP.get(device.deviceModel);
+        if (thingTypeId != null) {
+            return new ThingTypeUID(BoschSHCBindingConstants.BINDING_ID, thingTypeId.getId());
+        }
+        logger.debug("Unknown deviceModel '{}'! Please create a support request issue for this unknown device model.",
+                device.deviceModel);
+        return null;
+    }
+}
index 4df8799773fd9b84f1587c8bab169c38650f8a9d..71a02181dfc53bdb9c4d2e5ea576d6419a862d60 100644 (file)
@@ -70,7 +70,6 @@ public abstract class BoschSHCSystemService<TState extends BoschSHCServiceState>
     @Override
     public @Nullable TState getState()
             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
-
         BridgeHandler bridgeHandler = getBridgeHandler();
         if (bridgeHandler == null) {
             return null;
index 7a8a7c0898820f1f63c3b1c6117b18d00e4aec8d..a2afc39c1b215fc95d3656ed7ea19d121f1ee232 100644 (file)
@@ -38,12 +38,10 @@ public class BatteryLevelService extends BoschSHCService<DeviceServiceData> {
     @Override
     public @Nullable DeviceServiceData getState()
             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
-
         String deviceId = getDeviceId();
         if (deviceId == null) {
             return null;
         }
-
         BridgeHandler bridgeHandler = getBridgeHandler();
         if (bridgeHandler == null) {
             return null;
index 6d0a28610c1db4ed192a94400e97c592538c68e1..ffe23619dc425fea356e5e4f01d24b6d6064ea2a 100644 (file)
@@ -30,9 +30,12 @@ public class BoschSHCServiceState {
     /**
      * gson instance to convert a class to json string and back.
      */
-    private static final Gson gson = new Gson();
+    private static final Gson GSON = new Gson();
 
-    private static final Logger logger = LoggerFactory.getLogger(BoschSHCServiceState.class);
+    /**
+     * Logger marked as transient to exclude the logger from JSON serialization.
+     */
+    private final transient Logger logger = LoggerFactory.getLogger(BoschSHCServiceState.class);
 
     /**
      * State type. Initialized when instance is created.
@@ -67,7 +70,7 @@ public class BoschSHCServiceState {
 
     public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(String json,
             Class<TState> stateClass) {
-        var state = gson.fromJson(json, stateClass);
+        var state = GSON.fromJson(json, stateClass);
         if (state == null || !state.isValid()) {
             return null;
         }
@@ -77,7 +80,7 @@ public class BoschSHCServiceState {
 
     public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(JsonElement json,
             Class<TState> stateClass) {
-        var state = gson.fromJson(json, stateClass);
+        var state = GSON.fromJson(json, stateClass);
         if (state == null || !state.isValid()) {
             return null;
         }
index bb194b1391c635a1b2a07f8db50b05788a591f77..a7426b909c33114ee8c9dd69b24a594f766f4b30 100644 (file)
@@ -27,7 +27,6 @@ public enum SmokeDetectorCheckState {
     SMOKE_TEST_FAILED;
 
     public static SmokeDetectorCheckState from(String stateString) {
-
         try {
             return SmokeDetectorCheckState.valueOf(stateString);
         } catch (Exception a) {
index ff6b73646d61b4675dff1b539a9e2abdebf6c9cd..8a564ea8832ab36d040f5f325768579abab6aeb5 100644 (file)
@@ -135,6 +135,6 @@ offline.not-reachable = The Bosch Smart Home Controller is not reachable.
 offline.conf-error-ssl = The SSL connection to the Bosch Smart Home Controller is not possible.
 offline.long-polling-failed.http-client-null = Long polling failed and could not be restarted because http client is null.
 offline.long-polling-failed.trying-to-reconnect = Long polling failed, will try to reconnect.
-offline.interrupted = Conneting to Bosch Smart Home Controller was interrupted.
+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.
index 5809c1d4376474db8bfe9bc6f49e269ae92b82d5..04f217d10150086c4b6190d5ccfbaed0aff889fe 100644 (file)
@@ -195,7 +195,7 @@ class BoschHttpClientTest {
         when(response.getStatus()).thenReturn(500);
         ExecutionException e = assertThrows(ExecutionException.class,
                 () -> httpClient.sendRequest(request, SubscribeResult.class, SubscribeResult::isValid, null));
-        assertEquals("Request failed with status code 500", e.getMessage());
+        assertEquals("Send request failed with status code 500", e.getMessage());
     }
 
     @Test
diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/BridgeDiscoveryParticipantTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/BridgeDiscoveryParticipantTest.java
new file mode 100644 (file)
index 0000000..faf441a
--- /dev/null
@@ -0,0 +1,190 @@
+/**
+ * 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.discovery;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import javax.jmdns.ServiceInfo;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.http.HttpMethod;
+import org.eclipse.jetty.http.HttpStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.thing.ThingUID;
+
+/**
+ * BridgeDiscoveryParticipant Tester.
+ *
+ * @author Gerd Zanker - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+@NonNullByDefault
+public class BridgeDiscoveryParticipantTest {
+
+    @Nullable
+    private BridgeDiscoveryParticipant fixture;
+
+    private final String url = "https://192.168.0.123:8446/smarthome/public/information";
+
+    private @Mock @NonNullByDefault({}) ServiceInfo shcBridge;
+    private @Mock @NonNullByDefault({}) ServiceInfo otherDevice;
+
+    @BeforeEach
+    public void beforeEach() throws Exception {
+        when(shcBridge.getHostAddresses()).thenReturn(new String[] { "192.168.0.123" });
+        when(otherDevice.getHostAddresses()).thenReturn(new String[] { "192.168.0.1" });
+
+        ContentResponse contentResponse = mock(ContentResponse.class);
+        when(contentResponse.getContentAsString()).thenReturn(
+                "{\"apiVersions\":[\"2.9\",\"3.2\"], \"shcIpAddress\":\"192.168.0.123\", \"shcGeneration\":\"SHC_1\"}");
+        when(contentResponse.getStatus()).thenReturn(HttpStatus.OK_200);
+
+        Request mockRequest = mock(Request.class);
+        when(mockRequest.send()).thenReturn(contentResponse);
+        when(mockRequest.method((HttpMethod) any())).thenReturn(mockRequest);
+
+        HttpClient mockHttpClient = spy(HttpClient.class); // spy needed, because some final methods can't be mocked
+        when(mockHttpClient.newRequest(url)).thenReturn(mockRequest);
+
+        fixture = new BridgeDiscoveryParticipant(mockHttpClient);
+    }
+
+    /**
+     * 
+     * Method: getSupportedThingTypeUIDs()
+     * 
+     */
+
+    @Test
+    public void testGetSupportedThingTypeUIDs() {
+        assert fixture != null;
+        assertTrue(fixture.getSupportedThingTypeUIDs().contains(BoschSHCBindingConstants.THING_TYPE_SHC));
+    }
+
+    /**
+     * 
+     * Method: getServiceType()
+     * 
+     */
+    @Test
+    public void testGetServiceType() throws Exception {
+        assert fixture != null;
+        assertThat(fixture.getServiceType(), is("_http._tcp.local."));
+    }
+
+    @Test
+    public void testCreateResult() throws Exception {
+        assert fixture != null;
+        DiscoveryResult result = fixture.createResult(shcBridge);
+        assertNotNull(result);
+        assertThat(result.getBindingId(), is(BoschSHCBindingConstants.BINDING_ID));
+        assertThat(result.getThingUID().getId(), is("192-168-0-123"));
+        assertThat(result.getThingTypeUID().getId(), is("shc"));
+        assertThat(result.getLabel(), is("Bosch Smart Home Controller (192.168.0.123)"));
+    }
+
+    @Test
+    public void testCreateResultOtherDevice() throws Exception {
+        assert fixture != null;
+        DiscoveryResult result = fixture.createResult(otherDevice);
+        assertNull(result);
+    }
+
+    @Test
+    public void testGetThingUID() throws Exception {
+        assert fixture != null;
+        ThingUID thingUID = fixture.getThingUID(shcBridge);
+        assertNotNull(thingUID);
+        assertThat(thingUID.getBindingId(), is(BoschSHCBindingConstants.BINDING_ID));
+        assertThat(thingUID.getId(), is("192-168-0-123"));
+    }
+
+    @Test
+    public void testGetThingUIDOtherDevice() throws Exception {
+        assert fixture != null;
+        assertNull(fixture.getThingUID(otherDevice));
+    }
+
+    @Test
+    public void testGetBridgeAddress() throws Exception {
+        assert fixture != null;
+        assertThat(fixture.discoverBridge(shcBridge).shcIpAddress, is("192.168.0.123"));
+    }
+
+    @Test
+    public void testGetBridgeAddressOtherDevice() throws Exception {
+        assert fixture != null;
+        assertThat(fixture.discoverBridge(otherDevice).shcIpAddress, is(""));
+    }
+
+    @Test
+    public void testGetPublicInformationFromPossibleBridgeAddress() throws Exception {
+        assert fixture != null;
+        assertThat(fixture.getPublicInformationFromPossibleBridgeAddress("192.168.0.123").shcIpAddress,
+                is("192.168.0.123"));
+    }
+
+    @Test
+    public void testGetPublicInformationFromPossibleBridgeAddressInvalidContent() throws Exception {
+        assert fixture != null;
+
+        ContentResponse contentResponse = mock(ContentResponse.class);
+        when(contentResponse.getContentAsString()).thenReturn("{\"nothing\":\"useful\"}");
+        when(contentResponse.getStatus()).thenReturn(HttpStatus.OK_200);
+
+        Request mockRequest = mock(Request.class);
+        when(mockRequest.send()).thenReturn(contentResponse);
+        when(mockRequest.method((HttpMethod) any())).thenReturn(mockRequest);
+
+        HttpClient mockHttpClient = spy(HttpClient.class); // spy needed, because some final methods can't be mocked
+        when(mockHttpClient.newRequest(url)).thenReturn(mockRequest);
+
+        fixture = new BridgeDiscoveryParticipant(mockHttpClient);
+        assertThat(fixture.getPublicInformationFromPossibleBridgeAddress("shcAddress").shcIpAddress, is(""));
+    }
+
+    @Test
+    public void testGetPublicInformationFromPossibleBridgeAddressInvalidStatus() throws Exception {
+        assert fixture != null;
+
+        ContentResponse contentResponse = mock(ContentResponse.class);
+        // when(contentResponse.getContentAsString()).thenReturn("{\"nothing\":\"useful\"}"); no content needed
+        when(contentResponse.getStatus()).thenReturn(HttpStatus.BAD_REQUEST_400);
+
+        Request mockRequest = mock(Request.class);
+        when(mockRequest.send()).thenReturn(contentResponse);
+        when(mockRequest.method((HttpMethod) any())).thenReturn(mockRequest);
+
+        HttpClient mockHttpClient = spy(HttpClient.class); // spy needed, because some final methods can't be mocked
+        when(mockHttpClient.newRequest(url)).thenReturn(mockRequest);
+
+        fixture = new BridgeDiscoveryParticipant(mockHttpClient);
+        assertThat(fixture.getPublicInformationFromPossibleBridgeAddress("shcAddress").shcIpAddress, is(""));
+    }
+}
diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryServiceTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryServiceTest.java
new file mode 100644 (file)
index 0000000..9439144
--- /dev/null
@@ -0,0 +1,236 @@
+/**
+ * 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.discovery;
+
+import static org.hamcrest.CoreMatchers.*;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.ArrayList;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+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.core.config.discovery.DiscoveryListener;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryService;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.ThingUID;
+
+/**
+ * ThingDiscoveryService Tester.
+ *
+ * @author Gerd Zanker - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@NonNullByDefault
+public class ThingDiscoveryServiceTest {
+
+    private @NonNullByDefault({}) ThingDiscoveryService fixture;
+
+    private @Mock @NonNullByDefault({}) BridgeHandler bridgeHandler;
+    private @Mock @NonNullByDefault({}) DiscoveryListener discoveryListener;
+    private @Captor @NonNullByDefault({}) ArgumentCaptor<DiscoveryService> discoveryServiceCaptor;
+    private @Captor @NonNullByDefault({}) ArgumentCaptor<DiscoveryResult> discoveryResultCaptor;
+
+    @BeforeEach
+    void beforeEach() {
+        fixture = new ThingDiscoveryService();
+        fixture.addDiscoveryListener(discoveryListener);
+        fixture.setThingHandler(bridgeHandler);
+    }
+
+    private void mockBridgeCalls() {
+        // Set the Mock Bridge as the ThingHandler
+        ThingUID bridgeUID = new ThingUID(BoschSHCBindingConstants.THING_TYPE_SHC, "testSHC");
+        Bridge mockBridge = mock(Bridge.class);
+        when(mockBridge.getUID()).thenReturn(bridgeUID);
+        when(bridgeHandler.getThing()).thenReturn(mockBridge);
+    }
+
+    @Test
+    public void testStartScan() throws InterruptedException {
+        mockBridgeCalls();
+
+        fixture.activate();
+        fixture.startScan();
+
+        verify(bridgeHandler).getRooms();
+        verify(bridgeHandler).getDevices();
+
+        fixture.stopScan();
+        fixture.deactivate();
+    }
+
+    @Test
+    public void testStartScanWithoutBridgeHandler() {
+        mockBridgeCalls();
+
+        // No fixture.setThingHandler(bridgeHandler);
+        fixture.activate();
+        fixture.startScan();
+
+        // bridgeHandler not called, just no exception expected
+        fixture.stopScan();
+        fixture.deactivate();
+    }
+
+    @Test
+    public void testSetGetThingHandler() {
+        fixture.setThingHandler(bridgeHandler);
+        assertThat(fixture.getThingHandler(), is(bridgeHandler));
+    }
+
+    @Test
+    public void testAddDevices() {
+        mockBridgeCalls();
+
+        ArrayList<Device> devices = new ArrayList<>();
+        ArrayList<Room> emptyRooms = new ArrayList<>();
+
+        Device device1 = new Device();
+        device1.deviceModel = "TWINGUARD";
+        device1.id = "testDevice:ID";
+        device1.name = "Test Name";
+        devices.add(device1);
+        Device device2 = new Device();
+        device2.deviceModel = "TWINGUARD";
+        device2.id = "testDevice:2";
+        device2.name = "Second device";
+        devices.add(device2);
+
+        verify(discoveryListener, never()).thingDiscovered(any(), any());
+
+        fixture.addDevices(devices, emptyRooms);
+
+        // two calls for the two devices expected
+        verify(discoveryListener, times(2)).thingDiscovered(any(), any());
+    }
+
+    @Test
+    public void testAddDevicesWithNoDevices() {
+        ArrayList<Device> emptyDevices = new ArrayList<>();
+        ArrayList<Room> emptyRooms = new ArrayList<>();
+
+        verify(discoveryListener, never()).thingDiscovered(any(), any());
+
+        fixture.addDevices(emptyDevices, emptyRooms);
+
+        // nothing shall be discovered, but also no exception shall be thrown
+        verify(discoveryListener, never()).thingDiscovered(any(), any());
+    }
+
+    @Test
+    public void testAddDevice() {
+        mockBridgeCalls();
+
+        Device device = new Device();
+        device.deviceModel = "TWINGUARD";
+        device.id = "testDevice:ID";
+        device.name = "Test Name";
+        fixture.addDevice(device, "TestRoom");
+
+        verify(discoveryListener).thingDiscovered(discoveryServiceCaptor.capture(), discoveryResultCaptor.capture());
+
+        assertThat(discoveryServiceCaptor.getValue().getClass(), is(ThingDiscoveryService.class));
+        DiscoveryResult result = discoveryResultCaptor.getValue();
+        assertThat(result.getBindingId(), is(BoschSHCBindingConstants.BINDING_ID));
+        assertThat(result.getThingTypeUID(), is(BoschSHCBindingConstants.THING_TYPE_TWINGUARD));
+        assertThat(result.getThingUID().getId(), is("testDevice_ID"));
+        assertThat(result.getBridgeUID().getId(), is("testSHC"));
+        assertThat(result.getLabel(), is("Test Name"));
+        assertThat(result.getProperties().get("Location").toString(), is("TestRoom"));
+    }
+
+    @Test
+    public void testAddDeviceWithNiceNameAndAppendedRoomName() {
+        assertDeviceNiceName("-RoomClimateControl-", "TestRoom", "Room Climate Control TestRoom");
+    }
+
+    @Test
+    public void testAddDeviceWithNiceNameWithEmtpyRoomName() {
+        assertDeviceNiceName("-RoomClimateControl-", "", "Room Climate Control");
+    }
+
+    @Test
+    public void testAddDeviceWithNiceNameWithoutAppendingRoomName() {
+        assertDeviceNiceName("-SmokeDetectionSystem-", "TestRoom", "Smoke Detection System");
+    }
+
+    @Test
+    public void testAddDeviceWithNiceNameWithoutUsualName() {
+        assertDeviceNiceName("My other device", "TestRoom", "My other device");
+    }
+
+    private void assertDeviceNiceName(String deviceName, String roomName, String expectedNiceName) {
+        mockBridgeCalls();
+
+        Device device = new Device();
+        device.deviceModel = "TWINGUARD";
+        device.id = "testDevice:ID";
+        device.name = deviceName;
+        fixture.addDevice(device, roomName);
+        verify(discoveryListener).thingDiscovered(discoveryServiceCaptor.capture(), discoveryResultCaptor.capture());
+        assertThat(discoveryServiceCaptor.getValue().getClass(), is(ThingDiscoveryService.class));
+        DiscoveryResult result = discoveryResultCaptor.getValue();
+        assertThat(result.getLabel(), is(expectedNiceName));
+    }
+
+    @Test
+    public void testGetRoomForDevice() {
+        Device device = new Device();
+
+        ArrayList<Room> rooms = new ArrayList<>();
+        Room room1 = new Room();
+        room1.id = "r1";
+        room1.name = "Room1";
+        rooms.add(room1);
+        Room room2 = new Room();
+        room2.id = "r2";
+        room2.name = "Room 2";
+        rooms.add(room2);
+
+        device.roomId = "r1";
+        assertThat(fixture.getRoomNameForDevice(device, rooms), is("Room1"));
+
+        device.roomId = "r2";
+        assertThat(fixture.getRoomNameForDevice(device, rooms), is("Room 2"));
+
+        device.roomId = "unknown";
+        assertTrue(fixture.getRoomNameForDevice(device, rooms).isEmpty());
+    }
+
+    @Test
+    public void testGetThingTypeUID() {
+        Device device = new Device();
+
+        device.deviceModel = "invalid";
+        assertNull(fixture.getThingTypeUID(device));
+
+        // just two spot checks
+        device.deviceModel = "BBL";
+        assertThat(fixture.getThingTypeUID(device), is(BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL));
+        device.deviceModel = "TWINGUARD";
+        assertThat(fixture.getThingTypeUID(device), is(BoschSHCBindingConstants.THING_TYPE_TWINGUARD));
+    }
+}