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