]> git.basschouten.com Git - openhab-addons.git/blob
a9f4d65f593fab1b535abaa49ccaf1a1b9a595c9
[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.ecovacs.internal.api.impl;
14
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;
26 import java.util.Map;
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;
32
33 import javax.xml.parsers.ParserConfigurationException;
34 import javax.xml.transform.TransformerException;
35
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;
73
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;
78
79 /**
80  * @author Danny Baumann - Initial contribution
81  * @author Johannes Ptaszyk - Initial contribution
82  */
83 @NonNullByDefault
84 public final class EcovacsApiImpl implements EcovacsApi {
85     private final Logger logger = LoggerFactory.getLogger(EcovacsApiImpl.class);
86
87     private final HttpClient httpClient;
88     private final Gson gson = new Gson();
89
90     private final EcovacsApiConfiguration configuration;
91     private @Nullable PortalLoginResponse loginData;
92
93     public EcovacsApiImpl(HttpClient httpClient, EcovacsApiConfiguration configuration) {
94         this.httpClient = httpClient;
95         this.configuration = configuration;
96     }
97
98     @Override
99     public void loginAndGetAccessToken() throws EcovacsApiException, InterruptedException {
100         loginData = null;
101
102         AccessData accessData = login();
103         AuthCode authCode = getAuthCode(accessData);
104         loginData = portalLogin(authCode, accessData);
105     }
106
107     EcovacsApiConfiguration getConfig() {
108         return configuration;
109     }
110
111     @Nullable
112     PortalLoginResponse getLoginData() {
113         return loginData;
114     }
115
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());
129
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>>() {
134         }.getType();
135         return handleResponseWrapper(gson.fromJson(loginResponse.getContentAsString(), responseType));
136     }
137
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());
145
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>>() {
150         }.getType();
151         return handleResponseWrapper(gson.fromJson(authCodeResponse.getContentAsString(), responseType));
152     }
153
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");
164         }
165         return response;
166     }
167
168     @Override
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();
178                 }
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());
183                 continue;
184             }
185             DeviceDescription desc = descOpt.get();
186             if (desc.usesMqtt) {
187                 devices.add(new EcovacsIotMqDevice(dev, desc, this, gson));
188             } else {
189                 devices.add(new EcovacsXmppDevice(dev, desc, this, gson));
190             }
191         }
192         return devices;
193     }
194
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);
202             }
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);
206         }
207
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",
216                                 desc.deviceClass);
217                     }
218                 }
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);
222             }
223         }
224
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);
232                 }
233                 desc = desc.resolveLinkWith(linkedDescOpt.get());
234                 descEntry.setValue(desc);
235             }
236             desc.addImplicitCapabilities();
237         });
238
239         return descs;
240     }
241
242     private List<DeviceDescription> loadSupportedDeviceData(Reader input) throws IOException {
243         JsonReader reader = new JsonReader(input);
244         Type type = new TypeToken<List<DeviceDescription>>() {
245         }.getType();
246         return gson.fromJson(reader, type);
247     }
248
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();
256     }
257
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();
265     }
266
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;
271         try {
272             if (desc.protoVersion == ProtocolVersion.XML) {
273                 payload = command.getXmlPayload(null);
274                 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName, payload);
275             } else {
276                 payload = command.getJsonPayload(desc.protoVersion, gson);
277                 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName,
278                         gson.toJson(payload));
279             }
280         } catch (ParserConfigurationException | TransformerException e) {
281             logger.debug("Could not convert payload for {}", command, e);
282             throw new EcovacsApiException(e);
283         }
284
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));
290
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());
296         } else {
297             commandResponse = handleResponse(response, PortalIotCommandJsonResponse.class);
298             logger.trace("{}: Got response payload {}", device.getName(),
299                     ((PortalIotCommandJsonResponse) commandResponse).response);
300         }
301         if (!commandResponse.wasSuccessful()) {
302             final String msg = "Sending IOT command " + commandName + " failed: " + commandResponse.getErrorMessage();
303             throw new EcovacsApiException(msg, commandResponse.failedDueToAuthProblem());
304         }
305         try {
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);
310         }
311     }
312
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");
322         }
323         logger.trace("{}: Fetching cleaning logs yields {} records", device.getName(), responseObj.records.size());
324         return responseObj.records;
325     }
326
327     private PortalAuthRequestParameter createAuthData() {
328         PortalLoginResponse loginData = this.loginData;
329         if (loginData == null) {
330             throw new IllegalStateException("Not logged in");
331         }
332         return new PortalAuthRequestParameter(configuration.getPortalAUthRequestWith(), loginData.getUserId(),
333                 configuration.getRealm(), loginData.getToken(), configuration.getResource());
334     }
335
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");
340         }
341         if (!response.isSuccess()) {
342             throw new EcovacsApiException("API call failed: " + response.getMessage() + ", code " + response.getCode());
343         }
344         return response.getData();
345     }
346
347     private <T> T handleResponse(ContentResponse response, Class<T> clazz) throws EcovacsApiException {
348         @Nullable
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");
353         }
354         return respObject;
355     }
356
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()));
361
362         StringBuilder signOnText = new StringBuilder(clientKey);
363         signedRequestParameters.keySet().stream().sorted().forEach(key -> {
364             signOnText.append(key).append("=").append(signedRequestParameters.get(key));
365         });
366         signOnText.append(clientSecret);
367
368         signedRequestParameters.put("authAppkey", clientKey);
369         signedRequestParameters.put("authSign", MD5Util.getMD5Hash(signOnText.toString()));
370
371         Request request = httpClient.newRequest(url).method(HttpMethod.GET);
372         signedRequestParameters.forEach(request::param);
373
374         return request;
375     }
376
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)));
380     }
381
382     private ContentResponse executeRequest(Request request) throws EcovacsApiException, InterruptedException {
383         request.timeout(10, TimeUnit.SECONDS);
384         try {
385             ContentResponse response = request.send();
386             if (response.getStatus() != HttpStatus.OK_200) {
387                 throw new EcovacsApiException(response);
388             }
389             return response;
390         } catch (TimeoutException | ExecutionException e) {
391             throw new EcovacsApiException(e);
392         }
393     }
394 }