]> git.basschouten.com Git - openhab-addons.git/blob
2e9e612468f788685c729da5d419c78d898cb8e3
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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
17 import java.nio.charset.StandardCharsets;
18 import java.security.KeyStoreException;
19 import java.security.cert.Certificate;
20 import java.security.cert.CertificateEncodingException;
21 import java.util.Base64;
22 import java.util.HashMap;
23 import java.util.Map;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
27 import java.util.function.BiFunction;
28 import java.util.function.Predicate;
29
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.eclipse.jetty.client.HttpClient;
33 import org.eclipse.jetty.client.api.ContentResponse;
34 import org.eclipse.jetty.client.api.Request;
35 import org.eclipse.jetty.client.util.StringContentProvider;
36 import org.eclipse.jetty.http.HttpMethod;
37 import org.eclipse.jetty.http.HttpStatus;
38 import org.eclipse.jetty.util.ssl.SslContextFactory;
39 import org.openhab.binding.boschshc.internal.exceptions.BoschSHCException;
40 import org.openhab.binding.boschshc.internal.serialization.GsonUtils;
41 import org.slf4j.Logger;
42 import org.slf4j.LoggerFactory;
43
44 import com.google.gson.JsonSyntaxException;
45
46 /**
47  * HTTP client using own context with private and Bosch Certs
48  * to pair and connect to the Bosch Smart Home Controller.
49  *
50  * @author Gerd Zanker - Initial contribution
51  */
52 @NonNullByDefault
53 public class BoschHttpClient extends HttpClient {
54
55     private final Logger logger = LoggerFactory.getLogger(getClass());
56
57     private final String ipAddress;
58     private final String systemPassword;
59
60     public BoschHttpClient(String ipAddress, String systemPassword, SslContextFactory sslContextFactory) {
61         super(sslContextFactory);
62         this.ipAddress = ipAddress;
63         this.systemPassword = systemPassword;
64     }
65
66     /**
67      * Returns the public information URL for the Bosch SHC client addressed with the given IP address, using port 8446
68      * See https://github.com/BoschSmartHome/bosch-shc-api-docs/blob/master/postman/README.md
69      *
70      * @return URL for public information
71      */
72     public static String getPublicInformationUrl(String ipAddress) {
73         return String.format("https://%s:8446/smarthome/public/information", ipAddress);
74     }
75
76     /**
77      * Returns the public information URL for the current Bosch SHC client.
78      *
79      * @return URL for public information
80      */
81     public String getPublicInformationUrl() {
82         return getPublicInformationUrl(this.ipAddress);
83     }
84
85     /**
86      * Returns the pairing URL for the Bosch SHC clients, using port 8443.
87      * See https://github.com/BoschSmartHome/bosch-shc-api-docs/blob/master/postman/README.md
88      *
89      * @return URL for pairing
90      */
91     public String getPairingUrl() {
92         return String.format("https://%s:8443/smarthome/clients", this.ipAddress);
93     }
94
95     /**
96      * Returns a Bosch SHC URL for the endpoint, using port 8444.
97      *
98      * @param endpoint an endpoint, see https://apidocs.bosch-smarthome.com/local/index.html
99      * @return Bosch SHC URL for passed endpoint
100      */
101     public String getBoschShcUrl(String endpoint) {
102         return String.format("https://%s:8444/%s", this.ipAddress, endpoint);
103     }
104
105     /**
106      * Returns a SmartHome URL for the endpoint - shortcut of {@link #getBoschShcUrl(String)}
107      *
108      * @param endpoint an endpoint, see https://apidocs.bosch-smarthome.com/local/index.html
109      * @return SmartHome URL for passed endpoint
110      */
111     public String getBoschSmartHomeUrl(String endpoint) {
112         return this.getBoschShcUrl(String.format("smarthome/%s", endpoint));
113     }
114
115     /**
116      * Returns a URL to get or put a service state.
117      * <p>
118      * Example:
119      *
120      * <pre>
121      * https://localhost:8444/smarthome/devices/hdm:ZigBee:000d6f0016d1cdae/services/AirQualityLevel/state
122      * </pre>
123      *
124      * see https://apidocs.bosch-smarthome.com/local/index.html
125      *
126      * @param serviceName the name of the service
127      * @param deviceId the device identifier
128      * @return a URL to get or put a service state
129      */
130     public String getServiceStateUrl(String serviceName, String deviceId) {
131         return this.getBoschSmartHomeUrl(String.format("devices/%s/services/%s/state", deviceId, serviceName));
132     }
133
134     /**
135      * Returns a URL to get general information about a service.
136      * <p>
137      * Example:
138      *
139      * <pre>
140      * https://localhost:8444/smarthome/devices/hdm:ZigBee:000d6f0016d1cdae/services/BatteryLevel
141      * </pre>
142      *
143      * In some cases this URL has to be used to get the service state, for example for battery levels.
144      *
145      * @param serviceName the name of the service
146      * @param deviceId the device identifier
147      * @return a URL to retrieve general service information
148      */
149     public String getServiceUrl(String serviceName, String deviceId) {
150         return this.getBoschSmartHomeUrl(String.format("devices/%s/services/%s", deviceId, serviceName));
151     }
152
153     /**
154      * Checks if the Bosch SHC is online.
155      *
156      * The HTTP server could be offline (Timeout of request).
157      * Or during boot-up the server can response e.g. with SERVICE_UNAVAILABLE_503
158      *
159      * Will return true, if the server responds with the "public information".
160      *
161      *
162      * @return true if HTTP server is online
163      * @throws InterruptedException in case of an interrupt
164      */
165     public boolean isOnline() throws InterruptedException {
166         try {
167             String url = this.getPublicInformationUrl();
168             Request request = this.createRequest(url, GET);
169             ContentResponse contentResponse = request.send();
170             if (HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
171                 String content = contentResponse.getContentAsString();
172                 logger.debug("Online check completed with success: {} - status code: {}", content,
173                         contentResponse.getStatus());
174                 return true;
175             } else {
176                 logger.debug("Online check failed with status code: {}", contentResponse.getStatus());
177                 return false;
178             }
179         } catch (InterruptedException e) {
180             throw e;
181         } catch (Exception e) {
182             logger.debug("Online check failed because of {}!", e.getMessage(), e);
183             return false;
184         }
185     }
186
187     /**
188      * Checks if the Bosch SHC can be accessed.
189      *
190      * @return true if HTTP access to SHC devices was successful
191      * @throws InterruptedException in case of an interrupt
192      */
193     public boolean isAccessPossible() throws InterruptedException {
194         try {
195             String url = this.getBoschSmartHomeUrl("devices");
196             Request request = this.createRequest(url, GET);
197             ContentResponse contentResponse = request.send();
198             if (HttpStatus.getCode(contentResponse.getStatus()).isSuccess()) {
199                 String content = contentResponse.getContentAsString();
200                 logger.debug("Access check completed with success: {} - status code: {}", content,
201                         contentResponse.getStatus());
202                 return true;
203             } else {
204                 logger.debug("Access check failed with status code: {}", contentResponse.getStatus());
205                 return false;
206             }
207         } catch (InterruptedException e) {
208             throw e;
209         } catch (Exception e) {
210             logger.debug("Access check failed because of {}!", e.getMessage(), e);
211             return false;
212         }
213     }
214
215     /**
216      * Pairs this client with the Bosch SHC.
217      * Press pairing button on the Bosch Smart Home Controller!
218      *
219      * @return true if pairing was successful, otherwise false
220      * @throws InterruptedException in case of an interrupt
221      */
222     public boolean doPairing() throws InterruptedException {
223         logger.trace("Starting pairing openHAB Client with Bosch Smart Home Controller!");
224         logger.trace("Please press the Bosch Smart Home Controller button until LED starts blinking");
225
226         ContentResponse contentResponse;
227         try {
228             String publicCert = getCertFromSslContextFactory();
229             logger.trace("Pairing with SHC {}", ipAddress);
230
231             // JSON Rest content
232             Map<String, String> items = new HashMap<>();
233             items.put("@type", "client");
234             items.put("id", BoschSslUtil.getBoschShcClientId()); // Client Id contains the unique OpenHab instance Id
235             items.put("name", "oss_OpenHAB_Binding"); // Client name according to
236                                                       // https://github.com/BoschSmartHome/bosch-shc-api-docs#terms-and-conditions
237             items.put("primaryRole", "ROLE_RESTRICTED_CLIENT");
238             items.put("certificate", "-----BEGIN CERTIFICATE-----\r" + publicCert + "\r-----END CERTIFICATE-----");
239
240             String url = this.getPairingUrl();
241             Request request = this.createRequest(url, HttpMethod.POST, items).header("Systempassword",
242                     Base64.getEncoder().encodeToString(this.systemPassword.getBytes(StandardCharsets.UTF_8)));
243
244             contentResponse = request.send();
245
246             logger.trace("Pairing response complete: {} - return code: {}", contentResponse.getContentAsString(),
247                     contentResponse.getStatus());
248             if (201 == contentResponse.getStatus()) {
249                 logger.debug("Pairing successful.");
250                 return true;
251             } else {
252                 logger.info("Pairing failed with response status {}.", contentResponse.getStatus());
253                 return false;
254             }
255         } catch (TimeoutException | CertificateEncodingException | KeyStoreException | RuntimeException e) {
256             logger.warn("Pairing failed with exception {}", e.getMessage(), e);
257             return false;
258         } catch (ExecutionException e) {
259             // javax.net.ssl.SSLHandshakeException: General SSLEngine problem
260             // => usually the pairing failed, because hardware button was not pressed.
261             logger.trace("Pairing failed - Details: {}", e.getMessage());
262             logger.warn("Pairing failed. Was the Bosch Smart Home Controller button pressed?");
263             return false;
264         }
265     }
266
267     /**
268      * Creates a HTTP request.
269      *
270      * @param url for the HTTP request
271      * @param method for the HTTP request
272      * @return created HTTP request instance
273      */
274     public Request createRequest(String url, HttpMethod method) {
275         return this.createRequest(url, method, null);
276     }
277
278     /**
279      * Creates a HTTP request.
280      *
281      * @param url for the HTTP request
282      * @param method for the HTTP request
283      * @param content for the HTTP request
284      * @return created HTTP request instance
285      */
286     public Request createRequest(String url, HttpMethod method, @Nullable Object content) {
287         logger.trace("Create request for http client {}", this);
288
289         Request request = this.newRequest(url).method(method).header("Content-Type", "application/json")
290                 .header("api-version", "3.2") // see https://github.com/BoschSmartHome/bosch-shc-api-docs/issues/80
291                 .timeout(10, TimeUnit.SECONDS); // Set default timeout
292
293         if (content != null) {
294             String body = GsonUtils.DEFAULT_GSON_INSTANCE.toJson(content);
295             logger.trace("create request for {} and content {}", url, content);
296             request = request.content(new StringContentProvider(body));
297         } else {
298             logger.trace("create request for {}", url);
299         }
300
301         return request;
302     }
303
304     /**
305      * Sends a request and expects a response of the specified type.
306      *
307      * @param request Request to send
308      * @param responseContentClass Type of expected response
309      * @param contentValidator Checks if the parsed response is valid
310      * @param errorResponseHandler Optional ustom error response handling. If not provided a generic exception is thrown
311      * @throws ExecutionException in case of invalid HTTP request result
312      * @throws TimeoutException in case of an HTTP request timeout
313      * @throws InterruptedException in case of an interrupt
314      * @throws BoschSHCException in case of a custom handled error response
315      */
316     public <TContent> TContent sendRequest(Request request, Class<TContent> responseContentClass,
317             Predicate<TContent> contentValidator,
318             @Nullable BiFunction<Integer, String, BoschSHCException> errorResponseHandler)
319             throws InterruptedException, TimeoutException, ExecutionException, BoschSHCException {
320         logger.trace("Send request: {}", request);
321
322         ContentResponse contentResponse = request.send();
323
324         String textContent = contentResponse.getContentAsString();
325
326         Integer statusCode = contentResponse.getStatus();
327         if (!HttpStatus.getCode(statusCode).isSuccess()) {
328             if (errorResponseHandler != null) {
329                 throw errorResponseHandler.apply(statusCode, textContent);
330             } else {
331                 throw new ExecutionException(String.format("Send request failed with status code %s", statusCode),
332                         null);
333             }
334         }
335
336         logger.debug("Send request completed with success: {} - status code: {}", textContent, statusCode);
337
338         try {
339             @Nullable
340             TContent content = GsonUtils.DEFAULT_GSON_INSTANCE.fromJson(textContent, responseContentClass);
341             if (content == null) {
342                 throw new ExecutionException(String.format("Received no content in response, expected type %s",
343                         responseContentClass.getName()), null);
344             }
345             if (!contentValidator.test(content)) {
346                 throw new ExecutionException(String.format("Received invalid content for type %s: %s",
347                         responseContentClass.getName(), content), null);
348             }
349             return content;
350         } catch (JsonSyntaxException e) {
351             throw new ExecutionException(String.format("Received invalid content in response, expected type %s: %s",
352                     responseContentClass.getName(), e.getMessage()), e);
353         }
354     }
355
356     private String getCertFromSslContextFactory() throws KeyStoreException, CertificateEncodingException {
357         Certificate cert = this.getSslContextFactory().getKeyStore()
358                 .getCertificate(BoschSslUtil.getBoschShcServerId(ipAddress));
359         return Base64.getEncoder().encodeToString(cert.getEncoded());
360     }
361 }