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.FrameworkUtil;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
53 import com.google.gson.Gson;
54 import com.google.gson.JsonElement;
55 import com.google.gson.reflect.TypeToken;
58 * Representation of a connection with a Bosch Smart Home Controller bridge.
60 * @author Stefan Kästle - Initial contribution
61 * @author Gerd Zanker - added HttpClient with pairing support
62 * @author Christian Oeing - refactorings of e.g. server registration
63 * @author David Pace - Added support for custom endpoints and HTTP POST requests
66 public class BridgeHandler extends BaseBridgeHandler {
68 private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
71 * gson instance to convert a class to json string and back.
73 private final Gson gson = new Gson();
76 * Handler to do long polling.
78 private final LongPolling longPolling;
81 * HTTP client for all communications to and from the bridge.
83 * This member is package-protected to enable mocking in unit tests.
85 /* package */ @Nullable
86 BoschHttpClient httpClient;
88 private @Nullable ScheduledFuture<?> scheduledPairing;
90 public BridgeHandler(Bridge bridge) {
93 this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
97 public void initialize() {
98 logger.debug("Initialize {} Version {}", FrameworkUtil.getBundle(getClass()).getSymbolicName(),
99 FrameworkUtil.getBundle(getClass()).getVersion());
101 // Read configuration
102 BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
104 String ipAddress = config.ipAddress.trim();
105 if (ipAddress.isEmpty()) {
106 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
107 "@text/offline.conf-error-empty-ip");
111 String password = config.password.trim();
112 if (password.isEmpty()) {
113 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
114 "@text/offline.conf-error-empty-password");
118 SslContextFactory factory;
120 // prepare SSL key and certificates
121 factory = new BoschSslUtil(ipAddress).getSslContextFactory();
122 } catch (PairingFailedException e) {
123 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
124 "@text/offline.conf-error-ssl");
128 // Instantiate HttpClient with the SslContextFactory
129 BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
134 } catch (Exception e) {
135 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
136 String.format("Could not create http connection to controller: %s", e.getMessage()));
140 // general checks are OK, therefore set the status to unknown and wait for initial access
141 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
143 // Initialize bridge in the background.
144 // Start initial access the first time
145 scheduleInitialAccess(httpClient);
149 public void dispose() {
150 // Cancel scheduled pairing.
152 ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
153 if (scheduledPairing != null) {
154 scheduledPairing.cancel(true);
155 this.scheduledPairing = null;
158 // Stop long polling.
159 this.longPolling.stop();
162 BoschHttpClient httpClient = this.httpClient;
163 if (httpClient != null) {
166 } catch (Exception e) {
167 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage());
169 this.httpClient = null;
176 public void handleCommand(ChannelUID channelUID, Command command) {
180 * Schedule the initial access.
181 * Use a delay if pairing fails and next retry is scheduled.
183 private void scheduleInitialAccess(BoschHttpClient httpClient) {
184 this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
188 * Execute the initial access.
189 * Uses the HTTP Bosch SHC client
190 * to check if access if possible
191 * pairs this Bosch SHC Bridge with the SHC if necessary
192 * and starts the first log poll.
194 private void initialAccess(BoschHttpClient httpClient) {
195 logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
198 // check if SCH is offline
199 if (!httpClient.isOnline()) {
200 // update status already if access is not possible
201 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
202 "@text/offline.conf-error-offline");
203 // restart later initial access
204 scheduleInitialAccess(httpClient);
209 // check if SHC access is not possible and pairing necessary
210 if (!httpClient.isAccessPossible()) {
211 // update status description to show pairing test
212 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
213 "@text/offline.conf-error-pairing");
214 if (!httpClient.doPairing()) {
215 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
216 "@text/offline.conf-error-pairing");
218 // restart initial access - needed also in case of successful pairing to check access again
219 scheduleInitialAccess(httpClient);
223 // SHC is online and access is possible
224 // print rooms and devices
225 boolean thingReachable = true;
226 thingReachable &= this.getRooms();
227 thingReachable &= this.getDevices();
228 if (!thingReachable) {
229 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
230 "@text/offline.not-reachable");
231 // restart initial access
232 scheduleInitialAccess(httpClient);
236 // start long polling loop
237 this.updateStatus(ThingStatus.ONLINE);
239 this.longPolling.start(httpClient);
240 } catch (LongPollingFailedException e) {
241 this.handleLongPollFailure(e);
244 } catch (InterruptedException e) {
245 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
246 Thread.currentThread().interrupt();
251 * Get a list of connected devices from the Smart-Home Controller
253 * @throws InterruptedException in case bridge is stopped
255 private boolean getDevices() throws InterruptedException {
257 BoschHttpClient httpClient = this.httpClient;
258 if (httpClient == null) {
263 logger.debug("Sending http request to Bosch to request devices: {}", httpClient);
264 String url = httpClient.getBoschSmartHomeUrl("devices");
265 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
267 // check HTTP status code
268 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
269 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
273 String content = contentResponse.getContentAsString();
274 logger.debug("Request devices completed with success: {} - status code: {}", content,
275 contentResponse.getStatus());
277 Type collectionType = new TypeToken<ArrayList<Device>>() {
279 ArrayList<Device> devices = gson.fromJson(content, collectionType);
281 if (devices != null) {
282 for (Device d : devices) {
283 // Write found devices into openhab.log until we have implemented auto discovery
284 logger.info("Found device: name={} id={}", d.name, d.id);
285 if (d.deviceServiceIds != null) {
286 for (String s : d.deviceServiceIds) {
287 logger.info(".... service: {}", s);
292 } catch (TimeoutException | ExecutionException e) {
293 logger.warn("Request devices failed because of {}!", e.getMessage());
301 * Bridge callback handler for the results of long polls.
303 * It will check the results and
304 * forward the received states to the Bosch thing handlers.
306 * @param result Results from Long Polling
308 private void handleLongPollResult(LongPollResult result) {
309 for (DeviceServiceData deviceServiceData : result.result) {
310 handleDeviceServiceData(deviceServiceData);
315 * Processes a single long poll result.
317 * @param deviceServiceData object representing a single long poll result
319 private void handleDeviceServiceData(@Nullable DeviceServiceData deviceServiceData) {
320 if (deviceServiceData != null) {
321 JsonElement state = obtainState(deviceServiceData);
323 logger.debug("Got update for service {} of type {}: {}", deviceServiceData.id, deviceServiceData.type,
326 var updateDeviceId = deviceServiceData.deviceId;
327 if (updateDeviceId == null || state == null) {
331 logger.debug("Got update for device {}", updateDeviceId);
333 forwardStateToHandlers(deviceServiceData, state, updateDeviceId);
338 * Extracts the actual state object from the given {@link DeviceServiceData} instance.
340 * In some special cases like the <code>BatteryLevel</code> service the {@link DeviceServiceData} object itself
341 * contains the state.
342 * In all other cases, the state is contained in a sub-object named <code>state</code>.
344 * @param deviceServiceData the {@link DeviceServiceData} object from which the state should be obtained
345 * @return the state sub-object or the {@link DeviceServiceData} object itself
348 private JsonElement obtainState(DeviceServiceData deviceServiceData) {
349 // the battery level service receives no individual state object but rather requires the DeviceServiceData
351 if ("BatteryLevel".equals(deviceServiceData.id)) {
352 return gson.toJsonTree(deviceServiceData);
355 return deviceServiceData.state;
359 * Tries to find handlers for the device with the given ID and forwards the received state to the handlers.
361 * @param deviceServiceData object representing updates received in long poll results
362 * @param state the received state object as JSON element
363 * @param updateDeviceId the ID of the device for which the state update was received
365 private void forwardStateToHandlers(DeviceServiceData deviceServiceData, JsonElement state, String updateDeviceId) {
366 boolean handled = false;
368 Bridge bridge = this.getThing();
369 for (Thing childThing : bridge.getThings()) {
370 // All children of this should implement BoschSHCHandler
372 ThingHandler baseHandler = childThing.getHandler();
373 if (baseHandler != null && baseHandler instanceof BoschSHCHandler) {
374 BoschSHCHandler handler = (BoschSHCHandler) baseHandler;
376 String deviceId = handler.getBoschID();
379 logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
381 if (deviceId != null && updateDeviceId.equals(deviceId)) {
382 logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler,
383 deviceServiceData.id, state);
384 handler.processUpdate(deviceServiceData.id, state);
387 logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
392 logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
397 * Bridge callback handler for the failures during long polls.
399 * It will update the bridge status and try to access the SHC again.
401 * @param e error during long polling
403 private void handleLongPollFailure(Throwable e) {
404 logger.warn("Long polling failed, will try to reconnect", e);
406 BoschHttpClient httpClient = this.httpClient;
407 if (httpClient == null) {
408 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
409 "@text/offline.long-polling-failed.http-client-null");
413 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
414 "@text/offline.long-polling-failed.trying-to-reconnect");
415 scheduleInitialAccess(httpClient);
419 * Get a list of rooms from the Smart-Home controller
421 * @throws InterruptedException in case bridge is stopped
423 private boolean getRooms() throws InterruptedException {
425 BoschHttpClient httpClient = this.httpClient;
426 if (httpClient != null) {
428 logger.debug("Sending http request to Bosch to request rooms");
429 String url = httpClient.getBoschSmartHomeUrl("rooms");
430 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
432 // check HTTP status code
433 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
434 logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
438 String content = contentResponse.getContentAsString();
439 logger.debug("Request rooms completed with success: {} - status code: {}", content,
440 contentResponse.getStatus());
442 Type collectionType = new TypeToken<ArrayList<Room>>() {
445 ArrayList<Room> rooms = gson.fromJson(content, collectionType);
448 for (Room r : rooms) {
449 logger.info("Found room: {}", r.name);
454 } catch (TimeoutException | ExecutionException e) {
455 logger.warn("Request rooms failed because of {}!", e.getMessage());
463 public Device getDeviceInfo(String deviceId)
464 throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
466 BoschHttpClient httpClient = this.httpClient;
467 if (httpClient == null) {
468 throw new BoschSHCException("HTTP client not initialized");
471 String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
472 Request request = httpClient.createRequest(url, GET);
474 return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
475 JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
476 if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
477 if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
478 return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
480 return new BoschSHCException(
481 String.format("Request for info of device %s failed with status code %d and error code %s",
482 deviceId, errorResponse.statusCode, errorResponse.errorCode));
485 return new BoschSHCException(String.format("Request for info for device %s failed with status code %d",
486 deviceId, statusCode));
492 * Query the Bosch Smart Home Controller for the state of the given device.
494 * The URL used for retrieving the state has the following structure:
497 * https://{IP}:8444/smarthome/devices/{deviceId}/services/{serviceName}/state
500 * @param deviceId Id of device to get state for
501 * @param stateName Name of the state to query
502 * @param stateClass Class to convert the resulting JSON to
503 * @return the deserialized state object, may be <code>null</code>
504 * @throws ExecutionException
505 * @throws TimeoutException
506 * @throws InterruptedException
507 * @throws BoschSHCException
509 public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
510 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
512 BoschHttpClient httpClient = this.httpClient;
513 if (httpClient == null) {
514 logger.warn("HttpClient not initialized");
518 String url = httpClient.getServiceStateUrl(stateName, deviceId);
519 logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
520 return getState(httpClient, url, stateClass);
524 * Queries the Bosch Smart Home Controller for the state using an explicit endpoint.
526 * @param <T> Type to which the resulting JSON should be deserialized to
527 * @param endpoint The destination endpoint part of the URL
528 * @param stateClass Class to convert the resulting JSON to
529 * @return the deserialized state object, may be <code>null</code>
530 * @throws InterruptedException
531 * @throws TimeoutException
532 * @throws ExecutionException
533 * @throws BoschSHCException
535 public <T extends BoschSHCServiceState> @Nullable T getState(String endpoint, Class<T> stateClass)
536 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
538 BoschHttpClient httpClient = this.httpClient;
539 if (httpClient == null) {
540 logger.warn("HttpClient not initialized");
544 String url = httpClient.getBoschSmartHomeUrl(endpoint);
545 logger.debug("getState(): Requesting from Bosch: {}", url);
546 return getState(httpClient, url, stateClass);
550 * Sends a HTTP GET request in order to retrieve a state from the Bosch Smart Home Controller.
552 * @param <T> Type to which the resulting JSON should be deserialized to
553 * @param httpClient HTTP client used for sending the request
554 * @param url URL at which the state should be retrieved
555 * @param stateClass Class to convert the resulting JSON to
556 * @return the deserialized state object, may be <code>null</code>
557 * @throws InterruptedException
558 * @throws TimeoutException
559 * @throws ExecutionException
560 * @throws BoschSHCException
562 protected <T extends BoschSHCServiceState> @Nullable T getState(BoschHttpClient httpClient, String url,
563 Class<T> stateClass) throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
564 Request request = httpClient.createRequest(url, GET).header("Accept", "application/json");
566 ContentResponse contentResponse = request.send();
568 String content = contentResponse.getContentAsString();
569 logger.debug("getState(): Request complete: [{}] - return code: {}", content, contentResponse.getStatus());
571 int statusCode = contentResponse.getStatus();
572 if (statusCode != 200) {
573 JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
574 if (errorResponse != null) {
575 throw new BoschSHCException(
576 String.format("State request with URL %s failed with status code %d and error code %s", url,
577 errorResponse.statusCode, errorResponse.errorCode));
579 throw new BoschSHCException(
580 String.format("State request with URL %s failed with status code %d", url, statusCode));
585 T state = BoschSHCServiceState.fromJson(content, stateClass);
587 throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
593 * Sends a state change for a device to the controller
595 * @param deviceId Id of device to change state for
596 * @param serviceName Name of service of device to change state for
597 * @param state New state data to set for service
599 * @return Response of request
600 * @throws InterruptedException
601 * @throws ExecutionException
602 * @throws TimeoutException
604 public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
605 throws InterruptedException, TimeoutException, ExecutionException {
607 BoschHttpClient httpClient = this.httpClient;
608 if (httpClient == null) {
609 logger.warn("HttpClient not initialized");
614 String url = httpClient.getServiceStateUrl(serviceName, deviceId);
615 Request request = httpClient.createRequest(url, PUT, state);
618 return request.send();
622 * Sends a HTTP POST request without a request body to the given endpoint.
624 * @param endpoint The destination endpoint part of the URL
625 * @return the HTTP response
626 * @throws InterruptedException
627 * @throws TimeoutException
628 * @throws ExecutionException
630 public @Nullable Response postAction(String endpoint)
631 throws InterruptedException, TimeoutException, ExecutionException {
632 return postAction(endpoint, null);
636 * Sends a HTTP POST request with a request body to the given endpoint.
638 * @param <T> Type of the request
639 * @param endpoint The destination endpoint part of the URL
640 * @param requestBody object representing the request body to be sent, may be <code>null</code>
641 * @return the HTTP response
642 * @throws InterruptedException
643 * @throws TimeoutException
644 * @throws ExecutionException
646 public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
647 throws InterruptedException, TimeoutException, ExecutionException {
649 BoschHttpClient httpClient = this.httpClient;
650 if (httpClient == null) {
651 logger.warn("HttpClient not initialized");
655 String url = httpClient.getBoschSmartHomeUrl(endpoint);
656 Request request = httpClient.createRequest(url, POST, requestBody);
657 return request.send();
660 public @Nullable DeviceServiceData getServiceData(String deviceId, String serviceName)
661 throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
663 BoschHttpClient httpClient = this.httpClient;
664 if (httpClient == null) {
665 logger.warn("HttpClient not initialized");
669 String url = httpClient.getServiceUrl(serviceName, deviceId);
670 logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", serviceName, deviceId, url);
671 return getState(httpClient, url, DeviceServiceData.class);