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