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 {
105 Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
106 .header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
108 AuthenticationRequest authRequest = new AuthenticationRequest();
109 authRequest.device = "";
110 authRequest.osType = "Android";
111 authRequest.osVersion = "4.0";
112 authRequest.deviceManufacturer = "unknown";
113 authRequest.deviceType = "unknown";
114 String json = gson.toJson(authRequest);
115 request.content(new StringContentProvider(json));
116 request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
118 if (logger.isTraceEnabled()) {
119 logger.trace("POST request for {}", BASE_URL + "authenticate");
122 ContentResponse response = sendRequest(request);
123 status = response.getStatus();
124 if (status == HttpStatus.UNAUTHORIZED_401) {
125 throw new IndegoAuthenticationException("Authentication was rejected");
127 if (!HttpStatus.isSuccess(status)) {
128 throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
131 String jsonResponse = response.getContentAsString();
132 if (jsonResponse.isEmpty()) {
133 throw new IndegoInvalidResponseException("No content returned", status);
135 logger.trace("JSON response: '{}'", jsonResponse);
137 AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
138 if (authenticationResponse == null) {
139 throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse",
142 session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
143 getContextExpirationTimeFromCookie());
144 logger.debug("Initialized session {}", session);
145 } catch (JsonParseException e) {
146 throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e, status);
147 } catch (InterruptedException e) {
148 Thread.currentThread().interrupt();
149 throw new IndegoException(e);
150 } catch (TimeoutException | ExecutionException e) {
151 throw new IndegoException(e);
156 * Get context expiration time as a calculated {@link Instant} relative to now.
157 * The information is obtained from max age in the Bosch Indego SSO cookie.
158 * Please note that this cookie is only sent initially when authenticating, so
159 * the value will not be subject to any updates.
161 * @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
163 private Instant getContextExpirationTimeFromCookie() {
164 return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
165 .findFirst().map(c -> {
166 return Instant.now().plusSeconds(c.getMaxAge());
173 * Deauthenticate session. This method should be called as part of cleanup to reduce
174 * lingering sessions. This can potentially avoid killed sessions in situation with
175 * multiple clients (e.g. openHAB and mobile app) if restrictions on concurrent
176 * number of sessions would be put on the service.
178 * @throws IndegoException if any communication or parsing error occurred
180 public void deauthenticate() throws IndegoException {
181 if (session.isValid()) {
182 deleteRequest("authenticate");
183 session.invalidate();
188 * Wraps {@link #getRequest(String, Class)} into an authenticated session.
190 * @param path the relative path to which the request should be sent
191 * @param dtoClass the DTO class to which the JSON result should be deserialized
192 * @return the deserialized DTO from the JSON response
193 * @throws IndegoAuthenticationException if request was rejected as unauthorized
194 * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
195 * @throws IndegoException if any communication or parsing error occurred
197 private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
198 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
199 if (!session.isValid()) {
203 logger.debug("Session {} valid, skipping authentication", session);
204 return getRequest(path, dtoClass);
205 } catch (IndegoAuthenticationException e) {
206 if (logger.isTraceEnabled()) {
207 logger.trace("Context rejected", e);
209 logger.debug("Context rejected: {}", e.getMessage());
211 session.invalidate();
213 return getRequest(path, dtoClass);
218 * Sends a GET request to the server and returns the deserialized JSON response.
220 * @param path the relative path to which the request should be sent
221 * @param dtoClass the DTO class to which the JSON result should be deserialized
222 * @return the deserialized DTO from the JSON response
223 * @throws IndegoAuthenticationException if request was rejected as unauthorized
224 * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
225 * @throws IndegoException if any communication or parsing error occurred
227 private <T> T getRequest(String path, Class<? extends T> dtoClass)
228 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
231 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
232 session.getContextId());
233 if (logger.isTraceEnabled()) {
234 logger.trace("GET request for {}", BASE_URL + path);
236 ContentResponse response = sendRequest(request);
237 status = response.getStatus();
238 if (status == HttpStatus.UNAUTHORIZED_401) {
239 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
240 throw new IndegoAuthenticationException("Context rejected");
242 if (status == HttpStatus.GATEWAY_TIMEOUT_504) {
243 throw new IndegoUnreachableException("Gateway timeout");
245 if (!HttpStatus.isSuccess(status)) {
246 throw new IndegoException("The request failed with error: " + status);
248 String jsonResponse = response.getContentAsString();
249 if (jsonResponse.isEmpty()) {
250 throw new IndegoInvalidResponseException("No content returned", status);
252 logger.trace("JSON response: '{}'", jsonResponse);
255 T result = gson.fromJson(jsonResponse, dtoClass);
256 if (result == null) {
257 throw new IndegoInvalidResponseException("Parsed response is null", status);
260 } catch (JsonParseException e) {
261 throw new IndegoInvalidResponseException("Error parsing response", e, status);
262 } catch (InterruptedException e) {
263 Thread.currentThread().interrupt();
264 throw new IndegoException(e);
265 } catch (TimeoutException e) {
266 throw new IndegoException(e);
267 } catch (ExecutionException e) {
268 Throwable cause = e.getCause();
269 if (cause != null && cause instanceof HttpResponseException) {
270 Response response = ((HttpResponseException) cause).getResponse();
271 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
273 * When contextId is not valid, the service will respond with HTTP code 401 without
274 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
275 * HttpResponseException. We need to handle this in order to attempt
278 throw new IndegoAuthenticationException("Context rejected", e);
281 throw new IndegoException(e);
286 * Wraps {@link #getRawRequest(String)} into an authenticated session.
288 * @param path the relative path to which the request should be sent
289 * @return the raw data from the response
290 * @throws IndegoAuthenticationException if request was rejected as unauthorized
291 * @throws IndegoException if any communication or parsing error occurred
293 private RawType getRawRequestWithAuthentication(String path) throws IndegoAuthenticationException, IndegoException {
294 if (!session.isValid()) {
298 logger.debug("Session {} valid, skipping authentication", session);
299 return getRawRequest(path);
300 } catch (IndegoAuthenticationException e) {
301 if (logger.isTraceEnabled()) {
302 logger.trace("Context rejected", e);
304 logger.debug("Context rejected: {}", e.getMessage());
306 session.invalidate();
308 return getRawRequest(path);
313 * Sends a GET request to the server and returns the raw response.
315 * @param path the relative path to which the request should be sent
316 * @return the raw data from the response
317 * @throws IndegoAuthenticationException if request was rejected as unauthorized
318 * @throws IndegoException if any communication or parsing error occurred
320 private RawType getRawRequest(String path) throws IndegoAuthenticationException, IndegoException {
323 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
324 session.getContextId());
325 if (logger.isTraceEnabled()) {
326 logger.trace("GET request for {}", BASE_URL + path);
328 ContentResponse response = sendRequest(request);
329 status = response.getStatus();
330 if (status == HttpStatus.UNAUTHORIZED_401) {
331 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
332 throw new IndegoAuthenticationException("Context rejected");
334 if (!HttpStatus.isSuccess(status)) {
335 throw new IndegoException("The request failed with error: " + status);
337 byte[] data = response.getContent();
339 throw new IndegoInvalidResponseException("No data returned", status);
341 String contentType = response.getMediaType();
342 if (contentType == null || contentType.isEmpty()) {
343 throw new IndegoInvalidResponseException("No content-type returned", status);
345 logger.debug("Media download response: type {}, length {}", contentType, data.length);
347 return new RawType(data, contentType);
348 } catch (JsonParseException e) {
349 throw new IndegoInvalidResponseException("Error parsing response", e, status);
350 } catch (InterruptedException e) {
351 Thread.currentThread().interrupt();
352 throw new IndegoException(e);
353 } catch (TimeoutException e) {
354 throw new IndegoException(e);
355 } catch (ExecutionException e) {
356 Throwable cause = e.getCause();
357 if (cause != null && cause instanceof HttpResponseException) {
358 Response response = ((HttpResponseException) cause).getResponse();
359 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
361 * When contextId is not valid, the service will respond with HTTP code 401 without
362 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
363 * HttpResponseException. We need to handle this in order to attempt
366 throw new IndegoAuthenticationException("Context rejected", e);
369 throw new IndegoException(e);
374 * Wraps {@link #putRequest(String, Object)} into an authenticated session.
376 * @param path the relative path to which the request should be sent
377 * @param requestDto the DTO which should be sent to the server as JSON
378 * @throws IndegoAuthenticationException if request was rejected as unauthorized
379 * @throws IndegoException if any communication or parsing error occurred
381 private void putRequestWithAuthentication(String path, Object requestDto)
382 throws IndegoAuthenticationException, IndegoException {
383 if (!session.isValid()) {
387 logger.debug("Session {} valid, skipping authentication", session);
388 putRequest(path, requestDto);
389 } catch (IndegoAuthenticationException e) {
390 if (logger.isTraceEnabled()) {
391 logger.trace("Context rejected", e);
393 logger.debug("Context rejected: {}", e.getMessage());
395 session.invalidate();
397 putRequest(path, requestDto);
402 * Sends a PUT request to the server.
404 * @param path the relative path to which the request should be sent
405 * @param requestDto the DTO which should be sent to the server as JSON
406 * @throws IndegoAuthenticationException if request was rejected as unauthorized
407 * @throws IndegoException if any communication or parsing error occurred
409 private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
411 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
412 .header(CONTEXT_HEADER_NAME, session.getContextId())
413 .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
414 String payload = gson.toJson(requestDto);
415 request.content(new StringContentProvider(payload));
416 if (logger.isTraceEnabled()) {
417 logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
419 ContentResponse response = sendRequest(request);
420 int status = response.getStatus();
421 if (status == HttpStatus.UNAUTHORIZED_401) {
422 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
423 throw new IndegoAuthenticationException("Context rejected");
425 if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
426 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
428 if (!HttpStatus.isSuccess(status)) {
429 throw new IndegoException("The request failed with error: " + status);
431 } catch (JsonParseException e) {
432 throw new IndegoException("Error serializing request", e);
433 } catch (InterruptedException e) {
434 Thread.currentThread().interrupt();
435 throw new IndegoException(e);
436 } catch (TimeoutException e) {
437 throw new IndegoException(e);
438 } catch (ExecutionException e) {
439 Throwable cause = e.getCause();
440 if (cause != null && cause instanceof HttpResponseException) {
441 Response response = ((HttpResponseException) cause).getResponse();
442 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
444 * When contextId is not valid, the service will respond with HTTP code 401 without
445 * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
446 * HttpResponseException. We need to handle this in order to attempt
449 throw new IndegoAuthenticationException("Context rejected", e);
452 throw new IndegoException(e);
457 * Sends a DELETE request to the server.
459 * @param path the relative path to which the request should be sent
460 * @throws IndegoException if any communication or parsing error occurred
462 private void deleteRequest(String path) throws IndegoException {
464 Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.DELETE)
465 .header(CONTEXT_HEADER_NAME, session.getContextId());
466 if (logger.isTraceEnabled()) {
467 logger.trace("DELETE request for {}", BASE_URL + path);
469 ContentResponse response = sendRequest(request);
470 int status = response.getStatus();
471 if (!HttpStatus.isSuccess(status)) {
472 throw new IndegoException("The request failed with error: " + status);
474 } catch (InterruptedException e) {
475 Thread.currentThread().interrupt();
476 throw new IndegoException(e);
477 } catch (TimeoutException | ExecutionException e) {
478 throw new IndegoException(e);
483 * Send request. This method exists for the purpose of avoiding multiple calls to
484 * the server at the same time.
486 * @param request the {@link Request} to send
487 * @return a {@link ContentResponse} for this request
488 * @throws InterruptedException if send thread is interrupted
489 * @throws TimeoutException if send times out
490 * @throws ExecutionException if execution fails
492 private synchronized ContentResponse sendRequest(Request request)
493 throws InterruptedException, TimeoutException, ExecutionException {
494 return request.send();
498 * Gets serial number of the associated Indego device
500 * @return the serial number of the device
501 * @throws IndegoAuthenticationException if request was rejected as unauthorized
502 * @throws IndegoException if any communication or parsing error occurred
504 public synchronized String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
505 if (!session.isInitialized()) {
506 logger.debug("Session not yet initialized when serial number was requested; authenticating...");
509 return session.getSerialNumber();
513 * Queries the device state from the server.
515 * @return the device state
516 * @throws IndegoAuthenticationException if request was rejected as unauthorized
517 * @throws IndegoException if any communication or parsing error occurred
519 public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
520 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
521 DeviceStateResponse.class);
525 * Queries the device operating data from the server.
526 * Server will request this directly from the device, so operation might be slow.
528 * @return the device state
529 * @throws IndegoAuthenticationException if request was rejected as unauthorized
530 * @throws IndegoUnreachableException if device cannot be reached (gateway timeout error)
531 * @throws IndegoException if any communication or parsing error occurred
533 public OperatingDataResponse getOperatingData()
534 throws IndegoAuthenticationException, IndegoUnreachableException, IndegoException {
535 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/operatingData",
536 OperatingDataResponse.class);
540 * Queries the map generated by the device from the server.
542 * @return the garden map
543 * @throws IndegoAuthenticationException if request was rejected as unauthorized
544 * @throws IndegoException if any communication or parsing error occurred
546 public RawType getMap() throws IndegoAuthenticationException, IndegoException {
547 return getRawRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/map");
551 * Queries the calendar.
553 * @return the calendar
554 * @throws IndegoAuthenticationException if request was rejected as unauthorized
555 * @throws IndegoException if any communication or parsing error occurred
557 public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
558 DeviceCalendarResponse calendar = getRequestWithAuthentication(
559 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
564 * Sends a command to the Indego device.
566 * @param command the control command to send to the device
567 * @throws IndegoAuthenticationException if request was rejected as unauthorized
568 * @throws IndegoInvalidCommandException if the command was not processed correctly
569 * @throws IndegoException if any communication or parsing error occurred
571 public void sendCommand(DeviceCommand command)
572 throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
573 SetStateRequest request = new SetStateRequest();
574 request.state = command.getActionCode();
575 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
579 * Queries the predictive weather forecast.
581 * @return the weather forecast DTO
582 * @throws IndegoAuthenticationException if request was rejected as unauthorized
583 * @throws IndegoException if any communication or parsing error occurred
585 public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
586 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
587 LocationWeatherResponse.class);
591 * Queries the predictive adjustment.
593 * @return the predictive adjustment
594 * @throws IndegoAuthenticationException if request was rejected as unauthorized
595 * @throws IndegoException if any communication or parsing error occurred
597 public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
598 return getRequestWithAuthentication(
599 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
600 PredictiveAdjustment.class).adjustment;
604 * Sets the predictive adjustment.
606 * @param adjust the predictive adjustment
607 * @throws IndegoAuthenticationException if request was rejected as unauthorized
608 * @throws IndegoException if any communication or parsing error occurred
610 public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
611 final PredictiveAdjustment adjustment = new PredictiveAdjustment();
612 adjustment.adjustment = adjust;
613 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
618 * Queries predictive moving.
620 * @return predictive moving
621 * @throws IndegoAuthenticationException if request was rejected as unauthorized
622 * @throws IndegoException if any communication or parsing error occurred
624 public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
625 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive",
626 PredictiveStatus.class).enabled;
630 * Sets predictive moving.
633 * @throws IndegoAuthenticationException if request was rejected as unauthorized
634 * @throws IndegoException if any communication or parsing error occurred
636 public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
637 final PredictiveStatus status = new PredictiveStatus();
638 status.enabled = enable;
639 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
643 * Queries predictive last cutting as {@link Instant}.
645 * @return predictive last cutting
646 * @throws IndegoAuthenticationException if request was rejected as unauthorized
647 * @throws IndegoException if any communication or parsing error occurred
649 public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
651 return getRequestWithAuthentication(
652 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting",
653 PredictiveLastCuttingResponse.class).getLastCutting();
654 } catch (IndegoInvalidResponseException e) {
655 if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) {
663 * Queries predictive next cutting as {@link Instant}.
665 * @return predictive next cutting
666 * @throws IndegoAuthenticationException if request was rejected as unauthorized
667 * @throws IndegoException if any communication or parsing error occurred
669 public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
671 return getRequestWithAuthentication(
672 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
673 PredictiveNextCuttingResponse.class).getNextCutting();
674 } catch (IndegoInvalidResponseException e) {
675 if (e.getHttpStatusCode() == HttpStatus.NO_CONTENT_204) {
683 * Queries predictive exclusion time.
685 * @return predictive exclusion time DTO
686 * @throws IndegoAuthenticationException if request was rejected as unauthorized
687 * @throws IndegoException if any communication or parsing error occurred
689 public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
690 return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar",
691 DeviceCalendarResponse.class);
695 * Sets predictive exclusion time.
697 * @param calendar calendar DTO
698 * @throws IndegoAuthenticationException if request was rejected as unauthorized
699 * @throws IndegoException if any communication or parsing error occurred
701 public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
702 throws IndegoAuthenticationException, IndegoException {
703 putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);