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