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