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