]> git.basschouten.com Git - openhab-addons.git/blob
70ef46af5c39fe8b1f320b75eea564d8c4feaaba
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.boschindego.internal;
14
15 import java.net.URI;
16 import java.time.Instant;
17 import java.util.Base64;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.TimeoutException;
20
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.OperatingDataResponse;
42 import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse;
43 import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse;
44 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
45 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
46 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
47 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
48 import org.openhab.binding.boschindego.internal.exceptions.IndegoUnreachableException;
49 import org.openhab.core.library.types.RawType;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
52
53 import com.google.gson.Gson;
54 import com.google.gson.JsonParseException;
55
56 /**
57  * Controller for communicating with a Bosch Indego device through Bosch services.
58  * This class provides methods for retrieving state information as well as controlling
59  * the device.
60  * 
61  * The implementation is based on zazaz-de/iot-device-bosch-indego-controller, but
62  * rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for
63  * JSON parsing. Thanks to Oliver Schünemann for providing the original implementation.
64  * 
65  * @author Jacob Laursen - Initial contribution
66  */
67 @NonNullByDefault
68 public class IndegoController {
69
70     private static final String BASE_URL = "https://api.indego.iot.bosch-si.com/api/v1/";
71     private static final URI BASE_URI = URI.create(BASE_URL);
72     private static final String SERIAL_NUMBER_SUBPATH = "alms/";
73     private static final String SSO_COOKIE_NAME = "BOSCH_INDEGO_SSO";
74     private static final String CONTEXT_HEADER_NAME = "x-im-context-id";
75     private static final String CONTENT_TYPE_HEADER = "application/json";
76
77     private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
78     private final String basicAuthenticationHeader;
79     private final Gson gson = new Gson();
80     private final HttpClient httpClient;
81
82     private IndegoSession session = new IndegoSession();
83
84     /**
85      * Initialize the controller instance.
86      * 
87      * @param username the username for authenticating
88      * @param password the password
89      */
90     public IndegoController(HttpClient httpClient, String username, String password) {
91         this.httpClient = httpClient;
92         basicAuthenticationHeader = "Basic "
93                 + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
94     }
95
96     /**
97      * Authenticate with server and store session context and serial number.
98      * 
99      * @throws IndegoAuthenticationException if request was rejected as unauthorized
100      * @throws IndegoException if any communication or parsing error occurred
101      */
102     private void authenticate() throws IndegoAuthenticationException, IndegoException {
103         try {
104             Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
105                     .header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
106
107             AuthenticationRequest authRequest = new AuthenticationRequest();
108             authRequest.device = "";
109             authRequest.osType = "Android";
110             authRequest.osVersion = "4.0";
111             authRequest.deviceManufacturer = "unknown";
112             authRequest.deviceType = "unknown";
113             String json = gson.toJson(authRequest);
114             request.content(new StringContentProvider(json));
115             request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
116
117             if (logger.isTraceEnabled()) {
118                 logger.trace("POST request for {}", BASE_URL + "authenticate");
119             }
120
121             ContentResponse response = sendRequest(request);
122             int status = response.getStatus();
123             if (status == HttpStatus.UNAUTHORIZED_401) {
124                 throw new IndegoAuthenticationException("Authentication was rejected");
125             }
126             if (!HttpStatus.isSuccess(status)) {
127                 throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
128             }
129
130             String jsonResponse = response.getContentAsString();
131             if (jsonResponse.isEmpty()) {
132                 throw new IndegoInvalidResponseException("No content returned");
133             }
134             logger.trace("JSON response: '{}'", jsonResponse);
135
136             AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
137             if (authenticationResponse == null) {
138                 throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse");
139             }
140             session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
141                     getContextExpirationTimeFromCookie());
142             logger.debug("Initialized session {}", session);
143         } catch (JsonParseException e) {
144             throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e);
145         } catch (InterruptedException e) {
146             Thread.currentThread().interrupt();
147             throw new IndegoException(e);
148         } catch (TimeoutException | ExecutionException e) {
149             throw new IndegoException(e);
150         }
151     }
152
153     /**
154      * Get context expiration time as a calculated {@link Instant} relative to now.
155      * The information is obtained from max age in the Bosch Indego SSO cookie.
156      * Please note that this cookie is only sent initially when authenticating, so
157      * the value will not be subject to any updates.
158      * 
159      * @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
160      */
161     private Instant getContextExpirationTimeFromCookie() {
162         return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
163                 .findFirst().map(c -> {
164                     return Instant.now().plusSeconds(c.getMaxAge());
165                 }).orElseGet(() -> {
166                     return Instant.MIN;
167                 });
168     }
169
170     /**
171      * Deauthenticate session. This method should be called as part of cleanup to reduce
172      * lingering sessions. This can potentially avoid killed sessions in situation with
173      * multiple clients (e.g. openHAB and mobile app) if restrictions on concurrent
174      * number of sessions would be put on the service.
175      *
176      * @throws IndegoException if any communication or parsing error occurred
177      */
178     public void deauthenticate() throws IndegoException {
179         if (session.isValid()) {
180             deleteRequest("authenticate");
181             session.invalidate();
182         }
183     }
184
185     /**
186      * Wraps {@link #getRequest(String, Class)} into an authenticated session.
187      *
188      * @param path the relative path to which the request should be sent
189      * @param dtoClass the DTO class to which the JSON result should be deserialized
190      * @return the deserialized DTO from the JSON response
191      * @throws IndegoAuthenticationException if request was rejected as unauthorized
192      * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
193      * @throws IndegoException if any communication or parsing error occurred
194      */
195     private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
196             throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
197         if (!session.isValid()) {
198             authenticate();
199         }
200         try {
201             logger.debug("Session {} valid, skipping authentication", session);
202             return getRequest(path, dtoClass);
203         } catch (IndegoAuthenticationException e) {
204             if (logger.isTraceEnabled()) {
205                 logger.trace("Context rejected", e);
206             } else {
207                 logger.debug("Context rejected: {}", e.getMessage());
208             }
209             session.invalidate();
210             authenticate();
211             return getRequest(path, dtoClass);
212         }
213     }
214
215     /**
216      * Sends a GET request to the server and returns the deserialized JSON response.
217      * 
218      * @param path the relative path to which the request should be sent
219      * @param dtoClass the DTO class to which the JSON result should be deserialized
220      * @return the deserialized DTO from the JSON response
221      * @throws IndegoAuthenticationException if request was rejected as unauthorized
222      * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
223      * @throws IndegoException if any communication or parsing error occurred
224      */
225     private <T> T getRequest(String path, Class<? extends T> dtoClass)
226             throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
227         try {
228             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
229                     session.getContextId());
230             if (logger.isTraceEnabled()) {
231                 logger.trace("GET request for {}", BASE_URL + path);
232             }
233             ContentResponse response = sendRequest(request);
234             int 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");
238             }
239             if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
240                 throw new IndegoUnreachableException("Gateway timeout");
241             }
242             if (!HttpStatus.isSuccess(status)) {
243                 throw new IndegoException("The request failed with error: " + status);
244             }
245             String jsonResponse = response.getContentAsString();
246             if (jsonResponse.isEmpty()) {
247                 throw new IndegoInvalidResponseException("No content returned");
248             }
249             logger.trace("JSON response: '{}'", jsonResponse);
250
251             @Nullable
252             T result = gson.fromJson(jsonResponse, dtoClass);
253             if (result == null) {
254                 throw new IndegoInvalidResponseException("Parsed response is null");
255             }
256             return result;
257         } catch (JsonParseException e) {
258             throw new IndegoInvalidResponseException("Error parsing response", e);
259         } catch (InterruptedException e) {
260             Thread.currentThread().interrupt();
261             throw new IndegoException(e);
262         } catch (TimeoutException e) {
263             throw new IndegoException(e);
264         } catch (ExecutionException e) {
265             Throwable cause = e.getCause();
266             if (cause != null && cause instanceof HttpResponseException) {
267                 Response response = ((HttpResponseException) cause).getResponse();
268                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
269                     /*
270                      * When contextId is not valid, the service will respond with HTTP code 401 without
271                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
272                      * HttpResponseException. We need to handle this in order to attempt
273                      * reauthentication.
274                      */
275                     throw new IndegoAuthenticationException("Context rejected", e);
276                 }
277             }
278             throw new IndegoException(e);
279         }
280     }
281
282     /**
283      * Wraps {@link #getRawRequest(String)} into an authenticated session.
284      *
285      * @param path the relative path to which the request should be sent
286      * @return the raw data from the response
287      * @throws IndegoAuthenticationException if request was rejected as unauthorized
288      * @throws IndegoException if any communication or parsing error occurred
289      */
290     private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException {
291         if (!session.isValid()) {
292             authenticate();
293         }
294         try {
295             logger.debug("Session {} valid, skipping authentication", session);
296             return getRawRequest(path);
297         } catch (IndegoAuthenticationException e) {
298             if (logger.isTraceEnabled()) {
299                 logger.trace("Context rejected", e);
300             } else {
301                 logger.debug("Context rejected: {}", e.getMessage());
302             }
303             session.invalidate();
304             authenticate();
305             return getRawRequest(path);
306         }
307     }
308
309     /**
310      * Sends a GET request to the server and returns the raw response.
311      * 
312      * @param path the relative path to which the request should be sent
313      * @return the raw data from the response
314      * @throws IndegoAuthenticationException if request was rejected as unauthorized
315      * @throws IndegoException if any communication or parsing error occurred
316      */
317     private RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
318         try {
319             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
320                     session.getContextId());
321             if (logger.isTraceEnabled()) {
322                 logger.trace("GET request for {}", BASE_URL + path);
323             }
324             ContentResponse response = sendRequest(request);
325             int status = response.getStatus();
326             if (status == HttpStatus.UNAUTHORIZED_401) {
327                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
328                 throw new IndegoAuthenticationException("Context rejected");
329             }
330             if (!HttpStatus.isSuccess(status)) {
331                 throw new IndegoException("The request failed with error: " + status);
332             }
333             byte[] data = response.getContent();
334             if (data == null) {
335                 throw new IndegoInvalidResponseException("No data returned");
336             }
337             String contentType = response.getMediaType();
338             if (contentType == null || contentType.isEmpty()) {
339                 throw new IndegoInvalidResponseException("No content-type returned");
340             }
341             logger.debug("Media download response: type {}, length {}", contentType, data.length);
342
343             return new RawType(data, contentType);
344         } catch (JsonParseException e) {
345             throw new IndegoInvalidResponseException("Error parsing response", e);
346         } catch (InterruptedException e) {
347             Thread.currentThread().interrupt();
348             throw new IndegoException(e);
349         } catch (TimeoutException e) {
350             throw new IndegoException(e);
351         } catch (ExecutionException e) {
352             Throwable cause = e.getCause();
353             if (cause != null && cause instanceof HttpResponseException) {
354                 Response response = ((HttpResponseException) cause).getResponse();
355                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
356                     /*
357                      * When contextId is not valid, the service will respond with HTTP code 401 without
358                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
359                      * HttpResponseException. We need to handle this in order to attempt
360                      * reauthentication.
361                      */
362                     throw new IndegoAuthenticationException("Context rejected", e);
363                 }
364             }
365             throw new IndegoException(e);
366         }
367     }
368
369     /**
370      * Wraps {@link #putRequest(String, Object)} into an authenticated session.
371      * 
372      * @param path the relative path to which the request should be sent
373      * @param requestDto the DTO which should be sent to the server as JSON
374      * @throws IndegoAuthenticationException if request was rejected as unauthorized
375      * @throws IndegoException if any communication or parsing error occurred
376      */
377     private void putRequestWithAuthentication(String path, Object requestDto)
378             throws IndegoAuthenticationException, IndegoException {
379         if (!session.isValid()) {
380             authenticate();
381         }
382         try {
383             logger.debug("Session {} valid, skipping authentication", session);
384             putRequest(path, requestDto);
385         } catch (IndegoAuthenticationException e) {
386             if (logger.isTraceEnabled()) {
387                 logger.trace("Context rejected", e);
388             } else {
389                 logger.debug("Context rejected: {}", e.getMessage());
390             }
391             session.invalidate();
392             authenticate();
393             putRequest(path, requestDto);
394         }
395     }
396
397     /**
398      * Sends a PUT request to the server.
399      * 
400      * @param path the relative path to which the request should be sent
401      * @param requestDto the DTO which should be sent to the server as JSON
402      * @throws IndegoAuthenticationException if request was rejected as unauthorized
403      * @throws IndegoException if any communication or parsing error occurred
404      */
405     private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
406         try {
407             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
408                     .header(CONTEXT_HEADER_NAME, session.getContextId())
409                     .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
410             String payload = gson.toJson(requestDto);
411             request.content(new StringContentProvider(payload));
412             if (logger.isTraceEnabled()) {
413                 logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
414             }
415             ContentResponse response = sendRequest(request);
416             int status = response.getStatus();
417             if (status == HttpStatus.UNAUTHORIZED_401) {
418                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
419                 throw new IndegoAuthenticationException("Context rejected");
420             }
421             if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
422                 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
423             }
424             if (!HttpStatus.isSuccess(status)) {
425                 throw new IndegoException("The request failed with error: " + status);
426             }
427         } catch (JsonParseException e) {
428             throw new IndegoInvalidResponseException("Error serializing request", e);
429         } catch (InterruptedException e) {
430             Thread.currentThread().interrupt();
431             throw new IndegoException(e);
432         } catch (TimeoutException e) {
433             throw new IndegoException(e);
434         } catch (ExecutionException e) {
435             Throwable cause = e.getCause();
436             if (cause != null && cause instanceof HttpResponseException) {
437                 Response response = ((HttpResponseException) cause).getResponse();
438                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
439                     /*
440                      * When contextId is not valid, the service will respond with HTTP code 401 without
441                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
442                      * HttpResponseException. We need to handle this in order to attempt
443                      * reauthentication.
444                      */
445                     throw new IndegoAuthenticationException("Context rejected", e);
446                 }
447             }
448             throw new IndegoException(e);
449         }
450     }
451
452     /**
453      * Sends a DELETE request to the server.
454      * 
455      * @param path the relative path to which the request should be sent
456      * @throws IndegoException if any communication or parsing error occurred
457      */
458     private void deleteRequest(String path) throws IndegoException {
459         try {
460             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.DELETE)
461                     .header(CONTEXT_HEADER_NAME, session.getContextId());
462             if (logger.isTraceEnabled()) {
463                 logger.trace("DELETE request for {}", BASE_URL + path);
464             }
465             ContentResponse response = sendRequest(request);
466             int status = response.getStatus();
467             if (!HttpStatus.isSuccess(status)) {
468                 throw new IndegoException("The request failed with error: " + status);
469             }
470         } catch (InterruptedException e) {
471             Thread.currentThread().interrupt();
472             throw new IndegoException(e);
473         } catch (TimeoutException | ExecutionException e) {
474             throw new IndegoException(e);
475         }
476     }
477
478     /**
479      * Send request. This method exists for the purpose of avoiding multiple calls to
480      * the server at the same time.
481      * 
482      * @param request the {@link Request} to send
483      * @return a {@link ContentResponse} for this request
484      * @throws InterruptedException if send thread is interrupted
485      * @throws TimeoutException if send times out
486      * @throws ExecutionException if execution fails
487      */
488     private synchronized ContentResponse sendRequest(Request request)
489             throws InterruptedException, TimeoutException, ExecutionException {
490         return request.send();
491     }
492
493     /**
494      * Gets serial number of the associated Indego device
495      *
496      * @return the serial number of the device
497      * @throws IndegoAuthenticationException if request was rejected as unauthorized
498      * @throws IndegoException if any communication or parsing error occurred
499      */
500     public synchronized String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
501         if (!session.isInitialized()) {
502             logger.debug("Session not yet initialized when serial number was requested; authenticating...");
503             authenticate();
504         }
505         return session.getSerialNumber();
506     }
507
508     /**
509      * Queries the device state from the server.
510      * 
511      * @return the device state
512      * @throws IndegoAuthenticationException if request was rejected as unauthorized
513      * @throws IndegoException if any communication or parsing error occurred
514      */
515     public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
516         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
517                 DeviceStateResponse.class);
518     }
519
520     /**
521      * Queries the device operating data from the server.
522      * Server will request this directly from the device, so operation might be slow.
523      * 
524      * @return the device state
525      * @throws IndegoAuthenticationException if request was rejected as unauthorized
526      * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
527      * @throws IndegoException if any communication or parsing error occurred
528      */
529     public OperatingDataResponse getOperatingData()
530             throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
531         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData",
532                 OperatingDataResponse.class);
533     }
534
535     /**
536      * Queries the map generated by the device from the server.
537      * 
538      * @return the garden map
539      * @throws IndegoAuthenticationException if request was rejected as unauthorized
540      * @throws IndegoException if any communication or parsing error occurred
541      */
542     public RawType getMap() throws IndegoAuthenticationException, IndegoException {
543         return getRawRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map");
544     }
545
546     /**
547      * Queries the calendar.
548      * 
549      * @return the calendar
550      * @throws IndegoAuthenticationException if request was rejected as unauthorized
551      * @throws IndegoException if any communication or parsing error occurred
552      */
553     public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
554         DeviceCalendarResponse calendar = getRequestWithAuthentication(
555                 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
556         return calendar;
557     }
558
559     /**
560      * Sends a command to the Indego device.
561      * 
562      * @param command the control command to send to the device
563      * @throws IndegoAuthenticationException if request was rejected as unauthorized
564      * @throws IndegoInvalidCommandException if the command was not processed correctly
565      * @throws IndegoException if any communication or parsing error occurred
566      */
567     public void sendCommand(DeviceCommand command)
568             throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
569         SetStateRequest request = new SetStateRequest();
570         request.state = command.getActionCode();
571         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
572     }
573
574     /**
575      * Queries the predictive weather forecast.
576      * 
577      * @return the weather forecast DTO
578      * @throws IndegoAuthenticationException if request was rejected as unauthorized
579      * @throws IndegoException if any communication or parsing error occurred
580      */
581     public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
582         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
583                 LocationWeatherResponse.class);
584     }
585
586     /**
587      * Queries the predictive adjustment.
588      * 
589      * @return the predictive adjustment
590      * @throws IndegoAuthenticationException if request was rejected as unauthorized
591      * @throws IndegoException if any communication or parsing error occurred
592      */
593     public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
594         return getRequestWithAuthentication(
595                 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
596                 PredictiveAdjustment.class).adjustment;
597     }
598
599     /**
600      * Sets the predictive adjustment.
601      * 
602      * @param adjust the predictive adjustment
603      * @throws IndegoAuthenticationException if request was rejected as unauthorized
604      * @throws IndegoException if any communication or parsing error occurred
605      */
606     public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
607         final PredictiveAdjustment adjustment = new PredictiveAdjustment();
608         adjustment.adjustment = adjust;
609         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
610                 adjustment);
611     }
612
613     /**
614      * Queries predictive moving.
615      * 
616      * @return predictive moving
617      * @throws IndegoAuthenticationException if request was rejected as unauthorized
618      * @throws IndegoException if any communication or parsing error occurred
619      */
620     public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
621         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive",
622                 PredictiveStatus.class).enabled;
623     }
624
625     /**
626      * Sets predictive moving.
627      * 
628      * @param enable
629      * @throws IndegoAuthenticationException if request was rejected as unauthorized
630      * @throws IndegoException if any communication or parsing error occurred
631      */
632     public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
633         final PredictiveStatus status = new PredictiveStatus();
634         status.enabled = enable;
635         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
636     }
637
638     /**
639      * Queries predictive last cutting as {@link Instant}.
640      * 
641      * @return predictive last cutting
642      * @throws IndegoAuthenticationException if request was rejected as unauthorized
643      * @throws IndegoException if any communication or parsing error occurred
644      */
645     public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
646         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting",
647                 PredictiveLastCuttingResponse.class).getLastCutting();
648     }
649
650     /**
651      * Queries predictive next cutting as {@link Instant}.
652      * 
653      * @return predictive next cutting
654      * @throws IndegoAuthenticationException if request was rejected as unauthorized
655      * @throws IndegoException if any communication or parsing error occurred
656      */
657     public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
658         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
659                 PredictiveNextCuttingResponse.class).getNextCutting();
660     }
661
662     /**
663      * Queries predictive exclusion time.
664      * 
665      * @return predictive exclusion time DTO
666      * @throws IndegoAuthenticationException if request was rejected as unauthorized
667      * @throws IndegoException if any communication or parsing error occurred
668      */
669     public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
670         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar",
671                 DeviceCalendarResponse.class);
672     }
673
674     /**
675      * Sets predictive exclusion time.
676      * 
677      * @param calendar calendar DTO
678      * @throws IndegoAuthenticationException if request was rejected as unauthorized
679      * @throws IndegoException if any communication or parsing error occurred
680      */
681     public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
682             throws IndegoAuthenticationException, IndegoException {
683         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);
684     }
685 }