]> git.basschouten.com Git - openhab-addons.git/blob
dd50c8e7cf7fa09feb4df4d82c43f38c6f30f883
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.boschindego.internal;
14
15 import java.net.URI;
16 import java.time.Instant;
17 import java.util.Base64;
18 import java.util.concurrent.ExecutionException;
19 import java.util.concurrent.TimeoutException;
20
21 import org.eclipse.jdt.annotation.NonNullByDefault;
22 import org.eclipse.jdt.annotation.Nullable;
23 import org.eclipse.jetty.client.HttpClient;
24 import org.eclipse.jetty.client.HttpResponseException;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.eclipse.jetty.client.api.Response;
28 import org.eclipse.jetty.client.util.StringContentProvider;
29 import org.eclipse.jetty.http.HttpHeader;
30 import org.eclipse.jetty.http.HttpMethod;
31 import org.eclipse.jetty.http.HttpStatus;
32 import org.openhab.binding.boschindego.internal.dto.DeviceCommand;
33 import org.openhab.binding.boschindego.internal.dto.PredictiveAdjustment;
34 import org.openhab.binding.boschindego.internal.dto.PredictiveStatus;
35 import org.openhab.binding.boschindego.internal.dto.request.AuthenticationRequest;
36 import org.openhab.binding.boschindego.internal.dto.request.SetStateRequest;
37 import org.openhab.binding.boschindego.internal.dto.response.AuthenticationResponse;
38 import org.openhab.binding.boschindego.internal.dto.response.DeviceCalendarResponse;
39 import org.openhab.binding.boschindego.internal.dto.response.DeviceStateResponse;
40 import org.openhab.binding.boschindego.internal.dto.response.LocationWeatherResponse;
41 import org.openhab.binding.boschindego.internal.dto.response.PredictiveLastCuttingResponse;
42 import org.openhab.binding.boschindego.internal.dto.response.PredictiveNextCuttingResponse;
43 import org.openhab.binding.boschindego.internal.exceptions.IndegoAuthenticationException;
44 import org.openhab.binding.boschindego.internal.exceptions.IndegoException;
45 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidCommandException;
46 import org.openhab.binding.boschindego.internal.exceptions.IndegoInvalidResponseException;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
49
50 import com.google.gson.Gson;
51 import com.google.gson.JsonParseException;
52
53 /**
54  * Controller for communicating with a Bosch Indego device through Bosch services.
55  * This class provides methods for retrieving state information as well as controlling
56  * the device.
57  * 
58  * The implementation is based on zazaz-de/iot-device-bosch-indego-controller, but
59  * rewritten from scratch to use Jetty HTTP client for HTTP communication and GSON for
60  * JSON parsing. Thanks to Oliver Schünemann for providing the original implementation.
61  * 
62  * @author Jacob Laursen - Initial contribution
63  */
64 @NonNullByDefault
65 public class IndegoController {
66
67     private static final String BASE_URL = "https://api.indego.iot.bosch-si.com/api/v1/";
68     private static final URI BASE_URI = URI.create(BASE_URL);
69     private static final String SERIAL_NUMBER_SUBPATH = "alms/";
70     private static final String SSO_COOKIE_NAME = "BOSCH_INDEGO_SSO";
71     private static final String CONTEXT_HEADER_NAME = "x-im-context-id";
72     private static final String CONTENT_TYPE_HEADER = "application/json";
73
74     private final Logger logger = LoggerFactory.getLogger(IndegoController.class);
75     private final String basicAuthenticationHeader;
76     private final Gson gson = new Gson();
77     private final HttpClient httpClient;
78
79     private IndegoSession session = new IndegoSession();
80
81     /**
82      * Initialize the controller instance.
83      * 
84      * @param username the username for authenticating
85      * @param password the password
86      */
87     public IndegoController(HttpClient httpClient, String username, String password) {
88         this.httpClient = httpClient;
89         basicAuthenticationHeader = "Basic "
90                 + Base64.getEncoder().encodeToString((username + ":" + password).getBytes());
91     }
92
93     /**
94      * Authenticate with server and store session context and serial number.
95      * 
96      * @throws IndegoAuthenticationException if request was rejected as unauthorized
97      * @throws IndegoException if any communication or parsing error occurred
98      */
99     private void authenticate() throws IndegoAuthenticationException, IndegoException {
100         try {
101             Request request = httpClient.newRequest(BASE_URL + "authenticate").method(HttpMethod.POST)
102                     .header(HttpHeader.AUTHORIZATION, basicAuthenticationHeader);
103
104             AuthenticationRequest authRequest = new AuthenticationRequest();
105             authRequest.device = "";
106             authRequest.osType = "Android";
107             authRequest.osVersion = "4.0";
108             authRequest.deviceManufacturer = "unknown";
109             authRequest.deviceType = "unknown";
110             String json = gson.toJson(authRequest);
111             request.content(new StringContentProvider(json));
112             request.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
113
114             if (logger.isTraceEnabled()) {
115                 logger.trace("POST request for {}", BASE_URL + "authenticate");
116             }
117
118             ContentResponse response = sendRequest(request);
119             int status = response.getStatus();
120             if (status == HttpStatus.UNAUTHORIZED_401) {
121                 throw new IndegoAuthenticationException("Authentication was rejected");
122             }
123             if (!HttpStatus.isSuccess(status)) {
124                 throw new IndegoAuthenticationException("The request failed with HTTP error: " + status);
125             }
126
127             String jsonResponse = response.getContentAsString();
128             if (jsonResponse.isEmpty()) {
129                 throw new IndegoInvalidResponseException("No content returned");
130             }
131             logger.trace("JSON response: '{}'", jsonResponse);
132
133             AuthenticationResponse authenticationResponse = gson.fromJson(jsonResponse, AuthenticationResponse.class);
134             if (authenticationResponse == null) {
135                 throw new IndegoInvalidResponseException("Response could not be parsed as AuthenticationResponse");
136             }
137             session = new IndegoSession(authenticationResponse.contextId, authenticationResponse.serialNumber,
138                     getContextExpirationTimeFromCookie());
139             logger.debug("Initialized session {}", session);
140         } catch (JsonParseException e) {
141             throw new IndegoInvalidResponseException("Error parsing AuthenticationResponse", e);
142         } catch (InterruptedException e) {
143             Thread.currentThread().interrupt();
144             throw new IndegoException(e);
145         } catch (TimeoutException | ExecutionException e) {
146             throw new IndegoException(e);
147         }
148     }
149
150     /**
151      * Get context expiration time as a calculated {@link Instant} relative to now.
152      * The information is obtained from max age in the Bosch Indego SSO cookie.
153      * Please note that this cookie is only sent initially when authenticating, so
154      * the value will not be subject to any updates.
155      * 
156      * @return expiration time as {@link Instant} or {@link Instant#MIN} if not present
157      */
158     private Instant getContextExpirationTimeFromCookie() {
159         return httpClient.getCookieStore().get(BASE_URI).stream().filter(c -> SSO_COOKIE_NAME.equals(c.getName()))
160                 .findFirst().map(c -> {
161                     return Instant.now().plusSeconds(c.getMaxAge());
162                 }).orElseGet(() -> {
163                     return Instant.MIN;
164                 });
165     }
166
167     /**
168      * Wraps {@link #getRequest(String, Class)} into an authenticated session.
169      *
170      * @param path the relative path to which the request should be sent
171      * @param dtoClass the DTO class to which the JSON result should be deserialized
172      * @return the deserialized DTO from the JSON response
173      * @throws IndegoAuthenticationException if request was rejected as unauthorized
174      * @throws IndegoException if any communication or parsing error occurred
175      */
176     private <T> T getRequestWithAuthentication(String path, Class<? extends T> dtoClass)
177             throws IndegoAuthenticationException, IndegoException {
178         if (!session.isValid()) {
179             authenticate();
180         }
181         try {
182             logger.debug("Session {} valid, skipping authentication", session);
183             return getRequest(path, dtoClass);
184         } catch (IndegoAuthenticationException e) {
185             if (logger.isTraceEnabled()) {
186                 logger.trace("Context rejected", e);
187             } else {
188                 logger.debug("Context rejected: {}", e.getMessage());
189             }
190             session.invalidate();
191             authenticate();
192             return getRequest(path, dtoClass);
193         }
194     }
195
196     /**
197      * Sends a GET request to the server and returns the deserialized JSON response.
198      * 
199      * @param path the relative path to which the request should be sent
200      * @param dtoClass the DTO class to which the JSON result should be deserialized
201      * @return the deserialized DTO from the JSON response
202      * @throws IndegoAuthenticationException if request was rejected as unauthorized
203      * @throws IndegoException if any communication or parsing error occurred
204      */
205     private <T> T getRequest(String path, Class<? extends T> dtoClass)
206             throws IndegoAuthenticationException, IndegoException {
207         try {
208             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.GET).header(CONTEXT_HEADER_NAME,
209                     session.getContextId());
210             if (logger.isTraceEnabled()) {
211                 logger.trace("GET request for {}", BASE_URL + path);
212             }
213             ContentResponse response = sendRequest(request);
214             int status = response.getStatus();
215             if (status == HttpStatus.UNAUTHORIZED_401) {
216                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
217                 throw new IndegoAuthenticationException("Context rejected");
218             }
219             if (!HttpStatus.isSuccess(status)) {
220                 throw new IndegoException("The request failed with error: " + status);
221             }
222             String jsonResponse = response.getContentAsString();
223             if (jsonResponse.isEmpty()) {
224                 throw new IndegoInvalidResponseException("No content returned");
225             }
226             logger.trace("JSON response: '{}'", jsonResponse);
227
228             @Nullable
229             T result = gson.fromJson(jsonResponse, dtoClass);
230             if (result == null) {
231                 throw new IndegoInvalidResponseException("Parsed response is null");
232             }
233             return result;
234         } catch (JsonParseException e) {
235             throw new IndegoInvalidResponseException("Error parsing response", e);
236         } catch (InterruptedException e) {
237             Thread.currentThread().interrupt();
238             throw new IndegoException(e);
239         } catch (TimeoutException e) {
240             throw new IndegoException(e);
241         } catch (ExecutionException e) {
242             Throwable cause = e.getCause();
243             if (cause != null && cause instanceof HttpResponseException) {
244                 Response response = ((HttpResponseException) cause).getResponse();
245                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
246                     /*
247                      * When contextId is not valid, the service will respond with HTTP code 401 without
248                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
249                      * HttpResponseException. We need to handle this in order to attempt
250                      * reauthentication.
251                      */
252                     throw new IndegoAuthenticationException("Context rejected", e);
253                 }
254             }
255             throw new IndegoException(e);
256         }
257     }
258
259     /**
260      * Wraps {@link #putRequest(String, Object)} into an authenticated session.
261      * 
262      * @param path the relative path to which the request should be sent
263      * @param requestDto the DTO which should be sent to the server as JSON
264      * @throws IndegoAuthenticationException if request was rejected as unauthorized
265      * @throws IndegoException if any communication or parsing error occurred
266      */
267     private void putRequestWithAuthentication(String path, Object requestDto)
268             throws IndegoAuthenticationException, IndegoException {
269         if (!session.isValid()) {
270             authenticate();
271         }
272         try {
273             logger.debug("Session {} valid, skipping authentication", session);
274             putRequest(path, requestDto);
275         } catch (IndegoAuthenticationException e) {
276             if (logger.isTraceEnabled()) {
277                 logger.trace("Context rejected", e);
278             } else {
279                 logger.debug("Context rejected: {}", e.getMessage());
280             }
281             session.invalidate();
282             authenticate();
283             putRequest(path, requestDto);
284         }
285     }
286
287     /**
288      * Sends a PUT request to the server.
289      * 
290      * @param path the relative path to which the request should be sent
291      * @param requestDto the DTO which should be sent to the server as JSON
292      * @throws IndegoAuthenticationException if request was rejected as unauthorized
293      * @throws IndegoException if any communication or parsing error occurred
294      */
295     private void putRequest(String path, Object requestDto) throws IndegoAuthenticationException, IndegoException {
296         try {
297             Request request = httpClient.newRequest(BASE_URL + path).method(HttpMethod.PUT)
298                     .header(CONTEXT_HEADER_NAME, session.getContextId())
299                     .header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_HEADER);
300             String payload = gson.toJson(requestDto);
301             request.content(new StringContentProvider(payload));
302             if (logger.isTraceEnabled()) {
303                 logger.trace("PUT request for {} with payload '{}'", BASE_URL + path, payload);
304             }
305             ContentResponse response = sendRequest(request);
306             int status = response.getStatus();
307             if (status == HttpStatus.UNAUTHORIZED_401) {
308                 // This will currently not happen because "WWW-Authenticate" header is missing; see below.
309                 throw new IndegoAuthenticationException("Context rejected");
310             }
311             if (status == HttpStatus.INTERNAL_SERVER_ERROR_500) {
312                 throw new IndegoInvalidCommandException("The request failed with HTTP error: " + status);
313             }
314             if (!HttpStatus.isSuccess(status)) {
315                 throw new IndegoException("The request failed with error: " + status);
316             }
317         } catch (JsonParseException e) {
318             throw new IndegoInvalidResponseException("Error serializing request", e);
319         } catch (InterruptedException e) {
320             Thread.currentThread().interrupt();
321             throw new IndegoException(e);
322         } catch (TimeoutException e) {
323             throw new IndegoException(e);
324         } catch (ExecutionException e) {
325             Throwable cause = e.getCause();
326             if (cause != null && cause instanceof HttpResponseException) {
327                 Response response = ((HttpResponseException) cause).getResponse();
328                 if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
329                     /*
330                      * When contextId is not valid, the service will respond with HTTP code 401 without
331                      * any "WWW-Authenticate" header, violating RFC 7235. Jetty will then throw
332                      * HttpResponseException. We need to handle this in order to attempt
333                      * reauthentication.
334                      */
335                     throw new IndegoAuthenticationException("Context rejected", e);
336                 }
337             }
338             throw new IndegoException(e);
339         }
340     }
341
342     /**
343      * Send request. This method exists for the purpose of avoiding multiple calls to
344      * the server at the same time.
345      * 
346      * @param request the {@link Request} to send
347      * @return a {@link ContentResponse} for this request
348      * @throws InterruptedException if send thread is interrupted
349      * @throws TimeoutException if send times out
350      * @throws ExecutionException if execution fails
351      */
352     private synchronized ContentResponse sendRequest(Request request)
353             throws InterruptedException, TimeoutException, ExecutionException {
354         return request.send();
355     }
356
357     /**
358      * Gets serial number of the associated Indego device
359      *
360      * @return the serial number of the device
361      * @throws IndegoAuthenticationException if request was rejected as unauthorized
362      * @throws IndegoException if any communication or parsing error occurred
363      */
364     public String getSerialNumber() throws IndegoAuthenticationException, IndegoException {
365         if (!session.isInitialized()) {
366             logger.debug("Session not yet initialized when serial number was requested; authenticating...");
367             authenticate();
368         }
369         return session.getSerialNumber();
370     }
371
372     /**
373      * Queries the device state from the server.
374      * 
375      * @return the device state
376      * @throws IndegoAuthenticationException if request was rejected as unauthorized
377      * @throws IndegoException if any communication or parsing error occurred
378      */
379     public DeviceStateResponse getState() throws IndegoAuthenticationException, IndegoException {
380         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state",
381                 DeviceStateResponse.class);
382     }
383
384     /**
385      * Queries the calendar.
386      * 
387      * @return the calendar
388      * @throws IndegoAuthenticationException if request was rejected as unauthorized
389      * @throws IndegoException if any communication or parsing error occurred
390      */
391     public DeviceCalendarResponse getCalendar() throws IndegoAuthenticationException, IndegoException {
392         DeviceCalendarResponse calendar = getRequestWithAuthentication(
393                 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/calendar", DeviceCalendarResponse.class);
394         return calendar;
395     }
396
397     /**
398      * Sends a command to the Indego device.
399      * 
400      * @param command the control command to send to the device
401      * @throws IndegoAuthenticationException if request was rejected as unauthorized
402      * @throws IndegoInvalidCommandException if the command was not processed correctly
403      * @throws IndegoException if any communication or parsing error occurred
404      */
405     public void sendCommand(DeviceCommand command)
406             throws IndegoAuthenticationException, IndegoInvalidCommandException, IndegoException {
407         SetStateRequest request = new SetStateRequest();
408         request.state = command.getActionCode();
409         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/state", request);
410     }
411
412     /**
413      * Queries the predictive weather forecast.
414      * 
415      * @return the weather forecast DTO
416      * @throws IndegoAuthenticationException if request was rejected as unauthorized
417      * @throws IndegoException if any communication or parsing error occurred
418      */
419     public LocationWeatherResponse getWeather() throws IndegoAuthenticationException, IndegoException {
420         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/weather",
421                 LocationWeatherResponse.class);
422     }
423
424     /**
425      * Queries the predictive adjustment.
426      * 
427      * @return the predictive adjustment
428      * @throws IndegoAuthenticationException if request was rejected as unauthorized
429      * @throws IndegoException if any communication or parsing error occurred
430      */
431     public int getPredictiveAdjustment() throws IndegoAuthenticationException, IndegoException {
432         return getRequestWithAuthentication(
433                 SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
434                 PredictiveAdjustment.class).adjustment;
435     }
436
437     /**
438      * Sets the predictive adjustment.
439      * 
440      * @param adjust the predictive adjustment
441      * @throws IndegoAuthenticationException if request was rejected as unauthorized
442      * @throws IndegoException if any communication or parsing error occurred
443      */
444     public void setPredictiveAdjustment(final int adjust) throws IndegoAuthenticationException, IndegoException {
445         final PredictiveAdjustment adjustment = new PredictiveAdjustment();
446         adjustment.adjustment = adjust;
447         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/useradjustment",
448                 adjustment);
449     }
450
451     /**
452      * Queries predictive moving.
453      * 
454      * @return predictive moving
455      * @throws IndegoAuthenticationException if request was rejected as unauthorized
456      * @throws IndegoException if any communication or parsing error occurred
457      */
458     public boolean getPredictiveMoving() throws IndegoAuthenticationException, IndegoException {
459         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive",
460                 PredictiveStatus.class).enabled;
461     }
462
463     /**
464      * Sets predictive moving.
465      * 
466      * @param enable
467      * @throws IndegoAuthenticationException if request was rejected as unauthorized
468      * @throws IndegoException if any communication or parsing error occurred
469      */
470     public void setPredictiveMoving(final boolean enable) throws IndegoAuthenticationException, IndegoException {
471         final PredictiveStatus status = new PredictiveStatus();
472         status.enabled = enable;
473         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive", status);
474     }
475
476     /**
477      * Queries predictive last cutting as {@link Instant}.
478      * 
479      * @return predictive last cutting
480      * @throws IndegoAuthenticationException if request was rejected as unauthorized
481      * @throws IndegoException if any communication or parsing error occurred
482      */
483     public @Nullable Instant getPredictiveLastCutting() throws IndegoAuthenticationException, IndegoException {
484         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/lastcutting",
485                 PredictiveLastCuttingResponse.class).getLastCutting();
486     }
487
488     /**
489      * Queries predictive next cutting as {@link Instant}.
490      * 
491      * @return predictive next cutting
492      * @throws IndegoAuthenticationException if request was rejected as unauthorized
493      * @throws IndegoException if any communication or parsing error occurred
494      */
495     public @Nullable Instant getPredictiveNextCutting() throws IndegoAuthenticationException, IndegoException {
496         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/nextcutting",
497                 PredictiveNextCuttingResponse.class).getNextCutting();
498     }
499
500     /**
501      * Queries predictive exclusion time.
502      * 
503      * @return predictive exclusion time DTO
504      * @throws IndegoAuthenticationException if request was rejected as unauthorized
505      * @throws IndegoException if any communication or parsing error occurred
506      */
507     public DeviceCalendarResponse getPredictiveExclusionTime() throws IndegoAuthenticationException, IndegoException {
508         return getRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar",
509                 DeviceCalendarResponse.class);
510     }
511
512     /**
513      * Sets predictive exclusion time.
514      * 
515      * @param calendar calendar DTO
516      * @throws IndegoAuthenticationException if request was rejected as unauthorized
517      * @throws IndegoException if any communication or parsing error occurred
518      */
519     public void setPredictiveExclusionTime(final DeviceCalendarResponse calendar)
520             throws IndegoAuthenticationException, IndegoException {
521         putRequestWithAuthentication(SERIAL_NUMBER_SUBPATH + this.getSerialNumber() + "/predictive/calendar", calendar);
522     }
523 }