]> git.basschouten.com Git - openhab-addons.git/blob
1fe36dd1be6fdb31ed18aa01cce47c2b11c8fdf6
[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 IndegoException if any communication or parsing error occurred
193      */
194     private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
195             throws IndegoAuthenticationException, IndegoException {
196         if (!session.isValid()) {
197             authenticate();
198         }
199         try {
200             logger.debug("Session {} valid, skipping authentication", session);
201             return getRequest(path, dtoClass);
202         } catch (IndegoAuthenticationException e) {
203             if (logger.isTraceEnabled()) {
204                 logger.trace("Context rejected", e);
205             } else {
206                 logger.debug("Context rejected: {}", e.getMessage());
207             }
208             session.invalidate();
209             authenticate();
210             return getRequest(path, dtoClass);
211         }
212     }
213
214     /**
215      * Sends a GET request to the server and returns the deserialized JSON response.
216      * 
217      * @param path the relative path to which the request should be sent
218      * @param dtoClass the DTO class to which the JSON result should be deserialized
219      * @return the deserialized DTO from the JSON response
220      * @throws IndegoAuthenticationException if request was rejected as unauthorized
221      * @throws IndegoException if any communication or parsing error occurred
222      */
223     private <T> T getRequest(String path, Class<? extends T> dtoClass)
224             throws IndegoAuthenticationException, IndegoException {
225         try {
226             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
227                     session.getContextId());
228             if (logger.isTraceEnabled()) {
229                 logger.trace("GET request for {}", BASE_URL + path);
230             }
231             ContentResponse response = sendRequest(request);
232             int status = response.getStatus();
233             if (status == HttpStatus.UNAUTHORIZED_401) {
234                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
235                 throw new IndegoAuthenticationException("Context rejected");
236             }
237             if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
238                 throw new IndegoUnreachableException("Gateway timeout");
239             }
240             if (!HttpStatus.isSuccess(status)) {
241                 throw new IndegoException("The request failed with error: " + status);
242             }
243             String jsonResponse = response.getContentAsString();
244             if (jsonResponse.isEmpty()) {
245                 throw new IndegoInvalidResponseException("No content returned");
246             }
247             logger.trace("JSON response: '{}'", jsonResponse);
248
249             @Nullable
250             T result = gson.fromJson(jsonResponse, dtoClass);
251             if (result == null) {
252                 throw new IndegoInvalidResponseException("Parsed response is null");
253             }
254             return result;
255         } catch (JsonParseException e) {
256             throw new IndegoInvalidResponseException("Error parsing response", e);
257         } catch (InterruptedException e) {
258             Thread.currentThread().interrupt();
259             throw new IndegoException(e);
260         } catch (TimeoutException e) {
261             throw new IndegoException(e);
262         } catch (ExecutionException e) {
263             Throwable cause = e.getCause();
264             if (cause != null && cause instanceof HttpResponseException) {
265                 Response response = ((HttpResponseException) cause).getResponse();
266                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
267                     /*
268                      * When contextId is not valid, the service will respond with HTTP code 401 without
269                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
270                      * HttpResponseException. We need to handle this in order to attempt
271                      * reauthentication.
272                      */
273                     throw new IndegoAuthenticationException("Context rejected", e);
274                 }
275             }
276             throw new IndegoException(e);
277         }
278     }
279
280     /**
281      * Wraps {@link #getRawRequest(String)} into an authenticated session.
282      *
283      * @param path the relative path to which the request should be sent
284      * @return the raw data from the response
285      * @throws IndegoAuthenticationException if request was rejected as unauthorized
286      * @throws IndegoException if any communication or parsing error occurred
287      */
288     private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException {
289         if (!session.isValid()) {
290             authenticate();
291         }
292         try {
293             logger.debug("Session {} valid, skipping authentication", session);
294             return getRawRequest(path);
295         } catch (IndegoAuthenticationException e) {
296             if (logger.isTraceEnabled()) {
297                 logger.trace("Context rejected", e);
298             } else {
299                 logger.debug("Context rejected: {}", e.getMessage());
300             }
301             session.invalidate();
302             authenticate();
303             return getRawRequest(path);
304         }
305     }
306
307     /**
308      * Sends a GET request to the server and returns the raw response.
309      * 
310      * @param path the relative path to which the request should be sent
311      * @return the raw data from the response
312      * @throws IndegoAuthenticationException if request was rejected as unauthorized
313      * @throws IndegoException if any communication or parsing error occurred
314      */
315     private RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
316         try {
317             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
318                     session.getContextId());
319             if (logger.isTraceEnabled()) {
320                 logger.trace("GET request for {}", BASE_URL + path);
321             }
322             ContentResponse response = sendRequest(request);
323             int status = response.getStatus();
324             if (status == HttpStatus.UNAUTHORIZED_401) {
325                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
326                 throw new IndegoAuthenticationException("Context rejected");
327             }
328             if (!HttpStatus.isSuccess(status)) {
329                 throw new IndegoException("The request failed with error: " + status);
330             }
331             byte[] data = response.getContent();
332             if (data == null) {
333                 throw new IndegoInvalidResponseException("No data returned");
334             }
335             String contentType = response.getMediaType();
336             if (contentType == null || contentType.isEmpty()) {
337                 throw new IndegoInvalidResponseException("No content-type returned");
338             }
339             logger.debug("Media download response: type {}, length {}", contentType, data.length);
340
341             return new RawType(data, contentType);
342         } catch (JsonParseException e) {
343             throw new IndegoInvalidResponseException("Error parsing response", e);
344         } catch (InterruptedException e) {
345             Thread.currentThread().interrupt();
346             throw new IndegoException(e);
347         } catch (TimeoutException e) {
348             throw new IndegoException(e);
349         } catch (ExecutionException e) {
350             Throwable cause = e.getCause();
351             if (cause != null && cause instanceof HttpResponseException) {
352                 Response response = ((HttpResponseException) cause).getResponse();
353                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
354                     /*
355                      * When contextId is not valid, the service will respond with HTTP code 401 without
356                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
357                      * HttpResponseException. We need to handle this in order to attempt
358                      * reauthentication.
359                      */
360                     throw new IndegoAuthenticationException("Context rejected", e);
361                 }
362             }
363             throw new IndegoException(e);
364         }
365     }
366
367     /**
368      * Wraps {@link #putRequest(String, Object)} into an authenticated session.
369      * 
370      * @param path the relative path to which the request should be sent
371      * @param requestDto the DTO which should be sent to the server as JSON
372      * @throws IndegoAuthenticationException if request was rejected as unauthorized
373      * @throws IndegoException if any communication or parsing error occurred
374      */
375     private void putRequestWithAuthentication(String path, Object requestDto)
376             throws IndegoAuthenticationException, IndegoException {
377         if (!session.isValid()) {
378             authenticate();
379         }
380         try {
381             logger.debug("Session {} valid, skipping authentication", session);
382             putRequest(path, requestDto);
383         } catch (IndegoAuthenticationException e) {
384             if (logger.isTraceEnabled()) {
385                 logger.trace("Context rejected", e);
386             } else {
387                 logger.debug("Context rejected: {}", e.getMessage());
388             }
389             session.invalidate();
390             authenticate();
391             putRequest(path, requestDto);
392         }
393     }
394
395     /**
396      * Sends a PUT request to the server.
397      * 
398      * @param path the relative path to which the request should be sent
399      * @param requestDto the DTO which should be sent to the server as JSON
400      * @throws IndegoAuthenticationException if request was rejected as unauthorized
401      * @throws IndegoException if any communication or parsing error occurred
402      */
403     private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
404         try {
405             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
406                     .header(CONTEXT_HEADER_NAME, session.getContextId())
407                     .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
408             String payload = gson.toJson(requestDto);
409             request.content(new StringContentProvider(payload));
410             if (logger.isTraceEnabled()) {
411                 logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
412             }
413             ContentResponse response = sendRequest(request);
414             int status = response.getStatus();
415             if (status == HttpStatus.UNAUTHORIZED_401) {
416                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
417                 throw new IndegoAuthenticationException("Context rejected");
418             }
419             if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
420                 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
421             }
422             if (!HttpStatus.isSuccess(status)) {
423                 throw new IndegoException("The request failed with error: " + status);
424             }
425         } catch (JsonParseException e) {
426             throw new IndegoInvalidResponseException("Error serializing request", e);
427         } catch (InterruptedException e) {
428             Thread.currentThread().interrupt();
429             throw new IndegoException(e);
430         } catch (TimeoutException e) {
431             throw new IndegoException(e);
432         } catch (ExecutionException e) {
433             Throwable cause = e.getCause();
434             if (cause != null && cause instanceof HttpResponseException) {
435                 Response response = ((HttpResponseException) cause).getResponse();
436                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
437                     /*
438                      * When contextId is not valid, the service will respond with HTTP code 401 without
439                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
440                      * HttpResponseException. We need to handle this in order to attempt
441                      * reauthentication.
442                      */
443                     throw new IndegoAuthenticationException("Context rejected", e);
444                 }
445             }
446             throw new IndegoException(e);
447         }
448     }
449
450     /**
451      * Sends a DELETE request to the server.
452      * 
453      * @param path the relative path to which the request should be sent
454      * @throws IndegoException if any communication or parsing error occurred
455      */
456     private void deleteRequest(String path) throws IndegoException {
457         try {
458             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.DELETE)
459                     .header(CONTEXT_HEADER_NAME, session.getContextId());
460             if (logger.isTraceEnabled()) {
461                 logger.trace("DELETE request for {}", BASE_URL + path);
462             }
463             ContentResponse response = sendRequest(request);
464             int status = response.getStatus();
465             if (!HttpStatus.isSuccess(status)) {
466                 throw new IndegoException("The request failed with error: " + status);
467             }
468         } catch (InterruptedException e) {
469             Thread.currentThread().interrupt();
470             throw new IndegoException(e);
471         } catch (TimeoutException | ExecutionException e) {
472             throw new IndegoException(e);
473         }
474     }
475
476     /**
477      * Send request. This method exists for the purpose of avoiding multiple calls to
478      * the server at the same time.
479      * 
480      * @param request the {@link Request} to send
481      * @return a {@link ContentResponse} for this request
482      * @throws InterruptedException if send thread is interrupted
483      * @throws TimeoutException if send times out
484      * @throws ExecutionException if execution fails
485      */
486     private synchronized ContentResponse sendRequest(Request request)
487             throws InterruptedException, TimeoutException, ExecutionException {
488         return request.send();
489     }
490
491     /**
492      * Gets serial number of the associated Indego device
493      *
494      * @return the serial number of the device
495      * @throws IndegoAuthenticationException if request was rejected as unauthorized
496      * @throws IndegoException if any communication or parsing error occurred
497      */
498     public String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
499         if (!session.isInitialized()) {
500             logger.debug("Session not yet initialized when serial number was requested; authenticating...");
501             authenticate();
502         }
503         return session.getSerialNumber();
504     }
505
506     /**
507      * Queries the device state from the server.
508      * 
509      * @return the device state
510      * @throws IndegoAuthenticationException if request was rejected as unauthorized
511      * @throws IndegoException if any communication or parsing error occurred
512      */
513     public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
514         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
515                 DeviceStateResponse.class);
516     }
517
518     /**
519      * Queries the device operating data from the server.
520      * Server will request this directly from the device, so operation might be slow.
521      * 
522      * @return the device state
523      * @throws IndegoAuthenticationException if request was rejected as unauthorized
524      * @throws IndegoException if any communication or parsing error occurred
525      */
526     public OperatingDataResponse getOperatingData() throws IndegoAuthenticationException, IndegoException {
527         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData",
528                 OperatingDataResponse.class);
529     }
530
531     /**
532      * Queries the map generated by the device from the server.
533      * 
534      * @return the garden map
535      * @throws IndegoAuthenticationException if request was rejected as unauthorized
536      * @throws IndegoException if any communication or parsing error occurred
537      */
538     public RawType getMap() throws IndegoAuthenticationException, IndegoException {
539         return getRawRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map");
540     }
541
542     /**
543      * Queries the calendar.
544      * 
545      * @return the calendar
546      * @throws IndegoAuthenticationException if request was rejected as unauthorized
547      * @throws IndegoException if any communication or parsing error occurred
548      */
549     public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
550         DeviceCalendarResponse calendar = getRequestWithAuthentication(
551                 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
552         return calendar;
553     }
554
555     /**
556      * Sends a command to the Indego device.
557      * 
558      * @param command the control command to send to the device
559      * @throws IndegoAuthenticationException if request was rejected as unauthorized
560      * @throws IndegoInvalidCommandException if the command was not processed correctly
561      * @throws IndegoException if any communication or parsing error occurred
562      */
563     public void sendCommand(DeviceCommand command)
564             throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
565         SetStateRequest request = new SetStateRequest();
566         request.state = command.getActionCode();
567         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
568     }
569
570     /**
571      * Queries the predictive weather forecast.
572      * 
573      * @return the weather forecast DTO
574      * @throws IndegoAuthenticationException if request was rejected as unauthorized
575      * @throws IndegoException if any communication or parsing error occurred
576      */
577     public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
578         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
579                 LocationWeatherResponse.class);
580     }
581
582     /**
583      * Queries the predictive adjustment.
584      * 
585      * @return the predictive adjustment
586      * @throws IndegoAuthenticationException if request was rejected as unauthorized
587      * @throws IndegoException if any communication or parsing error occurred
588      */
589     public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
590         return getRequestWithAuthentication(
591                 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
592                 PredictiveAdjustment.class).adjustment;
593     }
594
595     /**
596      * Sets the predictive adjustment.
597      * 
598      * @param adjust the predictive adjustment
599      * @throws IndegoAuthenticationException if request was rejected as unauthorized
600      * @throws IndegoException if any communication or parsing error occurred
601      */
602     public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
603         final PredictiveAdjustment adjustment = new PredictiveAdjustment();
604         adjustment.adjustment = adjust;
605         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
606                 adjustment);
607     }
608
609     /**
610      * Queries predictive moving.
611      * 
612      * @return predictive moving
613      * @throws IndegoAuthenticationException if request was rejected as unauthorized
614      * @throws IndegoException if any communication or parsing error occurred
615      */
616     public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
617         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive",
618                 PredictiveStatus.class).enabled;
619     }
620
621     /**
622      * Sets predictive moving.
623      * 
624      * @param enable
625      * @throws IndegoAuthenticationException if request was rejected as unauthorized
626      * @throws IndegoException if any communication or parsing error occurred
627      */
628     public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
629         final PredictiveStatus status = new PredictiveStatus();
630         status.enabled = enable;
631         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
632     }
633
634     /**
635      * Queries predictive last cutting as {@link Instant}.
636      * 
637      * @return predictive last cutting
638      * @throws IndegoAuthenticationException if request was rejected as unauthorized
639      * @throws IndegoException if any communication or parsing error occurred
640      */
641     public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
642         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting",
643                 PredictiveLastCuttingResponse.class).getLastCutting();
644     }
645
646     /**
647      * Queries predictive next cutting as {@link Instant}.
648      * 
649      * @return predictive next cutting
650      * @throws IndegoAuthenticationException if request was rejected as unauthorized
651      * @throws IndegoException if any communication or parsing error occurred
652      */
653     public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
654         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
655                 PredictiveNextCuttingResponse.class).getNextCutting();
656     }
657
658     /**
659      * Queries predictive exclusion time.
660      * 
661      * @return predictive exclusion time DTO
662      * @throws IndegoAuthenticationException if request was rejected as unauthorized
663      * @throws IndegoException if any communication or parsing error occurred
664      */
665     public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
666         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar",
667                 DeviceCalendarResponse.class);
668     }
669
670     /**
671      * Sets predictive exclusion time.
672      * 
673      * @param calendar calendar DTO
674      * @throws IndegoAuthenticationException if request was rejected as unauthorized
675      * @throws IndegoException if any communication or parsing error occurred
676      */
677     public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
678             throws IndegoAuthenticationException, IndegoException {
679         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);
680     }
681 }