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