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