2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.boschshc.internal.devices.bridge;
15 import static org.eclipse.jetty.http.HttpMethod.*;
17 import java.lang.reflect.Type;
18 import java.util.ArrayList;
19 import java.util.Collection;
20 import java.util.Collections;
21 import java.util.List;
22 import java.util.Objects;
23 import java.util.Optional;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.api.ContentResponse;
32 import org.eclipse.jetty.client.api.Request;
33 import org.eclipse.jetty.client.api.Response;
34 import org.eclipse.jetty.http.HttpStatus;
35 import org.eclipse.jetty.util.ssl.SslContextFactory;
36 import org.openhab.binding.boschshc.internal.devices.BoschSHCHandler;
37 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
38 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
39 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
40 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
41 import org.openhab.binding.boschshc.internal.discovery.ThingDiscoveryService;
42 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
43 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
44 import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
45 import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
46 import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
47 import org.openhab.binding.boschshc.internal.services.dto.JsonRestExceptionResponse;
48 import org.openhab.core.thing.Bridge;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.binding.BaseBridgeHandler;
54 import org.openhab.core.thing.binding.ThingHandler;
55 import org.openhab.core.thing.binding.ThingHandlerService;
56 import org.openhab.core.types.Command;
57 import org.osgi.framework.Bundle;
58 import org.osgi.framework.FrameworkUtil;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
62 import com.google.gson.JsonElement;
63 import com.google.gson.reflect.TypeToken;
66 * Representation of a connection with a Bosch Smart Home Controller bridge.
68 * @author Stefan Kästle - Initial contribution
69 * @author Gerd Zanker - added HttpClient with pairing support
70 * @author Christian Oeing - refactorings of e.g. server registration
71 * @author David Pace - Added support for custom endpoints and HTTP POST requests
72 * @author Gerd Zanker - added thing discovery
75 public class BridgeHandler extends BaseBridgeHandler {
77 private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
80 * Handler to do long polling.
82 private final LongPolling longPolling;
85 * HTTP client for all communications to and from the bridge.
87 * This member is package-protected to enable mocking in unit tests.
89 /* package */ @Nullable
90 BoschHttpClient httpClient;
92 private @Nullable ScheduledFuture<?> scheduledPairing;
95 * SHC thing/device discovery service instance.
96 * Registered and unregistered if service is actived/deactived.
97 * Used to scan for things after bridge is paired with SHC.
99 private @Nullable ThingDiscoveryService thingDiscoveryService;
101 public BridgeHandler(Bridge bridge) {
104 this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
108 public Collection<Class<? extends ThingHandlerService>> getServices() {
109 return Collections.singleton(ThingDiscoveryService.class);
113 public void initialize() {
114 Bundle bundle = FrameworkUtil.getBundle(getClass());
115 if (bundle != null) {
116 logger.debug("Initialize {} Version {}", bundle.getSymbolicName(), bundle.getVersion());
119 // Read configuration
120 BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
122 String ipAddress = config.ipAddress.trim();
123 if (ipAddress.isEmpty()) {
124 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
125 "@text/offline.conf-error-empty-ip");
129 String password = config.password.trim();
130 if (password.isEmpty()) {
131 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
132 "@text/offline.conf-error-empty-password");
136 SslContextFactory factory;
138 // prepare SSL key and certificates
139 factory = new BoschSslUtil(ipAddress).getSslContextFactory();
140 } catch (PairingFailedException e) {
141 logger.debug("Error while obtaining SSL context factory.", e);
142 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
143 "@text/offline.conf-error-ssl");
147 // Instantiate HttpClient with the SslContextFactory
148 BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
153 } catch (Exception e) {
154 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
155 String.format("Could not create http connection to controller: %s", e.getMessage()));
159 // general checks are OK, therefore set the status to unknown and wait for initial access
160 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
162 // Initialize bridge in the background.
163 // Start initial access the first time
164 scheduleInitialAccess(httpClient);
168 public void dispose() {
169 // Cancel scheduled pairing.
171 ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
172 if (scheduledPairing != null) {
173 scheduledPairing.cancel(true);
174 this.scheduledPairing = null;
177 // Stop long polling.
178 this.longPolling.stop();
181 BoschHttpClient httpClient = this.httpClient;
182 if (httpClient != null) {
185 } catch (Exception e) {
186 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage(), e);
188 this.httpClient = null;
195 public void handleCommand(ChannelUID channelUID, Command command) {
196 // commands are handled by individual device handlers
200 * Schedule the initial access.
201 * Use a delay if pairing fails and next retry is scheduled.
203 private void scheduleInitialAccess(BoschHttpClient httpClient) {
204 this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
208 * Execute the initial access.
209 * Uses the HTTP Bosch SHC client
210 * to check if access if possible
211 * pairs this Bosch SHC Bridge with the SHC if necessary
212 * and starts the first log poll.
214 * This method is package-protected to enable unit testing.
216 /* package */ void initialAccess(BoschHttpClient httpClient) {
217 logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
220 // check if SCH is offline
221 if (!httpClient.isOnline()) {
222 // update status already if access is not possible
223 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
224 "@text/offline.conf-error-offline");
225 // restart later initial access
226 scheduleInitialAccess(httpClient);
231 // check if SHC access is not possible and pairing necessary
232 if (!httpClient.isAccessPossible()) {
233 // update status description to show pairing test
234 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
235 "@text/offline.conf-error-pairing");
236 if (!httpClient.doPairing()) {
237 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
238 "@text/offline.conf-error-pairing");
240 // restart initial access - needed also in case of successful pairing to check access again
241 scheduleInitialAccess(httpClient);
245 // SHC is online and access should possible
246 if (!checkBridgeAccess()) {
247 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
248 "@text/offline.not-reachable");
249 // restart initial access
250 scheduleInitialAccess(httpClient);
254 // do thing discovery after pairing
255 final ThingDiscoveryService discovery = thingDiscoveryService;
256 if (discovery != null) {
260 // start long polling loop
261 this.updateStatus(ThingStatus.ONLINE);
262 startLongPolling(httpClient);
264 } catch (InterruptedException e) {
265 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
266 Thread.currentThread().interrupt();
270 private void startLongPolling(BoschHttpClient httpClient) {
272 this.longPolling.start(httpClient);
273 } catch (LongPollingFailedException e) {
274 this.handleLongPollFailure(e);
279 * Check the bridge access by sending an HTTP request.
280 * Does not throw any exception in case the request fails.
282 public boolean checkBridgeAccess() throws InterruptedException {
284 BoschHttpClient httpClient = this.httpClient;
286 if (httpClient == null) {
291 logger.debug("Sending http request to BoschSHC to check access: {}", httpClient);
292 String url = httpClient.getBoschSmartHomeUrl("devices");
293 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
295 // check HTTP status code
296 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
297 logger.debug("Access check failed with status code: {}", contentResponse.getStatus());
303 } catch (TimeoutException | ExecutionException e) {
304 logger.warn("Access check failed because of {}!", e.getMessage());
310 * Get a list of connected devices from the Smart-Home Controller
312 * @throws InterruptedException in case bridge is stopped
314 public List<Device> getDevices() throws InterruptedException {
316 BoschHttpClient httpClient = this.httpClient;
317 if (httpClient == null) {
318 return Collections.emptyList();
322 logger.trace("Sending http request to Bosch to request devices: {}", httpClient);
323 String url = httpClient.getBoschSmartHomeUrl("devices");
324 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
326 // check HTTP status code
327 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
328 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
329 return Collections.emptyList();
332 String content = contentResponse.getContentAsString();
333 logger.trace("Request devices completed with success: {} - status code: {}", content,
334 contentResponse.getStatus());
336 Type collectionType = new TypeToken<ArrayList<Device>>() {
338 List<Device> nullableDevices = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content, collectionType);
339 return Optional.ofNullable(nullableDevices).orElse(Collections.emptyList());
340 } catch (TimeoutException | ExecutionException e) {
341 logger.debug("Request devices failed because of {}!", e.getMessage(), e);
342 return Collections.emptyList();
347 * Get a list of rooms from the Smart-Home controller
349 * @throws InterruptedException in case bridge is stopped
351 public List<Room> getRooms() throws InterruptedException {
352 List<Room> emptyRooms = new ArrayList<>();
354 BoschHttpClient httpClient = this.httpClient;
355 if (httpClient != null) {
357 logger.trace("Sending http request to Bosch to request rooms");
358 String url = httpClient.getBoschSmartHomeUrl("rooms");
359 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
361 // check HTTP status code
362 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
363 logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
367 String content = contentResponse.getContentAsString();
368 logger.trace("Request rooms completed with success: {} - status code: {}", content,
369 contentResponse.getStatus());
371 Type collectionType = new TypeToken<ArrayList<Room>>() {
374 ArrayList<Room> rooms = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content, collectionType);
375 return Objects.requireNonNullElse(rooms, emptyRooms);
376 } catch (TimeoutException | ExecutionException e) {
377 logger.debug("Request rooms failed because of {}!", e.getMessage());
385 public boolean registerDiscoveryListener(ThingDiscoveryService listener) {
386 if (thingDiscoveryService == null) {
387 thingDiscoveryService = listener;
394 public boolean unregisterDiscoveryListener() {
395 if (thingDiscoveryService != null) {
396 thingDiscoveryService = null;
404 * Bridge callback handler for the results of long polls.
406 * It will check the results and
407 * forward the received states to the Bosch thing handlers.
409 * @param result Results from Long Polling
411 private void handleLongPollResult(LongPollResult result) {
412 for (DeviceServiceData deviceServiceData : result.result) {
413 handleDeviceServiceData(deviceServiceData);
418 * Processes a single long poll result.
420 * @param deviceServiceData object representing a single long poll result
422 private void handleDeviceServiceData(@Nullable DeviceServiceData deviceServiceData) {
423 if (deviceServiceData != null) {
424 JsonElement state = obtainState(deviceServiceData);
426 logger.debug("Got update for service {} of type {}: {}", deviceServiceData.id, deviceServiceData.type,
429 var updateDeviceId = deviceServiceData.deviceId;
430 if (updateDeviceId == null || state == null) {
434 logger.debug("Got update for device {}", updateDeviceId);
436 forwardStateToHandlers(deviceServiceData, state, updateDeviceId);
441 * Extracts the actual state object from the given {@link DeviceServiceData} instance.
443 * In some special cases like the <code>BatteryLevel</code> service the {@link DeviceServiceData} object itself
444 * contains the state.
445 * In all other cases, the state is contained in a sub-object named <code>state</code>.
447 * @param deviceServiceData the {@link DeviceServiceData} object from which the state should be obtained
448 * @return the state sub-object or the {@link DeviceServiceData} object itself
451 private JsonElement obtainState(DeviceServiceData deviceServiceData) {
452 // the battery level service receives no individual state object but rather requires the DeviceServiceData
454 if ("BatteryLevel".equals(deviceServiceData.id)) {
455 return GsonUtils.DEFAULT_GSON_INSTANCE.toJsonTree(deviceServiceData);
458 return deviceServiceData.state;
462 * Tries to find handlers for the device with the given ID and forwards the received state to the handlers.
464 * @param deviceServiceData object representing updates received in long poll results
465 * @param state the received state object as JSON element
466 * @param updateDeviceId the ID of the device for which the state update was received
468 private void forwardStateToHandlers(DeviceServiceData deviceServiceData, JsonElement state, String updateDeviceId) {
469 boolean handled = false;
471 Bridge bridge = this.getThing();
472 for (Thing childThing : bridge.getThings()) {
473 // All children of this should implement BoschSHCHandler
475 ThingHandler baseHandler = childThing.getHandler();
476 if (baseHandler instanceof BoschSHCHandler handler) {
478 String deviceId = handler.getBoschID();
481 logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
483 if (deviceId != null && updateDeviceId.equals(deviceId)) {
484 logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler,
485 deviceServiceData.id, state);
486 handler.processUpdate(deviceServiceData.id, state);
489 logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
494 logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
499 * Bridge callback handler for the failures during long polls.
501 * It will update the bridge status and try to access the SHC again.
503 * @param e error during long polling
505 private void handleLongPollFailure(Throwable e) {
506 logger.warn("Long polling failed, will try to reconnect", e);
508 BoschHttpClient httpClient = this.httpClient;
509 if (httpClient == null) {
510 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
511 "@text/offline.long-polling-failed.http-client-null");
515 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
516 "@text/offline.long-polling-failed.trying-to-reconnect");
517 scheduleInitialAccess(httpClient);
520 public Device getDeviceInfo(String deviceId)
521 throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
523 BoschHttpClient httpClient = this.httpClient;
524 if (httpClient == null) {
525 throw new BoschSHCException("HTTP client not initialized");
528 String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
529 Request request = httpClient.createRequest(url, GET);
531 return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
532 JsonRestExceptionResponse errorResponse = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
533 JsonRestExceptionResponse.class);
534 if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
535 if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
536 return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
538 return new BoschSHCException(
539 String.format("Request for info of device %s failed with status code %d and error code %s",
540 deviceId, errorResponse.statusCode, errorResponse.errorCode));
543 return new BoschSHCException(String.format("Request for info of device %s failed with status code %d",
544 deviceId, statusCode));
550 * Query the Bosch Smart Home Controller for the state of the given device.
552 * The URL used for retrieving the state has the following structure:
555 * https://{IP}:8444/smarthome/devices/{deviceId}/services/{serviceName}/state
558 * @param deviceId Id of device to get state for
559 * @param stateName Name of the state to query
560 * @param stateClass Class to convert the resulting JSON to
561 * @return the deserialized state object, may be <code>null</code>
562 * @throws ExecutionException
563 * @throws TimeoutException
564 * @throws InterruptedException
565 * @throws BoschSHCException
567 public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
568 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
570 BoschHttpClient httpClient = this.httpClient;
571 if (httpClient == null) {
572 logger.warn("HttpClient not initialized");
576 String url = httpClient.getServiceStateUrl(stateName, deviceId);
577 logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
578 return getState(httpClient, url, stateClass);
582 * Queries the Bosch Smart Home Controller for the state using an explicit endpoint.
584 * @param <T> Type to which the resulting JSON should be deserialized to
585 * @param endpoint The destination endpoint part of the URL
586 * @param stateClass Class to convert the resulting JSON to
587 * @return the deserialized state object, may be <code>null</code>
588 * @throws InterruptedException
589 * @throws TimeoutException
590 * @throws ExecutionException
591 * @throws BoschSHCException
593 public <T extends BoschSHCServiceState> @Nullable T getState(String endpoint, Class<T> stateClass)
594 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
596 BoschHttpClient httpClient = this.httpClient;
597 if (httpClient == null) {
598 logger.warn("HttpClient not initialized");
602 String url = httpClient.getBoschSmartHomeUrl(endpoint);
603 logger.debug("getState(): Requesting from Bosch: {}", url);
604 return getState(httpClient, url, stateClass);
608 * Sends a HTTP GET request in order to retrieve a state from the Bosch Smart Home Controller.
610 * @param <T> Type to which the resulting JSON should be deserialized to
611 * @param httpClient HTTP client used for sending the request
612 * @param url URL at which the state should be retrieved
613 * @param stateClass Class to convert the resulting JSON to
614 * @return the deserialized state object, may be <code>null</code>
615 * @throws InterruptedException
616 * @throws TimeoutException
617 * @throws ExecutionException
618 * @throws BoschSHCException
620 protected <T extends BoschSHCServiceState> @Nullable T getState(BoschHttpClient httpClient, String url,
621 Class<T> stateClass) throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
622 Request request = httpClient.createRequest(url, GET).header("Accept", "application/json");
624 ContentResponse contentResponse = request.send();
626 String content = contentResponse.getContentAsString();
627 logger.debug("getState(): Request complete: [{}] - return code: {}", content, contentResponse.getStatus());
629 int statusCode = contentResponse.getStatus();
630 if (statusCode != 200) {
631 JsonRestExceptionResponse errorResponse = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
632 JsonRestExceptionResponse.class);
633 if (errorResponse != null) {
634 throw new BoschSHCException(
635 String.format("State request with URL %s failed with status code %d and error code %s", url,
636 errorResponse.statusCode, errorResponse.errorCode));
638 throw new BoschSHCException(
639 String.format("State request with URL %s failed with status code %d", url, statusCode));
644 T state = BoschSHCServiceState.fromJson(content, stateClass);
646 throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
652 * Sends a state change for a device to the controller
654 * @param deviceId Id of device to change state for
655 * @param serviceName Name of service of device to change state for
656 * @param state New state data to set for service
658 * @return Response of request
659 * @throws InterruptedException
660 * @throws ExecutionException
661 * @throws TimeoutException
663 public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
664 throws InterruptedException, TimeoutException, ExecutionException {
666 BoschHttpClient httpClient = this.httpClient;
667 if (httpClient == null) {
668 logger.warn("HttpClient not initialized");
673 String url = httpClient.getServiceStateUrl(serviceName, deviceId);
674 Request request = httpClient.createRequest(url, PUT, state);
677 return request.send();
681 * Sends a HTTP POST request without a request body to the given endpoint.
683 * @param endpoint The destination endpoint part of the URL
684 * @return the HTTP response
685 * @throws InterruptedException
686 * @throws TimeoutException
687 * @throws ExecutionException
689 public @Nullable Response postAction(String endpoint)
690 throws InterruptedException, TimeoutException, ExecutionException {
691 return postAction(endpoint, null);
695 * Sends a HTTP POST request with a request body to the given endpoint.
697 * @param <T> Type of the request
698 * @param endpoint The destination endpoint part of the URL
699 * @param requestBody object representing the request body to be sent, may be <code>null</code>
700 * @return the HTTP response
701 * @throws InterruptedException
702 * @throws TimeoutException
703 * @throws ExecutionException
705 public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
706 throws InterruptedException, TimeoutException, ExecutionException {
708 BoschHttpClient httpClient = this.httpClient;
709 if (httpClient == null) {
710 logger.warn("HttpClient not initialized");
714 String url = httpClient.getBoschSmartHomeUrl(endpoint);
715 Request request = httpClient.createRequest(url, POST, requestBody);
716 return request.send();
719 public @Nullable DeviceServiceData getServiceData(String deviceId, String serviceName)
720 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
722 BoschHttpClient httpClient = this.httpClient;
723 if (httpClient == null) {
724 logger.warn("HttpClient not initialized");
728 String url = httpClient.getServiceUrl(serviceName, deviceId);
729 logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", serviceName, deviceId, url);
730 return getState(httpClient, url, DeviceServiceData.class);