]> git.basschouten.com Git - openhab-addons.git/blob
2cf1f784b9833863b71b1390fde1e04e198edf58
[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.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;
22 import java.util.Map;
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;
29
30 import javax.xml.parsers.ParserConfigurationException;
31 import javax.xml.transform.TransformerException;
32
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;
69
70 import com.google.gson.Gson;
71 import com.google.gson.reflect.TypeToken;
72 import com.google.gson.stream.JsonReader;
73
74 /**
75  * @author Danny Baumann - Initial contribution
76  * @author Johannes Ptaszyk - Initial contribution
77  */
78 @NonNullByDefault
79 public final class EcovacsApiImpl implements EcovacsApi {
80     private final Logger logger = LoggerFactory.getLogger(EcovacsApiImpl.class);
81
82     private final HttpClient httpClient;
83     private final Gson gson = new Gson();
84
85     private final EcovacsApiConfiguration configuration;
86     private @Nullable PortalLoginResponse loginData;
87
88     public EcovacsApiImpl(HttpClient httpClient, EcovacsApiConfiguration configuration) {
89         this.httpClient = httpClient;
90         this.configuration = configuration;
91     }
92
93     @Override
94     public void loginAndGetAccessToken() throws EcovacsApiException, InterruptedException {
95         loginData = null;
96
97         AccessData accessData = login();
98         AuthCode authCode = getAuthCode(accessData);
99         loginData = portalLogin(authCode, accessData);
100     }
101
102     EcovacsApiConfiguration getConfig() {
103         return configuration;
104     }
105
106     @Nullable
107     PortalLoginResponse getLoginData() {
108         return loginData;
109     }
110
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());
124
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>>() {
129         }.getType();
130         return handleResponseWrapper(gson.fromJson(loginResponse.getContentAsString(), responseType));
131     }
132
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());
140
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>>() {
145         }.getType();
146         return handleResponseWrapper(gson.fromJson(authCodeResponse.getContentAsString(), responseType));
147     }
148
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");
159         }
160         return response;
161     }
162
163     @Override
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();
174                 }
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());
179                 continue;
180             }
181             DeviceDescription desc = descOpt.get();
182             if (desc.usesMqtt) {
183                 devices.add(new EcovacsIotMqDevice(dev, desc, this, gson));
184             } else {
185                 devices.add(new EcovacsXmppDevice(dev, desc, this, gson));
186             }
187         }
188         return devices;
189     }
190
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>>() {
196         }.getType();
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);
206                 }
207                 result = desc.resolveLinkWith(linkedDescOpt.get());
208             } else {
209                 result = desc;
210             }
211             result.addImplicitCapabilities();
212             return result;
213         }).collect(Collectors.toList());
214     }
215
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();
223     }
224
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();
232     }
233
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;
238         try {
239             if (desc.protoVersion == ProtocolVersion.XML) {
240                 payload = command.getXmlPayload(null);
241                 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName, payload);
242             } else {
243                 payload = command.getJsonPayload(desc.protoVersion, gson);
244                 logger.trace("{}: Sending IOT command {} with payload {}", device.getName(), commandName,
245                         gson.toJson(payload));
246             }
247         } catch (ParserConfigurationException | TransformerException e) {
248             logger.debug("Could not convert payload for {}", command, e);
249             throw new EcovacsApiException(e);
250         }
251
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));
257
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());
263         } else {
264             commandResponse = handleResponse(response, PortalIotCommandJsonResponse.class);
265             logger.trace("{}: Got response payload {}", device.getName(),
266                     ((PortalIotCommandJsonResponse) commandResponse).response);
267         }
268         if (!commandResponse.wasSuccessful()) {
269             final String msg = "Sending IOT command " + commandName + " failed: " + commandResponse.getErrorMessage();
270             throw new EcovacsApiException(msg, commandResponse.failedDueToAuthProblem());
271         }
272         try {
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);
277         }
278     }
279
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");
289         }
290         logger.trace("{}: Fetching cleaning logs yields {} records", device.getName(), responseObj.records.size());
291         return responseObj.records;
292     }
293
294     private PortalAuthRequestParameter createAuthData() {
295         PortalLoginResponse loginData = this.loginData;
296         if (loginData == null) {
297             throw new IllegalStateException("Not logged in");
298         }
299         return new PortalAuthRequestParameter(configuration.getPortalAUthRequestWith(), loginData.getUserId(),
300                 configuration.getRealm(), loginData.getToken(), configuration.getResource());
301     }
302
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");
307         }
308         if (!response.isSuccess()) {
309             throw new EcovacsApiException("API call failed: " + response.getMessage() + ", code " + response.getCode());
310         }
311         return response.getData();
312     }
313
314     private <T> T handleResponse(ContentResponse response, Class<T> clazz) throws EcovacsApiException {
315         @Nullable
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");
320         }
321         return respObject;
322     }
323
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()));
328
329         StringBuilder signOnText = new StringBuilder(clientKey);
330         signedRequestParameters.keySet().stream().sorted().forEach(key -> {
331             signOnText.append(key).append("=").append(signedRequestParameters.get(key));
332         });
333         signOnText.append(clientSecret);
334
335         signedRequestParameters.put("authAppkey", clientKey);
336         signedRequestParameters.put("authSign", MD5Util.getMD5Hash(signOnText.toString()));
337
338         Request request = httpClient.newRequest(url).method(HttpMethod.GET);
339         signedRequestParameters.forEach(request::param);
340
341         return request;
342     }
343
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)));
347     }
348
349     private ContentResponse executeRequest(Request request) throws EcovacsApiException, InterruptedException {
350         request.timeout(10, TimeUnit.SECONDS);
351         try {
352             ContentResponse response = request.send();
353             if (response.getStatus() != HttpStatus.OK_200) {
354                 throw new EcovacsApiException(response);
355             }
356             return response;
357         } catch (TimeoutException | ExecutionException e) {
358             throw new EcovacsApiException(e);
359         }
360     }
361 }