]> git.basschouten.com Git - openhab-addons.git/blob
6c0752c78d6b3487b1739d816c0cc0a32149fb1f
[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.services.dto.BoschSHCServiceState;
46 import org.openhab.binding.boschshc.internal.services.dto.JsonRestExceptionResponse;
47 import org.openhab.core.thing.Bridge;
48 import org.openhab.core.thing.ChannelUID;
49 import org.openhab.core.thing.Thing;
50 import org.openhab.core.thing.ThingStatus;
51 import org.openhab.core.thing.ThingStatusDetail;
52 import org.openhab.core.thing.binding.BaseBridgeHandler;
53 import org.openhab.core.thing.binding.ThingHandler;
54 import org.openhab.core.thing.binding.ThingHandlerService;
55 import org.openhab.core.types.Command;
56 import org.osgi.framework.Bundle;
57 import org.osgi.framework.FrameworkUtil;
58 import org.slf4j.Logger;
59 import org.slf4j.LoggerFactory;
60
61 import com.google.gson.Gson;
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      * gson instance to convert a class to json string and back.
81      */
82     private final Gson gson = new Gson();
83
84     /**
85      * Handler to do long polling.
86      */
87     private final LongPolling longPolling;
88
89     /**
90      * HTTP client for all communications to and from the bridge.
91      * <p>
92      * This member is package-protected to enable mocking in unit tests.
93      */
94     /* package */ @Nullable
95     BoschHttpClient httpClient;
96
97     private @Nullable ScheduledFuture<?> scheduledPairing;
98
99     /**
100      * SHC thing/device discovery service instance.
101      * Registered and unregistered if service is actived/deactived.
102      * Used to scan for things after bridge is paired with SHC.
103      */
104     private @Nullable ThingDiscoveryService thingDiscoveryService;
105
106     public BridgeHandler(Bridge bridge) {
107         super(bridge);
108
109         this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
110     }
111
112     @Override
113     public Collection<Class<? extends ThingHandlerService>> getServices() {
114         return Collections.singleton(ThingDiscoveryService.class);
115     }
116
117     @Override
118     public void initialize() {
119         Bundle bundle = FrameworkUtil.getBundle(getClass());
120         if (bundle != null) {
121             logger.debug("Initialize {} Version {}", bundle.getSymbolicName(), bundle.getVersion());
122         }
123
124         // Read configuration
125         BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
126
127         String ipAddress = config.ipAddress.trim();
128         if (ipAddress.isEmpty()) {
129             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
130                     "@text/offline.conf-error-empty-ip");
131             return;
132         }
133
134         String password = config.password.trim();
135         if (password.isEmpty()) {
136             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
137                     "@text/offline.conf-error-empty-password");
138             return;
139         }
140
141         SslContextFactory factory;
142         try {
143             // prepare SSL key and certificates
144             factory = new BoschSslUtil(ipAddress).getSslContextFactory();
145         } catch (PairingFailedException e) {
146             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
147                     "@text/offline.conf-error-ssl");
148             return;
149         }
150
151         // Instantiate HttpClient with the SslContextFactory
152         BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
153
154         // Start http client
155         try {
156             httpClient.start();
157         } catch (Exception e) {
158             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
159                     String.format("Could not create http connection to controller: %s", e.getMessage()));
160             return;
161         }
162
163         // general checks are OK, therefore set the status to unknown and wait for initial access
164         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
165
166         // Initialize bridge in the background.
167         // Start initial access the first time
168         scheduleInitialAccess(httpClient);
169     }
170
171     @Override
172     public void dispose() {
173         // Cancel scheduled pairing.
174         @Nullable
175         ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
176         if (scheduledPairing != null) {
177             scheduledPairing.cancel(true);
178             this.scheduledPairing = null;
179         }
180
181         // Stop long polling.
182         this.longPolling.stop();
183
184         @Nullable
185         BoschHttpClient httpClient = this.httpClient;
186         if (httpClient != null) {
187             try {
188                 httpClient.stop();
189             } catch (Exception e) {
190                 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage());
191             }
192             this.httpClient = null;
193         }
194
195         super.dispose();
196     }
197
198     @Override
199     public void handleCommand(ChannelUID channelUID, Command command) {
200     }
201
202     /**
203      * Schedule the initial access.
204      * Use a delay if pairing fails and next retry is scheduled.
205      */
206     private void scheduleInitialAccess(BoschHttpClient httpClient) {
207         this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
208     }
209
210     /**
211      * Execute the initial access.
212      * Uses the HTTP Bosch SHC client
213      * to check if access if possible
214      * pairs this Bosch SHC Bridge with the SHC if necessary
215      * and starts the first log poll.
216      * <p>
217      * This method is package-protected to enable unit testing.
218      */
219     /* package */ void initialAccess(BoschHttpClient httpClient) {
220         logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
221
222         try {
223             // check if SCH is offline
224             if (!httpClient.isOnline()) {
225                 // update status already if access is not possible
226                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
227                         "@text/offline.conf-error-offline");
228                 // restart later initial access
229                 scheduleInitialAccess(httpClient);
230                 return;
231             }
232
233             // SHC is online
234             // check if SHC access is not possible and pairing necessary
235             if (!httpClient.isAccessPossible()) {
236                 // update status description to show pairing test
237                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
238                         "@text/offline.conf-error-pairing");
239                 if (!httpClient.doPairing()) {
240                     this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
241                             "@text/offline.conf-error-pairing");
242                 }
243                 // restart initial access - needed also in case of successful pairing to check access again
244                 scheduleInitialAccess(httpClient);
245                 return;
246             }
247
248             // SHC is online and access should possible
249             if (!checkBridgeAccess()) {
250                 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
251                         "@text/offline.not-reachable");
252                 // restart initial access
253                 scheduleInitialAccess(httpClient);
254                 return;
255             }
256
257             // do thing discovery after pairing
258             final ThingDiscoveryService discovery = thingDiscoveryService;
259             if (discovery != null) {
260                 discovery.doScan();
261             }
262
263             // start long polling loop
264             this.updateStatus(ThingStatus.ONLINE);
265             try {
266                 this.longPolling.start(httpClient);
267             } catch (LongPollingFailedException e) {
268                 this.handleLongPollFailure(e);
269             }
270
271         } catch (InterruptedException e) {
272             this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
273             Thread.currentThread().interrupt();
274         }
275     }
276
277     /**
278      * Check the bridge access by sending an HTTP request.
279      * Does not throw any exception in case the request fails.
280      */
281     public boolean checkBridgeAccess() throws InterruptedException {
282         @Nullable
283         BoschHttpClient httpClient = this.httpClient;
284
285         if (httpClient == null) {
286             return false;
287         }
288
289         try {
290             logger.debug("Sending http request to BoschSHC to check access: {}", httpClient);
291             String url = httpClient.getBoschSmartHomeUrl("devices");
292             ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
293
294             // check HTTP status code
295             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
296                 logger.debug("Access check failed with status code: {}", contentResponse.getStatus());
297                 return false;
298             }
299
300             // Access OK
301             return true;
302         } catch (TimeoutException | ExecutionException e) {
303             logger.warn("Access check failed because of {}!", e.getMessage());
304             return false;
305         }
306     }
307
308     /**
309      * Get a list of connected devices from the Smart-Home Controller
310      *
311      * @throws InterruptedException in case bridge is stopped
312      */
313     public List<Device> getDevices() throws InterruptedException {
314         @Nullable
315         BoschHttpClient httpClient = this.httpClient;
316         if (httpClient == null) {
317             return Collections.emptyList();
318         }
319
320         try {
321             logger.trace("Sending http request to Bosch to request devices: {}", httpClient);
322             String url = httpClient.getBoschSmartHomeUrl("devices");
323             ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
324
325             // check HTTP status code
326             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
327                 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
328                 return Collections.emptyList();
329             }
330
331             String content = contentResponse.getContentAsString();
332             logger.trace("Request devices completed with success: {} - status code: {}", content,
333                     contentResponse.getStatus());
334
335             Type collectionType = new TypeToken<ArrayList<Device>>() {
336             }.getType();
337             @Nullable
338             List<Device> nullableDevices = gson.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());
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 = gson.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 gson.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 != null && baseHandler instanceof BoschSHCHandler) {
477                 BoschSHCHandler handler = (BoschSHCHandler) baseHandler;
478                 @Nullable
479                 String deviceId = handler.getBoschID();
480
481                 handled = true;
482                 logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
483
484                 if (deviceId != null && updateDeviceId.equals(deviceId)) {
485                     logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler,
486                             deviceServiceData.id, state);
487                     handler.processUpdate(deviceServiceData.id, state);
488                 }
489             } else {
490                 logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
491             }
492         }
493
494         if (!handled) {
495             logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
496         }
497     }
498
499     /**
500      * Bridge callback handler for the failures during long polls.
501      *
502      * It will update the bridge status and try to access the SHC again.
503      *
504      * @param e error during long polling
505      */
506     private void handleLongPollFailure(Throwable e) {
507         logger.warn("Long polling failed, will try to reconnect", e);
508         @Nullable
509         BoschHttpClient httpClient = this.httpClient;
510         if (httpClient == null) {
511             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
512                     "@text/offline.long-polling-failed.http-client-null");
513             return;
514         }
515
516         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
517                 "@text/offline.long-polling-failed.trying-to-reconnect");
518         scheduleInitialAccess(httpClient);
519     }
520
521     public Device getDeviceInfo(String deviceId)
522             throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
523         @Nullable
524         BoschHttpClient httpClient = this.httpClient;
525         if (httpClient == null) {
526             throw new BoschSHCException("HTTP client not initialized");
527         }
528
529         String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
530         Request request = httpClient.createRequest(url, GET);
531
532         return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
533             JsonRestExceptionResponse errorResponse = gson.fromJson(content, 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 = gson.fromJson(content, JsonRestExceptionResponse.class);
632             if (errorResponse != null) {
633                 throw new BoschSHCException(
634                         String.format("State request with URL %s failed with status code %d and error code %s", url,
635                                 errorResponse.statusCode, errorResponse.errorCode));
636             } else {
637                 throw new BoschSHCException(
638                         String.format("State request with URL %s failed with status code %d", url, statusCode));
639             }
640         }
641
642         @Nullable
643         T state = BoschSHCServiceState.fromJson(content, stateClass);
644         if (state == null) {
645             throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
646         }
647         return state;
648     }
649
650     /**
651      * Sends a state change for a device to the controller
652      *
653      * @param deviceId Id of device to change state for
654      * @param serviceName Name of service of device to change state for
655      * @param state New state data to set for service
656      *
657      * @return Response of request
658      * @throws InterruptedException
659      * @throws ExecutionException
660      * @throws TimeoutException
661      */
662     public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
663             throws InterruptedException, TimeoutException, ExecutionException {
664         @Nullable
665         BoschHttpClient httpClient = this.httpClient;
666         if (httpClient == null) {
667             logger.warn("HttpClient not initialized");
668             return null;
669         }
670
671         // Create request
672         String url = httpClient.getServiceStateUrl(serviceName, deviceId);
673         Request request = httpClient.createRequest(url, PUT, state);
674
675         // Send request
676         return request.send();
677     }
678
679     /**
680      * Sends a HTTP POST request without a request body to the given endpoint.
681      *
682      * @param endpoint The destination endpoint part of the URL
683      * @return the HTTP response
684      * @throws InterruptedException
685      * @throws TimeoutException
686      * @throws ExecutionException
687      */
688     public @Nullable Response postAction(String endpoint)
689             throws InterruptedException, TimeoutException, ExecutionException {
690         return postAction(endpoint, null);
691     }
692
693     /**
694      * Sends a HTTP POST request with a request body to the given endpoint.
695      *
696      * @param <T> Type of the request
697      * @param endpoint The destination endpoint part of the URL
698      * @param requestBody object representing the request body to be sent, may be <code>null</code>
699      * @return the HTTP response
700      * @throws InterruptedException
701      * @throws TimeoutException
702      * @throws ExecutionException
703      */
704     public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
705             throws InterruptedException, TimeoutException, ExecutionException {
706         @Nullable
707         BoschHttpClient httpClient = this.httpClient;
708         if (httpClient == null) {
709             logger.warn("HttpClient not initialized");
710             return null;
711         }
712
713         String url = httpClient.getBoschSmartHomeUrl(endpoint);
714         Request request = httpClient.createRequest(url, POST, requestBody);
715         return request.send();
716     }
717
718     public @Nullable DeviceServiceData getServiceData(String deviceId, String serviceName)
719             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
720         @Nullable
721         BoschHttpClient httpClient = this.httpClient;
722         if (httpClient == null) {
723             logger.warn("HttpClient not initialized");
724             return null;
725         }
726
727         String url = httpClient.getServiceUrl(serviceName, deviceId);
728         logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", serviceName, deviceId, url);
729         return getState(httpClient, url, DeviceServiceData.class);
730     }
731 }