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