]> git.basschouten.com Git - openhab-addons.git/blob
0f309bb5ae1d173ff0c267723a6b180232ad037f
[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.mielecloud.internal.webservice;
14
15 import java.net.URI;
16 import java.util.ArrayList;
17 import java.util.List;
18 import java.util.Optional;
19 import java.util.concurrent.ExecutionException;
20 import java.util.concurrent.ScheduledExecutorService;
21 import java.util.concurrent.TimeoutException;
22
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.eclipse.jetty.client.api.ContentResponse;
26 import org.eclipse.jetty.client.api.Request;
27 import org.openhab.binding.mielecloud.internal.webservice.api.json.Actions;
28 import org.openhab.binding.mielecloud.internal.webservice.api.json.DeviceCollection;
29 import org.openhab.binding.mielecloud.internal.webservice.api.json.Light;
30 import org.openhab.binding.mielecloud.internal.webservice.api.json.MieleSyntaxException;
31 import org.openhab.binding.mielecloud.internal.webservice.api.json.ProcessAction;
32 import org.openhab.binding.mielecloud.internal.webservice.exception.AuthorizationFailedException;
33 import org.openhab.binding.mielecloud.internal.webservice.exception.MieleWebserviceException;
34 import org.openhab.binding.mielecloud.internal.webservice.exception.MieleWebserviceInitializationException;
35 import org.openhab.binding.mielecloud.internal.webservice.exception.MieleWebserviceTransientException;
36 import org.openhab.binding.mielecloud.internal.webservice.exception.TooManyRequestsException;
37 import org.openhab.binding.mielecloud.internal.webservice.request.RequestFactory;
38 import org.openhab.binding.mielecloud.internal.webservice.request.RequestFactoryImpl;
39 import org.openhab.binding.mielecloud.internal.webservice.retry.AuthorizationFailedRetryStrategy;
40 import org.openhab.binding.mielecloud.internal.webservice.retry.NTimesRetryStrategy;
41 import org.openhab.binding.mielecloud.internal.webservice.retry.RetryStrategy;
42 import org.openhab.binding.mielecloud.internal.webservice.retry.RetryStrategyCombiner;
43 import org.openhab.binding.mielecloud.internal.webservice.sse.ServerSentEvent;
44 import org.openhab.binding.mielecloud.internal.webservice.sse.SseConnection;
45 import org.openhab.binding.mielecloud.internal.webservice.sse.SseListener;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import com.google.gson.Gson;
50 import com.google.gson.JsonSyntaxException;
51
52 /**
53  * Default implementation of the {@link MieleWebservice}.
54  *
55  * @author Björn Lange - Initial contribution
56  */
57 @NonNullByDefault
58 public final class DefaultMieleWebservice implements MieleWebservice, SseListener {
59     private static final String SERVER_ADDRESS = "https://api.mcs3.miele.com";
60     public static final String THIRD_PARTY_ENDPOINTS_BASENAME = SERVER_ADDRESS + "/thirdparty";
61     private static final String ENDPOINT_DEVICES = SERVER_ADDRESS + "/v1/devices/";
62     private static final String ENDPOINT_ACTIONS = ENDPOINT_DEVICES + "%s" + "/actions";
63     private static final String ENDPOINT_LOGOUT = THIRD_PARTY_ENDPOINTS_BASENAME + "/logout";
64     private static final String ENDPOINT_ALL_SSE_EVENTS = ENDPOINT_DEVICES + "all/events";
65
66     private static final String SSE_EVENT_TYPE_DEVICES = "devices";
67
68     private static final Gson GSON = new Gson();
69
70     private final Logger logger = LoggerFactory.getLogger(DefaultMieleWebservice.class);
71
72     private Optional<String> accessToken = Optional.empty();
73     private final RequestFactory requestFactory;
74
75     private final DeviceStateDispatcher deviceStateDispatcher;
76     private final List<ConnectionStatusListener> connectionStatusListeners = new ArrayList<>();
77
78     private final RetryStrategy retryStrategy;
79
80     private final SseConnection sseConnection;
81
82     /**
83      * Creates a new {@link DefaultMieleWebservice} with default retry configuration which is to retry failed operations
84      * once on a transient error. In case an authorization error occurs, a new access token is requested and a retry of
85      * the failed request is executed.
86      *
87      * @param configuration The configuration holding all parameters for constructing the instance.
88      * @throws MieleWebserviceInitializationException if initializing the HTTP client fails.
89      */
90     public DefaultMieleWebservice(MieleWebserviceConfiguration configuration) {
91         this(new RequestFactoryImpl(configuration.getHttpClientFactory(), configuration.getLanguageProvider()),
92                 new RetryStrategyCombiner(new NTimesRetryStrategy(1),
93                         new AuthorizationFailedRetryStrategy(configuration.getTokenRefresher(),
94                                 configuration.getServiceHandle())),
95                 new DeviceStateDispatcher(), configuration.getScheduler());
96     }
97
98     /**
99      * This constructor only exists for testing.
100      */
101     DefaultMieleWebservice(RequestFactory requestFactory, RetryStrategy retryStrategy,
102             DeviceStateDispatcher deviceStateDispatcher, ScheduledExecutorService scheduler) {
103         this.requestFactory = requestFactory;
104         this.retryStrategy = retryStrategy;
105         this.deviceStateDispatcher = deviceStateDispatcher;
106         this.sseConnection = new SseConnection(ENDPOINT_ALL_SSE_EVENTS, this::createSseRequest, scheduler);
107         this.sseConnection.addSseListener(this);
108     }
109
110     @Override
111     public void setAccessToken(String accessToken) {
112         this.accessToken = Optional.of(accessToken);
113     }
114
115     @Override
116     public boolean hasAccessToken() {
117         return accessToken.isPresent();
118     }
119
120     @Override
121     public synchronized void connectSse() {
122         sseConnection.connect();
123     }
124
125     @Override
126     public synchronized void disconnectSse() {
127         sseConnection.disconnect();
128     }
129
130     @Nullable
131     private Request createSseRequest(String endpoint) {
132         Optional<String> accessToken = this.accessToken;
133         if (!accessToken.isPresent()) {
134             logger.warn("No access token present.");
135             return null;
136         }
137
138         return requestFactory.createSseRequest(endpoint, accessToken.get());
139     }
140
141     @Override
142     public void onServerSentEvent(ServerSentEvent event) {
143         fireConnectionAlive();
144
145         if (!SSE_EVENT_TYPE_DEVICES.equals(event.getEvent())) {
146             return;
147         }
148
149         try {
150             deviceStateDispatcher.dispatchDeviceStateUpdates(DeviceCollection.fromJson(event.getData()));
151         } catch (MieleSyntaxException e) {
152             logger.warn("SSE payload is not valid Json: {}", event.getData());
153         }
154     }
155
156     private void fireConnectionAlive() {
157         connectionStatusListeners.forEach(ConnectionStatusListener::onConnectionAlive);
158     }
159
160     @Override
161     public void onConnectionError(ConnectionError connectionError, int failedReconnectAttempts) {
162         connectionStatusListeners.forEach(l -> l.onConnectionError(connectionError, failedReconnectAttempts));
163     }
164
165     @Override
166     public void fetchActions(String deviceId) {
167         Actions actions = retryStrategy.performRetryableOperation(() -> getActions(deviceId),
168                 e -> logger.warn("Cannot poll action state: {}. Retrying...", e.getMessage()));
169         if (actions != null) {
170             deviceStateDispatcher.dispatchActionStateUpdates(deviceId, actions);
171         } else {
172             logger.warn("Cannot poll action state. Response is missing actions.");
173         }
174     }
175
176     @Override
177     public void putProcessAction(String deviceId, ProcessAction processAction) {
178         if (processAction.equals(ProcessAction.UNKNOWN)) {
179             throw new IllegalArgumentException("Process action must not be UNKNOWN.");
180         }
181
182         String formattedProcessAction = GSON.toJson(processAction, ProcessAction.class);
183         formattedProcessAction = formattedProcessAction.substring(1, formattedProcessAction.length() - 1);
184         String json = "{\"processAction\":" + formattedProcessAction + "}";
185
186         logger.debug("Activate process action {} of Miele device {}", processAction.toString(), deviceId);
187         putActions(deviceId, json);
188     }
189
190     @Override
191     public void putLight(String deviceId, boolean enabled) {
192         Light light = enabled ? Light.ENABLE : Light.DISABLE;
193         String json = "{\"light\":" + light.format() + "}";
194
195         logger.debug("Set light of Miele device {} to {}", deviceId, enabled);
196         putActions(deviceId, json);
197     }
198
199     @Override
200     public void putPowerState(String deviceId, boolean enabled) {
201         String action = enabled ? "powerOn" : "powerOff";
202         String json = "{\"" + action + "\":true}";
203
204         logger.debug("Set power state of Miele device {} to {}", deviceId, action);
205         putActions(deviceId, json);
206     }
207
208     @Override
209     public void putProgram(String deviceId, long programId) {
210         String json = "{\"programId\":" + programId + "}";
211
212         logger.debug("Activate program with ID {} of Miele device {}", programId, deviceId);
213         putActions(deviceId, json);
214     }
215
216     @Override
217     public void logout() {
218         Optional<String> accessToken = this.accessToken;
219         if (!accessToken.isPresent()) {
220             logger.debug("No access token present.");
221             return;
222         }
223
224         try {
225             logger.debug("Invalidating Miele webservice access token.");
226             Request request = requestFactory.createPostRequest(ENDPOINT_LOGOUT, accessToken.get());
227             this.accessToken = Optional.empty();
228             sendRequest(request);
229         } catch (MieleWebserviceTransientException e) {
230             throw new MieleWebserviceException("Transient error occurred during logout.", e, e.getConnectionError());
231         }
232     }
233
234     /**
235      * Sends the given request and wraps the possible exceptions in Miele exception types.
236      *
237      * @param request The {@link Request} to send.
238      * @return The obtained {@link ContentResponse}.
239      * @throws MieleWebserviceException if an irrecoverable error occurred.
240      * @throws MieleWebserviceTransientException if a recoverable error occurred.
241      */
242     private ContentResponse sendRequest(Request request) {
243         try {
244             if (logger.isDebugEnabled()) {
245                 logger.debug("Send {} request to Miele webservice on uri {}",
246                         Optional.ofNullable(request).map(Request::getMethod).orElse("null"),
247                         Optional.ofNullable(request).map(Request::getURI).map(URI::toString).orElse("null"));
248             }
249
250             ContentResponse response = request.send();
251             logger.debug("Received response with status code {}", response.getStatus());
252             return response;
253         } catch (InterruptedException e) {
254             Thread.currentThread().interrupt();
255             throw new MieleWebserviceException("Interrupted.", e, ConnectionError.REQUEST_INTERRUPTED);
256         } catch (TimeoutException e) {
257             throw new MieleWebserviceTransientException("Request timed out.", e, ConnectionError.TIMEOUT);
258         } catch (ExecutionException e) {
259             throw new MieleWebserviceException("Request execution failed.", e,
260                     ConnectionError.REQUEST_EXECUTION_FAILED);
261         }
262     }
263
264     /**
265      * Gets all available device actions.
266      *
267      * @param deviceId The unique device ID.
268      *
269      * @throws MieleWebserviceException if an error occurs during webservice requests or content parsing.
270      * @throws MieleWebserviceTransientException if an error occurs during webservice requests or content parsing that
271      *             is recoverable by retrying the operation.
272      * @throws AuthorizationFailedException if the authorization against the webservice failed.
273      * @throws TooManyRequestsException if too many requests have been made against the webservice recently.
274      */
275     private Actions getActions(String deviceId) {
276         Optional<String> accessToken = this.accessToken;
277         if (!accessToken.isPresent()) {
278             throw new MieleWebserviceException("Missing access token.", ConnectionError.AUTHORIZATION_FAILED);
279         }
280
281         try {
282             logger.debug("Fetch action state description for Miele device {}", deviceId);
283             Request request = requestFactory.createGetRequest(String.format(ENDPOINT_ACTIONS, deviceId),
284                     accessToken.get());
285             ContentResponse response = sendRequest(request);
286             HttpUtil.checkHttpSuccess(response);
287             Actions actions = GSON.fromJson(response.getContentAsString(), Actions.class);
288             if (actions == null) {
289                 throw new MieleWebserviceTransientException("Failed to parse response message.",
290                         ConnectionError.RESPONSE_MALFORMED);
291             }
292             return actions;
293         } catch (JsonSyntaxException e) {
294             throw new MieleWebserviceTransientException("Failed to parse response message.", e,
295                     ConnectionError.RESPONSE_MALFORMED);
296         }
297     }
298
299     /**
300      * Performs a PUT request to the actions endpoint for the specified device.
301      *
302      * @param deviceId The ID of the device to PUT for.
303      * @param json The Json body to send with the request.
304      * @throws MieleWebserviceException if an error occurs during webservice requests or content parsing.
305      * @throws MieleWebserviceTransientException if an error occurs during webservice requests or content parsing that
306      *             is recoverable by retrying the operation.
307      * @throws AuthorizationFailedException if the authorization against the webservice failed.
308      * @throws TooManyRequestsException if too many requests have been made against the webservice recently.
309      */
310     private void putActions(String deviceId, String json) {
311         retryStrategy.performRetryableOperation(() -> {
312             Optional<String> accessToken = this.accessToken;
313             if (!accessToken.isPresent()) {
314                 throw new MieleWebserviceException("Missing access token.", ConnectionError.AUTHORIZATION_FAILED);
315             }
316
317             Request request = requestFactory.createPutRequest(String.format(ENDPOINT_ACTIONS, deviceId),
318                     accessToken.get(), json);
319             ContentResponse response = sendRequest(request);
320             HttpUtil.checkHttpSuccess(response);
321         }, e -> {
322             logger.warn("Failed to perform PUT request: {}. Retrying...", e.getMessage());
323         });
324     }
325
326     @Override
327     public void dispatchDeviceState(String deviceIdentifier) {
328         deviceStateDispatcher.dispatchDeviceState(deviceIdentifier);
329     }
330
331     @Override
332     public void addDeviceStateListener(DeviceStateListener listener) {
333         deviceStateDispatcher.addListener(listener);
334     }
335
336     @Override
337     public void removeDeviceStateListener(DeviceStateListener listener) {
338         deviceStateDispatcher.removeListener(listener);
339     }
340
341     @Override
342     public void addConnectionStatusListener(ConnectionStatusListener listener) {
343         connectionStatusListeners.add(listener);
344     }
345
346     @Override
347     public void removeConnectionStatusListener(ConnectionStatusListener listener) {
348         connectionStatusListeners.remove(listener);
349     }
350
351     @Override
352     public void close() throws Exception {
353         requestFactory.close();
354     }
355 }