2 * Copyright (c) 2010-2024 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.IOException;
16 import java.io.InputStreamReader;
17 import java.io.Reader;
18 import java.lang.reflect.Type;
19 import java.nio.file.Files;
20 import java.nio.file.Path;
21 import java.nio.file.Paths;
22 import java.util.ArrayList;
23 import java.util.Collections;
24 import java.util.HashMap;
25 import java.util.List;
27 import java.util.Objects;
28 import java.util.Optional;
29 import java.util.concurrent.ExecutionException;
30 import java.util.concurrent.TimeUnit;
31 import java.util.concurrent.TimeoutException;
33 import javax.xml.parsers.ParserConfigurationException;
34 import javax.xml.transform.TransformerException;
36 import org.eclipse.jdt.annotation.NonNullByDefault;
37 import org.eclipse.jdt.annotation.Nullable;
38 import org.eclipse.jetty.client.HttpClient;
39 import org.eclipse.jetty.client.api.ContentResponse;
40 import org.eclipse.jetty.client.api.Request;
41 import org.eclipse.jetty.client.util.StringContentProvider;
42 import org.eclipse.jetty.http.HttpHeader;
43 import org.eclipse.jetty.http.HttpMethod;
44 import org.eclipse.jetty.http.HttpStatus;
45 import org.openhab.binding.ecovacs.internal.api.EcovacsApi;
46 import org.openhab.binding.ecovacs.internal.api.EcovacsApiConfiguration;
47 import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
48 import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
49 import org.openhab.binding.ecovacs.internal.api.commands.IotDeviceCommand;
50 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalAuthRequest;
51 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalAuthRequestParameter;
52 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalCleanLogsRequest;
53 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalIotCommandRequest;
54 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalIotProductRequest;
55 import org.openhab.binding.ecovacs.internal.api.impl.dto.request.portal.PortalLoginRequest;
56 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.AccessData;
57 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.AuthCode;
58 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.main.ResponseWrapper;
59 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.AbstractPortalIotCommandResponse;
60 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.Device;
61 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.IotProduct;
62 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalCleanLogsResponse;
63 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalDeviceResponse;
64 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandJsonResponse;
65 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandXmlResponse;
66 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotProductResponse;
67 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalLoginResponse;
68 import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
69 import org.openhab.binding.ecovacs.internal.api.util.MD5Util;
70 import org.openhab.core.OpenHAB;
71 import org.slf4j.Logger;
72 import org.slf4j.LoggerFactory;
74 import com.google.gson.Gson;
75 import com.google.gson.JsonSyntaxException;
76 import com.google.gson.reflect.TypeToken;
77 import com.google.gson.stream.JsonReader;
80 * @author Danny Baumann - Initial contribution
81 * @author Johannes Ptaszyk - Initial contribution
84 public final class EcovacsApiImpl implements EcovacsApi {
85 private final Logger logger = LoggerFactory.getLogger(EcovacsApiImpl.class);
87 private final HttpClient httpClient;
88 private final Gson gson = new Gson();
90 private final EcovacsApiConfiguration configuration;
91 private @Nullable PortalLoginResponse loginData;
93 public EcovacsApiImpl(HttpClient httpClient, EcovacsApiConfiguration configuration) {
94 this.httpClient = httpClient;
95 this.configuration = configuration;
99 public void loginAndGetAccessToken() throws EcovacsApiException, InterruptedException {
102 AccessData accessData = login();
103 AuthCode authCode = getAuthCode(accessData);
104 loginData = portalLogin(authCode, accessData);
107 EcovacsApiConfiguration getConfig() {
108 return configuration;
112 PortalLoginResponse getLoginData() {
116 private AccessData login() throws EcovacsApiException, InterruptedException {
117 HashMap<String, String> loginParameters = new HashMap<>();
118 loginParameters.put("account", configuration.getUsername());
119 loginParameters.put("password", MD5Util.getMD5Hash(configuration.getPassword()));
120 loginParameters.put("requestId", MD5Util.getMD5Hash(String.valueOf(System.currentTimeMillis())));
121 loginParameters.put("authTimeZone", configuration.getTimeZone());
122 loginParameters.put("country", configuration.getCountry());
123 loginParameters.put("lang", configuration.getLanguage());
124 loginParameters.put("deviceId", configuration.getDeviceId());
125 loginParameters.put("appCode", configuration.getAppCode());
126 loginParameters.put("appVersion", configuration.getAppVersion());
127 loginParameters.put("channel", configuration.getChannel());
128 loginParameters.put("deviceType", configuration.getDeviceType());
130 Request loginRequest = createAuthRequest(EcovacsApiUrlFactory.getLoginUrl(configuration),
131 configuration.getClientKey(), configuration.getClientSecret(), loginParameters);
132 ContentResponse loginResponse = executeRequest(loginRequest);
133 Type responseType = new TypeToken<ResponseWrapper<AccessData>>() {
135 return handleResponseWrapper(gson.fromJson(loginResponse.getContentAsString(), responseType));
138 private AuthCode getAuthCode(AccessData accessData) throws EcovacsApiException, InterruptedException {
139 HashMap<String, String> authCodeParameters = new HashMap<>();
140 authCodeParameters.put("uid", accessData.getUid());
141 authCodeParameters.put("accessToken", accessData.getAccessToken());
142 authCodeParameters.put("bizType", configuration.getBizType());
143 authCodeParameters.put("deviceId", configuration.getDeviceId());
144 authCodeParameters.put("openId", configuration.getAuthOpenId());
146 Request authCodeRequest = createAuthRequest(EcovacsApiUrlFactory.getAuthUrl(configuration),
147 configuration.getAuthClientKey(), configuration.getAuthClientSecret(), authCodeParameters);
148 ContentResponse authCodeResponse = executeRequest(authCodeRequest);
149 Type responseType = new TypeToken<ResponseWrapper<AuthCode>>() {
151 return handleResponseWrapper(gson.fromJson(authCodeResponse.getContentAsString(), responseType));
154 private PortalLoginResponse portalLogin(AuthCode authCode, AccessData accessData)
155 throws EcovacsApiException, InterruptedException {
156 PortalLoginRequest loginRequestData = new PortalLoginRequest(PortalTodo.LOGIN_BY_TOKEN,
157 configuration.getCountry().toUpperCase(), "", configuration.getOrg(), configuration.getResource(),
158 configuration.getRealm(), authCode.getAuthCode(), accessData.getUid(), configuration.getEdition());
159 String userUrl = EcovacsApiUrlFactory.getPortalUsersUrl(configuration);
160 ContentResponse portalLoginResponse = executeRequest(createJsonRequest(userUrl, loginRequestData));
161 PortalLoginResponse response = handleResponse(portalLoginResponse, PortalLoginResponse.class);
162 if (!response.wasSuccessful()) {
163 throw new EcovacsApiException("Login failed");
169 public List<EcovacsDevice> getDevices() throws EcovacsApiException, InterruptedException {
170 Map<String, DeviceDescription> descriptions = getSupportedDeviceDescs();
171 List<IotProduct> products = null;
172 List<EcovacsDevice> devices = new ArrayList<>();
173 for (Device dev : getDeviceList()) {
174 Optional<DeviceDescription> descOpt = Optional.ofNullable(descriptions.get(dev.getDeviceClass()));
175 if (descOpt.isEmpty()) {
176 if (products == null) {
177 products = getIotProductMap();
179 String modelName = products.stream().filter(prod -> dev.getDeviceClass().equals(prod.getClassId()))
180 .findFirst().map(p -> p.getDefinition().name).orElse("UNKNOWN");
181 logger.info("Found unsupported device {} (class {}, company {}), ignoring.", modelName,
182 dev.getDeviceClass(), dev.getCompany());
185 DeviceDescription desc = descOpt.get();
187 devices.add(new EcovacsIotMqDevice(dev, desc, this, gson));
189 devices.add(new EcovacsXmppDevice(dev, desc, this, gson));
195 // maps device class -> device description
196 private Map<String, DeviceDescription> getSupportedDeviceDescs() {
197 Map<String, DeviceDescription> descs = new HashMap<>();
198 ClassLoader cl = Objects.requireNonNull(getClass().getClassLoader());
199 try (Reader reader = new InputStreamReader(cl.getResourceAsStream("devices/supported_device_list.json"))) {
200 for (DeviceDescription desc : loadSupportedDeviceData(reader)) {
201 descs.put(desc.deviceClass, desc);
203 logger.trace("Loaded {} built-in device descriptions", descs.size());
204 } catch (IOException | JsonSyntaxException e) {
205 logger.warn("Failed loading built-in device descriptions", e);
208 Path customDescsPath = Paths.get(OpenHAB.getUserDataFolder(), "ecovacs").resolve("custom_device_descs.json");
209 if (Files.exists(customDescsPath)) {
210 try (Reader reader = Files.newBufferedReader(customDescsPath)) {
211 int builtins = descs.size();
212 for (DeviceDescription desc : loadSupportedDeviceData(reader)) {
213 DeviceDescription builtinDesc = descs.put(desc.deviceClass, desc);
214 if (builtinDesc != null) {
215 logger.trace("Overriding built-in description for {} with custom description",
219 logger.trace("Loaded {} custom device descriptions", descs.size() - builtins);
220 } catch (IOException | JsonSyntaxException e) {
221 logger.warn("Failed loading custom device descriptions from {}", customDescsPath, e);
225 descs.entrySet().forEach(descEntry -> {
226 DeviceDescription desc = descEntry.getValue();
227 if (desc.deviceClassLink != null) {
228 Optional<DeviceDescription> linkedDescOpt = Optional.ofNullable(descs.get(desc.deviceClassLink));
229 if (linkedDescOpt.isEmpty()) {
230 logger.warn("Device description {} links unknown description {}", desc.deviceClass,
231 desc.deviceClassLink);
233 desc = desc.resolveLinkWith(linkedDescOpt.get());
234 descEntry.setValue(desc);
236 desc.addImplicitCapabilities();
242 private List<DeviceDescription> loadSupportedDeviceData(Reader input) throws IOException {
243 JsonReader reader = new JsonReader(input);
244 Type type = new TypeToken<List<DeviceDescription>>() {
246 return gson.fromJson(reader, type);
249 private List<Device> getDeviceList() throws EcovacsApiException, InterruptedException {
250 PortalAuthRequest data = new PortalAuthRequest(PortalTodo.GET_DEVICE_LIST, createAuthData());
251 String userUrl = EcovacsApiUrlFactory.getPortalUsersUrl(configuration);
252 ContentResponse deviceResponse = executeRequest(createJsonRequest(userUrl, data));
253 logger.trace("Got device list response {}", deviceResponse.getContentAsString());
254 List<Device> devices = handleResponse(deviceResponse, PortalDeviceResponse.class).getDevices();
255 return devices != null ? devices : Collections.emptyList();
258 private List<IotProduct> getIotProductMap() throws EcovacsApiException, InterruptedException {
259 PortalIotProductRequest data = new PortalIotProductRequest(createAuthData());
260 String url = EcovacsApiUrlFactory.getPortalProductIotMapUrl(configuration);
261 ContentResponse productResponse = executeRequest(createJsonRequest(url, data));
262 logger.trace("Got product list response {}", productResponse.getContentAsString());
263 List<IotProduct> products = handleResponse(productResponse, PortalIotProductResponse.class).getProducts();
264 return products != null ? products : Collections.emptyList();
267 public <T> T sendIotCommand(Device device, DeviceDescription desc, IotDeviceCommand<T> command)
268 throws EcovacsApiException, InterruptedException {
269 String commandName = command.getName(desc.protoVersion);
270 final Object payload;
272 if (desc.protoVersion == ProtocolVersion.XML) {
273 payload = command.getXmlPayload(null);
274 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName, payload);
276 payload = command.getJsonPayload(desc.protoVersion, gson);
277 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName,
278 gson.toJson(payload));
280 } catch (ParserConfigurationException | TransformerException e) {
281 logger.debug("Could not convert payload for {}", command, e);
282 throw new EcovacsApiException(e);
285 PortalIotCommandRequest data = new PortalIotCommandRequest(createAuthData(), commandName, payload,
286 device.getDid(), device.getResource(), device.getDeviceClass(),
287 desc.protoVersion != ProtocolVersion.XML);
288 String url = EcovacsApiUrlFactory.getPortalIotDeviceManagerUrl(configuration);
289 ContentResponse response = executeRequest(createJsonRequest(url, data));
291 final AbstractPortalIotCommandResponse commandResponse;
292 if (desc.protoVersion == ProtocolVersion.XML) {
293 commandResponse = handleResponse(response, PortalIotCommandXmlResponse.class);
294 logger.trace("{}: Got response payload {}", device.getName(),
295 ((PortalIotCommandXmlResponse) commandResponse).getResponsePayloadXml());
297 commandResponse = handleResponse(response, PortalIotCommandJsonResponse.class);
298 logger.trace("{}: Got response payload {}", device.getName(),
299 ((PortalIotCommandJsonResponse) commandResponse).response);
301 if (!commandResponse.wasSuccessful()) {
302 final String msg = "Sending IOT command " + commandName + " failed: " + commandResponse.getErrorMessage();
303 throw new EcovacsApiException(msg, commandResponse.failedDueToAuthProblem());
306 return command.convertResponse(commandResponse, desc.protoVersion, gson);
307 } catch (DataParsingException e) {
308 logger.debug("Converting response for command {} failed", command, e);
309 throw new EcovacsApiException(e);
313 public List<PortalCleanLogsResponse.LogRecord> fetchCleanLogs(Device device)
314 throws EcovacsApiException, InterruptedException {
315 PortalCleanLogsRequest data = new PortalCleanLogsRequest(createAuthData(), device.getDid(),
316 device.getResource());
317 String url = EcovacsApiUrlFactory.getPortalLogUrl(configuration);
318 ContentResponse response = executeRequest(createJsonRequest(url, data));
319 PortalCleanLogsResponse responseObj = handleResponse(response, PortalCleanLogsResponse.class);
320 if (!responseObj.wasSuccessful()) {
321 throw new EcovacsApiException("Fetching clean logs failed");
323 logger.trace("{}: Fetching cleaning logs yields {} records", device.getName(), responseObj.records.size());
324 return responseObj.records;
327 private PortalAuthRequestParameter createAuthData() {
328 PortalLoginResponse loginData = this.loginData;
329 if (loginData == null) {
330 throw new IllegalStateException("Not logged in");
332 return new PortalAuthRequestParameter(configuration.getPortalAUthRequestWith(), loginData.getUserId(),
333 configuration.getRealm(), loginData.getToken(), configuration.getResource());
336 private <T> T handleResponseWrapper(@Nullable ResponseWrapper<T> response) throws EcovacsApiException {
337 if (response == null) {
338 // should not happen in practice
339 throw new EcovacsApiException("No response received");
341 if (!response.isSuccess()) {
342 throw new EcovacsApiException("API call failed: " + response.getMessage() + ", code " + response.getCode());
344 return response.getData();
347 private <T> T handleResponse(ContentResponse response, Class<T> clazz) throws EcovacsApiException {
349 T respObject = gson.fromJson(response.getContentAsString(), clazz);
350 if (respObject == null) {
351 // should not happen in practice
352 throw new EcovacsApiException("No response received");
357 private Request createAuthRequest(String url, String clientKey, String clientSecret,
358 Map<String, String> requestSpecificParameters) {
359 HashMap<String, String> signedRequestParameters = new HashMap<>(requestSpecificParameters);
360 signedRequestParameters.put("authTimespan", String.valueOf(System.currentTimeMillis()));
362 StringBuilder signOnText = new StringBuilder(clientKey);
363 signedRequestParameters.keySet().stream().sorted().forEach(key -> {
364 signOnText.append(key).append("=").append(signedRequestParameters.get(key));
366 signOnText.append(clientSecret);
368 signedRequestParameters.put("authAppkey", clientKey);
369 signedRequestParameters.put("authSign", MD5Util.getMD5Hash(signOnText.toString()));
371 Request request = httpClient.newRequest(url).method(HttpMethod.GET);
372 signedRequestParameters.forEach(request::param);
377 private Request createJsonRequest(String url, Object data) {
378 return httpClient.newRequest(url).method(HttpMethod.POST).header(HttpHeader.CONTENT_TYPE, "application/json")
379 .content(new StringContentProvider(gson.toJson(data)));
382 private ContentResponse executeRequest(Request request) throws EcovacsApiException, InterruptedException {
383 request.timeout(10, TimeUnit.SECONDS);
385 ContentResponse response = request.send();
386 if (response.getStatus() != HttpStatus.OK_200) {
387 throw new EcovacsApiException(response);
390 } catch (TimeoutException | ExecutionException e) {
391 throw new EcovacsApiException(e);