diff --git a/bundles/org.openhab.binding.boschshc/README.md b/bundles/org.openhab.binding.boschshc/README.md index cd2fe0b83844e..f7241a0c6816e 100644 --- a/bundles/org.openhab.binding.boschshc/README.md +++ b/bundles/org.openhab.binding.boschshc/README.md @@ -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 diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCDeviceHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCDeviceHandler.java index ffeaf49d7f105..2168e1223f114 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCDeviceHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCDeviceHandler.java @@ -53,7 +53,6 @@ public BoschSHCDeviceHandler(Thing thing) { @Override public void initialize() { - var config = this.config = getConfigAs(BoschSHCConfiguration.class); String deviceId = config.id; diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java index 0ddb7ca420608..13f764f9386a5 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/BoschSHCHandler.java @@ -124,7 +124,6 @@ protected BoschSHCHandler(Thing thing) { */ @Override public void initialize() { - // Initialize device services try { this.initializeServices(); @@ -304,7 +303,6 @@ protected , TState extends BoschSHCServ protected , TState extends BoschSHCServiceState> void registerService( TService service, Consumer stateUpdateListener, Collection affectedChannels, boolean shouldFetchInitialState) throws BoschSHCException { - String deviceId = verifyBoschID(); service.initialize(getBridgeHandler(), deviceId, stateUpdateListener); this.registerService(service, affectedChannels); @@ -325,7 +323,6 @@ protected , TState extends BoschSHCServ */ private , TState extends BoschSHCServiceState> void fetchInitialState( TService service, Consumer stateUpdateListener) { - try { @Nullable TState serviceState = service.getState(); @@ -353,7 +350,6 @@ private , TState extends BoschSHCServic */ protected 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 diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClient.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClient.java index 211481a7b6613..a07f9f5be3b99 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClient.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClient.java @@ -65,13 +65,22 @@ public BoschHttpClient(String ipAddress, String systemPassword, SslContextFactor } /** - * 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 TContent sendRequest(Request request, Class response 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 diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java index 5856316b45c0b..6c0752c78d6b3 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/BridgeHandler.java @@ -16,6 +16,11 @@ 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.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.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 @@ * @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> getServices() { + return Collections.singleton(ThingDiscoveryService.class); + } + @Override public void initialize() { Bundle bundle = FrameworkUtil.getBundle(getClass()); @@ -225,12 +245,8 @@ private void scheduleInitialAccess(BoschHttpClient httpClient) { 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 @@ private void scheduleInitialAccess(BoschHttpClient httpClient) { 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 @@ private void scheduleInitialAccess(BoschHttpClient httpClient) { } } + /** + * 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 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>() { }.getType(); - ArrayList 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 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 getRooms() throws InterruptedException { + List 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>() { + }.getType(); + + ArrayList 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 @@ private void handleLongPollFailure(Throwable e) { 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>() { - }.getType(); - - ArrayList 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 index 0000000000000..24be8fd59ecc8 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/devices/bridge/dto/PublicInformation.java @@ -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. + *

+ * + * Currently, only the ipAddress is used for discovery. More fields can be added on demand. + *

+ * Json example: + * + *

+ * {
+ * "apiVersions":["1.2","2.1"],
+ * ...
+ * "shcIpAddress":"192.168.1.2",
+ * ...
+ * }
+ * 
+ * + * @author Gerd Zanker - Initial contribution + */ +public class PublicInformation { + public PublicInformation() { + this.shcIpAddress = ""; + this.shcGeneration = ""; + } + + public List 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 index 0000000000000..e0b8ac7fc1649 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/BridgeDiscoveryParticipant.java @@ -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 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 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 index 0000000000000..2759c5510fa5a --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryService.java @@ -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 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 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 rooms = shcBridgeHandler.getRooms(); + logger.debug("SHC has {} rooms", rooms.size()); + List 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 devices, List rooms) { + for (Device device : devices) { + addDevice(device, getRoomNameForDevice(device, rooms)); + } + } + + protected String getRoomNameForDevice(Device device, List 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; + } +} diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCSystemService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCSystemService.java index 4df8799773fd9..71a02181dfc53 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCSystemService.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/BoschSHCSystemService.java @@ -70,7 +70,6 @@ protected BoschSHCSystemService(String serviceName, Class stateClass, St @Override public @Nullable TState getState() throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException { - BridgeHandler bridgeHandler = getBridgeHandler(); if (bridgeHandler == null) { return null; diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/batterylevel/BatteryLevelService.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/batterylevel/BatteryLevelService.java index 7a8a7c0898820..a2afc39c1b215 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/batterylevel/BatteryLevelService.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/batterylevel/BatteryLevelService.java @@ -38,12 +38,10 @@ public BatteryLevelService() { @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; diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java index 6d0a28610c1db..ffe23619dc425 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/dto/BoschSHCServiceState.java @@ -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 @@ protected boolean isValid() { public static @Nullable TState fromJson(String json, Class stateClass) { - var state = gson.fromJson(json, stateClass); + var state = GSON.fromJson(json, stateClass); if (state == null || !state.isValid()) { return null; } @@ -77,7 +80,7 @@ protected boolean isValid() { public static @Nullable TState fromJson(JsonElement json, Class stateClass) { - var state = gson.fromJson(json, stateClass); + var state = GSON.fromJson(json, stateClass); if (state == null || !state.isValid()) { return null; } diff --git a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/smokedetectorcheck/SmokeDetectorCheckState.java b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/smokedetectorcheck/SmokeDetectorCheckState.java index bb194b1391c63..a7426b909c331 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/smokedetectorcheck/SmokeDetectorCheckState.java +++ b/bundles/org.openhab.binding.boschshc/src/main/java/org/openhab/binding/boschshc/internal/services/smokedetectorcheck/SmokeDetectorCheckState.java @@ -27,7 +27,6 @@ public enum SmokeDetectorCheckState { SMOKE_TEST_FAILED; public static SmokeDetectorCheckState from(String stateString) { - try { return SmokeDetectorCheckState.valueOf(stateString); } catch (Exception a) { diff --git a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties index ff6b73646d61b..8a564ea8832ab 100644 --- a/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties +++ b/bundles/org.openhab.binding.boschshc/src/main/resources/OH-INF/i18n/boschshc.properties @@ -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. diff --git a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClientTest.java b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClientTest.java index 5809c1d437647..04f217d101500 100644 --- a/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClientTest.java +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/devices/bridge/BoschHttpClientTest.java @@ -195,7 +195,7 @@ void sendRequestResponseError() 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 index 0000000000000..faf441ac51800 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/BridgeDiscoveryParticipantTest.java @@ -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 index 0000000000000..9439144768a03 --- /dev/null +++ b/bundles/org.openhab.binding.boschshc/src/test/java/org/openhab/binding/boschshc/internal/discovery/ThingDiscoveryServiceTest.java @@ -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 discoveryServiceCaptor; + private @Captor @NonNullByDefault({}) ArgumentCaptor 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 devices = new ArrayList<>(); + ArrayList 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 emptyDevices = new ArrayList<>(); + ArrayList 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 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)); + } +}