[boschshc] Bridge and Device Discovery (#14197)

* #14195 Bridge and Device Discovery

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

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

Added unit tests for Bridge and Device Discovery.

Signed-off-by: Gerd Zanker <gerd.zanker@web.de>
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

View File

@ -206,12 +206,15 @@ The smoke detector warns you in case of fire.
## Limitations ## Limitations
- Discovery of Things No major limitation known.
- Discovery of Bridge Check list of [openhab issues with "boshshc"](https://github.com/openhab/openhab-addons/issues?q=is%3Aissue+boschshc+)
## Discovery ## 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 ## Bridge Configuration
@ -239,19 +242,10 @@ Alternatively, the log can be viewed using the OpenHab Log Viewer (frontail) via
Example: Example:
```bash ```bash
2020-08-11 12:42:49.490 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Heizung id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX 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.
2020-08-11 12:42:49.495 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=-RoomClimateControl- id=roomClimateControl_hz_1 2023-03-20 20:30:48.026 [INFO ] [g.discovery.internal.PersistentInbox] - Added new thing 'boschshc:smoke-detector:yourBridgeName:hdm_HomeMaticIP_XXXXXXXXXXXXXXXXXXXXXXXX' to inbox.
2020-08-11 12:42:49.497 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=-VentilationService- id=ventilationService 2023-03-20 20:30:48.027 [INFO ] [g.discovery.internal.PersistentInbox] - Added new thing 'boschshc:twinguard:yourBridgeName:hdm_ZigBee_XXXXXXXXXXXXXXXX' to inbox.
2020-08-11 12:42:49.498 [INFO ] [chshc.internal.BoschSHCBridgeHandler] - Found device: name=Großes Fenster id=hdm:HomeMaticIP:3014F711A000XXXXXXXXXXXX 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.
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
``` ```
## Thing Configuration ## Thing Configuration

View File

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

View File

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

View File

@ -65,13 +65,22 @@ public class BoschHttpClient extends HttpClient {
} }
/** /**
* Returns the public information URL for the Bosch SHC clients, using port 8446. * Returns the public information URL for the Bosch SHC client addressed with the given IP address, using port 8446
* See https://github.com/BoschSmartHome/bosch-shc-api-docs/blob/master/postman/README.md * See https://github.com/BoschSmartHome/bosch-shc-api-docs/blob/master/postman/README.md
* *
* @return URL for public information * @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() { 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) { if (errorResponseHandler != null) {
throw errorResponseHandler.apply(statusCode, textContent); throw errorResponseHandler.apply(statusCode, textContent);
} else { } 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 { try {
@Nullable @Nullable

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -30,9 +30,12 @@ public class BoschSHCServiceState {
/** /**
* gson instance to convert a class to json string and back. * 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. * State type. Initialized when instance is created.
@ -67,7 +70,7 @@ public class BoschSHCServiceState {
public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(String json, public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(String json,
Class<TState> stateClass) { Class<TState> stateClass) {
var state = gson.fromJson(json, stateClass); var state = GSON.fromJson(json, stateClass);
if (state == null || !state.isValid()) { if (state == null || !state.isValid()) {
return null; return null;
} }
@ -77,7 +80,7 @@ public class BoschSHCServiceState {
public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(JsonElement json, public static <TState extends BoschSHCServiceState> @Nullable TState fromJson(JsonElement json,
Class<TState> stateClass) { Class<TState> stateClass) {
var state = gson.fromJson(json, stateClass); var state = GSON.fromJson(json, stateClass);
if (state == null || !state.isValid()) { if (state == null || !state.isValid()) {
return null; return null;
} }

View File

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

View File

@ -135,6 +135,6 @@ offline.not-reachable = The Bosch Smart Home Controller is not reachable.
offline.conf-error-ssl = The SSL connection to the Bosch Smart Home Controller is not possible. offline.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.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.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.empty-device-id = No device ID set.
offline.conf-error.invalid-device-id = Device ID is invalid. offline.conf-error.invalid-device-id = Device ID is invalid.

View File

@ -195,7 +195,7 @@ class BoschHttpClientTest {
when(response.getStatus()).thenReturn(500); when(response.getStatus()).thenReturn(500);
ExecutionException e = assertThrows(ExecutionException.class, ExecutionException e = assertThrows(ExecutionException.class,
() -> httpClient.sendRequest(request, SubscribeResult.class, SubscribeResult::isValid, null)); () -> 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 @Test

View File

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

View File

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