2 * Copyright (c) 2010-2023 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.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;
50 import com.google.gson.Gson;
51 import com.google.gson.JsonSyntaxException;
54 * Default implementation of the {@link MieleWebservice}.
56 * @author Björn Lange - Initial contribution
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";
67 private static final String SSE_EVENT_TYPE_DEVICES = "devices";
68 public static final String SSE_EVENT_TYPE_ACTIONS = "actions";
70 private static final Gson GSON = new Gson();
72 private final Logger logger = LoggerFactory.getLogger(DefaultMieleWebservice.class);
74 private Optional<String> accessToken = Optional.empty();
75 private final RequestFactory requestFactory;
77 private final DeviceStateDispatcher deviceStateDispatcher;
78 private final List<ConnectionStatusListener> connectionStatusListeners = new ArrayList<>();
80 private final RetryStrategy retryStrategy;
82 private final SseConnection sseConnection;
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.
89 * @param configuration The configuration holding all parameters for constructing the instance.
90 * @throws MieleWebserviceInitializationException if initializing the HTTP client fails.
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());
101 * This constructor only exists for testing.
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);
113 public void setAccessToken(String accessToken) {
114 this.accessToken = Optional.of(accessToken);
118 public boolean hasAccessToken() {
119 return accessToken.isPresent();
123 public synchronized void connectSse() {
124 sseConnection.connect();
128 public synchronized void disconnectSse() {
129 sseConnection.disconnect();
133 private Request createSseRequest(String endpoint) {
134 Optional<String> accessToken = this.accessToken;
135 if (accessToken.isEmpty()) {
136 logger.warn("No access token present.");
140 return requestFactory.createSseRequest(endpoint, accessToken.get());
144 public void onServerSentEvent(ServerSentEvent event) {
145 fireConnectionAlive();
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()) {
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,
164 onConnectionError(ConnectionError.AUTHORIZATION_FAILED, 0);
166 } catch (TooManyRequestsException e) {
167 logger.warn("Failed to fetch action state for device {}: {}", deviceIdentifier,
174 case SSE_EVENT_TYPE_DEVICES:
175 deviceStateDispatcher.dispatchDeviceStateUpdates(DeviceCollection.fromJson(event.getData()));
178 } catch (MieleSyntaxException e) {
179 logger.warn("SSE payload is not valid Json: {}", event.getData());
183 private void fireConnectionAlive() {
184 connectionStatusListeners.forEach(ConnectionStatusListener::onConnectionAlive);
188 public void onConnectionError(ConnectionError connectionError, int failedReconnectAttempts) {
189 connectionStatusListeners.forEach(l -> l.onConnectionError(connectionError, failedReconnectAttempts));
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);
199 logger.warn("Cannot poll action state. Response is missing actions.");
204 public void putProcessAction(String deviceId, ProcessAction processAction) {
205 if (processAction.equals(ProcessAction.UNKNOWN)) {
206 throw new IllegalArgumentException("Process action must not be UNKNOWN.");
209 String formattedProcessAction = GSON.toJson(processAction, ProcessAction.class);
210 formattedProcessAction = formattedProcessAction.substring(1, formattedProcessAction.length() - 1);
211 String json = "{\"processAction\":" + formattedProcessAction + "}";
213 logger.debug("Activate process action {} of Miele device {}", processAction.toString(), deviceId);
214 putActions(deviceId, json);
218 public void putLight(String deviceId, boolean enabled) {
219 Light light = enabled ? Light.ENABLE : Light.DISABLE;
220 String json = "{\"light\":" + light.format() + "}";
222 logger.debug("Set light of Miele device {} to {}", deviceId, enabled);
223 putActions(deviceId, json);
227 public void putPowerState(String deviceId, boolean enabled) {
228 String action = enabled ? "powerOn" : "powerOff";
229 String json = "{\"" + action + "\":true}";
231 logger.debug("Set power state of Miele device {} to {}", deviceId, action);
232 putActions(deviceId, json);
236 public void putProgram(String deviceId, long programId) {
237 String json = "{\"programId\":" + programId + "}";
239 logger.debug("Activate program with ID {} of Miele device {}", programId, deviceId);
240 putActions(deviceId, json);
244 public void logout() {
245 Optional<String> accessToken = this.accessToken;
246 if (accessToken.isEmpty()) {
247 logger.debug("No access token present.");
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());
262 * Sends the given request and wraps the possible exceptions in Miele exception types.
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.
269 private ContentResponse sendRequest(Request request) {
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"));
277 ContentResponse response = request.send();
278 logger.debug("Received response with status code {}", response.getStatus());
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);
292 * Gets all available device actions.
294 * @param deviceId The unique device ID.
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.
302 private Actions getActions(String deviceId) {
303 Optional<String> accessToken = this.accessToken;
304 if (accessToken.isEmpty()) {
305 throw new MieleWebserviceException("Missing access token.", ConnectionError.AUTHORIZATION_FAILED);
309 logger.debug("Fetch action state description for Miele device {}", deviceId);
310 Request request = requestFactory.createGetRequest(String.format(ENDPOINT_ACTIONS, deviceId),
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);
320 } catch (JsonSyntaxException e) {
321 throw new MieleWebserviceTransientException("Failed to parse response message.", e,
322 ConnectionError.RESPONSE_MALFORMED);
327 * Performs a PUT request to the actions endpoint for the specified device.
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.
337 private void putActions(String deviceId, String json) {
338 retryStrategy.performRetryableOperation(() -> {
339 Optional<String> accessToken = this.accessToken;
340 if (accessToken.isEmpty()) {
341 throw new MieleWebserviceException("Missing access token.", ConnectionError.AUTHORIZATION_FAILED);
344 Request request = requestFactory.createPutRequest(String.format(ENDPOINT_ACTIONS, deviceId),
345 accessToken.get(), json);
346 ContentResponse response = sendRequest(request);
347 HttpUtil.checkHttpSuccess(response);
349 logger.warn("Failed to perform PUT request: {}. Retrying...", e.getMessage());
354 public void dispatchDeviceState(String deviceIdentifier) {
355 deviceStateDispatcher.dispatchDeviceState(deviceIdentifier);
359 public void addDeviceStateListener(DeviceStateListener listener) {
360 deviceStateDispatcher.addListener(listener);
364 public void removeDeviceStateListener(DeviceStateListener listener) {
365 deviceStateDispatcher.removeListener(listener);
369 public void addConnectionStatusListener(ConnectionStatusListener listener) {
370 connectionStatusListeners.add(listener);
374 public void removeConnectionStatusListener(ConnectionStatusListener listener) {
375 connectionStatusListeners.remove(listener);
379 public void close() throws Exception {
380 requestFactory.close();