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