## 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
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
@Override
public void initialize() {
-
var config = this.config = getConfigAs(BoschSHCConfiguration.class);
String deviceId = config.id;
*/
@Override
public void initialize() {
-
// Initialize device services
try {
this.initializeServices();
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);
*/
private <TService extends BoschSHCService<TState>, TState extends BoschSHCServiceState> void fetchInitialState(
TService service, Consumer<TState> stateUpdateListener) {
-
try {
@Nullable
TState serviceState = service.getState();
*/
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
}
/**
- * 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);
}
/**
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
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;
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;
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;
* @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 {
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());
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
return;
}
+ // do thing discovery after pairing
+ final ThingDiscoveryService discovery = thingDiscoveryService;
+ if (discovery != null) {
+ discovery.doScan();
+ }
+
// start long polling loop
this.updateStatus(ThingStatus.ONLINE);
try {
}
}
+ /**
+ * 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;
}
/**
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
--- /dev/null
+/**
+ * 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;
+}
--- /dev/null
+/**
+ * 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();
+ }
+}
--- /dev/null
+/**
+ * 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;
+ }
+}
@Override
public @Nullable TState getState()
throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
-
BridgeHandler bridgeHandler = getBridgeHandler();
if (bridgeHandler == null) {
return null;
@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;
/**
* 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.
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;
}
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;
}
SMOKE_TEST_FAILED;
public static SmokeDetectorCheckState from(String stateString) {
-
try {
return SmokeDetectorCheckState.valueOf(stateString);
} catch (Exception a) {
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.
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
--- /dev/null
+/**
+ * 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(""));
+ }
+}
--- /dev/null
+/**
+ * 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));
+ }
+}