2 * Copyright (c) 2010-2022 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.boschindego.internal;
16 import java.time.Instant;
17 import java.util.Base64;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.TimeoutException;
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;
53 import com.google.gson.Gson;
54 import com.google.gson.JsonParseException;
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
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.
65 * @author Jacob Laursen - Initial contribution
68 public class IndegoController {
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";
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;
82 private IndegoSession session = new IndegoSession();
85 * Initialize the controller instance.
87 * @param username the username for authenticating
88 * @param password the password
90 public IndegoController(HttpClient httpClient, String username, String password) {
91 this.httpClient = httpClient;
92 basicAuthenticationHeader = "Basic "
93 + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
97 * Authenticate with server and store session context and serial number.
99 * @throws IndegoAuthenticationException if request was rejected as unauthorized
100 * @throws IndegoException if any communication or parsing error occurred
102 private void authenticate() throws IndegoAuthenticationException, IndegoException {
104 Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
105 .header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
107 AuthenticationRequest authRequest = new AuthenticationRequest();
108 authRequest.device = "";
109 authRequest.osType = "Android";
110 authRequest.osVersion = "4.0";
111 authRequest.deviceManufacturer = "unknown";
112 authRequest.deviceType = "unknown";
113 String json = gson.toJson(authRequest);
114 request.content(new StringContentProvider(json));
115 request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
117 if (logger.isTraceEnabled()) {
118 logger.trace("POST request for {}", BASE_URL + "authenticate");
121 ContentResponse response = sendRequest(request);
122 int status = response.getStatus();
123 if (status == HttpStatus.UNAUTHORIZED_401) {
124 throw new IndegoAuthenticationException("Authentication was rejected");
126 if (!HttpStatus.isSuccess(status)) {
127 throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
130 String jsonResponse = response.getContentAsString();
131 if (jsonResponse.isEmpty()) {
132 throw new IndegoInvalidResponseException("No content returned");
134 logger.trace("JSON response: '{}'", jsonResponse);
136 AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
137 if (authenticationResponse == null) {
138 throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse");
140 session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
141 getContextExpirationTimeFromCookie());
142 logger.debug("Initialized session {}", session);
143 } catch (JsonParseException e) {
144 throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e);
145 } catch (InterruptedException e) {
146 Thread.currentThread().interrupt();
147 throw new IndegoException(e);
148 } catch (TimeoutException | ExecutionException e) {
149 throw new IndegoException(e);
154 * Get context expiration time as a calculated {@link Instant} relative to now.
155 * The information is obtained from max age in the Bosch Indego SSO cookie.
156 * Please note that this cookie is only sent initially when authenticating, so
157 * the value will not be subject to any updates.
159 * @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
161 private Instant getContextExpirationTimeFromCookie() {
162 return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
163 .findFirst().map(c -> {
164 return Instant.now().plusSeconds(c.getMaxAge());
171 * Deauthenticate session. This method should be called as part of cleanup to reduce
172 * lingering sessions. This can potentially avoid killed sessions in situation with
173 * multiple clients (e.g. openHAB and mobile app) if restrictions on concurrent
174 * number of sessions would be put on the service.
176 * @throws IndegoException if any communication or parsing error occurred
178 public void deauthenticate() throws IndegoException {
179 if (session.isValid()) {
180 deleteRequest("authenticate");
181 session.invalidate();
186 * Wraps {@link #getRequest(String, Class)} into an authenticated session.
188 * @param path the relative path to which the request should be sent
189 * @param dtoClass the DTO class to which the JSON result should be deserialized
190 * @return the deserialized DTO from the JSON response
191 * @throws IndegoAuthenticationException if request was rejected as unauthorized
192 * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
193 * @throws IndegoException if any communication or parsing error occurred
195 private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
196 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
197 if (!session.isValid()) {
201 logger.debug("Session {} valid, skipping authentication", session);
202 return getRequest(path, dtoClass);
203 } catch (IndegoAuthenticationException e) {
204 if (logger.isTraceEnabled()) {
205 logger.trace("Context rejected", e);
207 logger.debug("Context rejected: {}", e.getMessage());
209 session.invalidate();
211 return getRequest(path, dtoClass);
216 * Sends a GET request to the server and returns the deserialized JSON response.
218 * @param path the relative path to which the request should be sent
219 * @param dtoClass the DTO class to which the JSON result should be deserialized
220 * @return the deserialized DTO from the JSON response
221 * @throws IndegoAuthenticationException if request was rejected as unauthorized
222 * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
223 * @throws IndegoException if any communication or parsing error occurred
225 private <T> T getRequest(String path, Class<? extends T> dtoClass)
226 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
228 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
229 session.getContextId());
230 if (logger.isTraceEnabled()) {
231 logger.trace("GET request for {}", BASE_URL + path);
233 ContentResponse response = sendRequest(request);
234 int status = response.getStatus();
235 if (status == HttpStatus.UNAUTHORIZED_401) {
236 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
237 throw new IndegoAuthenticationException("Context rejected");
239 if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
240 throw new IndegoUnreachableException("Gateway timeout");
242 if (!HttpStatus.isSuccess(status)) {
243 throw new IndegoException("The request failed with error: " + status);
245 String jsonResponse = response.getContentAsString();
246 if (jsonResponse.isEmpty()) {
247 throw new IndegoInvalidResponseException("No content returned");
249 logger.trace("JSON response: '{}'", jsonResponse);
252 T result = gson.fromJson(jsonResponse, dtoClass);
253 if (result == null) {
254 throw new IndegoInvalidResponseException("Parsed response is null");
257 } catch (JsonParseException e) {
258 throw new IndegoInvalidResponseException("Error parsing response", e);
259 } catch (InterruptedException e) {
260 Thread.currentThread().interrupt();
261 throw new IndegoException(e);
262 } catch (TimeoutException e) {
263 throw new IndegoException(e);
264 } catch (ExecutionException e) {
265 Throwable cause = e.getCause();
266 if (cause != null && cause instanceof HttpResponseException) {
267 Response response = ((HttpResponseException) cause).getResponse();
268 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
270 * When contextId is not valid, the service will respond with HTTP code 401 without
271 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
272 * HttpResponseException. We need to handle this in order to attempt
275 throw new IndegoAuthenticationException("Context rejected", e);
278 throw new IndegoException(e);
283 * Wraps {@link #getRawRequest(String)} into an authenticated session.
285 * @param path the relative path to which the request should be sent
286 * @return the raw data from the response
287 * @throws IndegoAuthenticationException if request was rejected as unauthorized
288 * @throws IndegoException if any communication or parsing error occurred
290 private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException {
291 if (!session.isValid()) {
295 logger.debug("Session {} valid, skipping authentication", session);
296 return getRawRequest(path);
297 } catch (IndegoAuthenticationException e) {
298 if (logger.isTraceEnabled()) {
299 logger.trace("Context rejected", e);
301 logger.debug("Context rejected: {}", e.getMessage());
303 session.invalidate();
305 return getRawRequest(path);
310 * Sends a GET request to the server and returns the raw response.
312 * @param path the relative path to which the request should be sent
313 * @return the raw data from the response
314 * @throws IndegoAuthenticationException if request was rejected as unauthorized
315 * @throws IndegoException if any communication or parsing error occurred
317 private RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
319 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
320 session.getContextId());
321 if (logger.isTraceEnabled()) {
322 logger.trace("GET request for {}", BASE_URL + path);
324 ContentResponse response = sendRequest(request);
325 int status = response.getStatus();
326 if (status == HttpStatus.UNAUTHORIZED_401) {
327 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
328 throw new IndegoAuthenticationException("Context rejected");
330 if (!HttpStatus.isSuccess(status)) {
331 throw new IndegoException("The request failed with error: " + status);
333 byte[] data = response.getContent();
335 throw new IndegoInvalidResponseException("No data returned");
337 String contentType = response.getMediaType();
338 if (contentType == null || contentType.isEmpty()) {
339 throw new IndegoInvalidResponseException("No content-type returned");
341 logger.debug("Media download response: type {}, length {}", contentType, data.length);
343 return new RawType(data, contentType);
344 } catch (JsonParseException e) {
345 throw new IndegoInvalidResponseException("Error parsing response", e);
346 } catch (InterruptedException e) {
347 Thread.currentThread().interrupt();
348 throw new IndegoException(e);
349 } catch (TimeoutException e) {
350 throw new IndegoException(e);
351 } catch (ExecutionException e) {
352 Throwable cause = e.getCause();
353 if (cause != null && cause instanceof HttpResponseException) {
354 Response response = ((HttpResponseException) cause).getResponse();
355 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
357 * When contextId is not valid, the service will respond with HTTP code 401 without
358 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
359 * HttpResponseException. We need to handle this in order to attempt
362 throw new IndegoAuthenticationException("Context rejected", e);
365 throw new IndegoException(e);
370 * Wraps {@link #putRequest(String, Object)} into an authenticated session.
372 * @param path the relative path to which the request should be sent
373 * @param requestDto the DTO which should be sent to the server as JSON
374 * @throws IndegoAuthenticationException if request was rejected as unauthorized
375 * @throws IndegoException if any communication or parsing error occurred
377 private void putRequestWithAuthentication(String path, Object requestDto)
378 throws IndegoAuthenticationException, IndegoException {
379 if (!session.isValid()) {
383 logger.debug("Session {} valid, skipping authentication", session);
384 putRequest(path, requestDto);
385 } catch (IndegoAuthenticationException e) {
386 if (logger.isTraceEnabled()) {
387 logger.trace("Context rejected", e);
389 logger.debug("Context rejected: {}", e.getMessage());
391 session.invalidate();
393 putRequest(path, requestDto);
398 * Sends a PUT request to the server.
400 * @param path the relative path to which the request should be sent
401 * @param requestDto the DTO which should be sent to the server as JSON
402 * @throws IndegoAuthenticationException if request was rejected as unauthorized
403 * @throws IndegoException if any communication or parsing error occurred
405 private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
407 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
408 .header(CONTEXT_HEADER_NAME, session.getContextId())
409 .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
410 String payload = gson.toJson(requestDto);
411 request.content(new StringContentProvider(payload));
412 if (logger.isTraceEnabled()) {
413 logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
415 ContentResponse response = sendRequest(request);
416 int status = response.getStatus();
417 if (status == HttpStatus.UNAUTHORIZED_401) {
418 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
419 throw new IndegoAuthenticationException("Context rejected");
421 if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
422 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
424 if (!HttpStatus.isSuccess(status)) {
425 throw new IndegoException("The request failed with error: " + status);
427 } catch (JsonParseException e) {
428 throw new IndegoInvalidResponseException("Error serializing request", e);
429 } catch (InterruptedException e) {
430 Thread.currentThread().interrupt();
431 throw new IndegoException(e);
432 } catch (TimeoutException e) {
433 throw new IndegoException(e);
434 } catch (ExecutionException e) {
435 Throwable cause = e.getCause();
436 if (cause != null && cause instanceof HttpResponseException) {
437 Response response = ((HttpResponseException) cause).getResponse();
438 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
440 * When contextId is not valid, the service will respond with HTTP code 401 without
441 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
442 * HttpResponseException. We need to handle this in order to attempt
445 throw new IndegoAuthenticationException("Context rejected", e);
448 throw new IndegoException(e);
453 * Sends a DELETE request to the server.
455 * @param path the relative path to which the request should be sent
456 * @throws IndegoException if any communication or parsing error occurred
458 private void deleteRequest(String path) throws IndegoException {
460 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.DELETE)
461 .header(CONTEXT_HEADER_NAME, session.getContextId());
462 if (logger.isTraceEnabled()) {
463 logger.trace("DELETE request for {}", BASE_URL + path);
465 ContentResponse response = sendRequest(request);
466 int status = response.getStatus();
467 if (!HttpStatus.isSuccess(status)) {
468 throw new IndegoException("The request failed with error: " + status);
470 } catch (InterruptedException e) {
471 Thread.currentThread().interrupt();
472 throw new IndegoException(e);
473 } catch (TimeoutException | ExecutionException e) {
474 throw new IndegoException(e);
479 * Send request. This method exists for the purpose of avoiding multiple calls to
480 * the server at the same time.
482 * @param request the {@link Request} to send
483 * @return a {@link ContentResponse} for this request
484 * @throws InterruptedException if send thread is interrupted
485 * @throws TimeoutException if send times out
486 * @throws ExecutionException if execution fails
488 private synchronized ContentResponse sendRequest(Request request)
489 throws InterruptedException, TimeoutException, ExecutionException {
490 return request.send();
494 * Gets serial number of the associated Indego device
496 * @return the serial number of the device
497 * @throws IndegoAuthenticationException if request was rejected as unauthorized
498 * @throws IndegoException if any communication or parsing error occurred
500 public synchronized String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
501 if (!session.isInitialized()) {
502 logger.debug("Session not yet initialized when serial number was requested; authenticating...");
505 return session.getSerialNumber();
509 * Queries the device state from the server.
511 * @return the device state
512 * @throws IndegoAuthenticationException if request was rejected as unauthorized
513 * @throws IndegoException if any communication or parsing error occurred
515 public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
516 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
517 DeviceStateResponse.class);
521 * Queries the device operating data from the server.
522 * Server will request this directly from the device, so operation might be slow.
524 * @return the device state
525 * @throws IndegoAuthenticationException if request was rejected as unauthorized
526 * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
527 * @throws IndegoException if any communication or parsing error occurred
529 public OperatingDataResponse getOperatingData()
530 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
531 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData",
532 OperatingDataResponse.class);
536 * Queries the map generated by the device from the server.
538 * @return the garden map
539 * @throws IndegoAuthenticationException if request was rejected as unauthorized
540 * @throws IndegoException if any communication or parsing error occurred
542 public RawType getMap() throws IndegoAuthenticationException, IndegoException {
543 return getRawRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map");
547 * Queries the calendar.
549 * @return the calendar
550 * @throws IndegoAuthenticationException if request was rejected as unauthorized
551 * @throws IndegoException if any communication or parsing error occurred
553 public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
554 DeviceCalendarResponse calendar = getRequestWithAuthentication(
555 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
560 * Sends a command to the Indego device.
562 * @param command the control command to send to the device
563 * @throws IndegoAuthenticationException if request was rejected as unauthorized
564 * @throws IndegoInvalidCommandException if the command was not processed correctly
565 * @throws IndegoException if any communication or parsing error occurred
567 public void sendCommand(DeviceCommand command)
568 throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
569 SetStateRequest request = new SetStateRequest();
570 request.state = command.getActionCode();
571 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
575 * Queries the predictive weather forecast.
577 * @return the weather forecast DTO
578 * @throws IndegoAuthenticationException if request was rejected as unauthorized
579 * @throws IndegoException if any communication or parsing error occurred
581 public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
582 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
583 LocationWeatherResponse.class);
587 * Queries the predictive adjustment.
589 * @return the predictive adjustment
590 * @throws IndegoAuthenticationException if request was rejected as unauthorized
591 * @throws IndegoException if any communication or parsing error occurred
593 public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
594 return getRequestWithAuthentication(
595 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
596 PredictiveAdjustment.class).adjustment;
600 * Sets the predictive adjustment.
602 * @param adjust the predictive adjustment
603 * @throws IndegoAuthenticationException if request was rejected as unauthorized
604 * @throws IndegoException if any communication or parsing error occurred
606 public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
607 final PredictiveAdjustment adjustment = new PredictiveAdjustment();
608 adjustment.adjustment = adjust;
609 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
614 * Queries predictive moving.
616 * @return predictive moving
617 * @throws IndegoAuthenticationException if request was rejected as unauthorized
618 * @throws IndegoException if any communication or parsing error occurred
620 public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
621 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive",
622 PredictiveStatus.class).enabled;
626 * Sets predictive moving.
629 * @throws IndegoAuthenticationException if request was rejected as unauthorized
630 * @throws IndegoException if any communication or parsing error occurred
632 public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
633 final PredictiveStatus status = new PredictiveStatus();
634 status.enabled = enable;
635 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
639 * Queries predictive last cutting as {@link Instant}.
641 * @return predictive last cutting
642 * @throws IndegoAuthenticationException if request was rejected as unauthorized
643 * @throws IndegoException if any communication or parsing error occurred
645 public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
646 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting",
647 PredictiveLastCuttingResponse.class).getLastCutting();
651 * Queries predictive next cutting as {@link Instant}.
653 * @return predictive next cutting
654 * @throws IndegoAuthenticationException if request was rejected as unauthorized
655 * @throws IndegoException if any communication or parsing error occurred
657 public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
658 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
659 PredictiveNextCuttingResponse.class).getNextCutting();
663 * Queries predictive exclusion time.
665 * @return predictive exclusion time DTO
666 * @throws IndegoAuthenticationException if request was rejected as unauthorized
667 * @throws IndegoException if any communication or parsing error occurred
669 public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
670 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar",
671 DeviceCalendarResponse.class);
675 * Sets predictive exclusion time.
677 * @param calendar calendar DTO
678 * @throws IndegoAuthenticationException if request was rejected as unauthorized
679 * @throws IndegoException if any communication or parsing error occurred
681 public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
682 throws IndegoAuthenticationException, IndegoException {
683 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);