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.IOException;
16 import java.util.List;
17 import java.util.Optional;
18 import java.util.Random;
19 import java.util.concurrent.ConcurrentHashMap;
20 import java.util.concurrent.ScheduledExecutorService;
22 import javax.xml.parsers.ParserConfigurationException;
23 import javax.xml.transform.TransformerException;
25 import org.eclipse.jdt.annotation.NonNullByDefault;
26 import org.eclipse.jdt.annotation.Nullable;
27 import org.jivesoftware.smack.ConnectionListener;
28 import org.jivesoftware.smack.SmackException;
29 import org.jivesoftware.smack.XMPPConnection;
30 import org.jivesoftware.smack.XMPPException;
31 import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler;
32 import org.jivesoftware.smack.packet.ErrorIQ;
33 import org.jivesoftware.smack.packet.IQ;
34 import org.jivesoftware.smack.packet.IQ.Type;
35 import org.jivesoftware.smack.packet.StanzaError;
36 import org.jivesoftware.smack.provider.IQProvider;
37 import org.jivesoftware.smack.provider.ProviderManager;
38 import org.jivesoftware.smack.roster.Roster;
39 import org.jivesoftware.smack.sasl.SASLErrorException;
40 import org.jivesoftware.smack.tcp.XMPPTCPConnection;
41 import org.jivesoftware.smack.tcp.XMPPTCPConnectionConfiguration;
42 import org.jivesoftware.smack.util.PacketParserUtils;
43 import org.jivesoftware.smackx.ping.PingManager;
44 import org.jxmpp.jid.Jid;
45 import org.jxmpp.jid.impl.JidCreate;
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.GetCleanLogsCommand;
50 import org.openhab.binding.ecovacs.internal.api.commands.GetFirmwareVersionCommand;
51 import org.openhab.binding.ecovacs.internal.api.commands.IotDeviceCommand;
52 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.Device;
53 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalIotCommandXmlResponse;
54 import org.openhab.binding.ecovacs.internal.api.impl.dto.response.portal.PortalLoginResponse;
55 import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
56 import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
57 import org.openhab.binding.ecovacs.internal.api.util.DataParsingException;
58 import org.openhab.binding.ecovacs.internal.api.util.SchedulerTask;
59 import org.openhab.binding.ecovacs.internal.api.util.XPathUtils;
60 import org.openhab.core.io.net.http.TrustAllTrustManager;
61 import org.slf4j.Logger;
62 import org.slf4j.LoggerFactory;
63 import org.xmlpull.v1.XmlPullParser;
65 import com.google.gson.Gson;
68 * @author Danny Baumann - Initial contribution
71 public class EcovacsXmppDevice implements EcovacsDevice {
72 private final Logger logger = LoggerFactory.getLogger(EcovacsXmppDevice.class);
74 private final Device device;
75 private final DeviceDescription desc;
76 private final EcovacsApiImpl api;
77 private final Gson gson;
78 private @Nullable IncomingMessageHandler messageHandler;
79 private @Nullable PingHandler pingHandler;
80 private @Nullable XMPPTCPConnection connection;
81 private @Nullable Jid ownAddress;
82 private @Nullable Jid targetAddress;
84 EcovacsXmppDevice(Device device, DeviceDescription desc, EcovacsApiImpl api, Gson gson) {
92 public String getSerialNumber() {
93 return device.getName();
97 public String getModelName() {
98 return desc.modelName;
102 public boolean hasCapability(DeviceCapability cap) {
103 return desc.capabilities.contains(cap);
107 public <T> T sendCommand(IotDeviceCommand<T> command) throws EcovacsApiException, InterruptedException {
108 IncomingMessageHandler handler = this.messageHandler;
109 XMPPConnection conn = this.connection;
110 Jid from = this.ownAddress;
111 Jid to = this.targetAddress;
112 if (handler == null || conn == null || from == null || to == null) {
113 throw new EcovacsApiException("Not connected to device");
117 // Devices sometimes send no answer to commands for unknown reasons. Ecovacs'
118 // app employs a similar retry mechanism, so this seems to be 'normal'.
119 for (int retry = 0; retry < 3; retry++) {
120 DeviceCommandIQ request = new DeviceCommandIQ(command, from, to);
121 CommandResponseHolder responseHolder = new CommandResponseHolder();
124 handler.registerPendingCommand(request.id, responseHolder);
126 logger.trace("{}: sending command {}, retry {}", getSerialNumber(),
127 command.getName(ProtocolVersion.XML), retry);
128 synchronized (responseHolder) {
129 conn.sendIqRequestAsync(request);
130 responseHolder.wait(1500);
133 handler.unregisterPendingCommand(request.id);
136 String response = responseHolder.response;
137 if (response != null) {
138 logger.trace("{}: Received command response XML {}", getSerialNumber(), response);
140 PortalIotCommandXmlResponse responseObj = new PortalIotCommandXmlResponse("", response, 0, "");
141 return command.convertResponse(responseObj, ProtocolVersion.XML, gson);
144 } catch (DataParsingException | ParserConfigurationException | TransformerException e) {
145 throw new EcovacsApiException(e);
148 throw new EcovacsApiException("No response for command " + command.getName(ProtocolVersion.XML));
152 public List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, InterruptedException {
153 return sendCommand(new GetCleanLogsCommand());
157 public void connect(final EventListener listener, final ScheduledExecutorService scheduler)
158 throws EcovacsApiException {
159 EcovacsApiConfiguration config = api.getConfig();
160 PortalLoginResponse loginData = api.getLoginData();
161 if (loginData == null) {
162 throw new EcovacsApiException("Can not connect when not logged in");
165 logger.trace("{}: Connecting to XMPP", getSerialNumber());
167 String password = String.format("0/%s/%s", loginData.getResource(), loginData.getToken());
168 String host = String.format("msg-%s.%s", config.getContinent(), config.getRealm());
171 Jid ownAddress = JidCreate.from(loginData.getUserId(), config.getRealm(), loginData.getResource());
172 Jid targetAddress = JidCreate.from(device.getDid(), device.getDeviceClass() + ".ecorobot.net", "atom");
174 XMPPTCPConnectionConfiguration connConfig = XMPPTCPConnectionConfiguration.builder().setHost(host)
175 .setPort(5223).setUsernameAndPassword(loginData.getUserId(), password)
176 .setResource(loginData.getResource()).setXmppDomain(config.getRealm())
177 .setCustomX509TrustManager(TrustAllTrustManager.getInstance()).setSendPresence(false).build();
179 XMPPTCPConnection conn = new XMPPTCPConnection(connConfig);
180 conn.addConnectionListener(new ConnectionListener() {
182 public void connected(@Nullable XMPPConnection connection) {
186 public void authenticated(@Nullable XMPPConnection connection, boolean resumed) {
190 public void connectionClosed() {
194 public void connectionClosedOnError(@Nullable Exception e) {
195 logger.trace("{}: XMPP connection failed", getSerialNumber(), e);
197 listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
202 PingHandler pingHandler = new PingHandler(conn, scheduler, listener, targetAddress);
203 messageHandler = new IncomingMessageHandler(listener);
205 Roster roster = Roster.getInstanceFor(conn);
206 roster.setRosterLoadedAtLogin(false);
208 conn.registerIQRequestHandler(messageHandler);
211 this.connection = conn;
212 this.ownAddress = ownAddress;
213 this.targetAddress = targetAddress;
214 this.pingHandler = pingHandler;
217 conn.setReplyTimeout(1000);
219 logger.trace("{}: XMPP connection established", getSerialNumber());
221 listener.onFirmwareVersionChanged(this, sendCommand(new GetFirmwareVersionCommand()));
223 } catch (SASLErrorException e) {
224 throw new EcovacsApiException(e, true);
225 } catch (XMPPException | SmackException | InterruptedException | IOException e) {
226 throw new EcovacsApiException(e);
231 public void disconnect(ScheduledExecutorService scheduler) {
232 PingHandler pingHandler = this.pingHandler;
233 if (pingHandler != null) {
236 this.pingHandler = null;
238 IncomingMessageHandler handler = this.messageHandler;
239 if (handler != null) {
242 this.messageHandler = null;
244 final XMPPTCPConnection conn = this.connection;
246 scheduler.execute(() -> conn.disconnect());
248 this.connection = null;
251 private class PingHandler {
252 private static final long INTERVAL_SECONDS = 30;
253 // After a failure, use shorter intervals since subsequent further failure is likely
254 private static final long POST_FAILURE_INTERVAL_SECONDS = 5;
255 private static final int MAX_FAILURES = 4;
257 private final XMPPTCPConnection connection;
258 private final PingManager pingManager;
259 private final EventListener listener;
260 private final Jid toAddress;
261 private final SchedulerTask pingTask;
262 private boolean started = false;
263 private int failedPings = 0;
265 PingHandler(XMPPTCPConnection connection, ScheduledExecutorService scheduler, EventListener listener, Jid to) {
266 this.connection = connection;
267 this.pingManager = PingManager.getInstanceFor(connection);
268 this.pingTask = new SchedulerTask(scheduler, logger, "Ping", this::sendPing);
269 this.listener = listener;
271 this.pingTask.setNamePrefix(getSerialNumber());
274 public void start() {
284 private void sendPing() {
285 long timeSinceLastStanza = (System.currentTimeMillis() - connection.getLastStanzaReceived()) / 1000;
286 if (timeSinceLastStanza < currentPingInterval()) {
287 scheduleNextPing(timeSinceLastStanza);
292 if (pingManager.ping(this.toAddress)) {
293 logger.trace("{}: Pinged device", getSerialNumber());
296 } catch (InterruptedException e) {
297 // only happens when we're stopped
298 } catch (SmackException e) {
300 logger.debug("{}: Ping failed (#{}): {})", getSerialNumber(), failedPings, e.getMessage());
301 if (failedPings >= MAX_FAILURES) {
302 listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
308 private synchronized void scheduleNextPing(long delta) {
311 pingTask.schedule(currentPingInterval() - delta);
315 private long currentPingInterval() {
316 return failedPings > 0 ? POST_FAILURE_INTERVAL_SECONDS : INTERVAL_SECONDS;
320 private class IncomingMessageHandler extends AbstractIqRequestHandler {
321 private final EventListener listener;
322 private final ReportParser parser;
323 private final ConcurrentHashMap<String, CommandResponseHolder> pendingCommands = new ConcurrentHashMap<>();
324 private boolean disposed;
326 IncomingMessageHandler(EventListener listener) {
327 super("query", "com:ctl", Type.set, Mode.async);
328 this.listener = listener;
329 this.parser = new XmlReportParser(EcovacsXmppDevice.this, listener, gson, logger);
332 void registerPendingCommand(String id, CommandResponseHolder responseHolder) {
333 pendingCommands.put(id, responseHolder);
336 void unregisterPendingCommand(String id) {
337 pendingCommands.remove(id);
345 public @Nullable IQ handleIQRequest(@Nullable IQ iqRequest) {
350 if (iqRequest instanceof DeviceCommandIQ iq) {
352 if (!iq.id.isEmpty()) {
353 CommandResponseHolder responseHolder = pendingCommands.remove(iq.id);
354 if (responseHolder != null) {
355 synchronized (responseHolder) {
356 responseHolder.response = iq.payload;
357 responseHolder.notifyAll();
361 Optional<String> eventNameOpt = XPathUtils.getFirstXPathMatchOpt(iq.payload, "//ctl/@td")
362 .map(n -> n.getNodeValue());
363 if (eventNameOpt.isPresent()) {
364 logger.trace("{}: Received event message XML {}", getSerialNumber(), iq.payload);
365 parser.handleMessage(eventNameOpt.get(), iq.payload);
367 logger.debug("{}: Got unexpected XML payload {}", getSerialNumber(), iq.payload);
370 } catch (DataParsingException e) {
371 listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
373 } else if (iqRequest instanceof ErrorIQ errorIQ) {
374 StanzaError error = errorIQ.getError();
375 logger.trace("{}: Got error response {}", getSerialNumber(), error);
376 listener.onEventStreamFailure(EcovacsXmppDevice.this,
377 new XMPPException.XMPPErrorException(iqRequest, error));
383 private static class CommandResponseHolder {
388 private static class DeviceCommandIQ extends IQ {
389 static final String TAG_NAME = "query";
390 static final String NAMESPACE = "com:ctl";
392 private final String payload;
396 public DeviceCommandIQ(IotDeviceCommand<?> cmd, Jid from, Jid to)
397 throws ParserConfigurationException, TransformerException {
398 super(TAG_NAME, NAMESPACE);
403 this.id = createRequestId();
404 this.payload = cmd.getXmlPayload(id);
408 public DeviceCommandIQ(@Nullable String id, String payload) {
409 super(TAG_NAME, NAMESPACE);
410 this.id = id != null ? id : "";
411 this.payload = payload.replaceAll("\n|\r", "");
415 protected @Nullable IQChildElementXmlStringBuilder getIQChildElementBuilder(
416 @Nullable IQChildElementXmlStringBuilder xml) {
418 xml.rightAngleBracket();
424 private String createRequestId() {
425 // Ecovacs' app uses numbers for request IDs, so better constrain ourselves to that as well
426 int random8DigitNumber = 10000000 + new Random().nextInt(90000000);
427 return Integer.toString(random8DigitNumber);
431 private static class CommandIQProvider extends IQProvider<@Nullable DeviceCommandIQ> {
433 public @Nullable DeviceCommandIQ parse(@Nullable XmlPullParser parser, int initialDepth) throws Exception {
435 DeviceCommandIQ packet = null;
437 if (parser == null) {
441 outerloop: while (true) {
442 switch (parser.next()) {
443 case XmlPullParser.START_TAG:
444 if (parser.getDepth() == initialDepth + 1) {
445 String id = parser.getAttributeValue("", "id");
446 String payload = PacketParserUtils.parseElement(parser).toString();
447 packet = new DeviceCommandIQ(id, payload);
450 case XmlPullParser.END_TAG:
451 if (parser.getDepth() == initialDepth) {
463 ProviderManager.addIQProvider(DeviceCommandIQ.TAG_NAME, DeviceCommandIQ.NAMESPACE, new CommandIQProvider());