]> git.basschouten.com Git - openhab-addons.git/blob
f04272c1ccad7067e6ee7ec4ac5753c5536c446d
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 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.concurrent.ExecutionException;
20 import java.util.concurrent.ScheduledFuture;
21 import java.util.concurrent.TimeUnit;
22 import java.util.concurrent.TimeoutException;
23
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.api.ContentResponse;
27 import org.eclipse.jetty.client.api.Request;
28 import org.eclipse.jetty.client.api.Response;
29 import org.eclipse.jetty.http.HttpStatus;
30 import org.eclipse.jetty.util.ssl.SslContextFactory;
31 import org.openhab.binding.boschshc.internal.devices.BoschSHCHandler;
32 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Device;
33 import org.openhab.binding.boschshc.internal.devices.bridge.dto.DeviceStatusUpdate;
34 import org.openhab.binding.boschshc.internal.devices.bridge.dto.LongPollResult;
35 import org.openhab.binding.boschshc.internal.devices.bridge.dto.Room;
36 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
37 import org.openhab.binding.boschshc.internal.exceptions.LongPollingFailedException;
38 import org.openhab.binding.boschshc.internal.exceptions.PairingFailedException;
39 import org.openhab.binding.boschshc.internal.services.dto.BoschSHCServiceState;
40 import org.openhab.binding.boschshc.internal.services.dto.JsonRestExceptionResponse;
41 import org.openhab.core.thing.Bridge;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.binding.BaseBridgeHandler;
47 import org.openhab.core.thing.binding.ThingHandler;
48 import org.openhab.core.types.Command;
49 import org.osgi.framework.FrameworkUtil;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52
53 import com.google.gson.Gson;
54 import com.google.gson.reflect.TypeToken;
55
56 /**
57  * Representation of a connection with a Bosch Smart Home Controller bridge.
58  *
59  * @author Stefan Kästle - Initial contribution
60  * @author Gerd Zanker - added HttpClient with pairing support
61  * @author Christian Oeing - refactorings of e.g. server registration
62  */
63 @NonNullByDefault
64 public class BridgeHandler extends BaseBridgeHandler {
65
66     private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
67
68     /**
69      * gson instance to convert a class to json string and back.
70      */
71     private final Gson gson = new Gson();
72
73     /**
74      * Handler to do long polling.
75      */
76     private final LongPolling longPolling;
77
78     private @Nullable BoschHttpClient httpClient;
79
80     private @Nullable ScheduledFuture<?> scheduledPairing;
81
82     public BridgeHandler(Bridge bridge) {
83         super(bridge);
84
85         this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
86     }
87
88     @Override
89     public void initialize() {
90         logger.debug("Initialize {} Version {}", FrameworkUtil.getBundle(getClass()).getSymbolicName(),
91                 FrameworkUtil.getBundle(getClass()).getVersion());
92
93         // Read configuration
94         BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
95
96         String ipAddress = config.ipAddress.trim();
97         if (ipAddress.isEmpty()) {
98             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
99                     "@text/offline.conf-error-empty-ip");
100             return;
101         }
102
103         String password = config.password.trim();
104         if (password.isEmpty()) {
105             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
106                     "@text/offline.conf-error-empty-password");
107             return;
108         }
109
110         SslContextFactory factory;
111         try {
112             // prepare SSL key and certificates
113             factory = new BoschSslUtil(ipAddress).getSslContextFactory();
114         } catch (PairingFailedException e) {
115             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
116                     "@text/offline.conf-error-ssl");
117             return;
118         }
119
120         // Instantiate HttpClient with the SslContextFactory
121         BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
122
123         // Start http client
124         try {
125             httpClient.start();
126         } catch (Exception e) {
127             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
128                     String.format("Could not create http connection to controller: %s", e.getMessage()));
129             return;
130         }
131
132         // general checks are OK, therefore set the status to unknown and wait for initial access
133         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
134
135         // Initialize bridge in the background.
136         // Start initial access the first time
137         scheduleInitialAccess(httpClient);
138     }
139
140     @Override
141     public void dispose() {
142         // Cancel scheduled pairing.
143         @Nullable
144         ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
145         if (scheduledPairing != null) {
146             scheduledPairing.cancel(true);
147             this.scheduledPairing = null;
148         }
149
150         // Stop long polling.
151         this.longPolling.stop();
152
153         @Nullable
154         BoschHttpClient httpClient = this.httpClient;
155         if (httpClient != null) {
156             try {
157                 httpClient.stop();
158             } catch (Exception e) {
159                 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage());
160             }
161             this.httpClient = null;
162         }
163
164         super.dispose();
165     }
166
167     @Override
168     public void handleCommand(ChannelUID channelUID, Command command) {
169     }
170
171     /**
172      * Schedule the initial access.
173      * Use a delay if pairing fails and next retry is scheduled.
174      */
175     private void scheduleInitialAccess(BoschHttpClient httpClient) {
176         this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
177     }
178
179     /**
180      * Execute the initial access.
181      * Uses the HTTP Bosch SHC client
182      * to check if access if possible
183      * pairs this Bosch SHC Bridge with the SHC if necessary
184      * and starts the first log poll.
185      */
186     private void initialAccess(BoschHttpClient httpClient) {
187         logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
188
189         try {
190             // check if SCH is offline
191             if (!httpClient.isOnline()) {
192                 // update status already if access is not possible
193                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
194                         "@text/offline.conf-error-offline");
195                 // restart later initial access
196                 scheduleInitialAccess(httpClient);
197                 return;
198             }
199
200             // SHC is online
201             // check if SHC access is not possible and pairing necessary
202             if (!httpClient.isAccessPossible()) {
203                 // update status description to show pairing test
204                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
205                         "@text/offline.conf-error-pairing");
206                 if (!httpClient.doPairing()) {
207                     this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
208                             "@text/offline.conf-error-pairing");
209                 }
210                 // restart initial access - needed also in case of successful pairing to check access again
211                 scheduleInitialAccess(httpClient);
212                 return;
213             }
214
215             // SHC is online and access is possible
216             // print rooms and devices
217             boolean thingReachable = true;
218             thingReachable &= this.getRooms();
219             thingReachable &= this.getDevices();
220             if (!thingReachable) {
221                 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
222                         "@text/offline.not-reachable");
223                 // restart initial access
224                 scheduleInitialAccess(httpClient);
225                 return;
226             }
227
228             // start long polling loop
229             this.updateStatus(ThingStatus.ONLINE);
230             try {
231                 this.longPolling.start(httpClient);
232             } catch (LongPollingFailedException e) {
233                 this.handleLongPollFailure(e);
234             }
235
236         } catch (InterruptedException e) {
237             this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
238             Thread.currentThread().interrupt();
239         }
240     }
241
242     /**
243      * Get a list of connected devices from the Smart-Home Controller
244      *
245      * @throws InterruptedException in case bridge is stopped
246      */
247     private boolean getDevices() throws InterruptedException {
248         @Nullable
249         BoschHttpClient httpClient = this.httpClient;
250         if (httpClient == null) {
251             return false;
252         }
253
254         try {
255             logger.debug("Sending http request to Bosch to request devices: {}", httpClient);
256             String url = httpClient.getBoschSmartHomeUrl("devices");
257             ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
258
259             // check HTTP status code
260             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
261                 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
262                 return false;
263             }
264
265             String content = contentResponse.getContentAsString();
266             logger.debug("Request devices completed with success: {} - status code: {}", content,
267                     contentResponse.getStatus());
268
269             Type collectionType = new TypeToken<ArrayList<Device>>() {
270             }.getType();
271             ArrayList<Device> devices = gson.fromJson(content, collectionType);
272
273             if (devices != null) {
274                 for (Device d : devices) {
275                     // Write found devices into openhab.log until we have implemented auto discovery
276                     logger.info("Found device: name={} id={}", d.name, d.id);
277                     if (d.deviceServiceIds != null) {
278                         for (String s : d.deviceServiceIds) {
279                             logger.info(".... service: {}", s);
280                         }
281                     }
282                 }
283             }
284         } catch (TimeoutException | ExecutionException e) {
285             logger.warn("Request devices failed because of {}!", e.getMessage());
286             return false;
287         }
288
289         return true;
290     }
291
292     /**
293      * Bridge callback handler for the results of long polls.
294      *
295      * It will check the result and
296      * forward the received to the bosch thing handlers.
297      *
298      * @param result Results from Long Polling
299      */
300     private void handleLongPollResult(LongPollResult result) {
301         for (DeviceStatusUpdate update : result.result) {
302             if (update != null && update.state != null) {
303                 logger.debug("Got update of type {}: {}", update.type, update.state);
304
305                 var updateDeviceId = update.deviceId;
306                 if (updateDeviceId == null) {
307                     continue;
308                 }
309
310                 logger.debug("Got update for {}", updateDeviceId);
311
312                 boolean handled = false;
313
314                 Bridge bridge = this.getThing();
315                 for (Thing childThing : bridge.getThings()) {
316                     // All children of this should implement BoschSHCHandler
317                     @Nullable
318                     ThingHandler baseHandler = childThing.getHandler();
319                     if (baseHandler != null && baseHandler instanceof BoschSHCHandler) {
320                         BoschSHCHandler handler = (BoschSHCHandler) baseHandler;
321                         @Nullable
322                         String deviceId = handler.getBoschID();
323
324                         handled = true;
325                         logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
326
327                         if (deviceId != null && updateDeviceId.equals(deviceId)) {
328                             logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler, update.id,
329                                     update.state);
330                             handler.processUpdate(update.id, update.state);
331                         }
332                     } else {
333                         logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
334                     }
335                 }
336
337                 if (!handled) {
338                     logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
339                 }
340             }
341         }
342     }
343
344     /**
345      * Bridge callback handler for the failures during long polls.
346      *
347      * It will update the bridge status and try to access the SHC again.
348      *
349      * @param e error during long polling
350      */
351     private void handleLongPollFailure(Throwable e) {
352         logger.warn("Long polling failed, will try to reconnect", e);
353         @Nullable
354         BoschHttpClient httpClient = this.httpClient;
355         if (httpClient == null) {
356             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
357                     "@text/offline.long-polling-failed.http-client-null");
358             return;
359         }
360
361         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
362                 "@text/offline.long-polling-failed.trying-to-reconnect");
363         scheduleInitialAccess(httpClient);
364     }
365
366     /**
367      * Get a list of rooms from the Smart-Home controller
368      *
369      * @throws InterruptedException in case bridge is stopped
370      */
371     private boolean getRooms() throws InterruptedException {
372         @Nullable
373         BoschHttpClient httpClient = this.httpClient;
374         if (httpClient != null) {
375             try {
376                 logger.debug("Sending http request to Bosch to request rooms");
377                 String url = httpClient.getBoschSmartHomeUrl("rooms");
378                 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
379
380                 // check HTTP status code
381                 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
382                     logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
383                     return false;
384                 }
385
386                 String content = contentResponse.getContentAsString();
387                 logger.debug("Request rooms completed with success: {} - status code: {}", content,
388                         contentResponse.getStatus());
389
390                 Type collectionType = new TypeToken<ArrayList<Room>>() {
391                 }.getType();
392
393                 ArrayList<Room> rooms = gson.fromJson(content, collectionType);
394
395                 if (rooms != null) {
396                     for (Room r : rooms) {
397                         logger.info("Found room: {}", r.name);
398                     }
399                 }
400
401                 return true;
402             } catch (TimeoutException | ExecutionException e) {
403                 logger.warn("Request rooms failed because of {}!", e.getMessage());
404                 return false;
405             }
406         } else {
407             return false;
408         }
409     }
410
411     public Device getDeviceInfo(String deviceId)
412             throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
413         @Nullable
414         BoschHttpClient httpClient = this.httpClient;
415         if (httpClient == null) {
416             throw new BoschSHCException("HTTP client not initialized");
417         }
418
419         String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
420         Request request = httpClient.createRequest(url, GET);
421
422         return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
423             JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
424             if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
425                 if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
426                     return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
427                 } else {
428                     return new BoschSHCException(
429                             String.format("Request for info of device %s failed with status code %d and error code %s",
430                                     deviceId, errorResponse.statusCode, errorResponse.errorCode));
431                 }
432             } else {
433                 return new BoschSHCException(String.format("Request for info for device %s failed with status code %d",
434                         deviceId, statusCode));
435             }
436         });
437     }
438
439     /**
440      * Query the Bosch Smart Home Controller for the state of the given thing.
441      *
442      * @param deviceId Id of device to get state for
443      * @param stateName Name of the state to query
444      * @param stateClass Class to convert the resulting JSON to
445      * @throws ExecutionException
446      * @throws TimeoutException
447      * @throws InterruptedException
448      * @throws BoschSHCException
449      */
450     public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
451             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
452         @Nullable
453         BoschHttpClient httpClient = this.httpClient;
454         if (httpClient == null) {
455             logger.warn("HttpClient not initialized");
456             return null;
457         }
458
459         String url = httpClient.getServiceUrl(stateName, deviceId);
460         Request request = httpClient.createRequest(url, GET).header("Accept", "application/json");
461
462         logger.debug("refreshState: Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
463
464         ContentResponse contentResponse = request.send();
465
466         String content = contentResponse.getContentAsString();
467         logger.debug("refreshState: Request complete: [{}] - return code: {}", content, contentResponse.getStatus());
468
469         int statusCode = contentResponse.getStatus();
470         if (statusCode != 200) {
471             JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
472             if (errorResponse != null) {
473                 throw new BoschSHCException(String.format(
474                         "State request for service %s of device %s failed with status code %d and error code %s",
475                         stateName, deviceId, errorResponse.statusCode, errorResponse.errorCode));
476             } else {
477                 throw new BoschSHCException(
478                         String.format("State request for service %s of device %s failed with status code %d", stateName,
479                                 deviceId, statusCode));
480             }
481         }
482
483         @Nullable
484         T state = BoschSHCServiceState.fromJson(content, stateClass);
485         if (state == null) {
486             throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
487         }
488         return state;
489     }
490
491     /**
492      * Sends a state change for a device to the controller
493      *
494      * @param deviceId Id of device to change state for
495      * @param serviceName Name of service of device to change state for
496      * @param state New state data to set for service
497      *
498      * @return Response of request
499      * @throws InterruptedException
500      * @throws ExecutionException
501      * @throws TimeoutException
502      */
503     public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
504             throws InterruptedException, TimeoutException, ExecutionException {
505         @Nullable
506         BoschHttpClient httpClient = this.httpClient;
507         if (httpClient == null) {
508             logger.warn("HttpClient not initialized");
509             return null;
510         }
511
512         // Create request
513         String url = httpClient.getServiceUrl(serviceName, deviceId);
514         Request request = httpClient.createRequest(url, PUT, state);
515
516         // Send request
517         return request.send();
518     }
519 }