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.ecovacs.internal.api.impl;
15 import java.io.InputStream;
16 import java.io.InputStreamReader;
17 import java.lang.reflect.Type;
18 import java.util.ArrayList;
19 import java.util.Collections;
20 import java.util.HashMap;
21 import java.util.List;
23 import java.util.Objects;
24 import java.util.Optional;
25 import java.util.concurrent.ExecutionException;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
28 import java.util.stream.Collectors;
30 import javax.xml.parsers.ParserConfigurationException;
31 import javax.xml.transform.TransformerException;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.eclipse.jetty.client.HttpClient;
36 import org.eclipse.jetty.client.api.ContentResponse;
37 import org.eclipse.jetty.client.api.Request;
38 import org.eclipse.jetty.client.util.StringContentProvider;
39 import org.eclipse.jetty.http.HttpHeader;
40 import org.eclipse.jetty.http.HttpMethod;
41 import org.eclipse.jetty.http.HttpStatus;
42 import org.openhab.binding.ecovacs.internal.api.EcovacsApi;
43 import org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration;
44 import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
45 import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
46 import org.openhab.binding.ecovacs.internal.api.commands.IotDeviceCommand;
47 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalAuthRequest;
48 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalAuthRequestParameter;
49 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalCleanLogsRequest;
50 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalIotCommandRequest;
51 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalIotProductRequest;
52 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalLoginRequest;
53 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.AccessData;
54 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.AuthCode;
55 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.ResponseWrapper;
56 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.AbstractPortalIotCommandResponse;
57 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.Device;
58 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.IotProduct;
59 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogsResponse;
60 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalDeviceResponse;
61 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandJsonResponse;
62 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandXmlResponse;
63 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotProductResponse;
64 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalLoginResponse;
65 import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
66 import org.openhab.binding.ecovacs.internal.api.util.MD5Util;
67 import org.slf4j.Logger;
68 import org.slf4j.LoggerFactory;
70 import com.google.gson.Gson;
71 import com.google.gson.reflect.TypeToken;
72 import com.google.gson.stream.JsonReader;
75 * @author Danny Baumann - Initial contribution
76 * @author Johannes Ptaszyk - Initial contribution
79 public final class EcovacsApiImpl implements EcovacsApi {
80 private final Logger logger = LoggerFactory.getLogger(EcovacsApiImpl.class);
82 private final HttpClient httpClient;
83 private final Gson gson = new Gson();
85 private final EcovacsApiConfiguration configuration;
86 private @Nullable PortalLoginResponse loginData;
88 public EcovacsApiImpl(HttpClient httpClient, EcovacsApiConfiguration configuration) {
89 this.httpClient = httpClient;
90 this.configuration = configuration;
94 public void loginAndGetAccessToken() throws EcovacsApiException, InterruptedException {
97 AccessData accessData = login();
98 AuthCode authCode = getAuthCode(accessData);
99 loginData = portalLogin(authCode, accessData);
102 EcovacsApiConfiguration getConfig() {
103 return configuration;
107 PortalLoginResponse getLoginData() {
111 private AccessData login() throws EcovacsApiException, InterruptedException {
112 HashMap<String, String> loginParameters = new HashMap<>();
113 loginParameters.put("account", configuration.getUsername());
114 loginParameters.put("password", MD5Util.getMD5Hash(configuration.getPassword()));
115 loginParameters.put("requestId", MD5Util.getMD5Hash(String.valueOf(System.currentTimeMillis())));
116 loginParameters.put("authTimeZone", configuration.getTimeZone());
117 loginParameters.put("country", configuration.getCountry());
118 loginParameters.put("lang", configuration.getLanguage());
119 loginParameters.put("deviceId", configuration.getDeviceId());
120 loginParameters.put("appCode", configuration.getAppCode());
121 loginParameters.put("appVersion", configuration.getAppVersion());
122 loginParameters.put("channel", configuration.getChannel());
123 loginParameters.put("deviceType", configuration.getDeviceType());
125 Request loginRequest = createAuthRequest(EcovacsApiUrlFactory.getLoginUrl(configuration),
126 configuration.getClientKey(), configuration.getClientSecret(), loginParameters);
127 ContentResponse loginResponse = executeRequest(loginRequest);
128 Type responseType = new TypeToken<ResponseWrapper<AccessData>>() {
130 return handleResponseWrapper(gson.fromJson(loginResponse.getContentAsString(), responseType));
133 private AuthCode getAuthCode(AccessData accessData) throws EcovacsApiException, InterruptedException {
134 HashMap<String, String> authCodeParameters = new HashMap<>();
135 authCodeParameters.put("uid", accessData.getUid());
136 authCodeParameters.put("accessToken", accessData.getAccessToken());
137 authCodeParameters.put("bizType", configuration.getBizType());
138 authCodeParameters.put("deviceId", configuration.getDeviceId());
139 authCodeParameters.put("openId", configuration.getAuthOpenId());
141 Request authCodeRequest = createAuthRequest(EcovacsApiUrlFactory.getAuthUrl(configuration),
142 configuration.getAuthClientKey(), configuration.getAuthClientSecret(), authCodeParameters);
143 ContentResponse authCodeResponse = executeRequest(authCodeRequest);
144 Type responseType = new TypeToken<ResponseWrapper<AuthCode>>() {
146 return handleResponseWrapper(gson.fromJson(authCodeResponse.getContentAsString(), responseType));
149 private PortalLoginResponse portalLogin(AuthCode authCode, AccessData accessData)
150 throws EcovacsApiException, InterruptedException {
151 PortalLoginRequest loginRequestData = new PortalLoginRequest(PortalTodo.LOGIN_BY_TOKEN,
152 configuration.getCountry().toUpperCase(), "", configuration.getOrg(), configuration.getResource(),
153 configuration.getRealm(), authCode.getAuthCode(), accessData.getUid(), configuration.getEdition());
154 String userUrl = EcovacsApiUrlFactory.getPortalUsersUrl(configuration);
155 ContentResponse portalLoginResponse = executeRequest(createJsonRequest(userUrl, loginRequestData));
156 PortalLoginResponse response = handleResponse(portalLoginResponse, PortalLoginResponse.class);
157 if (!response.wasSuccessful()) {
158 throw new EcovacsApiException("Login failed");
164 public List<EcovacsDevice> getDevices() throws EcovacsApiException, InterruptedException {
165 List<DeviceDescription> descriptions = getSupportedDeviceList();
166 List<IotProduct> products = null;
167 List<EcovacsDevice> devices = new ArrayList<>();
168 for (Device dev : getDeviceList()) {
169 Optional<DeviceDescription> descOpt = descriptions.stream()
170 .filter(d -> dev.getDeviceClass().equals(d.deviceClass)).findFirst();
171 if (!descOpt.isPresent()) {
172 if (products == null) {
173 products = getIotProductMap();
175 String modelName = products.stream().filter(prod -> dev.getDeviceClass().equals(prod.getClassId()))
176 .findFirst().map(p -> p.getDefinition().name).orElse("UNKNOWN");
177 logger.info("Found unsupported device {} (class {}, company {}), ignoring.", modelName,
178 dev.getDeviceClass(), dev.getCompany());
181 DeviceDescription desc = descOpt.get();
183 devices.add(new EcovacsIotMqDevice(dev, desc, this, gson));
185 devices.add(new EcovacsXmppDevice(dev, desc, this, gson));
191 private List<DeviceDescription> getSupportedDeviceList() {
192 ClassLoader cl = Objects.requireNonNull(getClass().getClassLoader());
193 InputStream is = cl.getResourceAsStream("devices/supported_device_list.json");
194 JsonReader reader = new JsonReader(new InputStreamReader(is));
195 Type type = new TypeToken<List<DeviceDescription>>() {
197 List<DeviceDescription> descs = gson.fromJson(reader, type);
198 return descs.stream().map(desc -> {
199 final DeviceDescription result;
200 if (desc.deviceClassLink != null) {
201 Optional<DeviceDescription> linkedDescOpt = descs.stream()
202 .filter(d -> d.deviceClass.equals(desc.deviceClassLink)).findFirst();
203 if (!linkedDescOpt.isPresent()) {
204 throw new IllegalStateException(
205 "Desc " + desc.deviceClass + " links unknown desc " + desc.deviceClassLink);
207 result = desc.resolveLinkWith(linkedDescOpt.get());
211 result.addImplicitCapabilities();
213 }).collect(Collectors.toList());
216 private List<Device> getDeviceList() throws EcovacsApiException, InterruptedException {
217 PortalAuthRequest data = new PortalAuthRequest(PortalTodo.GET_DEVICE_LIST, createAuthData());
218 String userUrl = EcovacsApiUrlFactory.getPortalUsersUrl(configuration);
219 ContentResponse deviceResponse = executeRequest(createJsonRequest(userUrl, data));
220 logger.trace("Got device list response {}", deviceResponse.getContentAsString());
221 List<Device> devices = handleResponse(deviceResponse, PortalDeviceResponse.class).getDevices();
222 return devices != null ? devices : Collections.emptyList();
225 private List<IotProduct> getIotProductMap() throws EcovacsApiException, InterruptedException {
226 PortalIotProductRequest data = new PortalIotProductRequest(createAuthData());
227 String url = EcovacsApiUrlFactory.getPortalProductIotMapUrl(configuration);
228 ContentResponse productResponse = executeRequest(createJsonRequest(url, data));
229 logger.trace("Got product list response {}", productResponse.getContentAsString());
230 List<IotProduct> products = handleResponse(productResponse, PortalIotProductResponse.class).getProducts();
231 return products != null ? products : Collections.emptyList();
234 public <T> T sendIotCommand(Device device, DeviceDescription desc, IotDeviceCommand<T> command)
235 throws EcovacsApiException, InterruptedException {
236 String commandName = command.getName(desc.protoVersion);
237 final Object payload;
239 if (desc.protoVersion == ProtocolVersion.XML) {
240 payload = command.getXmlPayload(null);
241 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName, payload);
243 payload = command.getJsonPayload(desc.protoVersion, gson);
244 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName,
245 gson.toJson(payload));
247 } catch (ParserConfigurationException | TransformerException e) {
248 logger.debug("Could not convert payload for {}", command, e);
249 throw new EcovacsApiException(e);
252 PortalIotCommandRequest data = new PortalIotCommandRequest(createAuthData(), commandName, payload,
253 device.getDid(), device.getResource(), device.getDeviceClass(),
254 desc.protoVersion != ProtocolVersion.XML);
255 String url = EcovacsApiUrlFactory.getPortalIotDeviceManagerUrl(configuration);
256 ContentResponse response = executeRequest(createJsonRequest(url, data));
258 final AbstractPortalIotCommandResponse commandResponse;
259 if (desc.protoVersion == ProtocolVersion.XML) {
260 commandResponse = handleResponse(response, PortalIotCommandXmlResponse.class);
261 logger.trace("{}: Got response payload {}", device.getName(),
262 ((PortalIotCommandXmlResponse) commandResponse).getResponsePayloadXml());
264 commandResponse = handleResponse(response, PortalIotCommandJsonResponse.class);
265 logger.trace("{}: Got response payload {}", device.getName(),
266 ((PortalIotCommandJsonResponse) commandResponse).response);
268 if (!commandResponse.wasSuccessful()) {
269 final String msg = "Sending IOT command " + commandName + " failed: " + commandResponse.getErrorMessage();
270 throw new EcovacsApiException(msg, commandResponse.failedDueToAuthProblem());
273 return command.convertResponse(commandResponse, desc.protoVersion, gson);
274 } catch (DataParsingException e) {
275 logger.debug("Converting response for command {} failed", command, e);
276 throw new EcovacsApiException(e);
280 public List<PortalCleanLogsResponse.LogRecord> fetchCleanLogs(Device device)
281 throws EcovacsApiException, InterruptedException {
282 PortalCleanLogsRequest data = new PortalCleanLogsRequest(createAuthData(), device.getDid(),
283 device.getResource());
284 String url = EcovacsApiUrlFactory.getPortalLogUrl(configuration);
285 ContentResponse response = executeRequest(createJsonRequest(url, data));
286 PortalCleanLogsResponse responseObj = handleResponse(response, PortalCleanLogsResponse.class);
287 if (!responseObj.wasSuccessful()) {
288 throw new EcovacsApiException("Fetching clean logs failed");
290 logger.trace("{}: Fetching cleaning logs yields {} records", device.getName(), responseObj.records.size());
291 return responseObj.records;
294 private PortalAuthRequestParameter createAuthData() {
295 PortalLoginResponse loginData = this.loginData;
296 if (loginData == null) {
297 throw new IllegalStateException("Not logged in");
299 return new PortalAuthRequestParameter(configuration.getPortalAUthRequestWith(), loginData.getUserId(),
300 configuration.getRealm(), loginData.getToken(), configuration.getResource());
303 private <T> T handleResponseWrapper(@Nullable ResponseWrapper<T> response) throws EcovacsApiException {
304 if (response == null) {
305 // should not happen in practice
306 throw new EcovacsApiException("No response received");
308 if (!response.isSuccess()) {
309 throw new EcovacsApiException("API call failed: " + response.getMessage() + ", code " + response.getCode());
311 return response.getData();
314 private <T> T handleResponse(ContentResponse response, Class<T> clazz) throws EcovacsApiException {
316 T respObject = gson.fromJson(response.getContentAsString(), clazz);
317 if (respObject == null) {
318 // should not happen in practice
319 throw new EcovacsApiException("No response received");
324 private Request createAuthRequest(String url, String clientKey, String clientSecret,
325 Map<String, String> requestSpecificParameters) {
326 HashMap<String, String> signedRequestParameters = new HashMap<>(requestSpecificParameters);
327 signedRequestParameters.put("authTimespan", String.valueOf(System.currentTimeMillis()));
329 StringBuilder signOnText = new StringBuilder(clientKey);
330 signedRequestParameters.keySet().stream().sorted().forEach(key -> {
331 signOnText.append(key).append("=").append(signedRequestParameters.get(key));
333 signOnText.append(clientSecret);
335 signedRequestParameters.put("authAppkey", clientKey);
336 signedRequestParameters.put("authSign", MD5Util.getMD5Hash(signOnText.toString()));
338 Request request = httpClient.newRequest(url).method(HttpMethod.GET);
339 signedRequestParameters.forEach(request::param);
344 private Request createJsonRequest(String url, Object data) {
345 return httpClient.newRequest(url).method(HttpMethod.POST).header(HttpHeader.CONTENT_TYPE, "application/json")
346 .content(new StringContentProvider(gson.toJson(data)));
349 private ContentResponse executeRequest(Request request) throws EcovacsApiException, InterruptedException {
350 request.timeout(10, TimeUnit.SECONDS);
352 ContentResponse response = request.send();
353 if (response.getStatus() != HttpStatus.OK_200) {
354 throw new EcovacsApiException(response);
357 } catch (TimeoutException | ExecutionException e) {
358 throw new EcovacsApiException(e);