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