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 java.time.Instant;
16 import java.util.Arrays;
17 import java.util.Collection;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.TimeoutException;
21 import org.eclipse.jdt.annotation.NonNullByDefault;
22 import org.eclipse.jdt.annotation.Nullable;
23 import org.eclipse.jetty.client.HttpClient;
24 import org.eclipse.jetty.client.HttpResponseException;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.eclipse.jetty.client.api.Response;
28 import org.eclipse.jetty.client.util.StringContentProvider;
29 import org.eclipse.jetty.http.HttpHeader;
30 import org.eclipse.jetty.http.HttpMethod;
31 import org.eclipse.jetty.http.HttpStatus;
32 import org.openhab.binding.boschindego.internal.dto.response.DevicePropertiesResponse;
33 import org.openhab.binding.boschindego.internal.dto.response.ErrorResponse;
34 import org.openhab.binding.boschindego.internal.dto.response.Mower;
35 import org.openhab.binding.boschindego.internal.dto.serialization.InstantDeserializer;
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.library.types.RawType;
42 import org.osgi.framework.FrameworkUtil;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
46 import com.google.gson.Gson;
47 import com.google.gson.GsonBuilder;
48 import com.google.gson.JsonParseException;
51 * Controller for communicating with a Bosch Indego services.
53 * @author Jacob Laursen - Initial contribution
56 public class IndegoController {
58 protected static final String SERIAL_NUMBER_SUBPATH = "alms/";
60 private static final String BASE_URL = "https://api.indego-cloud.iot.bosch-si.com/api/v1/";
61 private static final String CONTENT_TYPE_HEADER = "application/json";
63 private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
64 private final Gson gson = new GsonBuilder().registerTypeAdapter(Instant.class, new InstantDeserializer()).create();
65 private final HttpClient httpClient;
66 private final AuthorizationProvider authorizationProvider;
67 private final String userAgent;
70 * Initialize the controller instance.
72 * @param httpClient the HttpClient for communicating with the service
73 * @param authorizationProvider the AuthorizationProvider for authenticating with the service
75 public IndegoController(HttpClient httpClient, AuthorizationProvider authorizationProvider) {
76 this.httpClient = httpClient;
77 this.authorizationProvider = authorizationProvider;
78 userAgent = "openHAB/" + FrameworkUtil.getBundle(this.getClass()).getVersion().toString();
82 * Gets serial numbers of all the associated Indego devices.
84 * @return the serial numbers of the devices
85 * @throws IndegoAuthenticationException if request was rejected as unauthorized
86 * @throws IndegoException if any communication or parsing error occurred
88 public Collection<String> getSerialNumbers() throws IndegoAuthenticationException, IndegoException {
89 Mower[] mowers = getRequest(SERIAL_NUMBER_SUBPATH, Mower[].class);
91 return Arrays.stream(mowers).map(m -> m.serialNumber).toList();
95 * Queries the serial number and device service properties from the server.
97 * @param serialNumber the serial number of the device
98 * @return the device serial number and properties
99 * @throws IndegoAuthenticationException if request was rejected as unauthorized
100 * @throws IndegoException if any communication or parsing error occurred
102 public DevicePropertiesResponse getDeviceProperties(String serialNumber)
103 throws IndegoAuthenticationException, IndegoException {
104 return getRequest(SERIAL_NUMBER_SUBPATH + serialNumber + "/", DevicePropertiesResponse.class);
108 * Sends a GET request to the server and returns the deserialized JSON response.
110 * @param path the relative path to which the request should be sent
111 * @param dtoClass the DTO class to which the JSON result should be deserialized
112 * @return the deserialized DTO from the JSON response
113 * @throws IndegoAuthenticationException if request was rejected as unauthorized
114 * @throws IndegoTimeoutException if device cannot be reached (gateway timeout error)
115 * @throws IndegoException if any communication or parsing error occurred
117 protected <T> T getRequest(String path, Class<? extends T> dtoClass)
118 throws IndegoAuthenticationException, IndegoTimeoutException, IndegoException {
121 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
122 .header(HttpHeader.AUTHORIZATION, authorizationProvider.getAuthorizationHeader()).agent(userAgent);
123 if (logger.isTraceEnabled()) {
124 logger.trace("GET request for {}", BASE_URL + path);
126 ContentResponse response = sendRequest(request);
127 status = response.getStatus();
128 String jsonResponse = response.getContentAsString();
129 if (!jsonResponse.isEmpty()) {
130 logger.trace("JSON response: '{}'", jsonResponse);
132 if (status == HttpStatus.UNAUTHORIZED_401) {
133 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
134 throw new IndegoAuthenticationException("Unauthorized");
136 if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
137 throw new IndegoTimeoutException("Gateway timeout");
139 if (!HttpStatus.isSuccess(status)) {
140 throw new IndegoException("The request failed with error: " + status);
142 if (jsonResponse.isEmpty()) {
143 throw new IndegoInvalidResponseException("No content returned", status);
147 T result = gson.fromJson(jsonResponse, dtoClass);
148 if (result == null) {
149 throw new IndegoInvalidResponseException("Parsed response is null", status);
152 } catch (JsonParseException e) {
153 throw new IndegoInvalidResponseException("Error parsing response", e, status);
154 } catch (InterruptedException e) {
155 Thread.currentThread().interrupt();
156 throw new IndegoException(e);
157 } catch (TimeoutException e) {
158 throw new IndegoException(e);
159 } catch (ExecutionException e) {
160 Throwable cause = e.getCause();
161 if (cause != null && cause instanceof HttpResponseException httpResponseException) {
162 Response response = httpResponseException.getResponse();
163 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
165 * The service may respond with HTTP code 401 without any "WWW-Authenticate"
166 * header, violating RFC 7235. Jetty will then throw HttpResponseException.
167 * We need to handle this in order to attempt reauthentication.
169 throw new IndegoAuthenticationException("Unauthorized", e);
172 throw new IndegoException(e);
177 * Sends a GET request to the server and returns the raw response.
179 * @param path the relative path to which the request should be sent
180 * @return the raw data from the response
181 * @throws IndegoAuthenticationException if request was rejected as unauthorized
182 * @throws IndegoException if any communication or parsing error occurred
184 protected RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
187 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET)
188 .header(HttpHeader.AUTHORIZATION, authorizationProvider.getAuthorizationHeader()).agent(userAgent);
189 if (logger.isTraceEnabled()) {
190 logger.trace("GET request for {}", BASE_URL + path);
192 ContentResponse response = sendRequest(request);
193 status = response.getStatus();
194 if (status == HttpStatus.UNAUTHORIZED_401) {
195 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
196 throw new IndegoAuthenticationException("Context rejected");
198 if (!HttpStatus.isSuccess(status)) {
199 throw new IndegoException("The request failed with error: " + status);
201 byte[] data = response.getContent();
203 throw new IndegoInvalidResponseException("No data returned", status);
205 String contentType = response.getMediaType();
206 if (contentType == null || contentType.isEmpty()) {
207 throw new IndegoInvalidResponseException("No content-type returned", status);
209 logger.debug("Media download response: type {}, length {}", contentType, data.length);
211 return new RawType(data, contentType);
212 } catch (JsonParseException e) {
213 throw new IndegoInvalidResponseException("Error parsing response", e, status);
214 } catch (InterruptedException e) {
215 Thread.currentThread().interrupt();
216 throw new IndegoException(e);
217 } catch (TimeoutException e) {
218 throw new IndegoException(e);
219 } catch (ExecutionException e) {
220 Throwable cause = e.getCause();
221 if (cause != null && cause instanceof HttpResponseException httpResponseException) {
222 Response response = httpResponseException.getResponse();
223 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
225 * When contextId is not valid, the service will respond with HTTP code 401 without
226 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
227 * HttpResponseException. We need to handle this in order to attempt
230 throw new IndegoAuthenticationException("Context rejected", e);
233 throw new IndegoException(e);
238 * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
240 * @param path the relative path to which the request should be sent
241 * @param requestDto the DTO which should be sent to the server as JSON
242 * @throws IndegoAuthenticationException if request was rejected as unauthorized
243 * @throws IndegoException if any communication or parsing error occurred
245 protected void putRequestWithAuthentication(String path, Object requestDto)
246 throws IndegoAuthenticationException, IndegoException {
247 putPostRequest(HttpMethod.PUT, path, requestDto);
251 * Wraps {@link #putPostRequest(HttpMethod, String, Object)} into an authenticated session.
253 * @param path the relative path to which the request should be sent
254 * @throws IndegoAuthenticationException if request was rejected as unauthorized
255 * @throws IndegoException if any communication or parsing error occurred
257 protected void postRequest(String path) throws IndegoAuthenticationException, IndegoException {
258 putPostRequest(HttpMethod.POST, path, null);
262 * Sends a PUT/POST request to the server.
264 * @param method the type of request ({@link org.eclipse.jetty.http.HttpMethod#PUT} or
265 * {@link org.eclipse.jetty.http.HttpMethod#POST})
266 * @param path the relative path to which the request should be sent
267 * @param requestDto the DTO which should be sent to the server as JSON
268 * @throws IndegoAuthenticationException if request was rejected as unauthorized
269 * @throws IndegoException if any communication or parsing error occurred
271 protected void putPostRequest(HttpMethod method, String path, @Nullable Object requestDto)
272 throws IndegoAuthenticationException, IndegoException {
274 Request request = httpClient.newRequest(BASE_URL + path).method(method)
275 .header(HttpHeader.AUTHORIZATION, authorizationProvider.getAuthorizationHeader())
276 .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER).agent(userAgent);
277 if (requestDto != null) {
278 String payload = gson.toJson(requestDto);
279 request.content(new StringContentProvider(payload));
280 if (logger.isTraceEnabled()) {
281 logger.trace("{} request for {} with payload '{}'", method, BASE_URL + path, payload);
284 logger.trace("{} request for {} with no payload", method, BASE_URL + path);
286 ContentResponse response = sendRequest(request);
287 String jsonResponse = response.getContentAsString();
288 if (!jsonResponse.isEmpty()) {
289 logger.trace("JSON response: '{}'", jsonResponse);
291 int status = response.getStatus();
292 if (status == HttpStatus.UNAUTHORIZED_401) {
293 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
294 throw new IndegoAuthenticationException("Context rejected");
296 if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
298 ErrorResponse result = gson.fromJson(jsonResponse, ErrorResponse.class);
299 if (result != null) {
300 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status,
303 } catch (JsonParseException e) {
304 // Ignore missing error code, next line will throw.
306 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
308 if (!HttpStatus.isSuccess(status)) {
309 throw new IndegoException("The request failed with error: " + status);
311 } catch (JsonParseException e) {
312 throw new IndegoException("Error serializing request", e);
313 } catch (InterruptedException e) {
314 Thread.currentThread().interrupt();
315 throw new IndegoException(e);
316 } catch (TimeoutException e) {
317 throw new IndegoException(e);
318 } catch (ExecutionException e) {
319 Throwable cause = e.getCause();
320 if (cause != null && cause instanceof HttpResponseException httpResponseException) {
321 Response response = httpResponseException.getResponse();
322 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
324 * When contextId is not valid, the service will respond with HTTP code 401 without
325 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
326 * HttpResponseException. We need to handle this in order to attempt
329 throw new IndegoAuthenticationException("Context rejected", e);
332 throw new IndegoException(e);
337 * Send request. This method exists for the purpose of avoiding multiple calls to
338 * the server at the same time.
340 * @param request the {@link Request} to send
341 * @return a {@link ContentResponse} for this request
342 * @throws InterruptedException if send thread is interrupted
343 * @throws TimeoutException if send times out
344 * @throws ExecutionException if execution fails
346 protected synchronized ContentResponse sendRequest(Request request)
347 throws InterruptedException, TimeoutException, ExecutionException {
348 return request.send();