]> git.basschouten.com Git - openhab-addons.git/blob
c5c365d90683ccf9bda451ce146da47201d94ca3
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.util.List;
17 import java.util.Optional;
18 import java.util.Random;
19 import java.util.concurrent.ConcurrentHashMap;
20 import java.util.concurrent.ScheduledExecutorService;
21
22 import javax.xml.parsers.ParserConfigurationException;
23 import javax.xml.transform.TransformerException;
24
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;
64
65 import com.google.gson.Gson;
66
67 /**
68  * @author Danny Baumann - Initial contribution
69  */
70 @NonNullByDefault
71 public class EcovacsXmppDevice implements EcovacsDevice {
72     private final Logger logger = LoggerFactory.getLogger(EcovacsXmppDevice.class);
73
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;
83
84     EcovacsXmppDevice(Device device, DeviceDescription desc, EcovacsApiImpl api, Gson gson) {
85         this.device = device;
86         this.desc = desc;
87         this.api = api;
88         this.gson = gson;
89     }
90
91     @Override
92     public String getSerialNumber() {
93         return device.getName();
94     }
95
96     @Override
97     public String getModelName() {
98         return desc.modelName;
99     }
100
101     @Override
102     public boolean hasCapability(DeviceCapability cap) {
103         return desc.capabilities.contains(cap);
104     }
105
106     @Override
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");
114         }
115
116         try {
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();
122
123                 try {
124                     handler.registerPendingCommand(request.id, responseHolder);
125
126                     logger.trace("{}: sending command {}, retry {}", getSerialNumber(),
127                             command.getName(ProtocolVersion.XML), retry);
128                     synchronized (responseHolder) {
129                         conn.sendIqRequestAsync(request);
130                         responseHolder.wait(1500);
131                     }
132                 } finally {
133                     handler.unregisterPendingCommand(request.id);
134                 }
135
136                 String response = responseHolder.response;
137                 if (response != null) {
138                     logger.trace("{}: Received command response XML {}", getSerialNumber(), response);
139
140                     PortalIotCommandXmlResponse responseObj = new PortalIotCommandXmlResponse("", response, 0, "");
141                     return command.convertResponse(responseObj, ProtocolVersion.XML, gson);
142                 }
143             }
144         } catch (DataParsingException | ParserConfigurationException | TransformerException e) {
145             throw new EcovacsApiException(e);
146         }
147
148         throw new EcovacsApiException("No response for command " + command.getName(ProtocolVersion.XML));
149     }
150
151     @Override
152     public List<CleanLogRecord> getCleanLogs() throws EcovacsApiException, InterruptedException {
153         return sendCommand(new GetCleanLogsCommand());
154     }
155
156     @Override
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");
163         }
164
165         logger.trace("{}: Connecting to XMPP", getSerialNumber());
166
167         String password = String.format("0/%s/%s", loginData.getResource(), loginData.getToken());
168         String host = String.format("msg-%s.%s", config.getContinent(), config.getRealm());
169
170         try {
171             Jid ownAddress = JidCreate.from(loginData.getUserId(), config.getRealm(), loginData.getResource());
172             Jid targetAddress = JidCreate.from(device.getDid(), device.getDeviceClass() + ".ecorobot.net", "atom");
173
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();
178
179             XMPPTCPConnection conn = new XMPPTCPConnection(connConfig);
180             conn.addConnectionListener(new ConnectionListener() {
181                 @Override
182                 public void connected(@Nullable XMPPConnection connection) {
183                 }
184
185                 @Override
186                 public void authenticated(@Nullable XMPPConnection connection, boolean resumed) {
187                 }
188
189                 @Override
190                 public void connectionClosed() {
191                 }
192
193                 @Override
194                 public void connectionClosedOnError(@Nullable Exception e) {
195                     logger.trace("{}: XMPP connection failed", getSerialNumber(), e);
196                     if (e != null) {
197                         listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
198                     }
199                 }
200             });
201
202             PingHandler pingHandler = new PingHandler(conn, scheduler, listener, targetAddress);
203             messageHandler = new IncomingMessageHandler(listener);
204
205             Roster roster = Roster.getInstanceFor(conn);
206             roster.setRosterLoadedAtLogin(false);
207
208             conn.registerIQRequestHandler(messageHandler);
209             conn.connect();
210
211             this.connection = conn;
212             this.ownAddress = ownAddress;
213             this.targetAddress = targetAddress;
214             this.pingHandler = pingHandler;
215
216             conn.login();
217             conn.setReplyTimeout(1000);
218
219             logger.trace("{}: XMPP connection established", getSerialNumber());
220
221             listener.onFirmwareVersionChanged(this, sendCommand(new GetFirmwareVersionCommand()));
222             pingHandler.start();
223         } catch (SASLErrorException e) {
224             throw new EcovacsApiException(e, true);
225         } catch (XMPPException | SmackException | InterruptedException | IOException e) {
226             throw new EcovacsApiException(e);
227         }
228     }
229
230     @Override
231     public void disconnect(ScheduledExecutorService scheduler) {
232         PingHandler pingHandler = this.pingHandler;
233         if (pingHandler != null) {
234             pingHandler.stop();
235         }
236         this.pingHandler = null;
237
238         IncomingMessageHandler handler = this.messageHandler;
239         if (handler != null) {
240             handler.dispose();
241         }
242         this.messageHandler = null;
243
244         final XMPPTCPConnection conn = this.connection;
245         if (conn != null) {
246             scheduler.execute(() -> conn.disconnect());
247         }
248         this.connection = null;
249     }
250
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;
256
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;
264
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;
270             this.toAddress = to;
271             this.pingTask.setNamePrefix(getSerialNumber());
272         }
273
274         public void start() {
275             started = true;
276             scheduleNextPing(0);
277         }
278
279         public void stop() {
280             started = false;
281             pingTask.cancel();
282         }
283
284         private void sendPing() {
285             long timeSinceLastStanza = (System.currentTimeMillis() - connection.getLastStanzaReceived()) / 1000;
286             if (timeSinceLastStanza < currentPingInterval()) {
287                 scheduleNextPing(timeSinceLastStanza);
288                 return;
289             }
290
291             try {
292                 if (pingManager.ping(this.toAddress)) {
293                     logger.trace("{}: Pinged device", getSerialNumber());
294                     failedPings = 0;
295                 }
296             } catch (InterruptedException e) {
297                 // only happens when we're stopped
298             } catch (SmackException e) {
299                 ++failedPings;
300                 logger.debug("{}: Ping failed (#{}): {})", getSerialNumber(), failedPings, e.getMessage());
301                 if (failedPings >= MAX_FAILURES) {
302                     listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
303                 }
304             }
305             scheduleNextPing(0);
306         }
307
308         private synchronized void scheduleNextPing(long delta) {
309             pingTask.cancel();
310             if (started) {
311                 pingTask.schedule(currentPingInterval() - delta);
312             }
313         }
314
315         private long currentPingInterval() {
316             return failedPings > 0 ? POST_FAILURE_INTERVAL_SECONDS : INTERVAL_SECONDS;
317         }
318     }
319
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;
325
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);
330         }
331
332         void registerPendingCommand(String id, CommandResponseHolder responseHolder) {
333             pendingCommands.put(id, responseHolder);
334         }
335
336         void unregisterPendingCommand(String id) {
337             pendingCommands.remove(id);
338         }
339
340         void dispose() {
341             disposed = true;
342         }
343
344         @Override
345         public @Nullable IQ handleIQRequest(@Nullable IQ iqRequest) {
346             if (disposed) {
347                 return null;
348             }
349
350             if (iqRequest instanceof DeviceCommandIQ iq) {
351                 try {
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();
358                             }
359                         }
360                     } else {
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);
366                         } else {
367                             logger.debug("{}: Got unexpected XML payload {}", getSerialNumber(), iq.payload);
368                         }
369                     }
370                 } catch (DataParsingException e) {
371                     listener.onEventStreamFailure(EcovacsXmppDevice.this, e);
372                 }
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));
378             }
379             return null;
380         }
381     }
382
383     private static class CommandResponseHolder {
384         @Nullable
385         String response;
386     }
387
388     private static class DeviceCommandIQ extends IQ {
389         static final String TAG_NAME = "query";
390         static final String NAMESPACE = "com:ctl";
391
392         private final String payload;
393         final String id;
394
395         // request
396         public DeviceCommandIQ(IotDeviceCommand<?> cmd, Jid from, Jid to)
397                 throws ParserConfigurationException, TransformerException {
398             super(TAG_NAME, NAMESPACE);
399             setType(Type.set);
400             setTo(to);
401             setFrom(from);
402
403             this.id = createRequestId();
404             this.payload = cmd.getXmlPayload(id);
405         }
406
407         // response
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", "");
412         }
413
414         @Override
415         protected @Nullable IQChildElementXmlStringBuilder getIQChildElementBuilder(
416                 @Nullable IQChildElementXmlStringBuilder xml) {
417             if (xml != null) {
418                 xml.rightAngleBracket();
419                 xml.append(payload);
420             }
421             return xml;
422         }
423
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);
428         }
429     }
430
431     private static class CommandIQProvider extends IQProvider<@Nullable DeviceCommandIQ> {
432         @Override
433         public @Nullable DeviceCommandIQ parse(@Nullable XmlPullParser parser, int initialDepth) throws Exception {
434             @Nullable
435             DeviceCommandIQ packet = null;
436
437             if (parser == null) {
438                 return null;
439             }
440
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);
448                         }
449                         break;
450                     case XmlPullParser.END_TAG:
451                         if (parser.getDepth() == initialDepth) {
452                             break outerloop;
453                         }
454                         break;
455                 }
456             }
457
458             return packet;
459         }
460     }
461
462     static {
463         ProviderManager.addIQProvider(DeviceCommandIQ.TAG_NAME, DeviceCommandIQ.NAMESPACE, new CommandIQProvider());
464     }
465 }