2 * Copyright (c) 2010-2023 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.boschindego.internal;
15 import static org.openhab.binding.boschindego.internal.BoschIndegoBindingConstants.*;
17 import java.io.IOException;
18 import java.time.Instant;
19 import java.util.Arrays;
20 import java.util.Collection;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.TimeoutException;
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.eclipse.jetty.client.HttpClient;
27 import org.eclipse.jetty.client.HttpResponseException;
28 import org.eclipse.jetty.client.api.ContentResponse;
29 import org.eclipse.jetty.client.api.Request;
30 import org.eclipse.jetty.client.api.Response;
31 import org.eclipse.jetty.client.util.StringContentProvider;
32 import org.eclipse.jetty.http.HttpHeader;
33 import org.eclipse.jetty.http.HttpMethod;
34 import org.eclipse.jetty.http.HttpStatus;
35 import org.openhab.binding.boschindego.internal.dto.response.DevicePropertiesResponse;
36 import org.openhab.binding.boschindego.internal.dto.response.ErrorResponse;
37 import org.openhab.binding.boschindego.internal.dto.response.Mower;
38 import org.openhab.binding.boschindego.internal.dto.serialization.InstantDeserializer;
39 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
40 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
41 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
42 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
43 import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
44 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
45 import org.openhab.core.auth.client.oauth2.OAuthClientService;
46 import org.openhab.core.auth.client.oauth2.OAuthException;
47 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
48 import org.openhab.core.library.types.RawType;
49 import org.osgi.framework.FrameworkUtil;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
53 import com.google.gson.Gson;
54 import com.google.gson.GsonBuilder;
55 import com.google.gson.JsonParseException;
58 * Controller for communicating with a Bosch Indego services.
60 * @author Jacob Laursen - Initial contribution
63 public class IndegoController {
65 protected static final String SERIAL_NUMBER_SUBPATH = "alms/";
67 private static final String BASE_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/";
68 private static final String CONTENT_TYPE_HEADER = "application/json";
69 private static final String BEARER = "Bearer ";
71 private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
72 private final Gson gson = new GsonBuilder().registerTypeAdapter(Instant.class, new InstantDeserializer()).create();
73 private final HttpClient httpClient;
74 private final OAuthClientService oAuthClientService;
75 private final String userAgent;
78 * Initialize the controller instance.
80 * @param httpClient the HttpClient for communicating with the service
81 * @param oAuthClientService the OAuthClientService for authorization
83 public IndegoController(HttpClient httpClient, OAuthClientService oAuthClientService) {
84 this.httpClient = httpClient;
85 this.oAuthClientService = oAuthClientService;
86 userAgent = "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
90 * Gets serial numbers of all the associated Indego devices.
92 * @return the serial numbers of the devices
93 * @throws IndegoAuthenticationException if request was rejected as unauthorized
94 * @throws IndegoException if any communication or parsing error occurred
96 public Collection<String> getSerialNumbers() throws IndegoAuthenticationException, IndegoException {
97 Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class);
99 return Arrays.stream(mowers).map(m -> m.serialNumber).toList();
103 * Queries the serial number and device service properties from the server.
105 * @param serialNumber the serial number of the device
106 * @return the device serial number and properties
107 * @throws IndegoAuthenticationException if request was rejected as unauthorized
108 * @throws IndegoException if any communication or parsing error occurred
110 public DevicePropertiesResponse getDeviceProperties(String serialNumber)
111 throws IndegoAuthenticationException, IndegoException {
112 return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/", DevicePropertiesResponse.class);
115 private String getAuthorizationUrl() {
117 return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null);
118 } catch (OAuthException e) {
123 private String getAuthorizationHeader() throws IndegoException {
124 final AccessTokenResponse accessTokenResponse;
126 accessTokenResponse = oAuthClientService.getAccessTokenResponse();
127 } catch (OAuthException | OAuthResponseException e) {
128 logger.debug("Error fetching access token: {}", e.getMessage(), e);
129 throw new IndegoAuthenticationException(
130 "Error fetching access token. Invalid authcode? Please generate a new one -> "
131 + getAuthorizationUrl(),
133 } catch (IOException e) {
134 throw new IndegoException("An unexpected IOException occurred: " + e.getMessage(), e);
136 if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null
137 || accessTokenResponse.getAccessToken().isEmpty()) {
138 throw new IndegoAuthenticationException(
139 "No access token. Is this thing authorized? -> " + getAuthorizationUrl());
141 if (accessTokenResponse.getRefreshToken() == null || accessTokenResponse.getRefreshToken().isEmpty()) {
142 throw new IndegoAuthenticationException("No refresh token. Please reauthorize -> " + getAuthorizationUrl());
145 return BEARER + accessTokenResponse.getAccessToken();
149 * Sends a GET request to the server and returns the deserialized JSON response.
151 * @param path the relative path to which the request should be sent
152 * @param dtoClass the DTO class to which the JSON result should be deserialized
153 * @return the deserialized DTO from the JSON response
154 * @throws IndegoAuthenticationException if request was rejected as unauthorized
155 * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
156 * @throws IndegoException if any communication or parsing error occurred
158 protected <T> T getRequest(String path, Class<? extends T> dtoClass)
159 throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
162 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
163 .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
164 if (logger.isTraceEnabled()) {
165 logger.trace("GET request for {}", BASE_URL + path);
167 ContentResponse response = sendRequest(request);
168 status = response.getStatus();
169 String jsonResponse = response.getContentAsString();
170 if (!jsonResponse.isEmpty()) {
171 logger.trace("JSON response: '{}'", jsonResponse);
173 if (status == HttpStatus.UNAUTHORIZED_401) {
174 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
175 throw new IndegoAuthenticationException("Unauthorized");
177 if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
178 throw new IndegoTimeoutException("Gateway timeout");
180 if (!HttpStatus.isSuccess(status)) {
181 throw new IndegoException("The request failed with error: " + status);
183 if (jsonResponse.isEmpty()) {
184 throw new IndegoInvalidResponseException("No content returned", status);
188 T result = gson.fromJson(jsonResponse, dtoClass);
189 if (result == null) {
190 throw new IndegoInvalidResponseException("Parsed response is null", status);
193 } catch (JsonParseException e) {
194 throw new IndegoInvalidResponseException("Error parsing response", e, status);
195 } catch (InterruptedException e) {
196 Thread.currentThread().interrupt();
197 throw new IndegoException(e);
198 } catch (TimeoutException e) {
199 throw new IndegoException(e);
200 } catch (ExecutionException e) {
201 Throwable cause = e.getCause();
202 if (cause != null && cause instanceof HttpResponseException) {
203 Response response = ((HttpResponseException) cause).getResponse();
204 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
206 * The service may respond with HTTP code 401 without any "WWW-Authenticate"
207 * header, violating RFC 7235. Jetty will then throw HttpResponseException.
208 * We need to handle this in order to attempt reauthentication.
210 throw new IndegoAuthenticationException("Unauthorized", e);
213 throw new IndegoException(e);
218 * Sends a GET request to the server and returns the raw response.
220 * @param path the relative path to which the request should be sent
221 * @return the raw data from the response
222 * @throws IndegoAuthenticationException if request was rejected as unauthorized
223 * @throws IndegoException if any communication or parsing error occurred
225 protected RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
228 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
229 .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
230 if (logger.isTraceEnabled()) {
231 logger.trace("GET request for {}", BASE_URL + path);
233 ContentResponse response = sendRequest(request);
234 status = response.getStatus();
235 if (status == HttpStatus.UNAUTHORIZED_401) {
236 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
237 throw new IndegoAuthenticationException("Context rejected");
239 if (!HttpStatus.isSuccess(status)) {
240 throw new IndegoException("The request failed with error: " + status);
242 byte[] data = response.getContent();
244 throw new IndegoInvalidResponseException("No data returned", status);
246 String contentType = response.getMediaType();
247 if (contentType == null || contentType.isEmpty()) {
248 throw new IndegoInvalidResponseException("No content-type returned", status);
250 logger.debug("Media download response: type {}, length {}", contentType, data.length);
252 return new RawType(data, contentType);
253 } catch (JsonParseException e) {
254 throw new IndegoInvalidResponseException("Error parsing response", e, status);
255 } catch (InterruptedException e) {
256 Thread.currentThread().interrupt();
257 throw new IndegoException(e);
258 } catch (TimeoutException e) {
259 throw new IndegoException(e);
260 } catch (ExecutionException e) {
261 Throwable cause = e.getCause();
262 if (cause != null && cause instanceof HttpResponseException) {
263 Response response = ((HttpResponseException) cause).getResponse();
264 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
266 * When contextId is not valid, the service will respond with HTTP code 401 without
267 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
268 * HttpResponseException. We need to handle this in order to attempt
271 throw new IndegoAuthenticationException("Context rejected", e);
274 throw new IndegoException(e);
279 * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
281 * @param path the relative path to which the request should be sent
282 * @param requestDto the DTO which should be sent to the server as JSON
283 * @throws IndegoAuthenticationException if request was rejected as unauthorized
284 * @throws IndegoException if any communication or parsing error occurred
286 protected void putRequestWithAuthentication(String path, Object requestDto)
287 throws IndegoAuthenticationException, IndegoException {
288 putPostRequest(HttpMethod.PUT, path, requestDto);
292 * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
294 * @param path the relative path to which the request should be sent
295 * @throws IndegoAuthenticationException if request was rejected as unauthorized
296 * @throws IndegoException if any communication or parsing error occurred
298 protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException {
299 putPostRequest(HttpMethod.POST, path, null);
303 * Sends a PUT/POST request to the server.
305 * @param method the type of request ({@link HttpMethod.PUT} or {@link HttpMethod.POST})
306 * @param path the relative path to which the request should be sent
307 * @param requestDto the DTO which should be sent to the server as JSON
308 * @throws IndegoAuthenticationException if request was rejected as unauthorized
309 * @throws IndegoException if any communication or parsing error occurred
311 protected void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto)
312 throws IndegoAuthenticationException, IndegoException {
314 Request request = httpClient.newRequest(BASE_URL + path).method(method)
315 .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader())
316 .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER).agent(userAgent);
317 if (requestDto != null) {
318 String payload = gson.toJson(requestDto);
319 request.content(new StringContentProvider(payload));
320 if (logger.isTraceEnabled()) {
321 logger.trace("{} request for {} with payload '{}'", method, BASE_URL + path, payload);
324 logger.trace("{} request for {} with no payload", method, BASE_URL + path);
326 ContentResponse response = sendRequest(request);
327 String jsonResponse = response.getContentAsString();
328 if (!jsonResponse.isEmpty()) {
329 logger.trace("JSON response: '{}'", jsonResponse);
331 int status = response.getStatus();
332 if (status == HttpStatus.UNAUTHORIZED_401) {
333 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
334 throw new IndegoAuthenticationException("Context rejected");
336 if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
338 ErrorResponse result = gson.fromJson(jsonResponse, ErrorResponse.class);
339 if (result != null) {
340 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status,
343 } catch (JsonParseException e) {
344 // Ignore missing error code, next line will throw.
346 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
348 if (!HttpStatus.isSuccess(status)) {
349 throw new IndegoException("The request failed with error: " + status);
351 } catch (JsonParseException e) {
352 throw new IndegoException("Error serializing request", e);
353 } catch (InterruptedException e) {
354 Thread.currentThread().interrupt();
355 throw new IndegoException(e);
356 } catch (TimeoutException e) {
357 throw new IndegoException(e);
358 } catch (ExecutionException e) {
359 Throwable cause = e.getCause();
360 if (cause != null && cause instanceof HttpResponseException) {
361 Response response = ((HttpResponseException) cause).getResponse();
362 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
364 * When contextId is not valid, the service will respond with HTTP code 401 without
365 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
366 * HttpResponseException. We need to handle this in order to attempt
369 throw new IndegoAuthenticationException("Context rejected", e);
372 throw new IndegoException(e);
377 * Send request. This method exists for the purpose of avoiding multiple calls to
378 * the server at the same time.
380 * @param request the {@link Request} to send
381 * @return a {@link ContentResponse} for this request
382 * @throws InterruptedException if send thread is interrupted
383 * @throws TimeoutException if send times out
384 * @throws ExecutionException if execution fails
386 protected synchronized ContentResponse sendRequest(Request request)
387 throws InterruptedException, TimeoutException, ExecutionException {
388 return request.send();