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