2 * Copyright (c) 2010-2022 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;
16 import java.time.Instant;
17 import java.util.Base64;
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.DeviceCommand;
33 import org.openhab.binding.boschindego.internal.dto.PredictiveAdjustment;
34 import org.openhab.binding.boschindego.internal.dto.PredictiveStatus;
35 import org.openhab.binding.boschindego.internal.dto.request.AuthenticationRequest;
36 import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest;
37 import org.openhab.binding.boschindego.internal.dto.response.AuthenticationResponse;
38 import org.openhab.binding.boschindego.internal.dto.response.DeviceCalendarResponse;
39 import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
40 import org.openhab.binding.boschindego.internal.dto.response.LocationWeatherResponse;
41 import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse;
42 import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse;
43 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
44 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
45 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
46 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
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 device through Bosch services.
55 * This class provides methods for retrieving state information as well as controlling
58 * The implementation is based on zazaz-de/iot-device-bosch-indego-controller, but
59 * rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for
60 * JSON parsing. Thanks to Oliver Schünemann for providing the original implementation.
62 * @author Jacob Laursen - Initial contribution
65 public class IndegoController {
67 private static final String BASE_URL = "https://api.indego.iot.bosch-si.com/api/v1/";
68 private static final URI BASE_URI = URI.create(BASE_URL);
69 private static final String SERIAL_NUMBER_SUBPATH = "alms/";
70 private static final String SSO_COOKIE_NAME = "BOSCH_INDEGO_SSO";
71 private static final String CONTEXT_HEADER_NAME = "x-im-context-id";
72 private static final String CONTENT_TYPE_HEADER = "application/json";
74 private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
75 private final String basicAuthenticationHeader;
76 private final Gson gson = new Gson();
77 private final HttpClient httpClient;
79 private IndegoSession session = new IndegoSession();
82 * Initialize the controller instance.
84 * @param username the username for authenticating
85 * @param password the password
87 public IndegoController(HttpClient httpClient, String username, String password) {
88 this.httpClient = httpClient;
89 basicAuthenticationHeader = "Basic "
90 + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
94 * Authenticate with server and store session context and serial number.
96 * @throws IndegoAuthenticationException if request was rejected as unauthorized
97 * @throws IndegoException if any communication or parsing error occurred
99 private void authenticate() throws IndegoAuthenticationException, IndegoException {
101 Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
102 .header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
104 AuthenticationRequest authRequest = new AuthenticationRequest();
105 authRequest.device = "";
106 authRequest.osType = "Android";
107 authRequest.osVersion = "4.0";
108 authRequest.deviceManufacturer = "unknown";
109 authRequest.deviceType = "unknown";
110 String json = gson.toJson(authRequest);
111 request.content(new StringContentProvider(json));
112 request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
114 if (logger.isTraceEnabled()) {
115 logger.trace("POST request for {}", BASE_URL + "authenticate");
118 ContentResponse response = sendRequest(request);
119 int status = response.getStatus();
120 if (status == HttpStatus.UNAUTHORIZED_401) {
121 throw new IndegoAuthenticationException("Authentication was rejected");
123 if (!HttpStatus.isSuccess(status)) {
124 throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
127 String jsonResponse = response.getContentAsString();
128 if (jsonResponse.isEmpty()) {
129 throw new IndegoInvalidResponseException("No content returned");
131 logger.trace("JSON response: '{}'", jsonResponse);
133 AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
134 if (authenticationResponse == null) {
135 throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse");
137 session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
138 getContextExpirationTimeFromCookie());
139 logger.debug("Initialized session {}", session);
140 } catch (JsonParseException e) {
141 throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e);
142 } catch (InterruptedException e) {
143 Thread.currentThread().interrupt();
144 throw new IndegoException(e);
145 } catch (TimeoutException | ExecutionException e) {
146 throw new IndegoException(e);
151 * Get context expiration time as a calculated {@link Instant} relative to now.
152 * The information is obtained from max age in the Bosch Indego SSO cookie.
153 * Please note that this cookie is only sent initially when authenticating, so
154 * the value will not be subject to any updates.
156 * @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
158 private Instant getContextExpirationTimeFromCookie() {
159 return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
160 .findFirst().map(c -> {
161 return Instant.now().plusSeconds(c.getMaxAge());
168 * Wraps {@link #getRequest(String, Class)} into an authenticated session.
170 * @param path the relative path to which the request should be sent
171 * @param dtoClass the DTO class to which the JSON result should be deserialized
172 * @return the deserialized DTO from the JSON response
173 * @throws IndegoAuthenticationException if request was rejected as unauthorized
174 * @throws IndegoException if any communication or parsing error occurred
176 private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
177 throws IndegoAuthenticationException, IndegoException {
178 if (!session.isValid()) {
182 logger.debug("Session {} valid, skipping authentication", session);
183 return getRequest(path, dtoClass);
184 } catch (IndegoAuthenticationException e) {
185 if (logger.isTraceEnabled()) {
186 logger.trace("Context rejected", e);
188 logger.debug("Context rejected: {}", e.getMessage());
190 session.invalidate();
192 return getRequest(path, dtoClass);
197 * Sends a GET request to the server and returns the deserialized JSON response.
199 * @param path the relative path to which the request should be sent
200 * @param dtoClass the DTO class to which the JSON result should be deserialized
201 * @return the deserialized DTO from the JSON response
202 * @throws IndegoAuthenticationException if request was rejected as unauthorized
203 * @throws IndegoException if any communication or parsing error occurred
205 private <T> T getRequest(String path, Class<? extends T> dtoClass)
206 throws IndegoAuthenticationException, IndegoException {
208 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
209 session.getContextId());
210 if (logger.isTraceEnabled()) {
211 logger.trace("GET request for {}", BASE_URL + path);
213 ContentResponse response = sendRequest(request);
214 int status = response.getStatus();
215 if (status == HttpStatus.UNAUTHORIZED_401) {
216 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
217 throw new IndegoAuthenticationException("Context rejected");
219 if (!HttpStatus.isSuccess(status)) {
220 throw new IndegoException("The request failed with error: " + status);
222 String jsonResponse = response.getContentAsString();
223 if (jsonResponse.isEmpty()) {
224 throw new IndegoInvalidResponseException("No content returned");
226 logger.trace("JSON response: '{}'", jsonResponse);
229 T result = gson.fromJson(jsonResponse, dtoClass);
230 if (result == null) {
231 throw new IndegoInvalidResponseException("Parsed response is null");
234 } catch (JsonParseException e) {
235 throw new IndegoInvalidResponseException("Error parsing response", e);
236 } catch (InterruptedException e) {
237 Thread.currentThread().interrupt();
238 throw new IndegoException(e);
239 } catch (TimeoutException e) {
240 throw new IndegoException(e);
241 } catch (ExecutionException e) {
242 Throwable cause = e.getCause();
243 if (cause != null && cause instanceof HttpResponseException) {
244 Response response = ((HttpResponseException) cause).getResponse();
245 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
247 * When contextId is not valid, the service will respond with HTTP code 401 without
248 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
249 * HttpResponseException. We need to handle this in order to attempt
252 throw new IndegoAuthenticationException("Context rejected", e);
255 throw new IndegoException(e);
260 * Wraps {@link #putRequest(String, Object)} into an authenticated session.
262 * @param path the relative path to which the request should be sent
263 * @param requestDto the DTO which should be sent to the server as JSON
264 * @throws IndegoAuthenticationException if request was rejected as unauthorized
265 * @throws IndegoException if any communication or parsing error occurred
267 private void putRequestWithAuthentication(String path, Object requestDto)
268 throws IndegoAuthenticationException, IndegoException {
269 if (!session.isValid()) {
273 logger.debug("Session {} valid, skipping authentication", session);
274 putRequest(path, requestDto);
275 } catch (IndegoAuthenticationException e) {
276 if (logger.isTraceEnabled()) {
277 logger.trace("Context rejected", e);
279 logger.debug("Context rejected: {}", e.getMessage());
281 session.invalidate();
283 putRequest(path, requestDto);
288 * Sends a PUT request to the server.
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 private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
297 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
298 .header(CONTEXT_HEADER_NAME, session.getContextId())
299 .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
300 String payload = gson.toJson(requestDto);
301 request.content(new StringContentProvider(payload));
302 if (logger.isTraceEnabled()) {
303 logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
305 ContentResponse response = sendRequest(request);
306 int status = response.getStatus();
307 if (status == HttpStatus.UNAUTHORIZED_401) {
308 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
309 throw new IndegoAuthenticationException("Context rejected");
311 if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
312 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
314 if (!HttpStatus.isSuccess(status)) {
315 throw new IndegoException("The request failed with error: " + status);
317 } catch (JsonParseException e) {
318 throw new IndegoInvalidResponseException("Error serializing request", e);
319 } catch (InterruptedException e) {
320 Thread.currentThread().interrupt();
321 throw new IndegoException(e);
322 } catch (TimeoutException e) {
323 throw new IndegoException(e);
324 } catch (ExecutionException e) {
325 Throwable cause = e.getCause();
326 if (cause != null && cause instanceof HttpResponseException) {
327 Response response = ((HttpResponseException) cause).getResponse();
328 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
330 * When contextId is not valid, the service will respond with HTTP code 401 without
331 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
332 * HttpResponseException. We need to handle this in order to attempt
335 throw new IndegoAuthenticationException("Context rejected", e);
338 throw new IndegoException(e);
343 * Send request. This method exists for the purpose of avoiding multiple calls to
344 * the server at the same time.
346 * @param request the {@link Request} to send
347 * @return a {@link ContentResponse} for this request
348 * @throws InterruptedException if send thread is interrupted
349 * @throws TimeoutException if send times out
350 * @throws ExecutionException if execution fails
352 private synchronized ContentResponse sendRequest(Request request)
353 throws InterruptedException, TimeoutException, ExecutionException {
354 return request.send();
358 * Gets serial number of the associated Indego device
360 * @return the serial number of the device
361 * @throws IndegoAuthenticationException if request was rejected as unauthorized
362 * @throws IndegoException if any communication or parsing error occurred
364 public String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
365 if (!session.isInitialized()) {
366 logger.debug("Session not yet initialized when serial number was requested; authenticating...");
369 return session.getSerialNumber();
373 * Queries the device state from the server.
375 * @return the device state
376 * @throws IndegoAuthenticationException if request was rejected as unauthorized
377 * @throws IndegoException if any communication or parsing error occurred
379 public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
380 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
381 DeviceStateResponse.class);
385 * Queries the calendar.
387 * @return the calendar
388 * @throws IndegoAuthenticationException if request was rejected as unauthorized
389 * @throws IndegoException if any communication or parsing error occurred
391 public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
392 DeviceCalendarResponse calendar = getRequestWithAuthentication(
393 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
398 * Sends a command to the Indego device.
400 * @param command the control command to send to the device
401 * @throws IndegoAuthenticationException if request was rejected as unauthorized
402 * @throws IndegoInvalidCommandException if the command was not processed correctly
403 * @throws IndegoException if any communication or parsing error occurred
405 public void sendCommand(DeviceCommand command)
406 throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
407 SetStateRequest request = new SetStateRequest();
408 request.state = command.getActionCode();
409 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
413 * Queries the predictive weather forecast.
415 * @return the weather forecast DTO
416 * @throws IndegoAuthenticationException if request was rejected as unauthorized
417 * @throws IndegoException if any communication or parsing error occurred
419 public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
420 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
421 LocationWeatherResponse.class);
425 * Queries the predictive adjustment.
427 * @return the predictive adjustment
428 * @throws IndegoAuthenticationException if request was rejected as unauthorized
429 * @throws IndegoException if any communication or parsing error occurred
431 public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
432 return getRequestWithAuthentication(
433 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
434 PredictiveAdjustment.class).adjustment;
438 * Sets the predictive adjustment.
440 * @param adjust the predictive adjustment
441 * @throws IndegoAuthenticationException if request was rejected as unauthorized
442 * @throws IndegoException if any communication or parsing error occurred
444 public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
445 final PredictiveAdjustment adjustment = new PredictiveAdjustment();
446 adjustment.adjustment = adjust;
447 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
452 * Queries predictive moving.
454 * @return predictive moving
455 * @throws IndegoAuthenticationException if request was rejected as unauthorized
456 * @throws IndegoException if any communication or parsing error occurred
458 public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
459 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive",
460 PredictiveStatus.class).enabled;
464 * Sets predictive moving.
467 * @throws IndegoAuthenticationException if request was rejected as unauthorized
468 * @throws IndegoException if any communication or parsing error occurred
470 public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
471 final PredictiveStatus status = new PredictiveStatus();
472 status.enabled = enable;
473 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
477 * Queries predictive last cutting as {@link Instant}.
479 * @return predictive last cutting
480 * @throws IndegoAuthenticationException if request was rejected as unauthorized
481 * @throws IndegoException if any communication or parsing error occurred
483 public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
484 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting",
485 PredictiveLastCuttingResponse.class).getLastCutting();
489 * Queries predictive next cutting as {@link Instant}.
491 * @return predictive next cutting
492 * @throws IndegoAuthenticationException if request was rejected as unauthorized
493 * @throws IndegoException if any communication or parsing error occurred
495 public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
496 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
497 PredictiveNextCuttingResponse.class).getNextCutting();
501 * Queries predictive exclusion time.
503 * @return predictive exclusion time DTO
504 * @throws IndegoAuthenticationException if request was rejected as unauthorized
505 * @throws IndegoException if any communication or parsing error occurred
507 public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
508 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar",
509 DeviceCalendarResponse.class);
513 * Sets predictive exclusion time.
515 * @param calendar calendar DTO
516 * @throws IndegoAuthenticationException if request was rejected as unauthorized
517 * @throws IndegoException if any communication or parsing error occurred
519 public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
520 throws IndegoAuthenticationException, IndegoException {
521 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);