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