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