]> git.basschouten.com Git - openhab-addons.git/blob
54e14d749d33510b5631ca48d413705116bc1c2c
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.boschshc.internal.devices.bridge;
14
15 import static org.eclipse.jetty.http.HttpMethod.*;
16
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.Set;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.api.ContentResponse;
33 import org.eclipse.jetty.client.api.Request;
34 import org.eclipse.jetty.client.api.Response;
35 import org.eclipse.jetty.http.HttpStatus;
36 import org.eclipse.jetty.util.ssl.SslContextFactory;
37 import org.openhab.binding.boschshc.internal.devices.BoschSHCHandler;
38 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
39 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceServiceData;
40 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
41 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
42 import org.openhab.binding.boschshc.internal.discovery.ThingDiscoveryService;
43 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
44 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
45 import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
46 import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
47 import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
48 import org.openhab.binding.boschshc.internal.services.dto.JsonRestExceptionResponse;
49 import org.openhab.core.thing.Bridge;
50 import org.openhab.core.thing.ChannelUID;
51 import org.openhab.core.thing.Thing;
52 import org.openhab.core.thing.ThingStatus;
53 import org.openhab.core.thing.ThingStatusDetail;
54 import org.openhab.core.thing.binding.BaseBridgeHandler;
55 import org.openhab.core.thing.binding.ThingHandler;
56 import org.openhab.core.thing.binding.ThingHandlerService;
57 import org.openhab.core.types.Command;
58 import org.osgi.framework.Bundle;
59 import org.osgi.framework.FrameworkUtil;
60 import org.slf4j.Logger;
61 import org.slf4j.LoggerFactory;
62
63 import com.google.gson.JsonElement;
64 import com.google.gson.reflect.TypeToken;
65
66 /**
67  * Representation of a connection with a Bosch Smart Home Controller bridge.
68  *
69  * @author Stefan Kästle - Initial contribution
70  * @author Gerd Zanker - added HttpClient with pairing support
71  * @author Christian Oeing - refactorings of e.g. server registration
72  * @author David Pace - Added support for custom endpoints and HTTP POST requests
73  * @author Gerd Zanker - added thing discovery
74  */
75 @NonNullByDefault
76 public class BridgeHandler extends BaseBridgeHandler {
77
78     private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
79
80     /**
81      * Handler to do long polling.
82      */
83     private final LongPolling longPolling;
84
85     /**
86      * HTTP client for all communications to and from the bridge.
87      * <p>
88      * This member is package-protected to enable mocking in unit tests.
89      */
90     /* package */ @Nullable
91     BoschHttpClient httpClient;
92
93     private @Nullable ScheduledFuture<?> scheduledPairing;
94
95     /**
96      * SHC thing/device discovery service instance.
97      * Registered and unregistered if service is actived/deactived.
98      * Used to scan for things after bridge is paired with SHC.
99      */
100     private @Nullable ThingDiscoveryService thingDiscoveryService;
101
102     public BridgeHandler(Bridge bridge) {
103         super(bridge);
104
105         this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
106     }
107
108     @Override
109     public Collection<Class<? extends ThingHandlerService>> getServices() {
110         return Set.of(ThingDiscoveryService.class);
111     }
112
113     @Override
114     public void initialize() {
115         Bundle bundle = FrameworkUtil.getBundle(getClass());
116         if (bundle != null) {
117             logger.debug("Initialize {} Version {}", bundle.getSymbolicName(), bundle.getVersion());
118         }
119
120         // Read configuration
121         BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
122
123         String ipAddress = config.ipAddress.trim();
124         if (ipAddress.isEmpty()) {
125             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
126                     "@text/offline.conf-error-empty-ip");
127             return;
128         }
129
130         String password = config.password.trim();
131         if (password.isEmpty()) {
132             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
133                     "@text/offline.conf-error-empty-password");
134             return;
135         }
136
137         SslContextFactory factory;
138         try {
139             // prepare SSL key and certificates
140             factory = new BoschSslUtil(ipAddress).getSslContextFactory();
141         } catch (PairingFailedException e) {
142             logger.debug("Error while obtaining SSL context factory.", e);
143             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
144                     "@text/offline.conf-error-ssl");
145             return;
146         }
147
148         // Instantiate HttpClient with the SslContextFactory
149         BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
150
151         // Start http client
152         try {
153             httpClient.start();
154         } catch (Exception e) {
155             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
156                     String.format("Could not create http connection to controller: %s", e.getMessage()));
157             return;
158         }
159
160         // general checks are OK, therefore set the status to unknown and wait for initial access
161         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
162
163         // Initialize bridge in the background.
164         // Start initial access the first time
165         scheduleInitialAccess(httpClient);
166     }
167
168     @Override
169     public void dispose() {
170         // Cancel scheduled pairing.
171         @Nullable
172         ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
173         if (scheduledPairing != null) {
174             scheduledPairing.cancel(true);
175             this.scheduledPairing = null;
176         }
177
178         // Stop long polling.
179         this.longPolling.stop();
180
181         @Nullable
182         BoschHttpClient httpClient = this.httpClient;
183         if (httpClient != null) {
184             try {
185                 httpClient.stop();
186             } catch (Exception e) {
187                 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage(), e);
188             }
189             this.httpClient = null;
190         }
191
192         super.dispose();
193     }
194
195     @Override
196     public void handleCommand(ChannelUID channelUID, Command command) {
197         // commands are handled by individual device handlers
198     }
199
200     /**
201      * Schedule the initial access.
202      * Use a delay if pairing fails and next retry is scheduled.
203      */
204     private void scheduleInitialAccess(BoschHttpClient httpClient) {
205         this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
206     }
207
208     /**
209      * Execute the initial access.
210      * Uses the HTTP Bosch SHC client
211      * to check if access if possible
212      * pairs this Bosch SHC Bridge with the SHC if necessary
213      * and starts the first log poll.
214      * <p>
215      * This method is package-protected to enable unit testing.
216      */
217     /* package */ void initialAccess(BoschHttpClient httpClient) {
218         logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
219
220         try {
221             // check if SCH is offline
222             if (!httpClient.isOnline()) {
223                 // update status already if access is not possible
224                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
225                         "@text/offline.conf-error-offline");
226                 // restart later initial access
227                 scheduleInitialAccess(httpClient);
228                 return;
229             }
230
231             // SHC is online
232             // check if SHC access is not possible and pairing necessary
233             if (!httpClient.isAccessPossible()) {
234                 // update status description to show pairing test
235                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
236                         "@text/offline.conf-error-pairing");
237                 if (!httpClient.doPairing()) {
238                     this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
239                             "@text/offline.conf-error-pairing");
240                 }
241                 // restart initial access - needed also in case of successful pairing to check access again
242                 scheduleInitialAccess(httpClient);
243                 return;
244             }
245
246             // SHC is online and access should possible
247             if (!checkBridgeAccess()) {
248                 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
249                         "@text/offline.not-reachable");
250                 // restart initial access
251                 scheduleInitialAccess(httpClient);
252                 return;
253             }
254
255             // do thing discovery after pairing
256             final ThingDiscoveryService discovery = thingDiscoveryService;
257             if (discovery != null) {
258                 discovery.doScan();
259             }
260
261             // start long polling loop
262             this.updateStatus(ThingStatus.ONLINE);
263             startLongPolling(httpClient);
264
265         } catch (InterruptedException e) {
266             this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
267             Thread.currentThread().interrupt();
268         }
269     }
270
271     private void startLongPolling(BoschHttpClient httpClient) {
272         try {
273             this.longPolling.start(httpClient);
274         } catch (LongPollingFailedException e) {
275             this.handleLongPollFailure(e);
276         }
277     }
278
279     /**
280      * Check the bridge access by sending an HTTP request.
281      * Does not throw any exception in case the request fails.
282      */
283     public boolean checkBridgeAccess() throws InterruptedException {
284         @Nullable
285         BoschHttpClient httpClient = this.httpClient;
286
287         if (httpClient == null) {
288             return false;
289         }
290
291         try {
292             logger.debug("Sending http request to BoschSHC to check access: {}", httpClient);
293             String url = httpClient.getBoschSmartHomeUrl("devices");
294             ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
295
296             // check HTTP status code
297             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
298                 logger.debug("Access check failed with status code: {}", contentResponse.getStatus());
299                 return false;
300             }
301
302             // Access OK
303             return true;
304         } catch (TimeoutException | ExecutionException e) {
305             logger.warn("Access check failed because of {}!", e.getMessage());
306             return false;
307         }
308     }
309
310     /**
311      * Get a list of connected devices from the Smart-Home Controller
312      *
313      * @throws InterruptedException in case bridge is stopped
314      */
315     public List<Device> getDevices() throws InterruptedException {
316         @Nullable
317         BoschHttpClient httpClient = this.httpClient;
318         if (httpClient == null) {
319             return Collections.emptyList();
320         }
321
322         try {
323             logger.trace("Sending http request to Bosch to request devices: {}", httpClient);
324             String url = httpClient.getBoschSmartHomeUrl("devices");
325             ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
326
327             // check HTTP status code
328             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
329                 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
330                 return Collections.emptyList();
331             }
332
333             String content = contentResponse.getContentAsString();
334             logger.trace("Request devices completed with success: {} - status code: {}", content,
335                     contentResponse.getStatus());
336
337             Type collectionType = new TypeToken<ArrayList<Device>>() {
338             }.getType();
339             List<Device> nullableDevices = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content, collectionType);
340             return Optional.ofNullable(nullableDevices).orElse(Collections.emptyList());
341         } catch (TimeoutException | ExecutionException e) {
342             logger.debug("Request devices failed because of {}!", e.getMessage(), e);
343             return Collections.emptyList();
344         }
345     }
346
347     /**
348      * Get a list of rooms from the Smart-Home controller
349      *
350      * @throws InterruptedException in case bridge is stopped
351      */
352     public List<Room> getRooms() throws InterruptedException {
353         List<Room> emptyRooms = new ArrayList<>();
354         @Nullable
355         BoschHttpClient httpClient = this.httpClient;
356         if (httpClient != null) {
357             try {
358                 logger.trace("Sending http request to Bosch to request rooms");
359                 String url = httpClient.getBoschSmartHomeUrl("rooms");
360                 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
361
362                 // check HTTP status code
363                 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
364                     logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
365                     return emptyRooms;
366                 }
367
368                 String content = contentResponse.getContentAsString();
369                 logger.trace("Request rooms completed with success: {} - status code: {}", content,
370                         contentResponse.getStatus());
371
372                 Type collectionType = new TypeToken<ArrayList<Room>>() {
373                 }.getType();
374
375                 ArrayList<Room> rooms = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content, collectionType);
376                 return Objects.requireNonNullElse(rooms, emptyRooms);
377             } catch (TimeoutException | ExecutionException e) {
378                 logger.debug("Request rooms failed because of {}!", e.getMessage());
379                 return emptyRooms;
380             }
381         } else {
382             return emptyRooms;
383         }
384     }
385
386     public boolean registerDiscoveryListener(ThingDiscoveryService listener) {
387         if (thingDiscoveryService == null) {
388             thingDiscoveryService = listener;
389             return true;
390         }
391
392         return false;
393     }
394
395     public boolean unregisterDiscoveryListener() {
396         if (thingDiscoveryService != null) {
397             thingDiscoveryService = null;
398             return true;
399         }
400
401         return false;
402     }
403
404     /**
405      * Bridge callback handler for the results of long polls.
406      *
407      * It will check the results and
408      * forward the received states to the Bosch thing handlers.
409      *
410      * @param result Results from Long Polling
411      */
412     private void handleLongPollResult(LongPollResult result) {
413         for (DeviceServiceData deviceServiceData : result.result) {
414             handleDeviceServiceData(deviceServiceData);
415         }
416     }
417
418     /**
419      * Processes a single long poll result.
420      *
421      * @param deviceServiceData object representing a single long poll result
422      */
423     private void handleDeviceServiceData(@Nullable DeviceServiceData deviceServiceData) {
424         if (deviceServiceData != null) {
425             JsonElement state = obtainState(deviceServiceData);
426
427             logger.debug("Got update for service {} of type {}: {}", deviceServiceData.id, deviceServiceData.type,
428                     state);
429
430             var updateDeviceId = deviceServiceData.deviceId;
431             if (updateDeviceId == null || state == null) {
432                 return;
433             }
434
435             logger.debug("Got update for device {}", updateDeviceId);
436
437             forwardStateToHandlers(deviceServiceData, state, updateDeviceId);
438         }
439     }
440
441     /**
442      * Extracts the actual state object from the given {@link DeviceServiceData} instance.
443      * <p>
444      * In some special cases like the <code>BatteryLevel</code> service the {@link DeviceServiceData} object itself
445      * contains the state.
446      * In all other cases, the state is contained in a sub-object named <code>state</code>.
447      *
448      * @param deviceServiceData the {@link DeviceServiceData} object from which the state should be obtained
449      * @return the state sub-object or the {@link DeviceServiceData} object itself
450      */
451     @Nullable
452     private JsonElement obtainState(DeviceServiceData deviceServiceData) {
453         // the battery level service receives no individual state object but rather requires the DeviceServiceData
454         // structure
455         if ("BatteryLevel".equals(deviceServiceData.id)) {
456             return GsonUtils.DEFAULT_GSON_INSTANCE.toJsonTree(deviceServiceData);
457         }
458
459         return deviceServiceData.state;
460     }
461
462     /**
463      * Tries to find handlers for the device with the given ID and forwards the received state to the handlers.
464      *
465      * @param deviceServiceData object representing updates received in long poll results
466      * @param state the received state object as JSON element
467      * @param updateDeviceId the ID of the device for which the state update was received
468      */
469     private void forwardStateToHandlers(DeviceServiceData deviceServiceData, JsonElement state, String updateDeviceId) {
470         boolean handled = false;
471
472         Bridge bridge = this.getThing();
473         for (Thing childThing : bridge.getThings()) {
474             // All children of this should implement BoschSHCHandler
475             @Nullable
476             ThingHandler baseHandler = childThing.getHandler();
477             if (baseHandler instanceof BoschSHCHandler handler) {
478                 @Nullable
479                 String deviceId = handler.getBoschID();
480
481                 handled = true;
482                 logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
483
484                 if (deviceId != null && updateDeviceId.equals(deviceId)) {
485                     logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler,
486                             deviceServiceData.id, state);
487                     handler.processUpdate(deviceServiceData.id, state);
488                 }
489             } else {
490                 logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
491             }
492         }
493
494         if (!handled) {
495             logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
496         }
497     }
498
499     /**
500      * Bridge callback handler for the failures during long polls.
501      *
502      * It will update the bridge status and try to access the SHC again.
503      *
504      * @param e error during long polling
505      */
506     private void handleLongPollFailure(Throwable e) {
507         logger.warn("Long polling failed, will try to reconnect", e);
508         @Nullable
509         BoschHttpClient httpClient = this.httpClient;
510         if (httpClient == null) {
511             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
512                     "@text/offline.long-polling-failed.http-client-null");
513             return;
514         }
515
516         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
517                 "@text/offline.long-polling-failed.trying-to-reconnect");
518         scheduleInitialAccess(httpClient);
519     }
520
521     public Device getDeviceInfo(String deviceId)
522             throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
523         @Nullable
524         BoschHttpClient httpClient = this.httpClient;
525         if (httpClient == null) {
526             throw new BoschSHCException("HTTP client not initialized");
527         }
528
529         String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
530         Request request = httpClient.createRequest(url, GET);
531
532         return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
533             JsonRestExceptionResponse errorResponse = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
534                     JsonRestExceptionResponse.class);
535             if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
536                 if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
537                     return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
538                 } else {
539                     return new BoschSHCException(
540                             String.format("Request for info of device %s failed with status code %d and error code %s",
541                                     deviceId, errorResponse.statusCode, errorResponse.errorCode));
542                 }
543             } else {
544                 return new BoschSHCException(String.format("Request for info of device %s failed with status code %d",
545                         deviceId, statusCode));
546             }
547         });
548     }
549
550     /**
551      * Query the Bosch Smart Home Controller for the state of the given device.
552      * <p>
553      * The URL used for retrieving the state has the following structure:
554      *
555      * <pre>
556      * https://{IP}:8444/smarthome/devices/{deviceId}/services/{serviceName}/state
557      * </pre>
558      *
559      * @param deviceId Id of device to get state for
560      * @param stateName Name of the state to query
561      * @param stateClass Class to convert the resulting JSON to
562      * @return the deserialized state object, may be <code>null</code>
563      * @throws ExecutionException
564      * @throws TimeoutException
565      * @throws InterruptedException
566      * @throws BoschSHCException
567      */
568     public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
569             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
570         @Nullable
571         BoschHttpClient httpClient = this.httpClient;
572         if (httpClient == null) {
573             logger.warn("HttpClient not initialized");
574             return null;
575         }
576
577         String url = httpClient.getServiceStateUrl(stateName, deviceId);
578         logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
579         return getState(httpClient, url, stateClass);
580     }
581
582     /**
583      * Queries the Bosch Smart Home Controller for the state using an explicit endpoint.
584      *
585      * @param <T> Type to which the resulting JSON should be deserialized to
586      * @param endpoint The destination endpoint part of the URL
587      * @param stateClass Class to convert the resulting JSON to
588      * @return the deserialized state object, may be <code>null</code>
589      * @throws InterruptedException
590      * @throws TimeoutException
591      * @throws ExecutionException
592      * @throws BoschSHCException
593      */
594     public <T extends BoschSHCServiceState> @Nullable T getState(String endpoint, Class<T> stateClass)
595             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
596         @Nullable
597         BoschHttpClient httpClient = this.httpClient;
598         if (httpClient == null) {
599             logger.warn("HttpClient not initialized");
600             return null;
601         }
602
603         String url = httpClient.getBoschSmartHomeUrl(endpoint);
604         logger.debug("getState(): Requesting from Bosch: {}", url);
605         return getState(httpClient, url, stateClass);
606     }
607
608     /**
609      * Sends a HTTP GET request in order to retrieve a state from the Bosch Smart Home Controller.
610      *
611      * @param <T> Type to which the resulting JSON should be deserialized to
612      * @param httpClient HTTP client used for sending the request
613      * @param url URL at which the state should be retrieved
614      * @param stateClass Class to convert the resulting JSON to
615      * @return the deserialized state object, may be <code>null</code>
616      * @throws InterruptedException
617      * @throws TimeoutException
618      * @throws ExecutionException
619      * @throws BoschSHCException
620      */
621     protected <T extends BoschSHCServiceState> @Nullable T getState(BoschHttpClient httpClient, String url,
622             Class<T> stateClass) throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
623         Request request = httpClient.createRequest(url, GET).header("Accept", "application/json");
624
625         ContentResponse contentResponse = request.send();
626
627         String content = contentResponse.getContentAsString();
628         logger.debug("getState(): Request complete: [{}] - return code: {}", content, contentResponse.getStatus());
629
630         int statusCode = contentResponse.getStatus();
631         if (statusCode != 200) {
632             JsonRestExceptionResponse errorResponse = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(content,
633                     JsonRestExceptionResponse.class);
634             if (errorResponse != null) {
635                 throw new BoschSHCException(
636                         String.format("State request with URL %s failed with status code %d and error code %s", url,
637                                 errorResponse.statusCode, errorResponse.errorCode));
638             } else {
639                 throw new BoschSHCException(
640                         String.format("State request with URL %s failed with status code %d", url, statusCode));
641             }
642         }
643
644         @Nullable
645         T state = BoschSHCServiceState.fromJson(content, stateClass);
646         if (state == null) {
647             throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
648         }
649         return state;
650     }
651
652     /**
653      * Sends a state change for a device to the controller
654      *
655      * @param deviceId Id of device to change state for
656      * @param serviceName Name of service of device to change state for
657      * @param state New state data to set for service
658      *
659      * @return Response of request
660      * @throws InterruptedException
661      * @throws ExecutionException
662      * @throws TimeoutException
663      */
664     public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
665             throws InterruptedException, TimeoutException, ExecutionException {
666         @Nullable
667         BoschHttpClient httpClient = this.httpClient;
668         if (httpClient == null) {
669             logger.warn("HttpClient not initialized");
670             return null;
671         }
672
673         // Create request
674         String url = httpClient.getServiceStateUrl(serviceName, deviceId);
675         Request request = httpClient.createRequest(url, PUT, state);
676
677         // Send request
678         return request.send();
679     }
680
681     /**
682      * Sends a HTTP POST request without a request body to the given endpoint.
683      *
684      * @param endpoint The destination endpoint part of the URL
685      * @return the HTTP response
686      * @throws InterruptedException
687      * @throws TimeoutException
688      * @throws ExecutionException
689      */
690     public @Nullable Response postAction(String endpoint)
691             throws InterruptedException, TimeoutException, ExecutionException {
692         return postAction(endpoint, null);
693     }
694
695     /**
696      * Sends a HTTP POST request with a request body to the given endpoint.
697      *
698      * @param <T> Type of the request
699      * @param endpoint The destination endpoint part of the URL
700      * @param requestBody object representing the request body to be sent, may be <code>null</code>
701      * @return the HTTP response
702      * @throws InterruptedException
703      * @throws TimeoutException
704      * @throws ExecutionException
705      */
706     public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
707             throws InterruptedException, TimeoutException, ExecutionException {
708         @Nullable
709         BoschHttpClient httpClient = this.httpClient;
710         if (httpClient == null) {
711             logger.warn("HttpClient not initialized");
712             return null;
713         }
714
715         String url = httpClient.getBoschSmartHomeUrl(endpoint);
716         Request request = httpClient.createRequest(url, POST, requestBody);
717         return request.send();
718     }
719
720     public @Nullable DeviceServiceData getServiceData(String deviceId, String serviceName)
721             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
722         @Nullable
723         BoschHttpClient httpClient = this.httpClient;
724         if (httpClient == null) {
725             logger.warn("HttpClient not initialized");
726             return null;
727         }
728
729         String url = httpClient.getServiceUrl(serviceName, deviceId);
730         logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", serviceName, deviceId, url);
731         return getState(httpClient, url, DeviceServiceData.class);
732     }
733 }