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