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