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