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