]> git.basschouten.com Git - openhab-addons.git/blob
a7a134ed049eb3a21b1d091540c40c4af6356531
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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  * @author David Pace - Added support for custom endpoints and HTTP POST requests
63  */
64 @NonNullByDefault
65 public class BridgeHandler extends BaseBridgeHandler {
66
67     private final Logger logger = LoggerFactory.getLogger(BridgeHandler.class);
68
69     /**
70      * gson instance to convert a class to json string and back.
71      */
72     private final Gson gson = new Gson();
73
74     /**
75      * Handler to do long polling.
76      */
77     private final LongPolling longPolling;
78
79     /**
80      * HTTP client for all communications to and from the bridge.
81      * <p>
82      * This member is package-protected to enable mocking in unit tests.
83      */
84     /* package */ @Nullable
85     BoschHttpClient httpClient;
86
87     private @Nullable ScheduledFuture<?> scheduledPairing;
88
89     public BridgeHandler(Bridge bridge) {
90         super(bridge);
91
92         this.longPolling = new LongPolling(this.scheduler, this::handleLongPollResult, this::handleLongPollFailure);
93     }
94
95     @Override
96     public void initialize() {
97         logger.debug("Initialize {} Version {}", FrameworkUtil.getBundle(getClass()).getSymbolicName(),
98                 FrameworkUtil.getBundle(getClass()).getVersion());
99
100         // Read configuration
101         BridgeConfiguration config = getConfigAs(BridgeConfiguration.class);
102
103         String ipAddress = config.ipAddress.trim();
104         if (ipAddress.isEmpty()) {
105             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
106                     "@text/offline.conf-error-empty-ip");
107             return;
108         }
109
110         String password = config.password.trim();
111         if (password.isEmpty()) {
112             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
113                     "@text/offline.conf-error-empty-password");
114             return;
115         }
116
117         SslContextFactory factory;
118         try {
119             // prepare SSL key and certificates
120             factory = new BoschSslUtil(ipAddress).getSslContextFactory();
121         } catch (PairingFailedException e) {
122             this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
123                     "@text/offline.conf-error-ssl");
124             return;
125         }
126
127         // Instantiate HttpClient with the SslContextFactory
128         BoschHttpClient httpClient = this.httpClient = new BoschHttpClient(ipAddress, password, factory);
129
130         // Start http client
131         try {
132             httpClient.start();
133         } catch (Exception e) {
134             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
135                     String.format("Could not create http connection to controller: %s", e.getMessage()));
136             return;
137         }
138
139         // general checks are OK, therefore set the status to unknown and wait for initial access
140         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE);
141
142         // Initialize bridge in the background.
143         // Start initial access the first time
144         scheduleInitialAccess(httpClient);
145     }
146
147     @Override
148     public void dispose() {
149         // Cancel scheduled pairing.
150         @Nullable
151         ScheduledFuture<?> scheduledPairing = this.scheduledPairing;
152         if (scheduledPairing != null) {
153             scheduledPairing.cancel(true);
154             this.scheduledPairing = null;
155         }
156
157         // Stop long polling.
158         this.longPolling.stop();
159
160         @Nullable
161         BoschHttpClient httpClient = this.httpClient;
162         if (httpClient != null) {
163             try {
164                 httpClient.stop();
165             } catch (Exception e) {
166                 logger.debug("HttpClient failed on bridge disposal: {}", e.getMessage());
167             }
168             this.httpClient = null;
169         }
170
171         super.dispose();
172     }
173
174     @Override
175     public void handleCommand(ChannelUID channelUID, Command command) {
176     }
177
178     /**
179      * Schedule the initial access.
180      * Use a delay if pairing fails and next retry is scheduled.
181      */
182     private void scheduleInitialAccess(BoschHttpClient httpClient) {
183         this.scheduledPairing = scheduler.schedule(() -> initialAccess(httpClient), 15, TimeUnit.SECONDS);
184     }
185
186     /**
187      * Execute the initial access.
188      * Uses the HTTP Bosch SHC client
189      * to check if access if possible
190      * pairs this Bosch SHC Bridge with the SHC if necessary
191      * and starts the first log poll.
192      */
193     private void initialAccess(BoschHttpClient httpClient) {
194         logger.debug("Initializing Bosch SHC Bridge: {} - HTTP client is: {}", this, httpClient);
195
196         try {
197             // check if SCH is offline
198             if (!httpClient.isOnline()) {
199                 // update status already if access is not possible
200                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
201                         "@text/offline.conf-error-offline");
202                 // restart later initial access
203                 scheduleInitialAccess(httpClient);
204                 return;
205             }
206
207             // SHC is online
208             // check if SHC access is not possible and pairing necessary
209             if (!httpClient.isAccessPossible()) {
210                 // update status description to show pairing test
211                 this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
212                         "@text/offline.conf-error-pairing");
213                 if (!httpClient.doPairing()) {
214                     this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.CONFIGURATION_ERROR,
215                             "@text/offline.conf-error-pairing");
216                 }
217                 // restart initial access - needed also in case of successful pairing to check access again
218                 scheduleInitialAccess(httpClient);
219                 return;
220             }
221
222             // SHC is online and access is possible
223             // print rooms and devices
224             boolean thingReachable = true;
225             thingReachable &= this.getRooms();
226             thingReachable &= this.getDevices();
227             if (!thingReachable) {
228                 this.updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
229                         "@text/offline.not-reachable");
230                 // restart initial access
231                 scheduleInitialAccess(httpClient);
232                 return;
233             }
234
235             // start long polling loop
236             this.updateStatus(ThingStatus.ONLINE);
237             try {
238                 this.longPolling.start(httpClient);
239             } catch (LongPollingFailedException e) {
240                 this.handleLongPollFailure(e);
241             }
242
243         } catch (InterruptedException e) {
244             this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE, "@text/offline.interrupted");
245             Thread.currentThread().interrupt();
246         }
247     }
248
249     /**
250      * Get a list of connected devices from the Smart-Home Controller
251      *
252      * @throws InterruptedException in case bridge is stopped
253      */
254     private boolean getDevices() throws InterruptedException {
255         @Nullable
256         BoschHttpClient httpClient = this.httpClient;
257         if (httpClient == null) {
258             return false;
259         }
260
261         try {
262             logger.debug("Sending http request to Bosch to request devices: {}", httpClient);
263             String url = httpClient.getBoschSmartHomeUrl("devices");
264             ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
265
266             // check HTTP status code
267             if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
268                 logger.debug("Request devices failed with status code: {}", contentResponse.getStatus());
269                 return false;
270             }
271
272             String content = contentResponse.getContentAsString();
273             logger.debug("Request devices completed with success: {} - status code: {}", content,
274                     contentResponse.getStatus());
275
276             Type collectionType = new TypeToken<ArrayList<Device>>() {
277             }.getType();
278             ArrayList<Device> devices = gson.fromJson(content, collectionType);
279
280             if (devices != null) {
281                 for (Device d : devices) {
282                     // Write found devices into openhab.log until we have implemented auto discovery
283                     logger.info("Found device: name={} id={}", d.name, d.id);
284                     if (d.deviceServiceIds != null) {
285                         for (String s : d.deviceServiceIds) {
286                             logger.info(".... service: {}", s);
287                         }
288                     }
289                 }
290             }
291         } catch (TimeoutException | ExecutionException e) {
292             logger.warn("Request devices failed because of {}!", e.getMessage());
293             return false;
294         }
295
296         return true;
297     }
298
299     /**
300      * Bridge callback handler for the results of long polls.
301      *
302      * It will check the result and
303      * forward the received to the bosch thing handlers.
304      *
305      * @param result Results from Long Polling
306      */
307     private void handleLongPollResult(LongPollResult result) {
308         for (DeviceStatusUpdate update : result.result) {
309             if (update != null && update.state != null) {
310                 logger.debug("Got update for service {} of type {}: {}", update.id, update.type, update.state);
311
312                 var updateDeviceId = update.deviceId;
313                 if (updateDeviceId == null) {
314                     continue;
315                 }
316
317                 logger.debug("Got update for device {}", updateDeviceId);
318
319                 boolean handled = false;
320
321                 Bridge bridge = this.getThing();
322                 for (Thing childThing : bridge.getThings()) {
323                     // All children of this should implement BoschSHCHandler
324                     @Nullable
325                     ThingHandler baseHandler = childThing.getHandler();
326                     if (baseHandler != null && baseHandler instanceof BoschSHCHandler) {
327                         BoschSHCHandler handler = (BoschSHCHandler) baseHandler;
328                         @Nullable
329                         String deviceId = handler.getBoschID();
330
331                         handled = true;
332                         logger.debug("Registered device: {} - looking for {}", deviceId, updateDeviceId);
333
334                         if (deviceId != null && updateDeviceId.equals(deviceId)) {
335                             logger.debug("Found child: {} - calling processUpdate (id: {}) with {}", handler, update.id,
336                                     update.state);
337                             handler.processUpdate(update.id, update.state);
338                         }
339                     } else {
340                         logger.warn("longPoll: child handler for {} does not implement Bosch SHC handler", baseHandler);
341                     }
342                 }
343
344                 if (!handled) {
345                     logger.debug("Could not find a thing for device ID: {}", updateDeviceId);
346                 }
347             }
348         }
349     }
350
351     /**
352      * Bridge callback handler for the failures during long polls.
353      *
354      * It will update the bridge status and try to access the SHC again.
355      *
356      * @param e error during long polling
357      */
358     private void handleLongPollFailure(Throwable e) {
359         logger.warn("Long polling failed, will try to reconnect", e);
360         @Nullable
361         BoschHttpClient httpClient = this.httpClient;
362         if (httpClient == null) {
363             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR,
364                     "@text/offline.long-polling-failed.http-client-null");
365             return;
366         }
367
368         this.updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.UNKNOWN.NONE,
369                 "@text/offline.long-polling-failed.trying-to-reconnect");
370         scheduleInitialAccess(httpClient);
371     }
372
373     /**
374      * Get a list of rooms from the Smart-Home controller
375      *
376      * @throws InterruptedException in case bridge is stopped
377      */
378     private boolean getRooms() throws InterruptedException {
379         @Nullable
380         BoschHttpClient httpClient = this.httpClient;
381         if (httpClient != null) {
382             try {
383                 logger.debug("Sending http request to Bosch to request rooms");
384                 String url = httpClient.getBoschSmartHomeUrl("rooms");
385                 ContentResponse contentResponse = httpClient.createRequest(url, GET).send();
386
387                 // check HTTP status code
388                 if (!HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
389                     logger.debug("Request rooms failed with status code: {}", contentResponse.getStatus());
390                     return false;
391                 }
392
393                 String content = contentResponse.getContentAsString();
394                 logger.debug("Request rooms completed with success: {} - status code: {}", content,
395                         contentResponse.getStatus());
396
397                 Type collectionType = new TypeToken<ArrayList<Room>>() {
398                 }.getType();
399
400                 ArrayList<Room> rooms = gson.fromJson(content, collectionType);
401
402                 if (rooms != null) {
403                     for (Room r : rooms) {
404                         logger.info("Found room: {}", r.name);
405                     }
406                 }
407
408                 return true;
409             } catch (TimeoutException | ExecutionException e) {
410                 logger.warn("Request rooms failed because of {}!", e.getMessage());
411                 return false;
412             }
413         } else {
414             return false;
415         }
416     }
417
418     public Device getDeviceInfo(String deviceId)
419             throws BoschSHCException, InterruptedException, TimeoutException, ExecutionException {
420         @Nullable
421         BoschHttpClient httpClient = this.httpClient;
422         if (httpClient == null) {
423             throw new BoschSHCException("HTTP client not initialized");
424         }
425
426         String url = httpClient.getBoschSmartHomeUrl(String.format("devices/%s", deviceId));
427         Request request = httpClient.createRequest(url, GET);
428
429         return httpClient.sendRequest(request, Device.class, Device::isValid, (Integer statusCode, String content) -> {
430             JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
431             if (errorResponse != null && JsonRestExceptionResponse.isValid(errorResponse)) {
432                 if (errorResponse.errorCode.equals(JsonRestExceptionResponse.ENTITY_NOT_FOUND)) {
433                     return new BoschSHCException("@text/offline.conf-error.invalid-device-id");
434                 } else {
435                     return new BoschSHCException(
436                             String.format("Request for info of device %s failed with status code %d and error code %s",
437                                     deviceId, errorResponse.statusCode, errorResponse.errorCode));
438                 }
439             } else {
440                 return new BoschSHCException(String.format("Request for info for device %s failed with status code %d",
441                         deviceId, statusCode));
442             }
443         });
444     }
445
446     /**
447      * Query the Bosch Smart Home Controller for the state of the given device.
448      * <p>
449      * The URL used for retrieving the state has the following structure:
450      * 
451      * <pre>
452      * https://{IP}:8444/smarthome/devices/{deviceId}/services/{serviceName}/state
453      * </pre>
454      *
455      * @param deviceId Id of device to get state for
456      * @param stateName Name of the state to query
457      * @param stateClass Class to convert the resulting JSON to
458      * @return the deserialized state object, may be <code>null</code>
459      * @throws ExecutionException
460      * @throws TimeoutException
461      * @throws InterruptedException
462      * @throws BoschSHCException
463      */
464     public <T extends BoschSHCServiceState> @Nullable T getState(String deviceId, String stateName, Class<T> stateClass)
465             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
466         @Nullable
467         BoschHttpClient httpClient = this.httpClient;
468         if (httpClient == null) {
469             logger.warn("HttpClient not initialized");
470             return null;
471         }
472
473         String url = httpClient.getServiceUrl(stateName, deviceId);
474         logger.debug("getState(): Requesting \"{}\" from Bosch: {} via {}", stateName, deviceId, url);
475         return getState(httpClient, url, stateClass);
476     }
477
478     /**
479      * Queries the Bosch Smart Home Controller for the state using an explicit endpoint.
480      * 
481      * @param <T> Type to which the resulting JSON should be deserialized to
482      * @param endpoint The destination endpoint part of the URL
483      * @param stateClass Class to convert the resulting JSON to
484      * @return the deserialized state object, may be <code>null</code>
485      * @throws InterruptedException
486      * @throws TimeoutException
487      * @throws ExecutionException
488      * @throws BoschSHCException
489      */
490     public <T extends BoschSHCServiceState> @Nullable T getState(String endpoint, Class<T> stateClass)
491             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
492         @Nullable
493         BoschHttpClient httpClient = this.httpClient;
494         if (httpClient == null) {
495             logger.warn("HttpClient not initialized");
496             return null;
497         }
498
499         String url = httpClient.getBoschSmartHomeUrl(endpoint);
500         logger.debug("getState(): Requesting from Bosch: {}", url);
501         return getState(httpClient, url, stateClass);
502     }
503
504     /**
505      * Sends a HTTP GET request in order to retrieve a state from the Bosch Smart Home Controller.
506      * 
507      * @param <T> Type to which the resulting JSON should be deserialized to
508      * @param httpClient HTTP client used for sending the request
509      * @param url URL at which the state should be retrieved
510      * @param stateClass Class to convert the resulting JSON to
511      * @return the deserialized state object, may be <code>null</code>
512      * @throws InterruptedException
513      * @throws TimeoutException
514      * @throws ExecutionException
515      * @throws BoschSHCException
516      */
517     protected <T extends BoschSHCServiceState> @Nullable T getState(BoschHttpClient httpClient, String url,
518             Class<T> stateClass) throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
519         Request request = httpClient.createRequest(url, GET).header("Accept", "application/json");
520
521         ContentResponse contentResponse = request.send();
522
523         String content = contentResponse.getContentAsString();
524         logger.debug("getState(): Request complete: [{}] - return code: {}", content, contentResponse.getStatus());
525
526         int statusCode = contentResponse.getStatus();
527         if (statusCode != 200) {
528             JsonRestExceptionResponse errorResponse = gson.fromJson(content, JsonRestExceptionResponse.class);
529             if (errorResponse != null) {
530                 throw new BoschSHCException(
531                         String.format("State request with URL %s failed with status code %d and error code %s", url,
532                                 errorResponse.statusCode, errorResponse.errorCode));
533             } else {
534                 throw new BoschSHCException(
535                         String.format("State request with URL %s failed with status code %d", url, statusCode));
536             }
537         }
538
539         @Nullable
540         T state = BoschSHCServiceState.fromJson(content, stateClass);
541         if (state == null) {
542             throw new BoschSHCException(String.format("Received invalid, expected type %s", stateClass.getName()));
543         }
544         return state;
545     }
546
547     /**
548      * Sends a state change for a device to the controller
549      *
550      * @param deviceId Id of device to change state for
551      * @param serviceName Name of service of device to change state for
552      * @param state New state data to set for service
553      *
554      * @return Response of request
555      * @throws InterruptedException
556      * @throws ExecutionException
557      * @throws TimeoutException
558      */
559     public <T extends BoschSHCServiceState> @Nullable Response putState(String deviceId, String serviceName, T state)
560             throws InterruptedException, TimeoutException, ExecutionException {
561         @Nullable
562         BoschHttpClient httpClient = this.httpClient;
563         if (httpClient == null) {
564             logger.warn("HttpClient not initialized");
565             return null;
566         }
567
568         // Create request
569         String url = httpClient.getServiceUrl(serviceName, deviceId);
570         Request request = httpClient.createRequest(url, PUT, state);
571
572         // Send request
573         return request.send();
574     }
575
576     /**
577      * Sends a HTTP POST request without a request body to the given endpoint.
578      * 
579      * @param endpoint The destination endpoint part of the URL
580      * @return the HTTP response
581      * @throws InterruptedException
582      * @throws TimeoutException
583      * @throws ExecutionException
584      */
585     public @Nullable Response postAction(String endpoint)
586             throws InterruptedException, TimeoutException, ExecutionException {
587         return postAction(endpoint, null);
588     }
589
590     /**
591      * Sends a HTTP POST request with a request body to the given endpoint.
592      * 
593      * @param <T> Type of the request
594      * @param endpoint The destination endpoint part of the URL
595      * @param requestBody object representing the request body to be sent, may be <code>null</code>
596      * @return the HTTP response
597      * @throws InterruptedException
598      * @throws TimeoutException
599      * @throws ExecutionException
600      */
601     public <T extends BoschSHCServiceState> @Nullable Response postAction(String endpoint, @Nullable T requestBody)
602             throws InterruptedException, TimeoutException, ExecutionException {
603         @Nullable
604         BoschHttpClient httpClient = this.httpClient;
605         if (httpClient == null) {
606             logger.warn("HttpClient not initialized");
607             return null;
608         }
609
610         String url = httpClient.getBoschSmartHomeUrl(endpoint);
611         Request request = httpClient.createRequest(url, POST, requestBody);
612         return request.send();
613     }
614 }