2 * Copyright (c) 2010-2021 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.mielecloud.internal.webservice;
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;
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;
49 import com.google.gson.Gson;
50 import com.google.gson.JsonSyntaxException;
53 * Default implementation of the {@link MieleWebservice}.
55 * @author Björn Lange - Initial contribution
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";
66 private static final String SSE_EVENT_TYPE_DEVICES = "devices";
68 private static final Gson GSON = new Gson();
70 private final Logger logger = LoggerFactory.getLogger(DefaultMieleWebservice.class);
72 private Optional<String> accessToken = Optional.empty();
73 private final RequestFactory requestFactory;
75 private final DeviceStateDispatcher deviceStateDispatcher;
76 private final List<ConnectionStatusListener> connectionStatusListeners = new ArrayList<>();
78 private final RetryStrategy retryStrategy;
80 private final SseConnection sseConnection;
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.
87 * @param configuration The configuration holding all parameters for constructing the instance.
88 * @throws MieleWebserviceInitializationException if initializing the HTTP client fails.
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());
99 * This constructor only exists for testing.
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);
111 public void setAccessToken(String accessToken) {
112 this.accessToken = Optional.of(accessToken);
116 public boolean hasAccessToken() {
117 return accessToken.isPresent();
121 public synchronized void connectSse() {
122 sseConnection.connect();
126 public synchronized void disconnectSse() {
127 sseConnection.disconnect();
131 private Request createSseRequest(String endpoint) {
132 Optional<String> accessToken = this.accessToken;
133 if (!accessToken.isPresent()) {
134 logger.warn("No access token present.");
138 return requestFactory.createSseRequest(endpoint, accessToken.get());
142 public void onServerSentEvent(ServerSentEvent event) {
143 fireConnectionAlive();
145 if (!SSE_EVENT_TYPE_DEVICES.equals(event.getEvent())) {
150 deviceStateDispatcher.dispatchDeviceStateUpdates(DeviceCollection.fromJson(event.getData()));
151 } catch (MieleSyntaxException e) {
152 logger.warn("SSE payload is not valid Json: {}", event.getData());
156 private void fireConnectionAlive() {
157 connectionStatusListeners.forEach(ConnectionStatusListener::onConnectionAlive);
161 public void onConnectionError(ConnectionError connectionError, int failedReconnectAttempts) {
162 connectionStatusListeners.forEach(l -> l.onConnectionError(connectionError, failedReconnectAttempts));
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);
172 logger.warn("Cannot poll action state. Response is missing actions.");
177 public void putProcessAction(String deviceId, ProcessAction processAction) {
178 if (processAction.equals(ProcessAction.UNKNOWN)) {
179 throw new IllegalArgumentException("Process action must not be UNKNOWN.");
182 String formattedProcessAction = GSON.toJson(processAction, ProcessAction.class);
183 formattedProcessAction = formattedProcessAction.substring(1, formattedProcessAction.length() - 1);
184 String json = "{\"processAction\":" + formattedProcessAction + "}";
186 logger.debug("Activate process action {} of Miele device {}", processAction.toString(), deviceId);
187 putActions(deviceId, json);
191 public void putLight(String deviceId, boolean enabled) {
192 Light light = enabled ? Light.ENABLE : Light.DISABLE;
193 String json = "{\"light\":" + light.format() + "}";
195 logger.debug("Set light of Miele device {} to {}", deviceId, enabled);
196 putActions(deviceId, json);
200 public void putPowerState(String deviceId, boolean enabled) {
201 String action = enabled ? "powerOn" : "powerOff";
202 String json = "{\"" + action + "\":true}";
204 logger.debug("Set power state of Miele device {} to {}", deviceId, action);
205 putActions(deviceId, json);
209 public void putProgram(String deviceId, long programId) {
210 String json = "{\"programId\":" + programId + "}";
212 logger.debug("Activate program with ID {} of Miele device {}", programId, deviceId);
213 putActions(deviceId, json);
217 public void logout() {
218 Optional<String> accessToken = this.accessToken;
219 if (!accessToken.isPresent()) {
220 logger.debug("No access token present.");
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());
235 * Sends the given request and wraps the possible exceptions in Miele exception types.
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.
242 private ContentResponse sendRequest(Request request) {
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"));
250 ContentResponse response = request.send();
251 logger.debug("Received response with status code {}", response.getStatus());
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);
265 * Gets all available device actions.
267 * @param deviceId The unique device ID.
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.
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);
282 logger.debug("Fetch action state description for Miele device {}", deviceId);
283 Request request = requestFactory.createGetRequest(String.format(ENDPOINT_ACTIONS, deviceId),
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);
293 } catch (JsonSyntaxException e) {
294 throw new MieleWebserviceTransientException("Failed to parse response message.", e,
295 ConnectionError.RESPONSE_MALFORMED);
300 * Performs a PUT request to the actions endpoint for the specified device.
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.
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);
317 Request request = requestFactory.createPutRequest(String.format(ENDPOINT_ACTIONS, deviceId),
318 accessToken.get(), json);
319 ContentResponse response = sendRequest(request);
320 HttpUtil.checkHttpSuccess(response);
322 logger.warn("Failed to perform PUT request: {}. Retrying...", e.getMessage());
327 public void dispatchDeviceState(String deviceIdentifier) {
328 deviceStateDispatcher.dispatchDeviceState(deviceIdentifier);
332 public void addDeviceStateListener(DeviceStateListener listener) {
333 deviceStateDispatcher.addListener(listener);
337 public void removeDeviceStateListener(DeviceStateListener listener) {
338 deviceStateDispatcher.removeListener(listener);
342 public void addConnectionStatusListener(ConnectionStatusListener listener) {
343 connectionStatusListeners.add(listener);
347 public void removeConnectionStatusListener(ConnectionStatusListener listener) {
348 connectionStatusListeners.remove(listener);
352 public void close() throws Exception {
353 requestFactory.close();