2 * Copyright (c) 2010-2022 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.DeviceStatusUpdate;
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.FrameworkUtil;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
53 import com.google.gson.Gson;
54 import com.google.gson.reflect.TypeToken;
57 * Representation of a connection with a Bosch Smart Home Controller bridge.
59 * @author Stefan Kästle - Initial contribution
60 * @author Gerd Zanker - added HttpClient with pairing support
61 * @author Christian Oeing - refactorings of e.g. server registration
62 * @author David Pace - Added support for custom endpoints and HTTP POST requests
65 public class BridgeHandler extends BaseBridgeHandler {
67 private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
70 * gson instance to convert a class to json string and back.
72 private final Gson gson = new Gson();
75 * Handler to do long polling.
77 private final LongPolling longPolling;
80 * HTTP client for all communications to and from the bridge.
82 * This member is package-protected to enable mocking in unit tests.
84 /* package */ @Nullable
85 BoschHttpClient httpClient;
87 private @Nullable ScheduledFuture<?> scheduledPairing;
89 public BridgeHandler(Bridge bridge) {
92 this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
96 public void initialize() {
97 logger.debug("Initialize {} Version {}", FrameworkUtil.getBundle(getClass()).getSymbolicName(),
98 FrameworkUtil.getBundle(getClass()).getVersion());
100 // Read configuration
101 BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
103 String ipAddress = config.ipAddress.trim();
104 if (ipAddress.isEmpty()) {
105 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
106 "@text/offline.conf-error-empty-ip");
110 String password = config.password.trim();
111 if (password.isEmpty()) {
112 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
113 "@text/offline.conf-error-empty-password");
117 SslContextFactory factory;
119 // prepare SSL key and certificates
120 factory = new BoschSslUtil(ipAddress).getSslContextFactory();
121 } catch (PairingFailedException e) {
122 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
123 "@text/offline.conf-error-ssl");
127 // Instantiate HttpClient with the SslContextFactory
128 BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
133 } catch (Exception e) {
134 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
135 String.format("Could not create http connection to controller: %s", e.getMessage()));
139 // general checks are OK, therefore set the status to unknown and wait for initial access
140 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
142 // Initialize bridge in the background.
143 // Start initial access the first time
144 scheduleInitialAccess(httpClient);
148 public void dispose() {
149 // Cancel scheduled pairing.
151 ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
152 if (scheduledPairing != null) {
153 scheduledPairing.cancel(true);
154 this.scheduledPairing = null;
157 // Stop long polling.
158 this.longPolling.stop();
161 BoschHttpClient httpClient = this.httpClient;
162 if (httpClient != null) {
165 } catch (Exception e) {
166 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage());
168 this.httpClient = null;
175 public void handleCommand(ChannelUID channelUID, Command command) {
179 * Schedule the initial access.
180 * Use a delay if pairing fails and next retry is scheduled.
182 private void scheduleInitialAccess(BoschHttpClient httpClient) {
183 this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
187 * Execute the initial access.
188 * Uses the HTTP Bosch SHC client
189 * to check if access if possible
190 * pairs this Bosch SHC Bridge with the SHC if necessary
191 * and starts the first log poll.
193 private void initialAccess(BoschHttpClient httpClient) {
194 logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
197 // check if SCH is offline
198 if (!httpClient.isOnline()) {
199 // update status already if access is not possible
200 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
201 "@text/offline.conf-error-offline");
202 // restart later initial access
203 scheduleInitialAccess(httpClient);
208 // check if SHC access is not possible and pairing necessary
209 if (!httpClient.isAccessPossible()) {
210 // update status description to show pairing test
211 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
212 "@text/offline.conf-error-pairing");
213 if (!httpClient.doPairing()) {
214 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
215 "@text/offline.conf-error-pairing");
217 // restart initial access - needed also in case of successful pairing to check access again
218 scheduleInitialAccess(httpClient);
222 // SHC is online and access is possible
223 // print rooms and devices
224 boolean thingReachable = true;
225 thingReachable &= this.getRooms();
226 thingReachable &= this.getDevices();
227 if (!thingReachable) {
228 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
229 "@text/offline.not-reachable");
230 // restart initial access
231 scheduleInitialAccess(httpClient);
235 // start long polling loop
236 this.updateStatus(ThingStatus.ONLINE);
238 this.longPolling.start(httpClient);
239 } catch (LongPollingFailedException e) {
240 this.handleLongPollFailure(e);
243 } catch (InterruptedException e) {
244 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
245 Thread.currentThread().interrupt();
250 * Get a list of connected devices from the Smart-Home Controller
252 * @throws InterruptedException in case bridge is stopped
254 private boolean getDevices() throws InterruptedException {
256 BoschHttpClient httpClient = this.httpClient;
257 if (httpClient == null) {
262 logger.debug("Sending http request to Bosch to request devices: {}", httpClient);
263 String url = httpClient.getBoschSmartHomeUrl("devices");
264 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
266 // check HTTP status code
267 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
268 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
272 String content = contentResponse.getContentAsString();
273 logger.debug("Request devices completed with success: {} - status code: {}", content,
274 contentResponse.getStatus());
276 Type collectionType = new TypeToken<ArrayList<Device>>() {
278 ArrayList<Device> devices = gson.fromJson(content, collectionType);
280 if (devices != null) {
281 for (Device d : devices) {
282 // Write found devices into openhab.log until we have implemented auto discovery
283 logger.info("Found device: name={} id={}", d.name, d.id);
284 if (d.deviceServiceIds != null) {
285 for (String s : d.deviceServiceIds) {
286 logger.info(".... service: {}", s);
291 } catch (TimeoutException | ExecutionException e) {
292 logger.warn("Request devices failed because of {}!", e.getMessage());
300 * Bridge callback handler for the results of long polls.
302 * It will check the result and
303 * forward the received to the bosch thing handlers.
305 * @param result Results from Long Polling
307 private void handleLongPollResult(LongPollResult result) {
308 for (DeviceStatusUpdate update : result.result) {
309 if (update != null && update.state != null) {
310 logger.debug("Got update for service {} of type {}: {}", update.id, update.type, update.state);
312 var updateDeviceId = update.deviceId;
313 if (updateDeviceId == null) {
317 logger.debug("Got update for device {}", updateDeviceId);
319 boolean handled = false;
321 Bridge bridge = this.getThing();
322 for (Thing childThing : bridge.getThings()) {
323 // All children of this should implement BoschSHCHandler
325 ThingHandler baseHandler = childThing.getHandler();
326 if (baseHandler != null && baseHandler instanceof BoschSHCHandler) {
327 BoschSHCHandler handler = (BoschSHCHandler) baseHandler;
329 String deviceId = handler.getBoschID();
332 logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
334 if (deviceId != null && updateDeviceId.equals(deviceId)) {
335 logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler, update.id,
337 handler.processUpdate(update.id, update.state);
340 logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
345 logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
352 * Bridge callback handler for the failures during long polls.
354 * It will update the bridge status and try to access the SHC again.
356 * @param e error during long polling
358 private void handleLongPollFailure(Throwable e) {
359 logger.warn("Long polling failed, will try to reconnect", e);
361 BoschHttpClient httpClient = this.httpClient;
362 if (httpClient == null) {
363 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
364 "@text/offline.long-polling-failed.http-client-null");
368 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
369 "@text/offline.long-polling-failed.trying-to-reconnect");
370 scheduleInitialAccess(httpClient);
374 * Get a list of rooms from the Smart-Home controller
376 * @throws InterruptedException in case bridge is stopped
378 private boolean getRooms() throws InterruptedException {
380 BoschHttpClient httpClient = this.httpClient;
381 if (httpClient != null) {
383 logger.debug("Sending http request to Bosch to request rooms");
384 String url = httpClient.getBoschSmartHomeUrl("rooms");
385 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
387 // check HTTP status code
388 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
389 logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
393 String content = contentResponse.getContentAsString();
394 logger.debug("Request rooms completed with success: {} - status code: {}", content,
395 contentResponse.getStatus());
397 Type collectionType = new TypeToken<ArrayList<Room>>() {
400 ArrayList<Room> rooms = gson.fromJson(content, collectionType);
403 for (Room r : rooms) {
404 logger.info("Found room: {}", r.name);
409 } catch (TimeoutException | ExecutionException e) {
410 logger.warn("Request rooms failed because of {}!", e.getMessage());
418 public Device getDeviceInfo(String deviceId)
419 throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
421 BoschHttpClient httpClient = this.httpClient;
422 if (httpClient == null) {
423 throw new BoschSHCException("HTTP client not initialized");
426 String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
427 Request request = httpClient.createRequest(url, GET);
429 return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
430 JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
431 if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
432 if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
433 return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
435 return new BoschSHCException(
436 String.format("Request for info of device %s failed with status code %d and error code %s",
437 deviceId, errorResponse.statusCode, errorResponse.errorCode));
440 return new BoschSHCException(String.format("Request for info for device %s failed with status code %d",
441 deviceId, statusCode));
447 * Query the Bosch Smart Home Controller for the state of the given device.
449 * The URL used for retrieving the state has the following structure:
452 * https://{IP}:8444/smarthome/devices/{deviceId}/services/{serviceName}/state
455 * @param deviceId Id of device to get state for
456 * @param stateName Name of the state to query
457 * @param stateClass Class to convert the resulting JSON to
458 * @return the deserialized state object, may be <code>null</code>
459 * @throws ExecutionException
460 * @throws TimeoutException
461 * @throws InterruptedException
462 * @throws BoschSHCException
464 public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
465 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
467 BoschHttpClient httpClient = this.httpClient;
468 if (httpClient == null) {
469 logger.warn("HttpClient not initialized");
473 String url = httpClient.getServiceUrl(stateName, deviceId);
474 logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
475 return getState(httpClient, url, stateClass);
479 * Queries the Bosch Smart Home Controller for the state using an explicit endpoint.
481 * @param <T> Type to which the resulting JSON should be deserialized to
482 * @param endpoint The destination endpoint part of the URL
483 * @param stateClass Class to convert the resulting JSON to
484 * @return the deserialized state object, may be <code>null</code>
485 * @throws InterruptedException
486 * @throws TimeoutException
487 * @throws ExecutionException
488 * @throws BoschSHCException
490 public <T extends BoschSHCServiceState> @Nullable T getState(String endpoint, Class<T> stateClass)
491 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
493 BoschHttpClient httpClient = this.httpClient;
494 if (httpClient == null) {
495 logger.warn("HttpClient not initialized");
499 String url = httpClient.getBoschSmartHomeUrl(endpoint);
500 logger.debug("getState(): Requesting from Bosch: {}", url);
501 return getState(httpClient, url, stateClass);
505 * Sends a HTTP GET request in order to retrieve a state from the Bosch Smart Home Controller.
507 * @param <T> Type to which the resulting JSON should be deserialized to
508 * @param httpClient HTTP client used for sending the request
509 * @param url URL at which the state should be retrieved
510 * @param stateClass Class to convert the resulting JSON to
511 * @return the deserialized state object, may be <code>null</code>
512 * @throws InterruptedException
513 * @throws TimeoutException
514 * @throws ExecutionException
515 * @throws BoschSHCException
517 protected <T extends BoschSHCServiceState> @Nullable T getState(BoschHttpClient httpClient, String url,
518 Class<T> stateClass) throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
519 Request request = httpClient.createRequest(url, GET).header("Accept", "application/json");
521 ContentResponse contentResponse = request.send();
523 String content = contentResponse.getContentAsString();
524 logger.debug("getState(): Request complete: [{}] - return code: {}", content, contentResponse.getStatus());
526 int statusCode = contentResponse.getStatus();
527 if (statusCode != 200) {
528 JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
529 if (errorResponse != null) {
530 throw new BoschSHCException(
531 String.format("State request with URL %s failed with status code %d and error code %s", url,
532 errorResponse.statusCode, errorResponse.errorCode));
534 throw new BoschSHCException(
535 String.format("State request with URL %s failed with status code %d", url, statusCode));
540 T state = BoschSHCServiceState.fromJson(content, stateClass);
542 throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
548 * Sends a state change for a device to the controller
550 * @param deviceId Id of device to change state for
551 * @param serviceName Name of service of device to change state for
552 * @param state New state data to set for service
554 * @return Response of request
555 * @throws InterruptedException
556 * @throws ExecutionException
557 * @throws TimeoutException
559 public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
560 throws InterruptedException, TimeoutException, ExecutionException {
562 BoschHttpClient httpClient = this.httpClient;
563 if (httpClient == null) {
564 logger.warn("HttpClient not initialized");
569 String url = httpClient.getServiceUrl(serviceName, deviceId);
570 Request request = httpClient.createRequest(url, PUT, state);
573 return request.send();
577 * Sends a HTTP POST request without a request body to the given endpoint.
579 * @param endpoint The destination endpoint part of the URL
580 * @return the HTTP response
581 * @throws InterruptedException
582 * @throws TimeoutException
583 * @throws ExecutionException
585 public @Nullable Response postAction(String endpoint)
586 throws InterruptedException, TimeoutException, ExecutionException {
587 return postAction(endpoint, null);
591 * Sends a HTTP POST request with a request body to the given endpoint.
593 * @param <T> Type of the request
594 * @param endpoint The destination endpoint part of the URL
595 * @param requestBody object representing the request body to be sent, may be <code>null</code>
596 * @return the HTTP response
597 * @throws InterruptedException
598 * @throws TimeoutException
599 * @throws ExecutionException
601 public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
602 throws InterruptedException, TimeoutException, ExecutionException {
604 BoschHttpClient httpClient = this.httpClient;
605 if (httpClient == null) {
606 logger.warn("HttpClient not initialized");
610 String url = httpClient.getBoschSmartHomeUrl(endpoint);
611 Request request = httpClient.createRequest(url, POST, requestBody);
612 return request.send();