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.concurrent.ExecutionException;
20 import java.util.concurrent.ScheduledFuture;
21 import java.util.concurrent.TimeUnit;
22 import java.util.concurrent.TimeoutException;
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.api.ContentResponse;
27 import org.eclipse.jetty.client.api.Request;
28 import org.eclipse.jetty.client.api.Response;
29 import org.eclipse.jetty.http.HttpStatus;
30 import org.eclipse.jetty.util.ssl.SslContextFactory;
31 import org.openhab.binding.boschshc.internal.devices.BoschSHCHandler;
32 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
33 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
34 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
35 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
36 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
37 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
38 import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
39 import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
40 import org.openhab.binding.boschshc.internal.services.dto.JsonRestExceptionResponse;
41 import org.openhab.core.thing.Bridge;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.binding.BaseBridgeHandler;
47 import org.openhab.core.thing.binding.ThingHandler;
48 import org.openhab.core.types.Command;
49 import org.osgi.framework.Bundle;
50 import org.osgi.framework.FrameworkUtil;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
54 import com.google.gson.Gson;
55 import com.google.gson.JsonElement;
56 import com.google.gson.reflect.TypeToken;
59 * Representation of a connection with a Bosch Smart Home Controller bridge.
61 * @author Stefan Kästle - Initial contribution
62 * @author Gerd Zanker - added HttpClient with pairing support
63 * @author Christian Oeing - refactorings of e.g. server registration
64 * @author David Pace - Added support for custom endpoints and HTTP POST requests
67 public class BridgeHandler extends BaseBridgeHandler {
69 private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
72 * gson instance to convert a class to json string and back.
74 private final Gson gson = new Gson();
77 * Handler to do long polling.
79 private final LongPolling longPolling;
82 * HTTP client for all communications to and from the bridge.
84 * This member is package-protected to enable mocking in unit tests.
86 /* package */ @Nullable
87 BoschHttpClient httpClient;
89 private @Nullable ScheduledFuture<?> scheduledPairing;
91 public BridgeHandler(Bridge bridge) {
94 this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
98 public void initialize() {
99 Bundle bundle = FrameworkUtil.getBundle(getClass());
100 if (bundle != null) {
101 logger.debug("Initialize {} Version {}", bundle.getSymbolicName(), bundle.getVersion());
104 // Read configuration
105 BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
107 String ipAddress = config.ipAddress.trim();
108 if (ipAddress.isEmpty()) {
109 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
110 "@text/offline.conf-error-empty-ip");
114 String password = config.password.trim();
115 if (password.isEmpty()) {
116 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
117 "@text/offline.conf-error-empty-password");
121 SslContextFactory factory;
123 // prepare SSL key and certificates
124 factory = new BoschSslUtil(ipAddress).getSslContextFactory();
125 } catch (PairingFailedException e) {
126 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
127 "@text/offline.conf-error-ssl");
131 // Instantiate HttpClient with the SslContextFactory
132 BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
137 } catch (Exception e) {
138 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
139 String.format("Could not create http connection to controller: %s", e.getMessage()));
143 // general checks are OK, therefore set the status to unknown and wait for initial access
144 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
146 // Initialize bridge in the background.
147 // Start initial access the first time
148 scheduleInitialAccess(httpClient);
152 public void dispose() {
153 // Cancel scheduled pairing.
155 ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
156 if (scheduledPairing != null) {
157 scheduledPairing.cancel(true);
158 this.scheduledPairing = null;
161 // Stop long polling.
162 this.longPolling.stop();
165 BoschHttpClient httpClient = this.httpClient;
166 if (httpClient != null) {
169 } catch (Exception e) {
170 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage());
172 this.httpClient = null;
179 public void handleCommand(ChannelUID channelUID, Command command) {
183 * Schedule the initial access.
184 * Use a delay if pairing fails and next retry is scheduled.
186 private void scheduleInitialAccess(BoschHttpClient httpClient) {
187 this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
191 * Execute the initial access.
192 * Uses the HTTP Bosch SHC client
193 * to check if access if possible
194 * pairs this Bosch SHC Bridge with the SHC if necessary
195 * and starts the first log poll.
197 * This method is package-protected to enable unit testing.
199 /* package */ void initialAccess(BoschHttpClient httpClient) {
200 logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
203 // check if SCH is offline
204 if (!httpClient.isOnline()) {
205 // update status already if access is not possible
206 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
207 "@text/offline.conf-error-offline");
208 // restart later initial access
209 scheduleInitialAccess(httpClient);
214 // check if SHC access is not possible and pairing necessary
215 if (!httpClient.isAccessPossible()) {
216 // update status description to show pairing test
217 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
218 "@text/offline.conf-error-pairing");
219 if (!httpClient.doPairing()) {
220 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
221 "@text/offline.conf-error-pairing");
223 // restart initial access - needed also in case of successful pairing to check access again
224 scheduleInitialAccess(httpClient);
228 // SHC is online and access is possible
229 // print rooms and devices
230 boolean thingReachable = true;
231 thingReachable &= this.getRooms();
232 thingReachable &= this.getDevices();
233 if (!thingReachable) {
234 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
235 "@text/offline.not-reachable");
236 // restart initial access
237 scheduleInitialAccess(httpClient);
241 // start long polling loop
242 this.updateStatus(ThingStatus.ONLINE);
244 this.longPolling.start(httpClient);
245 } catch (LongPollingFailedException e) {
246 this.handleLongPollFailure(e);
249 } catch (InterruptedException e) {
250 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
251 Thread.currentThread().interrupt();
256 * Get a list of connected devices from the Smart-Home Controller
258 * @throws InterruptedException in case bridge is stopped
260 private boolean getDevices() throws InterruptedException {
262 BoschHttpClient httpClient = this.httpClient;
263 if (httpClient == null) {
268 logger.debug("Sending http request to Bosch to request devices: {}", httpClient);
269 String url = httpClient.getBoschSmartHomeUrl("devices");
270 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
272 // check HTTP status code
273 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
274 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
278 String content = contentResponse.getContentAsString();
279 logger.debug("Request devices completed with success: {} - status code: {}", content,
280 contentResponse.getStatus());
282 Type collectionType = new TypeToken<ArrayList<Device>>() {
284 ArrayList<Device> devices = gson.fromJson(content, collectionType);
286 if (devices != null) {
287 for (Device d : devices) {
288 // Write found devices into openhab.log until we have implemented auto discovery
289 logger.info("Found device: name={} id={}", d.name, d.id);
290 if (d.deviceServiceIds != null) {
291 for (String s : d.deviceServiceIds) {
292 logger.info(".... service: {}", s);
297 } catch (TimeoutException | ExecutionException e) {
298 logger.warn("Request devices failed because of {}!", e.getMessage());
306 * Bridge callback handler for the results of long polls.
308 * It will check the results and
309 * forward the received states to the Bosch thing handlers.
311 * @param result Results from Long Polling
313 private void handleLongPollResult(LongPollResult result) {
314 for (DeviceServiceData deviceServiceData : result.result) {
315 handleDeviceServiceData(deviceServiceData);
320 * Processes a single long poll result.
322 * @param deviceServiceData object representing a single long poll result
324 private void handleDeviceServiceData(@Nullable DeviceServiceData deviceServiceData) {
325 if (deviceServiceData != null) {
326 JsonElement state = obtainState(deviceServiceData);
328 logger.debug("Got update for service {} of type {}: {}", deviceServiceData.id, deviceServiceData.type,
331 var updateDeviceId = deviceServiceData.deviceId;
332 if (updateDeviceId == null || state == null) {
336 logger.debug("Got update for device {}", updateDeviceId);
338 forwardStateToHandlers(deviceServiceData, state, updateDeviceId);
343 * Extracts the actual state object from the given {@link DeviceServiceData} instance.
345 * In some special cases like the <code>BatteryLevel</code> service the {@link DeviceServiceData} object itself
346 * contains the state.
347 * In all other cases, the state is contained in a sub-object named <code>state</code>.
349 * @param deviceServiceData the {@link DeviceServiceData} object from which the state should be obtained
350 * @return the state sub-object or the {@link DeviceServiceData} object itself
353 private JsonElement obtainState(DeviceServiceData deviceServiceData) {
354 // the battery level service receives no individual state object but rather requires the DeviceServiceData
356 if ("BatteryLevel".equals(deviceServiceData.id)) {
357 return gson.toJsonTree(deviceServiceData);
360 return deviceServiceData.state;
364 * Tries to find handlers for the device with the given ID and forwards the received state to the handlers.
366 * @param deviceServiceData object representing updates received in long poll results
367 * @param state the received state object as JSON element
368 * @param updateDeviceId the ID of the device for which the state update was received
370 private void forwardStateToHandlers(DeviceServiceData deviceServiceData, JsonElement state, String updateDeviceId) {
371 boolean handled = false;
373 Bridge bridge = this.getThing();
374 for (Thing childThing : bridge.getThings()) {
375 // All children of this should implement BoschSHCHandler
377 ThingHandler baseHandler = childThing.getHandler();
378 if (baseHandler != null && baseHandler instanceof BoschSHCHandler) {
379 BoschSHCHandler handler = (BoschSHCHandler) baseHandler;
381 String deviceId = handler.getBoschID();
384 logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
386 if (deviceId != null && updateDeviceId.equals(deviceId)) {
387 logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler,
388 deviceServiceData.id, state);
389 handler.processUpdate(deviceServiceData.id, state);
392 logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
397 logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
402 * Bridge callback handler for the failures during long polls.
404 * It will update the bridge status and try to access the SHC again.
406 * @param e error during long polling
408 private void handleLongPollFailure(Throwable e) {
409 logger.warn("Long polling failed, will try to reconnect", e);
411 BoschHttpClient httpClient = this.httpClient;
412 if (httpClient == null) {
413 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
414 "@text/offline.long-polling-failed.http-client-null");
418 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
419 "@text/offline.long-polling-failed.trying-to-reconnect");
420 scheduleInitialAccess(httpClient);
424 * Get a list of rooms from the Smart-Home controller
426 * @throws InterruptedException in case bridge is stopped
428 private boolean getRooms() throws InterruptedException {
430 BoschHttpClient httpClient = this.httpClient;
431 if (httpClient != null) {
433 logger.debug("Sending http request to Bosch to request rooms");
434 String url = httpClient.getBoschSmartHomeUrl("rooms");
435 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
437 // check HTTP status code
438 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
439 logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
443 String content = contentResponse.getContentAsString();
444 logger.debug("Request rooms completed with success: {} - status code: {}", content,
445 contentResponse.getStatus());
447 Type collectionType = new TypeToken<ArrayList<Room>>() {
450 ArrayList<Room> rooms = gson.fromJson(content, collectionType);
453 for (Room r : rooms) {
454 logger.info("Found room: {}", r.name);
459 } catch (TimeoutException | ExecutionException e) {
460 logger.warn("Request rooms failed because of {}!", e.getMessage());
468 public Device getDeviceInfo(String deviceId)
469 throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
471 BoschHttpClient httpClient = this.httpClient;
472 if (httpClient == null) {
473 throw new BoschSHCException("HTTP client not initialized");
476 String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
477 Request request = httpClient.createRequest(url, GET);
479 return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
480 JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
481 if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
482 if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
483 return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
485 return new BoschSHCException(
486 String.format("Request for info of device %s failed with status code %d and error code %s",
487 deviceId, errorResponse.statusCode, errorResponse.errorCode));
490 return new BoschSHCException(String.format("Request for info of device %s failed with status code %d",
491 deviceId, statusCode));
497 * Query the Bosch Smart Home Controller for the state of the given device.
499 * The URL used for retrieving the state has the following structure:
502 * https://{IP}:8444/smarthome/devices/{deviceId}/services/{serviceName}/state
505 * @param deviceId Id of device to get state for
506 * @param stateName Name of the state to query
507 * @param stateClass Class to convert the resulting JSON to
508 * @return the deserialized state object, may be <code>null</code>
509 * @throws ExecutionException
510 * @throws TimeoutException
511 * @throws InterruptedException
512 * @throws BoschSHCException
514 public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
515 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
517 BoschHttpClient httpClient = this.httpClient;
518 if (httpClient == null) {
519 logger.warn("HttpClient not initialized");
523 String url = httpClient.getServiceStateUrl(stateName, deviceId);
524 logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
525 return getState(httpClient, url, stateClass);
529 * Queries the Bosch Smart Home Controller for the state using an explicit endpoint.
531 * @param <T> Type to which the resulting JSON should be deserialized to
532 * @param endpoint The destination endpoint part of the URL
533 * @param stateClass Class to convert the resulting JSON to
534 * @return the deserialized state object, may be <code>null</code>
535 * @throws InterruptedException
536 * @throws TimeoutException
537 * @throws ExecutionException
538 * @throws BoschSHCException
540 public <T extends BoschSHCServiceState> @Nullable T getState(String endpoint, Class<T> stateClass)
541 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
543 BoschHttpClient httpClient = this.httpClient;
544 if (httpClient == null) {
545 logger.warn("HttpClient not initialized");
549 String url = httpClient.getBoschSmartHomeUrl(endpoint);
550 logger.debug("getState(): Requesting from Bosch: {}", url);
551 return getState(httpClient, url, stateClass);
555 * Sends a HTTP GET request in order to retrieve a state from the Bosch Smart Home Controller.
557 * @param <T> Type to which the resulting JSON should be deserialized to
558 * @param httpClient HTTP client used for sending the request
559 * @param url URL at which the state should be retrieved
560 * @param stateClass Class to convert the resulting JSON to
561 * @return the deserialized state object, may be <code>null</code>
562 * @throws InterruptedException
563 * @throws TimeoutException
564 * @throws ExecutionException
565 * @throws BoschSHCException
567 protected <T extends BoschSHCServiceState> @Nullable T getState(BoschHttpClient httpClient, String url,
568 Class<T> stateClass) throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
569 Request request = httpClient.createRequest(url, GET).header("Accept", "application/json");
571 ContentResponse contentResponse = request.send();
573 String content = contentResponse.getContentAsString();
574 logger.debug("getState(): Request complete: [{}] - return code: {}", content, contentResponse.getStatus());
576 int statusCode = contentResponse.getStatus();
577 if (statusCode != 200) {
578 JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
579 if (errorResponse != null) {
580 throw new BoschSHCException(
581 String.format("State request with URL %s failed with status code %d and error code %s", url,
582 errorResponse.statusCode, errorResponse.errorCode));
584 throw new BoschSHCException(
585 String.format("State request with URL %s failed with status code %d", url, statusCode));
590 T state = BoschSHCServiceState.fromJson(content, stateClass);
592 throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
598 * Sends a state change for a device to the controller
600 * @param deviceId Id of device to change state for
601 * @param serviceName Name of service of device to change state for
602 * @param state New state data to set for service
604 * @return Response of request
605 * @throws InterruptedException
606 * @throws ExecutionException
607 * @throws TimeoutException
609 public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
610 throws InterruptedException, TimeoutException, ExecutionException {
612 BoschHttpClient httpClient = this.httpClient;
613 if (httpClient == null) {
614 logger.warn("HttpClient not initialized");
619 String url = httpClient.getServiceStateUrl(serviceName, deviceId);
620 Request request = httpClient.createRequest(url, PUT, state);
623 return request.send();
627 * Sends a HTTP POST request without a request body to the given endpoint.
629 * @param endpoint The destination endpoint part of the URL
630 * @return the HTTP response
631 * @throws InterruptedException
632 * @throws TimeoutException
633 * @throws ExecutionException
635 public @Nullable Response postAction(String endpoint)
636 throws InterruptedException, TimeoutException, ExecutionException {
637 return postAction(endpoint, null);
641 * Sends a HTTP POST request with a request body to the given endpoint.
643 * @param <T> Type of the request
644 * @param endpoint The destination endpoint part of the URL
645 * @param requestBody object representing the request body to be sent, may be <code>null</code>
646 * @return the HTTP response
647 * @throws InterruptedException
648 * @throws TimeoutException
649 * @throws ExecutionException
651 public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
652 throws InterruptedException, TimeoutException, ExecutionException {
654 BoschHttpClient httpClient = this.httpClient;
655 if (httpClient == null) {
656 logger.warn("HttpClient not initialized");
660 String url = httpClient.getBoschSmartHomeUrl(endpoint);
661 Request request = httpClient.createRequest(url, POST, requestBody);
662 return request.send();
665 public @Nullable DeviceServiceData getServiceData(String deviceId, String serviceName)
666 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
668 BoschHttpClient httpClient = this.httpClient;
669 if (httpClient == null) {
670 logger.warn("HttpClient not initialized");
674 String url = httpClient.getServiceUrl(serviceName, deviceId);
675 logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", serviceName, deviceId, url);
676 return getState(httpClient, url, DeviceServiceData.class);