2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.boschshc.internal.devices.bridge;
15 import static org.eclipse.jetty.http.HttpMethod.GET;
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;
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;
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;
46 import com.google.gson.JsonSyntaxException;
49 * HTTP client using own context with private and Bosch Certs
50 * to pair and connect to the Bosch Smart Home Controller.
52 * @author Gerd Zanker - Initial contribution
55 public class BoschHttpClient extends HttpClient {
57 private final Logger logger = LoggerFactory.getLogger(getClass());
60 * Default number of seconds for HTTP request timeouts
62 public static final long DEFAULT_TIMEOUT_SECONDS = 10;
65 * The time unit used for default HTTP request timeouts
67 public static final TimeUnit DEFAULT_TIMEOUT_UNIT = TimeUnit.SECONDS;
69 private final String ipAddress;
70 private final String systemPassword;
72 public BoschHttpClient(String ipAddress, String systemPassword, SslContextFactory sslContextFactory) {
73 super(sslContextFactory);
74 this.ipAddress = ipAddress;
75 this.systemPassword = systemPassword;
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
82 * @return URL for public information
84 public static String getPublicInformationUrl(String ipAddress) {
85 return String.format("https://%s:8446/smarthome/public/information", ipAddress);
89 * Returns the public information URL for the current Bosch SHC client.
91 * @return URL for public information
93 public String getPublicInformationUrl() {
94 return getPublicInformationUrl(this.ipAddress);
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
101 * @return URL for pairing
103 public String getPairingUrl() {
104 return String.format("https://%s:8443/smarthome/clients", this.ipAddress);
108 * Returns a Bosch SHC URL for the endpoint, using port 8444.
110 * @param endpoint an endpoint, see https://apidocs.bosch-smarthome.com/local/index.html
111 * @return Bosch SHC URL for passed endpoint
113 public String getBoschShcUrl(String endpoint) {
114 return String.format("https://%s:8444/%s", this.ipAddress, endpoint);
118 * Returns a SmartHome URL for the endpoint - shortcut of {@link #getBoschShcUrl(String)}
120 * @param endpoint an endpoint, see https://apidocs.bosch-smarthome.com/local/index.html
121 * @return SmartHome URL for passed endpoint
123 public String getBoschSmartHomeUrl(String endpoint) {
124 return this.getBoschShcUrl(String.format("smarthome/%s", endpoint));
128 * Returns a URL to get or put a service state.
133 * https://localhost:8444/smarthome/devices/hdm:ZigBee:000d6f0016d1cdae/services/AirQualityLevel/state
136 * see https://apidocs.bosch-smarthome.com/local/index.html
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
142 public String getServiceStateUrl(String serviceName, String deviceId) {
143 return this.getBoschSmartHomeUrl(String.format("devices/%s/services/%s/state", deviceId, serviceName));
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));
151 return getServiceStateUrl(serviceName, deviceId);
156 * Returns a URL to get general information about a service.
161 * https://localhost:8444/smarthome/devices/hdm:ZigBee:000d6f0016d1cdae/services/BatteryLevel
164 * In some cases this URL has to be used to get the service state, for example for battery levels.
166 * @param serviceName the name of the service
167 * @param deviceId the device identifier
168 * @return a URL to retrieve general service information
170 public String getServiceUrl(String serviceName, String deviceId) {
171 return this.getBoschSmartHomeUrl(String.format("devices/%s/services/%s", deviceId, serviceName));
175 * Checks if the Bosch SHC is online.
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
180 * Will return true, if the server responds with the "public information".
183 * @return true if HTTP server is online
184 * @throws InterruptedException in case of an interrupt
186 public boolean isOnline() throws InterruptedException {
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());
197 logger.debug("Online check failed with status code: {}", contentResponse.getStatus());
200 } catch (InterruptedException e) {
202 } catch (Exception e) {
203 logger.debug("Online check failed because of {}!", e.getMessage(), e);
209 * Checks if the Bosch SHC can be accessed.
211 * @return true if HTTP access to SHC devices was successful
212 * @throws InterruptedException in case of an interrupt
214 public boolean isAccessPossible() throws InterruptedException {
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());
225 logger.debug("Access check failed with status code: {}", contentResponse.getStatus());
228 } catch (InterruptedException e) {
230 } catch (Exception e) {
231 logger.debug("Access check failed because of {}!", e.getMessage(), e);
237 * Pairs this client with the Bosch SHC.
238 * Press pairing button on the Bosch Smart Home Controller!
240 * @return true if pairing was successful, otherwise false
241 * @throws InterruptedException in case of an interrupt
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");
247 ContentResponse contentResponse;
249 String publicCert = getCertFromSslContextFactory();
250 logger.trace("Pairing with SHC {}", ipAddress);
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-----");
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)));
265 contentResponse = request.send();
267 logger.trace("Pairing response complete: {} - return code: {}", contentResponse.getContentAsString(),
268 contentResponse.getStatus());
269 if (201 == contentResponse.getStatus()) {
270 logger.debug("Pairing successful.");
273 logger.info("Pairing failed with response status {}.", contentResponse.getStatus());
276 } catch (TimeoutException | CertificateEncodingException | KeyStoreException | RuntimeException e) {
277 logger.warn("Pairing failed with exception {}", e.getMessage(), e);
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?");
289 * Creates a HTTP request.
291 * @param url for the HTTP request
292 * @param method for the HTTP request
293 * @return created HTTP request instance
295 public Request createRequest(String url, HttpMethod method) {
296 return this.createRequest(url, method, null);
300 * Creates a HTTP request.
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
307 public Request createRequest(String url, HttpMethod method, @Nullable Object content) {
308 logger.trace("Create request for http client {}", this);
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
314 if (content != null) {
316 if (content.getClass().isAssignableFrom(UserStateServiceState.class)) {
317 body = ((UserStateServiceState) content).getStateAsString();
319 body = GsonUtils.DEFAULT_GSON_INSTANCE.toJson(content);
321 logger.trace("create request for {} and content {}", url, body);
322 request = request.content(new StringContentProvider(body));
324 logger.trace("create request for {}", url);
331 * Sends a request and expects a response of the specified type.
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
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);
348 ContentResponse contentResponse = request.send();
350 String textContent = contentResponse.getContentAsString();
352 Integer statusCode = contentResponse.getStatus();
353 if (!HttpStatus.getCode(statusCode).isSuccess()) {
354 if (errorResponseHandler != null) {
355 throw errorResponseHandler.apply(statusCode, textContent);
357 throw new ExecutionException(String.format("Send request failed with status code %s", statusCode),
362 logger.debug("Send request completed with success: {} - status code: {}", textContent, statusCode);
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);
371 if (!contentValidator.test(content)) {
372 throw new ExecutionException(String.format("Received invalid content for type %s: %s",
373 responseContentClass.getName(), content), null);
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);
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());