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.util.Arrays;
19 import java.util.Collection;
20 import java.util.concurrent.ExecutionException;
21 import java.util.concurrent.TimeoutException;
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.eclipse.jetty.client.HttpClient;
26 import org.eclipse.jetty.client.HttpResponseException;
27 import org.eclipse.jetty.client.api.ContentResponse;
28 import org.eclipse.jetty.client.api.Request;
29 import org.eclipse.jetty.client.api.Response;
30 import org.eclipse.jetty.client.util.StringContentProvider;
31 import org.eclipse.jetty.http.HttpHeader;
32 import org.eclipse.jetty.http.HttpMethod;
33 import org.eclipse.jetty.http.HttpStatus;
34 import org.openhab.binding.boschindego.internal.dto.response.ErrorResponse;
35 import org.openhab.binding.boschindego.internal.dto.response.Mower;
36 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
37 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
38 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
39 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
40 import org.openhab.binding.boschindego.internal.exceptions.IndegoTimeoutException;
41 import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
42 import org.openhab.core.auth.client.oauth2.OAuthClientService;
43 import org.openhab.core.auth.client.oauth2.OAuthException;
44 import org.openhab.core.auth.client.oauth2.OAuthResponseException;
45 import org.openhab.core.library.types.RawType;
46 import org.osgi.framework.FrameworkUtil;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
50 import com.google.gson.Gson;
51 import com.google.gson.JsonParseException;
54 * Controller for communicating with a Bosch Indego services.
56 * @author Jacob Laursen - Initial contribution
59 public class IndegoController {
61 protected static final String SERIAL_NUMBER_SUBPATH = "alms/";
63 private static final String BASE_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/";
64 private static final String CONTENT_TYPE_HEADER = "application/json";
66 private static final String BEARER = "Bearer ";
68 private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
69 private final Gson gson = new Gson();
70 private final HttpClient httpClient;
71 private final OAuthClientService oAuthClientService;
72 private final String userAgent;
75 * Initialize the controller instance.
77 * @param httpClient the HttpClient for communicating with the service
78 * @param oAuthClientService the OAuthClientService for authorization
80 public IndegoController(HttpClient httpClient, OAuthClientService oAuthClientService) {
81 this.httpClient = httpClient;
82 this.oAuthClientService = oAuthClientService;
83 userAgent = "openHAB " + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
87 * Gets serial numbers of all the associated Indego devices.
89 * @return the serial numbers of the devices
90 * @throws IndegoAuthenticationException if request was rejected as unauthorized
91 * @throws IndegoException if any communication or parsing error occurred
93 public Collection<String> getSerialNumbers() throws IndegoAuthenticationException, IndegoException {
94 Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class);
96 return Arrays.stream(mowers).map(m -> m.serialNumber).toList();
99 private String getAuthorizationUrl() {
101 return oAuthClientService.getAuthorizationUrl(BSK_REDIRECT_URI, BSK_SCOPE, null);
102 } catch (OAuthException e) {
107 private String getAuthorizationHeader() throws IndegoException {
108 final AccessTokenResponse accessTokenResponse;
110 accessTokenResponse = oAuthClientService.getAccessTokenResponse();
111 } catch (OAuthException | OAuthResponseException e) {
112 logger.debug("Error fetching access token: {}", e.getMessage(), e);
113 throw new IndegoAuthenticationException(
114 "Error fetching access token. Invalid authcode? Please generate a new one -> "
115 + getAuthorizationUrl(),
117 } catch (IOException e) {
118 throw new IndegoException("An unexpected IOException occurred: " + e.getMessage(), e);
120 if (accessTokenResponse == null || accessTokenResponse.getAccessToken() == null
121 || accessTokenResponse.getAccessToken().isEmpty()) {
122 throw new IndegoAuthenticationException(
123 "No access token. Is this thing authorized? -> " + getAuthorizationUrl());
125 if (accessTokenResponse.getRefreshToken() == null || accessTokenResponse.getRefreshToken().isEmpty()) {
126 throw new IndegoAuthenticationException("No refresh token. Please reauthorize -> " + getAuthorizationUrl());
129 return BEARER + accessTokenResponse.getAccessToken();
133 * Sends a GET request to the server and returns the deserialized JSON response.
135 * @param path the relative path to which the request should be sent
136 * @param dtoClass the DTO class to which the JSON result should be deserialized
137 * @return the deserialized DTO from the JSON response
138 * @throws IndegoAuthenticationException if request was rejected as unauthorized
139 * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
140 * @throws IndegoException if any communication or parsing error occurred
142 protected <T> T getRequest(String path, Class<? extends T> dtoClass)
143 throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
146 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
147 .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
148 if (logger.isTraceEnabled()) {
149 logger.trace("GET request for {}", BASE_URL + path);
151 ContentResponse response = sendRequest(request);
152 status = response.getStatus();
153 String jsonResponse = response.getContentAsString();
154 if (!jsonResponse.isEmpty()) {
155 logger.trace("JSON response: '{}'", jsonResponse);
157 if (status == HttpStatus.UNAUTHORIZED_401) {
158 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
159 throw new IndegoAuthenticationException("Unauthorized");
161 if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
162 throw new IndegoTimeoutException("Gateway timeout");
164 if (!HttpStatus.isSuccess(status)) {
165 throw new IndegoException("The request failed with error: " + status);
167 if (jsonResponse.isEmpty()) {
168 throw new IndegoInvalidResponseException("No content returned", status);
172 T result = gson.fromJson(jsonResponse, dtoClass);
173 if (result == null) {
174 throw new IndegoInvalidResponseException("Parsed response is null", status);
177 } catch (JsonParseException e) {
178 throw new IndegoInvalidResponseException("Error parsing response", e, status);
179 } catch (InterruptedException e) {
180 Thread.currentThread().interrupt();
181 throw new IndegoException(e);
182 } catch (TimeoutException e) {
183 throw new IndegoException(e);
184 } catch (ExecutionException e) {
185 Throwable cause = e.getCause();
186 if (cause != null && cause instanceof HttpResponseException) {
187 Response response = ((HttpResponseException) cause).getResponse();
188 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
190 * The service may respond with HTTP code 401 without any "WWW-Authenticate"
191 * header, violating RFC 7235. Jetty will then throw HttpResponseException.
192 * We need to handle this in order to attempt reauthentication.
194 throw new IndegoAuthenticationException("Unauthorized", e);
197 throw new IndegoException(e);
202 * Sends a GET request to the server and returns the raw response.
204 * @param path the relative path to which the request should be sent
205 * @return the raw data from the response
206 * @throws IndegoAuthenticationException if request was rejected as unauthorized
207 * @throws IndegoException if any communication or parsing error occurred
209 protected RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
212 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
213 .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader()).agent(userAgent);
214 if (logger.isTraceEnabled()) {
215 logger.trace("GET request for {}", BASE_URL + path);
217 ContentResponse response = sendRequest(request);
218 status = response.getStatus();
219 if (status == HttpStatus.UNAUTHORIZED_401) {
220 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
221 throw new IndegoAuthenticationException("Context rejected");
223 if (!HttpStatus.isSuccess(status)) {
224 throw new IndegoException("The request failed with error: " + status);
226 byte[] data = response.getContent();
228 throw new IndegoInvalidResponseException("No data returned", status);
230 String contentType = response.getMediaType();
231 if (contentType == null || contentType.isEmpty()) {
232 throw new IndegoInvalidResponseException("No content-type returned", status);
234 logger.debug("Media download response: type {}, length {}", contentType, data.length);
236 return new RawType(data, contentType);
237 } catch (JsonParseException e) {
238 throw new IndegoInvalidResponseException("Error parsing response", e, status);
239 } catch (InterruptedException e) {
240 Thread.currentThread().interrupt();
241 throw new IndegoException(e);
242 } catch (TimeoutException e) {
243 throw new IndegoException(e);
244 } catch (ExecutionException e) {
245 Throwable cause = e.getCause();
246 if (cause != null && cause instanceof HttpResponseException) {
247 Response response = ((HttpResponseException) cause).getResponse();
248 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
250 * When contextId is not valid, the service will respond with HTTP code 401 without
251 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
252 * HttpResponseException. We need to handle this in order to attempt
255 throw new IndegoAuthenticationException("Context rejected", e);
258 throw new IndegoException(e);
263 * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
265 * @param path the relative path to which the request should be sent
266 * @param requestDto the DTO which should be sent to the server as JSON
267 * @throws IndegoAuthenticationException if request was rejected as unauthorized
268 * @throws IndegoException if any communication or parsing error occurred
270 protected void putRequestWithAuthentication(String path, Object requestDto)
271 throws IndegoAuthenticationException, IndegoException {
272 putPostRequest(HttpMethod.PUT, path, requestDto);
276 * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
278 * @param path the relative path to which the request should be sent
279 * @throws IndegoAuthenticationException if request was rejected as unauthorized
280 * @throws IndegoException if any communication or parsing error occurred
282 protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException {
283 putPostRequest(HttpMethod.POST, path, null);
287 * Sends a PUT/POST request to the server.
289 * @param method the type of request ({@link HttpMethod.PUT} or {@link HttpMethod.POST})
290 * @param path the relative path to which the request should be sent
291 * @param requestDto the DTO which should be sent to the server as JSON
292 * @throws IndegoAuthenticationException if request was rejected as unauthorized
293 * @throws IndegoException if any communication or parsing error occurred
295 protected void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto)
296 throws IndegoAuthenticationException, IndegoException {
298 Request request = httpClient.newRequest(BASE_URL + path).method(method)
299 .header(HttpHeader.AUTHORIZATION, getAuthorizationHeader())
300 .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER).agent(userAgent);
301 if (requestDto != null) {
302 String payload = gson.toJson(requestDto);
303 request.content(new StringContentProvider(payload));
304 if (logger.isTraceEnabled()) {
305 logger.trace("{} request for {} with payload '{}'", method, BASE_URL + path, payload);
308 logger.trace("{} request for {} with no payload", method, BASE_URL + path);
310 ContentResponse response = sendRequest(request);
311 String jsonResponse = response.getContentAsString();
312 if (!jsonResponse.isEmpty()) {
313 logger.trace("JSON response: '{}'", jsonResponse);
315 int status = response.getStatus();
316 if (status == HttpStatus.UNAUTHORIZED_401) {
317 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
318 throw new IndegoAuthenticationException("Context rejected");
320 if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
322 ErrorResponse result = gson.fromJson(jsonResponse, ErrorResponse.class);
323 if (result != null) {
324 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status,
327 } catch (JsonParseException e) {
328 // Ignore missing error code, next line will throw.
330 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
332 if (!HttpStatus.isSuccess(status)) {
333 throw new IndegoException("The request failed with error: " + status);
335 } catch (JsonParseException e) {
336 throw new IndegoException("Error serializing request", e);
337 } catch (InterruptedException e) {
338 Thread.currentThread().interrupt();
339 throw new IndegoException(e);
340 } catch (TimeoutException e) {
341 throw new IndegoException(e);
342 } catch (ExecutionException e) {
343 Throwable cause = e.getCause();
344 if (cause != null && cause instanceof HttpResponseException) {
345 Response response = ((HttpResponseException) cause).getResponse();
346 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
348 * When contextId is not valid, the service will respond with HTTP code 401 without
349 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
350 * HttpResponseException. We need to handle this in order to attempt
353 throw new IndegoAuthenticationException("Context rejected", e);
356 throw new IndegoException(e);
361 * Send request. This method exists for the purpose of avoiding multiple calls to
362 * the server at the same time.
364 * @param request the {@link Request} to send
365 * @return a {@link ContentResponse} for this request
366 * @throws InterruptedException if send thread is interrupted
367 * @throws TimeoutException if send times out
368 * @throws ExecutionException if execution fails
370 protected synchronized ContentResponse sendRequest(Request request)
371 throws InterruptedException, TimeoutException, ExecutionException {
372 return request.send();