[boschshc] Bridge and Device Discovery ()

*  Bridge and Device Discovery

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

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

Added unit tests for Bridge and Device Discovery.

Signed-off-by: Gerd Zanker <gerd.zanker@web.de>
This commit is contained in:
Gerd Zanker 2023-03-25 00:26:24 +01:00 committed by GitHub
parent c30893281c
commit 3b08217ff7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1045 additions and 109 deletions

@ -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

@ -53,7 +53,6 @@ public abstract class BoschSHCDeviceHandler extends BoschSHCHandler {
@Override
public void initialize() {
var config = this.config = getConfigAs(BoschSHCConfiguration.class);
String deviceId = config.id;

@ -124,7 +124,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
*/
@Override
public void initialize() {
// Initialize device services
try {
this.initializeServices();
@ -304,7 +303,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
protected <TService extends BoschSHCService<TState>, TState extends BoschSHCServiceState> void registerService(
TService service, Consumer<TState> stateUpdateListener, Collection<String> affectedChannels,
boolean shouldFetchInitialState) throws BoschSHCException {
String deviceId = verifyBoschID();
service.initialize(getBridgeHandler(), deviceId, stateUpdateListener);
this.registerService(service, affectedChannels);
@ -325,7 +323,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
*/
private <TService extends BoschSHCService<TState>, TState extends BoschSHCServiceState> void fetchInitialState(
TService service, Consumer<TState> stateUpdateListener) {
try {
@Nullable
TState serviceState = service.getState();
@ -353,7 +350,6 @@ public abstract class BoschSHCHandler extends BaseThingHandler {
*/
protected <TService extends AbstractBoschSHCService> void registerStatelessService(TService service)
throws BoschSHCException {
String deviceId = verifyBoschID();
service.initialize(getBridgeHandler(), deviceId);
// do not register in service list because the service can not receive state updates

@ -65,13 +65,22 @@ public class BoschHttpClient extends HttpClient {
}
/**
* Returns the public information URL for the Bosch SHC clients, using port 8446.
* Returns the public information URL for the Bosch SHC client addressed with the given IP address, using port 8446
* See https://github.com/BoschSmartHome/bosch-shc-api-docs/blob/master/postman/README.md
*
* @return URL for public information
*/
public static String getPublicInformationUrl(String ipAddress) {
return String.format("https://%s:8446/smarthome/public/information", ipAddress);
}
/**
* Returns the public information URL for the current Bosch SHC client.
*
* @return URL for public information
*/
public String getPublicInformationUrl() {
return String.format("https://%s:8446/smarthome/public/information", this.ipAddress);
return getPublicInformationUrl(this.ipAddress);
}
/**
@ -316,11 +325,12 @@ public class BoschHttpClient extends HttpClient {
if (errorResponseHandler != null) {
throw errorResponseHandler.apply(statusCode, textContent);
} else {
throw new ExecutionException(String.format("Request failed with status code %s", statusCode), null);
throw new ExecutionException(String.format("Send request failed with status code %s", statusCode),
null);
}
}
logger.debug("Received response: {} - status: {}", textContent, statusCode);
logger.debug("Send request completed with success: {} - status code: {}", textContent, statusCode);
try {
@Nullable

@ -16,6 +16,11 @@ import static org.eclipse.jetty.http.HttpMethod.*;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
@ -33,6 +38,7 @@ import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
import org.openhab.binding.boschshc.internal.discovery.ThingDiscoveryService;
import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
@ -45,6 +51,7 @@ import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.openhab.core.types.Command;
import org.osgi.framework.Bundle;
import org.osgi.framework.FrameworkUtil;
@ -62,6 +69,7 @@ import com.google.gson.reflect.TypeToken;
* @author Gerd Zanker - added HttpClient with pairing support
* @author Christian Oeing - refactorings of e.g. server registration
* @author David Pace - Added support for custom endpoints and HTTP POST requests
* @author Gerd Zanker - added thing discovery
*/
@NonNullByDefault
public class BridgeHandler extends BaseBridgeHandler {
@ -88,12 +96,24 @@ public class BridgeHandler extends BaseBridgeHandler {
private @Nullable ScheduledFuture<?> scheduledPairing;
/**
* SHC thing/device discovery service instance.
* Registered and unregistered if service is actived/deactived.
* Used to scan for things after bridge is paired with SHC.
*/
private @Nullable ThingDiscoveryService thingDiscoveryService;
public BridgeHandler(Bridge bridge) {
super(bridge);
this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
}
@Override
public Collection<Class<? extends ThingHandlerService>> getServices() {
return Collections.singleton(ThingDiscoveryService.class);
}
@Override
public void initialize() {
Bundle bundle = FrameworkUtil.getBundle(getClass());
@ -225,12 +245,8 @@ public class BridgeHandler extends BaseBridgeHandler {
return;
}
// SHC is online and access is possible
// print rooms and devices
boolean thingReachable = true;
thingReachable &= this.getRooms();
thingReachable &= this.getDevices();
if (!thingReachable) {
// SHC is online and access should possible
if (!checkBridgeAccess()) {
this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
"@text/offline.not-reachable");
// restart initial access
@ -238,6 +254,12 @@ public class BridgeHandler extends BaseBridgeHandler {
return;
}
// do thing discovery after pairing
final ThingDiscoveryService discovery = thingDiscoveryService;
if (discovery != null) {
discovery.doScan();
}
// start long polling loop
this.updateStatus(ThingStatus.ONLINE);
try {
@ -253,53 +275,129 @@ public class BridgeHandler extends BaseBridgeHandler {
}
/**
* Get a list of connected devices from the Smart-Home Controller
*
* @throws InterruptedException in case bridge is stopped
* Check the bridge access by sending an HTTP request.
* Does not throw any exception in case the request fails.
*/
private boolean getDevices() throws InterruptedException {
public boolean checkBridgeAccess() throws InterruptedException {
@Nullable
BoschHttpClient httpClient = this.httpClient;
if (httpClient == null) {
return false;
}
try {
logger.debug("Sending http request to Bosch to request devices: {}", httpClient);
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
*/
public List<Device> getDevices() throws InterruptedException {
@Nullable
BoschHttpClient httpClient = this.httpClient;
if (httpClient == null) {
return Collections.emptyList();
}
try {
logger.trace("Sending http request to Bosch to request devices: {}", httpClient);
String url = httpClient.getBoschSmartHomeUrl("devices");
ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
// 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.warn("Request devices failed because of {}!", e.getMessage());
return false;
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;
}
} else {
return emptyRooms;
}
}
public boolean registerDiscoveryListener(ThingDiscoveryService listener) {
if (thingDiscoveryService == null) {
thingDiscoveryService = listener;
return true;
}
return true;
return false;
}
public boolean unregisterDiscoveryListener() {
if (thingDiscoveryService != null) {
thingDiscoveryService = null;
return true;
}
return false;
}
/**
@ -420,51 +518,6 @@ public class BridgeHandler extends BaseBridgeHandler {
scheduleInitialAccess(httpClient);
}
/**
* Get a list of rooms from the Smart-Home controller
*
* @throws InterruptedException in case bridge is stopped
*/
private boolean getRooms() throws InterruptedException {
@Nullable
BoschHttpClient httpClient = this.httpClient;
if (httpClient != null) {
try {
logger.debug("Sending http request to Bosch to request rooms");
String url = httpClient.getBoschSmartHomeUrl("rooms");
ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
// check HTTP status code
if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
return false;
}
String content = contentResponse.getContentAsString();
logger.debug("Request rooms completed with success: {} - status code: {}", content,
contentResponse.getStatus());
Type collectionType = new TypeToken<ArrayList<Room>>() {
}.getType();
ArrayList<Room> rooms = gson.fromJson(content, collectionType);
if (rooms != null) {
for (Room r : rooms) {
logger.info("Found room: {}", r.name);
}
}
return true;
} catch (TimeoutException | ExecutionException e) {
logger.warn("Request rooms failed because of {}!", e.getMessage());
return false;
}
} else {
return false;
}
}
public Device getDeviceInfo(String deviceId)
throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
@Nullable

@ -0,0 +1,45 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.boschshc.internal.devices.bridge.dto;
import java.util.List;
/**
* Public Information of the controller.
* <p>
*
* Currently, only the ipAddress is used for discovery. More fields can be added on demand.
* <p>
* Json example:
*
* <pre>
* {
* "apiVersions":["1.2","2.1"],
* ...
* "shcIpAddress":"192.168.1.2",
* ...
* }
* </pre>
*
* @author Gerd Zanker - Initial contribution
*/
public class PublicInformation {
public PublicInformation() {
this.shcIpAddress = "";
this.shcGeneration = "";
}
public List<String> apiVersions;
public String shcIpAddress;
public String shcGeneration;
}

@ -0,0 +1,161 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.boschshc.internal.discovery;
import static org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants.BINDING_ID;
import java.time.Duration;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeoutException;
import javax.jmdns.ServiceInfo;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.util.ssl.SslContextFactory;
import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
import org.openhab.binding.boschshc.internal.devices.bridge.BoschHttpClient;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.PublicInformation;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.config.discovery.mdns.MDNSDiscoveryParticipant;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
/**
* The {@link BridgeDiscoveryParticipant} is responsible discovering the
* Bosch Smart Home Controller as a Bridge with the mDNS services.
*
* @author Gerd Zanker - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "discovery.boschsmarthomebridge")
public class BridgeDiscoveryParticipant implements MDNSDiscoveryParticipant {
private static final long TTL_SECONDS = Duration.ofHours(1).toSeconds();
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(BoschSHCBindingConstants.THING_TYPE_SHC);
private final Logger logger = LoggerFactory.getLogger(BridgeDiscoveryParticipant.class);
private final HttpClient httpClient;
private final Gson gson = new Gson();
/// SHC Bridge Information, read via public REST API if bridge is detected. Otherwise, strings are empty.
private PublicInformation bridgeInformation = new PublicInformation();
@Activate
public BridgeDiscoveryParticipant(@Reference HttpClientFactory httpClientFactory) {
// create http client upfront to later request public information from SHC
SslContextFactory sslContextFactory = new SslContextFactory.Client.Client(true); // Accept all certificates
sslContextFactory.setTrustAll(true);
sslContextFactory.setValidateCerts(false);
sslContextFactory.setValidatePeerCerts(false);
sslContextFactory.setEndpointIdentificationAlgorithm(null);
httpClient = httpClientFactory.createHttpClient(BINDING_ID, sslContextFactory);
}
protected BridgeDiscoveryParticipant(HttpClient customHttpClient) {
httpClient = customHttpClient;
}
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return SUPPORTED_THING_TYPES_UIDS;
}
@Override
public String getServiceType() {
return "_http._tcp.local.";
}
@Override
public @Nullable DiscoveryResult createResult(ServiceInfo serviceInfo) {
logger.trace("Bridge Discovery started for {}", serviceInfo);
@Nullable
final ThingUID uid = getThingUID(serviceInfo);
if (uid == null) {
return null;
}
logger.trace("Discovered Bosch Smart Home Controller at {}", bridgeInformation.shcIpAddress);
return DiscoveryResultBuilder.create(uid)
.withLabel("Bosch Smart Home Controller (" + bridgeInformation.shcIpAddress + ")")
.withProperty("ipAddress", bridgeInformation.shcIpAddress)
.withProperty("shcGeneration", bridgeInformation.shcGeneration)
.withProperty("apiVersions", bridgeInformation.apiVersions).withTTL(TTL_SECONDS).build();
}
@Override
public @Nullable ThingUID getThingUID(ServiceInfo serviceInfo) {
String ipAddress = discoverBridge(serviceInfo).shcIpAddress;
if (!ipAddress.isBlank()) {
return new ThingUID(BoschSHCBindingConstants.THING_TYPE_SHC, ipAddress.replace('.', '-'));
}
return null;
}
protected PublicInformation discoverBridge(ServiceInfo serviceInfo) {
logger.trace("Discovering serviceInfo {}", serviceInfo);
if (serviceInfo.getHostAddresses() != null && serviceInfo.getHostAddresses().length > 0
&& !serviceInfo.getHostAddresses()[0].isEmpty()) {
String address = serviceInfo.getHostAddresses()[0];
logger.trace("Discovering InetAddress {}", address);
// store all information for later access
bridgeInformation = getPublicInformationFromPossibleBridgeAddress(address);
}
return bridgeInformation;
}
protected PublicInformation getPublicInformationFromPossibleBridgeAddress(String ipAddress) {
String url = BoschHttpClient.getPublicInformationUrl(ipAddress);
logger.trace("Discovering ipAddress {}", url);
try {
httpClient.start();
ContentResponse contentResponse = httpClient.newRequest(url).method(HttpMethod.GET).send();
// check HTTP status code
if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
logger.debug("Discovering failed with status code: {}", contentResponse.getStatus());
return new PublicInformation();
}
// get content from response
String content = contentResponse.getContentAsString();
logger.trace("Discovered SHC - public info {}", content);
PublicInformation bridgeInfo = gson.fromJson(content, PublicInformation.class);
if (bridgeInfo.shcIpAddress != null) {
return bridgeInfo;
}
} catch (TimeoutException | ExecutionException e) {
logger.debug("Discovering failed with exception {}", e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.debug("Discovering failed during http client request {}", e.getMessage());
}
return new PublicInformation();
}
}

@ -0,0 +1,253 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.boschshc.internal.discovery;
import java.util.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
import org.openhab.core.config.discovery.AbstractDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.ThingHandler;
import org.openhab.core.thing.binding.ThingHandlerService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link ThingDiscoveryService} is responsible to discover Bosch Smart Home things.
* The paired SHC BridgeHandler is required to get the lists of rooms and devices.
* With this data the openhab things are discovered.
*
* The order to make this work is
* 1. SHC bridge is created, e.v via openhab UI
* 2. Service is instantiated setBridgeHandler of this service is called
* 3. Service is activated
* 4. Service registers itself as discoveryLister at the bridge
* 5. bridge calls startScan after bridge is paired and things can be discovered
*
* @author Gerd Zanker - Initial contribution
*/
@NonNullByDefault
public class ThingDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService {
private static final int SEARCH_TIME = 1;
private final Logger logger = LoggerFactory.getLogger(ThingDiscoveryService.class);
private @Nullable BridgeHandler shcBridgeHandler;
protected static final Set<ThingTypeUID> SUPPORTED_THING_TYPES = Set.of(
BoschSHCBindingConstants.THING_TYPE_INWALL_SWITCH, BoschSHCBindingConstants.THING_TYPE_TWINGUARD,
BoschSHCBindingConstants.THING_TYPE_WINDOW_CONTACT, BoschSHCBindingConstants.THING_TYPE_MOTION_DETECTOR,
BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL, BoschSHCBindingConstants.THING_TYPE_THERMOSTAT,
BoschSHCBindingConstants.THING_TYPE_CLIMATE_CONTROL, BoschSHCBindingConstants.THING_TYPE_WALL_THERMOSTAT,
BoschSHCBindingConstants.THING_TYPE_CAMERA_360, BoschSHCBindingConstants.THING_TYPE_CAMERA_EYES,
BoschSHCBindingConstants.THING_TYPE_INTRUSION_DETECTION_SYSTEM,
BoschSHCBindingConstants.THING_TYPE_SMART_PLUG_COMPACT, BoschSHCBindingConstants.THING_TYPE_SMART_BULB,
BoschSHCBindingConstants.THING_TYPE_SMOKE_DETECTOR);
// @formatter:off
protected static final Map<String, ThingTypeUID> DEVICEMODEL_TO_THINGTYPE_MAP = Map.ofEntries(
new AbstractMap.SimpleEntry<>("BBL", BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL),
new AbstractMap.SimpleEntry<>("TWINGUARD", BoschSHCBindingConstants.THING_TYPE_TWINGUARD),
new AbstractMap.SimpleEntry<>("PSM", BoschSHCBindingConstants.THING_TYPE_INWALL_SWITCH),
new AbstractMap.SimpleEntry<>("PLUG_COMPACT", BoschSHCBindingConstants.THING_TYPE_SMART_PLUG_COMPACT),
new AbstractMap.SimpleEntry<>("CAMERA_360", BoschSHCBindingConstants.THING_TYPE_CAMERA_360),
new AbstractMap.SimpleEntry<>("CAMERA_EYES", BoschSHCBindingConstants.THING_TYPE_CAMERA_EYES),
new AbstractMap.SimpleEntry<>("BWTH", BoschSHCBindingConstants.THING_TYPE_THERMOSTAT), // wall thermostat
new AbstractMap.SimpleEntry<>("THB", BoschSHCBindingConstants.THING_TYPE_WALL_THERMOSTAT), // wall thermostat with batteries
new AbstractMap.SimpleEntry<>("SD", BoschSHCBindingConstants.THING_TYPE_SMOKE_DETECTOR),
new AbstractMap.SimpleEntry<>("MD", BoschSHCBindingConstants.THING_TYPE_MOTION_DETECTOR),
new AbstractMap.SimpleEntry<>("ROOM_CLIMATE_CONTROL", BoschSHCBindingConstants.THING_TYPE_CLIMATE_CONTROL),
new AbstractMap.SimpleEntry<>("INTRUSION_DETECTION_SYSTEM", BoschSHCBindingConstants.THING_TYPE_INTRUSION_DETECTION_SYSTEM),
new AbstractMap.SimpleEntry<>("HUE_LIGHT", BoschSHCBindingConstants.THING_TYPE_SMART_BULB),
new AbstractMap.SimpleEntry<>("WRC2", BoschSHCBindingConstants.THING_TYPE_WINDOW_CONTACT)
// Future Extension: map deviceModel names to BoschSHC Thing Types when they are supported
// new AbstractMap.SimpleEntry<>("SMOKE_DETECTION_SYSTEM", BoschSHCBindingConstants.),
// new AbstractMap.SimpleEntry<>("PRESENCE_SIMULATION_SERVICE", BoschSHCBindingConstants.),
// new AbstractMap.SimpleEntry<>("VENTILATION_SERVICE", BoschSHCBindingConstants.),
// new AbstractMap.SimpleEntry<>("HUE_BRIDGE", BoschSHCBindingConstants.)
// new AbstractMap.SimpleEntry<>("HUE_BRIDGE_MANAGER*", BoschSHCBindingConstants.)
// new AbstractMap.SimpleEntry<>("HUE_LIGHT_ROOM_CONTROL", BoschSHCBindingConstants.)
);
// @formatter:on
public ThingDiscoveryService() {
super(SUPPORTED_THING_TYPES, SEARCH_TIME);
}
@Override
public void activate() {
logger.trace("activate");
final BridgeHandler handler = shcBridgeHandler;
if (handler != null) {
handler.registerDiscoveryListener(this);
}
}
@Override
public void deactivate() {
logger.trace("deactivate");
final BridgeHandler handler = shcBridgeHandler;
if (handler != null) {
removeOlderResults(new Date().getTime(), handler.getThing().getUID());
handler.unregisterDiscoveryListener();
}
super.deactivate();
}
@Override
protected void startScan() {
if (shcBridgeHandler == null) {
logger.debug("The shcBridgeHandler is empty, no manual scan is currently possible");
return;
}
try {
doScan();
} catch (InterruptedException e) {
// Restore interrupted state...
Thread.currentThread().interrupt();
}
}
@Override
protected synchronized void stopScan() {
logger.debug("Stop manual scan on bridge {}",
shcBridgeHandler != null ? shcBridgeHandler.getThing().getUID() : "?");
super.stopScan();
final BridgeHandler handler = shcBridgeHandler;
if (handler != null) {
removeOlderResults(getTimestampOfLastScan(), handler.getThing().getUID());
}
}
@Override
public void setThingHandler(@Nullable ThingHandler handler) {
if (handler instanceof BridgeHandler) {
logger.trace("Set bridge handler {}", handler);
shcBridgeHandler = (BridgeHandler) handler;
}
}
@Override
public @Nullable ThingHandler getThingHandler() {
return shcBridgeHandler;
}
public void doScan() throws InterruptedException {
logger.debug("Start manual scan on bridge {}", shcBridgeHandler.getThing().getUID());
// use shcBridgeHandler to getDevices()
List<Room> rooms = shcBridgeHandler.getRooms();
logger.debug("SHC has {} rooms", rooms.size());
List<Device> devices = shcBridgeHandler.getDevices();
logger.debug("SHC has {} devices", devices.size());
// Write found devices into openhab.log to support manual configuration
for (Device d : devices) {
logger.debug("Found device: name={} id={}", d.name, d.id);
if (d.deviceServiceIds != null) {
for (String s : d.deviceServiceIds) {
logger.debug(".... service: {}", s);
}
}
}
addDevices(devices, rooms);
}
protected void addDevices(List<Device> devices, List<Room> rooms) {
for (Device device : devices) {
addDevice(device, getRoomNameForDevice(device, rooms));
}
}
protected String getRoomNameForDevice(Device device, List<Room> rooms) {
return rooms.stream().filter(room -> room.id.equals(device.roomId)).findAny().map(r -> r.name).orElse("");
}
protected void addDevice(Device device, String roomName) {
// see startScan for the runtime null check of shcBridgeHandler
assert shcBridgeHandler != null;
logger.trace("Discovering device {}", device.name);
logger.trace("- details: id {}, roomId {}, deviceModel {}", device.id, device.roomId, device.deviceModel);
ThingTypeUID thingTypeUID = getThingTypeUID(device);
if (thingTypeUID == null) {
return;
}
logger.trace("- got thingTypeID '{}' for deviceModel '{}'", thingTypeUID.getId(), device.deviceModel);
ThingUID thingUID = new ThingUID(thingTypeUID, shcBridgeHandler.getThing().getUID(),
device.id.replace(':', '_'));
logger.trace("- got thingUID '{}' for device: '{}'", thingUID, device);
DiscoveryResultBuilder discoveryResult = DiscoveryResultBuilder.create(thingUID).withThingType(thingTypeUID)
.withProperty("id", device.id).withLabel(getNiceName(device.name, roomName));
if (null != shcBridgeHandler) {
discoveryResult.withBridge(shcBridgeHandler.getThing().getUID());
}
if (!roomName.isEmpty()) {
discoveryResult.withProperty("Location", roomName);
}
thingDiscovered(discoveryResult.build());
logger.debug("Discovered device '{}' with thingTypeUID={}, thingUID={}, id={}, deviceModel={}", device.name,
thingUID, thingTypeUID, device.id, device.deviceModel);
}
private String getNiceName(String name, String roomName) {
if (!name.startsWith("-"))
return name;
// convert "-IntrusionDetectionSystem-" into "Intrusion Detection System"
// convert "-RoomClimateControl-" into "Room Climate Control myRoomName"
final char[] chars = name.toCharArray();
StringBuilder niceNameBuilder = new StringBuilder(32);
for (int pos = 0; pos < chars.length; pos++) {
// skip "-"
if (chars[pos] == '-') {
continue;
}
// convert "CamelCase" into "Camel Case", skipping the first Uppercase after the "-"
if (pos > 1 && Character.getType(chars[pos]) == Character.UPPERCASE_LETTER) {
niceNameBuilder.append(" ");
}
niceNameBuilder.append(chars[pos]);
}
// append roomName for "Room Climate Control", because it appears for each room with a thermostat
if (!roomName.isEmpty() && niceNameBuilder.toString().startsWith("Room Climate Control")) {
niceNameBuilder.append(" ").append(roomName);
}
return niceNameBuilder.toString();
}
protected @Nullable ThingTypeUID getThingTypeUID(Device device) {
@Nullable
ThingTypeUID thingTypeId = DEVICEMODEL_TO_THINGTYPE_MAP.get(device.deviceModel);
if (thingTypeId != null) {
return new ThingTypeUID(BoschSHCBindingConstants.BINDING_ID, thingTypeId.getId());
}
logger.debug("Unknown deviceModel '{}'! Please create a support request issue for this unknown device model.",
device.deviceModel);
return null;
}
}

@ -70,7 +70,6 @@ public abstract class BoschSHCSystemService<TState extends BoschSHCServiceState>
@Override
public @Nullable TState getState()
throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
BridgeHandler bridgeHandler = getBridgeHandler();
if (bridgeHandler == null) {
return null;

@ -38,12 +38,10 @@ public class BatteryLevelService extends BoschSHCService<DeviceServiceData> {
@Override
public @Nullable DeviceServiceData getState()
throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
String deviceId = getDeviceId();
if (deviceId == null) {
return null;
}
BridgeHandler bridgeHandler = getBridgeHandler();
if (bridgeHandler == null) {
return null;

@ -30,9 +30,12 @@ public class BoschSHCServiceState {
/**
* gson instance to convert a class to json string and back.
*/
private static final Gson gson = new Gson();
private static final Gson GSON = new Gson();
private static final Logger logger = LoggerFactory.getLogger(BoschSHCServiceState.class);
/**
* Logger marked as transient to exclude the logger from JSON serialization.
*/
private final transient Logger logger = LoggerFactory.getLogger(BoschSHCServiceState.class);
/**
* State type. Initialized when instance is created.
@ -67,7 +70,7 @@ public class BoschSHCServiceState {
public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(String json,
Class<TState> stateClass) {
var state = gson.fromJson(json, stateClass);
var state = GSON.fromJson(json, stateClass);
if (state == null || !state.isValid()) {
return null;
}
@ -77,7 +80,7 @@ public class BoschSHCServiceState {
public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(JsonElement json,
Class<TState> stateClass) {
var state = gson.fromJson(json, stateClass);
var state = GSON.fromJson(json, stateClass);
if (state == null || !state.isValid()) {
return null;
}

@ -27,7 +27,6 @@ public enum SmokeDetectorCheckState {
SMOKE_TEST_FAILED;
public static SmokeDetectorCheckState from(String stateString) {
try {
return SmokeDetectorCheckState.valueOf(stateString);
} catch (Exception a) {

@ -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.

@ -195,7 +195,7 @@ class BoschHttpClientTest {
when(response.getStatus()).thenReturn(500);
ExecutionException e = assertThrows(ExecutionException.class,
() -> httpClient.sendRequest(request, SubscribeResult.class, SubscribeResult::isValid, null));
assertEquals("Request failed with status code 500", e.getMessage());
assertEquals("Send request failed with status code 500", e.getMessage());
}
@Test

@ -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(""));
}
}

@ -0,0 +1,236 @@
/**
* Copyright (c) 2010-2023 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.boschshc.internal.discovery;
import static org.hamcrest.CoreMatchers.*;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.ArrayList;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openhab.binding.boschshc.internal.devices.BoschSHCBindingConstants;
import org.openhab.binding.boschshc.internal.devices.bridge.BridgeHandler;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
import org.openhab.core.config.discovery.DiscoveryListener;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryService;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingUID;
/**
* ThingDiscoveryService Tester.
*
* @author Gerd Zanker - Initial contribution
*/
@ExtendWith(MockitoExtension.class)
@NonNullByDefault
public class ThingDiscoveryServiceTest {
private @NonNullByDefault({}) ThingDiscoveryService fixture;
private @Mock @NonNullByDefault({}) BridgeHandler bridgeHandler;
private @Mock @NonNullByDefault({}) DiscoveryListener discoveryListener;
private @Captor @NonNullByDefault({}) ArgumentCaptor<DiscoveryService> discoveryServiceCaptor;
private @Captor @NonNullByDefault({}) ArgumentCaptor<DiscoveryResult> discoveryResultCaptor;
@BeforeEach
void beforeEach() {
fixture = new ThingDiscoveryService();
fixture.addDiscoveryListener(discoveryListener);
fixture.setThingHandler(bridgeHandler);
}
private void mockBridgeCalls() {
// Set the Mock Bridge as the ThingHandler
ThingUID bridgeUID = new ThingUID(BoschSHCBindingConstants.THING_TYPE_SHC, "testSHC");
Bridge mockBridge = mock(Bridge.class);
when(mockBridge.getUID()).thenReturn(bridgeUID);
when(bridgeHandler.getThing()).thenReturn(mockBridge);
}
@Test
public void testStartScan() throws InterruptedException {
mockBridgeCalls();
fixture.activate();
fixture.startScan();
verify(bridgeHandler).getRooms();
verify(bridgeHandler).getDevices();
fixture.stopScan();
fixture.deactivate();
}
@Test
public void testStartScanWithoutBridgeHandler() {
mockBridgeCalls();
// No fixture.setThingHandler(bridgeHandler);
fixture.activate();
fixture.startScan();
// bridgeHandler not called, just no exception expected
fixture.stopScan();
fixture.deactivate();
}
@Test
public void testSetGetThingHandler() {
fixture.setThingHandler(bridgeHandler);
assertThat(fixture.getThingHandler(), is(bridgeHandler));
}
@Test
public void testAddDevices() {
mockBridgeCalls();
ArrayList<Device> devices = new ArrayList<>();
ArrayList<Room> emptyRooms = new ArrayList<>();
Device device1 = new Device();
device1.deviceModel = "TWINGUARD";
device1.id = "testDevice:ID";
device1.name = "Test Name";
devices.add(device1);
Device device2 = new Device();
device2.deviceModel = "TWINGUARD";
device2.id = "testDevice:2";
device2.name = "Second device";
devices.add(device2);
verify(discoveryListener, never()).thingDiscovered(any(), any());
fixture.addDevices(devices, emptyRooms);
// two calls for the two devices expected
verify(discoveryListener, times(2)).thingDiscovered(any(), any());
}
@Test
public void testAddDevicesWithNoDevices() {
ArrayList<Device> emptyDevices = new ArrayList<>();
ArrayList<Room> emptyRooms = new ArrayList<>();
verify(discoveryListener, never()).thingDiscovered(any(), any());
fixture.addDevices(emptyDevices, emptyRooms);
// nothing shall be discovered, but also no exception shall be thrown
verify(discoveryListener, never()).thingDiscovered(any(), any());
}
@Test
public void testAddDevice() {
mockBridgeCalls();
Device device = new Device();
device.deviceModel = "TWINGUARD";
device.id = "testDevice:ID";
device.name = "Test Name";
fixture.addDevice(device, "TestRoom");
verify(discoveryListener).thingDiscovered(discoveryServiceCaptor.capture(), discoveryResultCaptor.capture());
assertThat(discoveryServiceCaptor.getValue().getClass(), is(ThingDiscoveryService.class));
DiscoveryResult result = discoveryResultCaptor.getValue();
assertThat(result.getBindingId(), is(BoschSHCBindingConstants.BINDING_ID));
assertThat(result.getThingTypeUID(), is(BoschSHCBindingConstants.THING_TYPE_TWINGUARD));
assertThat(result.getThingUID().getId(), is("testDevice_ID"));
assertThat(result.getBridgeUID().getId(), is("testSHC"));
assertThat(result.getLabel(), is("Test Name"));
assertThat(result.getProperties().get("Location").toString(), is("TestRoom"));
}
@Test
public void testAddDeviceWithNiceNameAndAppendedRoomName() {
assertDeviceNiceName("-RoomClimateControl-", "TestRoom", "Room Climate Control TestRoom");
}
@Test
public void testAddDeviceWithNiceNameWithEmtpyRoomName() {
assertDeviceNiceName("-RoomClimateControl-", "", "Room Climate Control");
}
@Test
public void testAddDeviceWithNiceNameWithoutAppendingRoomName() {
assertDeviceNiceName("-SmokeDetectionSystem-", "TestRoom", "Smoke Detection System");
}
@Test
public void testAddDeviceWithNiceNameWithoutUsualName() {
assertDeviceNiceName("My other device", "TestRoom", "My other device");
}
private void assertDeviceNiceName(String deviceName, String roomName, String expectedNiceName) {
mockBridgeCalls();
Device device = new Device();
device.deviceModel = "TWINGUARD";
device.id = "testDevice:ID";
device.name = deviceName;
fixture.addDevice(device, roomName);
verify(discoveryListener).thingDiscovered(discoveryServiceCaptor.capture(), discoveryResultCaptor.capture());
assertThat(discoveryServiceCaptor.getValue().getClass(), is(ThingDiscoveryService.class));
DiscoveryResult result = discoveryResultCaptor.getValue();
assertThat(result.getLabel(), is(expectedNiceName));
}
@Test
public void testGetRoomForDevice() {
Device device = new Device();
ArrayList<Room> rooms = new ArrayList<>();
Room room1 = new Room();
room1.id = "r1";
room1.name = "Room1";
rooms.add(room1);
Room room2 = new Room();
room2.id = "r2";
room2.name = "Room 2";
rooms.add(room2);
device.roomId = "r1";
assertThat(fixture.getRoomNameForDevice(device, rooms), is("Room1"));
device.roomId = "r2";
assertThat(fixture.getRoomNameForDevice(device, rooms), is("Room 2"));
device.roomId = "unknown";
assertTrue(fixture.getRoomNameForDevice(device, rooms).isEmpty());
}
@Test
public void testGetThingTypeUID() {
Device device = new Device();
device.deviceModel = "invalid";
assertNull(fixture.getThingTypeUID(device));
// just two spot checks
device.deviceModel = "BBL";
assertThat(fixture.getThingTypeUID(device), is(BoschSHCBindingConstants.THING_TYPE_SHUTTER_CONTROL));
device.deviceModel = "TWINGUARD";
assertThat(fixture.getThingTypeUID(device), is(BoschSHCBindingConstants.THING_TYPE_TWINGUARD));
}
}