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