From c9ef10c4ce93921681948717a5e2a107e900213e Mon Sep 17 00:00:00 2001 From: rimago Date: Fri, 18 Jun 2021 10:38:51 +0200 Subject: [PATCH] [irobot] Fix password discovery and command sending for Roomba I-Models. (using gson) (#10860) * Fix password discovery for Roomba I-Models. Signed-off-by: Alexander Falkenstern * [irobot] remove json-path dependency (use gson instead) Signed-off-by: Florian Binder * [irobot] fix checkstyle warnings, preserve backward compatibility, and remove unused parameters Signed-off-by: Florian Binder Co-authored-by: Alexander Falkenstern --- bundles/org.openhab.binding.irobot/README.md | 30 +- .../internal/IRobotBindingConstants.java | 15 + .../binding/irobot/internal/RawMQTT.java | 182 --------- .../irobot/internal/RoombaConfiguration.java | 27 -- .../internal/config/IRobotConfiguration.java | 54 +++ .../discovery/IRobotDiscoveryService.java | 127 ++++--- .../irobot/internal/dto/IdentProtocol.java | 134 ------- .../irobot/internal/dto/MQTTProtocol.java | 76 +++- .../handler/IRobotConnectionHandler.java | 178 +++++++++ .../internal/handler/RoombaHandler.java | 354 +++++++----------- .../irobot/internal/utils/LoginRequester.java | 125 +++++++ .../main/resources/OH-INF/config/thing.xml | 24 ++ .../thing/{thing-types.xml => thing.xml} | 13 +- .../internal/handler/RoombaHandlerTest.java | 76 ++-- 14 files changed, 742 insertions(+), 673 deletions(-) delete mode 100644 bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RawMQTT.java delete mode 100644 bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RoombaConfiguration.java create mode 100644 bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/config/IRobotConfiguration.java delete mode 100644 bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/IdentProtocol.java create mode 100644 bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/IRobotConnectionHandler.java create mode 100644 bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/utils/LoginRequester.java create mode 100644 bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/config/thing.xml rename bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/thing/{thing-types.xml => thing.xml} (96%) diff --git a/bundles/org.openhab.binding.irobot/README.md b/bundles/org.openhab.binding.irobot/README.md index 5cf406fb1cdd0..902d2fdc6365b 100644 --- a/bundles/org.openhab.binding.irobot/README.md +++ b/bundles/org.openhab.binding.irobot/README.md @@ -1,32 +1,36 @@ # iRobot Binding -This binding provides integration of products by iRobot company (https://www.irobot.com/). It is currently developed to support Roomba 900 -series robotic vacuum cleaner with built-in Wi-Fi module. The binding interfaces to the robot directly without any need for a dedicated MQTT server. +This binding provides integration of products by iRobot company (https://www.irobot.com/). It is currently developed +to support Roomba vacuum cleaner/mopping robots with built-in Wi-Fi module. The binding interfaces to the robot directly +without any need for a dedicated MQTT server. ## Supported Things -- iRobot Roomba robotic vacuum cleaner (https://www.irobot.com/roomba). The binding has been developed and tested with Roomba 930. -- iRobot Braava has also been reported to (partially) work. Automatic configuration and password retrieval does not work. Add the robot manually as Roomba and use external tools (like Dorita980) in order to retrieve the password. +- iRobot Roomba robotic vacuum cleaner (https://www.irobot.com/roomba). +- iRobot Braava has also been reported to (partially) work. +- In general, the channel list is far from complete. There is a lot to do now. ## Discovery Roombas on the same network will be discovered automatically, however in order to connect to them a password is needed. The -password is a machine-generated string, which is unfortunately not exposed by the original iRobot smartphone application, but -it can be downloaded from the robot itself. If no password is configured, the Thing enters "CONFIGURATION PENDING" state. +password is a machine-generated string, which is unfortunately not exposed by the original iRobot smartphone application, +but it can be downloaded from the robot itself. If no password is configured, the Thing enters "CONFIGURATION PENDING" state. Now you need to perform authorization by pressing and holding the HOME button on your robot until it plays series of tones (approximately 2 seconds). The Wi-Fi indicator on the robot will flash for 30 seconds, the binding should automatically receive the password and go ONLINE. -After you've done this procedure you can write the password somewhere in case if you need to reconfigure your binding. It's not -known, however, whether the password is eternal or can change during factory reset. +After you've done this procedure you can write the password somewhere in case if you need to reconfigure your binding. It's +not known, however, whether the password is eternal or can change during factory reset. ## Thing Configuration +| Parameter | Type | Required | Default | Description | +| --------- | :-----: | :-------: | :------: | ----------------- | +| ipaddress | String | Yes | | Robot IP address | +| blid | String | No | | Robot ID | +| password | String | No | | Robot Password | -| Parameter | Meaning | -|-----------|----------------------------------------| -| ipaddress | IP address (or hostname) of your robot | -| password | Password for the robot | +All parameters will be autodiscovered. If using textual configuration, then `ipaddress` shall be specified. ## Channels @@ -140,11 +144,13 @@ Error codes. Data type is string in order to be able to utilize mapping to human | 76 | Hardware problem detected | ## Cleaning specific regions + You can clean one or many specific regions of a given map by sending the following String to the command channel: ``` cleanRegions:;,,.. ``` + The easiest way to determine the pmapId and region_ids is to monitor the last_command channel while starting a new mission for the specific region with the iRobot-App. ## Known Problems / Caveats diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/IRobotBindingConstants.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/IRobotBindingConstants.java index 4628b8ce28435..c6a7926b0ee38 100644 --- a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/IRobotBindingConstants.java +++ b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/IRobotBindingConstants.java @@ -12,7 +12,10 @@ */ package org.openhab.binding.irobot.internal; +import javax.net.ssl.TrustManager; + import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.io.net.http.TrustAllTrustManager; import org.openhab.core.thing.ThingTypeUID; /** @@ -21,6 +24,7 @@ * * @author hkuhn42 - Initial contribution * @author Pavel Fedin - rename and update + * @author Alexander Falkenstern - Add support for I7 series */ @NonNullByDefault public class IRobotBindingConstants { @@ -30,6 +34,9 @@ public class IRobotBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_ROOMBA = new ThingTypeUID(BINDING_ID, "roomba"); + // Something goes wrong... + public static final String UNKNOWN = "UNKNOWN"; + // List of all Channel ids public static final String CHANNEL_COMMAND = "command"; public static final String CHANNEL_CYCLE = "cycle"; @@ -69,4 +76,12 @@ public class IRobotBindingConstants { public static final String PASSES_AUTO = "auto"; public static final String PASSES_1 = "1"; public static final String PASSES_2 = "2"; + + // Connection and config constants + public static final int MQTT_PORT = 8883; + public static final int UDP_PORT = 5678; + public static final TrustManager[] TRUST_MANAGERS = { TrustAllTrustManager.getInstance() }; + + public static final String ROBOT_BLID = "blid"; + public static final String ROBOT_PASSWORD = "password"; } diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RawMQTT.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RawMQTT.java deleted file mode 100644 index 5940f8fec7650..0000000000000 --- a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RawMQTT.java +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright (c) 2010-2021 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.irobot.internal; - -import java.io.IOException; -import java.io.InputStream; -import java.net.InetAddress; -import java.net.Socket; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; - -/** - * A "raw MQTT" client for sending custom "get password" request. - * Seems pretty much reinventing a bicycle, but it looks like HiveMq - * doesn't provide for sending and receiving custom packets. - * - * @author Pavel Fedin - Initial contribution - * - */ -@NonNullByDefault -public class RawMQTT { - public static final int ROOMBA_MQTT_PORT = 8883; - - private Socket socket; - - public static class Packet { - public byte message; - public byte[] payload; - - Packet(byte msg, byte[] data) { - message = msg; - payload = data; - } - - public boolean isValidPasswdPacket() { - return message == PasswdPacket.MESSAGE && payload.length >= PasswdPacket.HEADER_SIZE; - } - }; - - public static class PasswdPacket extends Packet { - static final byte MESSAGE = (byte) 0xF0; // MQTT Reserved - static final int MAGIC = 0x293bccef; - static final byte HEADER_SIZE = 5; - private ByteBuffer buffer; - - public PasswdPacket(Packet raw) { - super(raw.message, raw.payload); - buffer = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); - } - - public int getMagic() { - return buffer.getInt(0); - } - - public byte getStatus() { - return buffer.get(4); - } - - public @Nullable String getPassword() { - if (getStatus() != 0) { - return null; - } - - int length = payload.length - HEADER_SIZE; - byte[] passwd = new byte[length]; - - buffer.position(HEADER_SIZE); - buffer.get(passwd); - - return new String(passwd, StandardCharsets.ISO_8859_1); - } - } - - // Roomba MQTT is using SSL with custom root CA certificate. - private static class MQTTTrustManager implements X509TrustManager { - @Override - public X509Certificate @Nullable [] getAcceptedIssuers() { - return null; - } - - @Override - public void checkClientTrusted(X509Certificate @Nullable [] arg0, @Nullable String arg1) - throws CertificateException { - } - - @Override - public void checkServerTrusted(X509Certificate @Nullable [] certs, @Nullable String authMethod) - throws CertificateException { - } - } - - public static TrustManager[] getTrustManagers() { - return new TrustManager[] { new MQTTTrustManager() }; - } - - public RawMQTT(InetAddress host, int port) throws KeyManagementException, NoSuchAlgorithmException, IOException { - SSLContext sc = SSLContext.getInstance("SSL"); - - sc.init(null, getTrustManagers(), new java.security.SecureRandom()); - socket = sc.getSocketFactory().createSocket(host, ROOMBA_MQTT_PORT); - socket.setSoTimeout(3000); - } - - public void close() throws IOException { - socket.close(); - } - - public void requestPassword() throws IOException { - final byte[] passwdRequest = new byte[7]; - ByteBuffer buffer = ByteBuffer.wrap(passwdRequest).order(ByteOrder.LITTLE_ENDIAN); - - buffer.put(PasswdPacket.MESSAGE); - buffer.put(PasswdPacket.HEADER_SIZE); - buffer.putInt(PasswdPacket.MAGIC); - buffer.put((byte) 0); - - socket.getOutputStream().write(passwdRequest); - } - - public @Nullable Packet readPacket() throws IOException { - byte[] header = new byte[2]; - int l = receive(header); - - if (l < header.length) { - return null; - } - - byte[] data = new byte[header[1]]; - l = receive(data); - - if (l != header[1]) { - return null; - } else { - return new Packet(header[0], data); - } - } - - private int receive(byte[] data) throws IOException { - int received = 0; - byte[] buffer = new byte[1024]; - InputStream in = socket.getInputStream(); - - while (received < data.length) { - int l = in.read(buffer); - - if (l <= 0) { - break; // EOF - } - - if (received + l > data.length) { - l = data.length - received; - } - - System.arraycopy(buffer, 0, data, received, l); - received += l; - } - - return received; - } -} diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RoombaConfiguration.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RoombaConfiguration.java deleted file mode 100644 index 66d9be891bc53..0000000000000 --- a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/RoombaConfiguration.java +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Copyright (c) 2010-2021 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.irobot.internal; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * Roomba Thing configuration - * - * @author Pavel Fedin - Initial contribution - * - */ -@NonNullByDefault -public class RoombaConfiguration { - public String ipaddress = ""; - public String password = ""; -} diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/config/IRobotConfiguration.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/config/IRobotConfiguration.java new file mode 100644 index 0000000000000..c97f2d2790581 --- /dev/null +++ b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/config/IRobotConfiguration.java @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.irobot.internal.config; + +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.UNKNOWN; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link IRobotConfiguration} is a class for IRobot thing configuration + * + * @author Pavel Fedin - Initial contribution + * @author Alexander Falkenstern - Add supported robot type + */ +@NonNullByDefault +public class IRobotConfiguration { + private String ipaddress = UNKNOWN; + private String password = UNKNOWN; + private String blid = UNKNOWN; + + public String getIpAddress() { + return ipaddress; + } + + public void setIpAddress(final String ipaddress) { + this.ipaddress = ipaddress.trim(); + } + + public String getPassword() { + return password.isBlank() ? UNKNOWN : password; + } + + public void setPassword(final String password) { + this.password = password; + } + + public String getBlid() { + return blid.isBlank() ? UNKNOWN : blid; + } + + public void setBlid(final String blid) { + this.blid = blid; + } +} diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/discovery/IRobotDiscoveryService.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/discovery/IRobotDiscoveryService.java index 46f52ee187152..9ea09e20a7668 100644 --- a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/discovery/IRobotDiscoveryService.java +++ b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/discovery/IRobotDiscoveryService.java @@ -12,25 +12,31 @@ */ package org.openhab.binding.irobot.internal.discovery; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.THING_TYPE_ROOMBA; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.UDP_PORT; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.UNKNOWN; + import java.io.IOException; +import java.io.StringReader; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.irobot.internal.IRobotBindingConstants; -import org.openhab.binding.irobot.internal.dto.IdentProtocol; -import org.openhab.binding.irobot.internal.dto.IdentProtocol.IdentData; +import org.openhab.binding.irobot.internal.dto.MQTTProtocol.DiscoveryResponse; +import org.openhab.binding.irobot.internal.utils.LoginRequester; import org.openhab.core.config.discovery.AbstractDiscoveryService; -import org.openhab.core.config.discovery.DiscoveryResult; import org.openhab.core.config.discovery.DiscoveryResultBuilder; import org.openhab.core.config.discovery.DiscoveryService; import org.openhab.core.net.NetUtil; @@ -39,24 +45,31 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.JsonParseException; +import com.google.gson.Gson; +import com.google.gson.stream.JsonReader; /** - * Discovery service for iRobots + * Discovery service for iRobots. The {@link LoginRequester#getBlid} and + * {@link IRobotDiscoveryService} are heavily related to each other. * * @author Pavel Fedin - Initial contribution + * @author Alexander Falkenstern - Add support for I7 series * */ -@Component(service = DiscoveryService.class, configurationPid = "discovery.irobot") @NonNullByDefault +@Component(service = DiscoveryService.class, configurationPid = "discovery.irobot") public class IRobotDiscoveryService extends AbstractDiscoveryService { private final Logger logger = LoggerFactory.getLogger(IRobotDiscoveryService.class); + + private final Gson gson = new Gson(); + private final Runnable scanner; private @Nullable ScheduledFuture backgroundFuture; public IRobotDiscoveryService() { - super(Collections.singleton(IRobotBindingConstants.THING_TYPE_ROOMBA), 30, true); + super(Collections.singleton(THING_TYPE_ROOMBA), 30, true); + scanner = createScanner(); } @@ -88,18 +101,48 @@ protected void startScan() { private Runnable createScanner() { return () -> { + Set robots = new HashSet<>(); long timestampOfLastScan = getTimestampOfLastScan(); for (InetAddress broadcastAddress : getBroadcastAddresses()) { logger.debug("Starting broadcast for {}", broadcastAddress.toString()); - try (DatagramSocket socket = IdentProtocol.sendRequest(broadcastAddress)) { - DatagramPacket incomingPacket; + final byte[] bRequest = "irobotmcs".getBytes(StandardCharsets.UTF_8); + DatagramPacket request = new DatagramPacket(bRequest, bRequest.length, broadcastAddress, UDP_PORT); + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(1000); // One second + socket.setReuseAddress(true); + socket.setBroadcast(true); + socket.send(request); + + byte @Nullable [] reply = null; + while ((reply = receive(socket)) != null) { + robots.add(new String(reply, StandardCharsets.UTF_8)); + } + } catch (IOException exception) { + logger.debug("Error sending broadcast: {}", exception.toString()); + } + } - while ((incomingPacket = receivePacket(socket)) != null) { - discover(incomingPacket); + for (final String json : robots) { + + JsonReader jsonReader = new JsonReader(new StringReader(json)); + DiscoveryResponse msg = gson.fromJson(jsonReader, DiscoveryResponse.class); + + // Only firmware version 2 and above are supported via MQTT, therefore check it + if ((msg.ver != null) && (Integer.parseInt(msg.ver) > 1) && "mqtt".equalsIgnoreCase(msg.proto)) { + final String address = msg.ip; + final String mac = msg.mac; + final String sku = msg.sku; + if (!address.isEmpty() && !sku.isEmpty() && !mac.isEmpty()) { + ThingUID thingUID = new ThingUID(THING_TYPE_ROOMBA, mac.replace(":", "")); + DiscoveryResultBuilder builder = DiscoveryResultBuilder.create(thingUID); + builder = builder.withProperty("mac", mac).withRepresentationProperty("mac"); + builder = builder.withProperty("ipaddress", address); + + String name = msg.robotname; + builder = builder.withLabel("iRobot " + (!name.isEmpty() ? name : UNKNOWN)); + thingDiscovered(builder.build()); } - } catch (IOException e) { - logger.warn("Error sending broadcast: {}", e.toString()); } } @@ -107,59 +150,31 @@ private Runnable createScanner() { }; } + private byte @Nullable [] receive(DatagramSocket socket) { + try { + final byte[] bReply = new byte[1024]; + DatagramPacket reply = new DatagramPacket(bReply, bReply.length); + socket.receive(reply); + return Arrays.copyOfRange(reply.getData(), reply.getOffset(), reply.getLength()); + } catch (IOException exception) { + // This is not really an error, eventually we get a timeout due to a loop in the caller + return null; + } + } + private List getBroadcastAddresses() { ArrayList addresses = new ArrayList<>(); for (String broadcastAddress : NetUtil.getAllBroadcastAddresses()) { try { addresses.add(InetAddress.getByName(broadcastAddress)); - } catch (UnknownHostException e) { + } catch (UnknownHostException exception) { // The broadcastAddress is supposed to be raw IP, not a hostname, like 192.168.0.255. // Getting UnknownHost on it would be totally strange, some internal system error. - logger.warn("Error broadcasting to {}: {}", broadcastAddress, e.getMessage()); + logger.warn("Error broadcasting to {}: {}", broadcastAddress, exception.getMessage()); } } return addresses; } - - private @Nullable DatagramPacket receivePacket(DatagramSocket socket) { - try { - return IdentProtocol.receiveResponse(socket); - } catch (IOException e) { - // This is not really an error, eventually we get a timeout - // due to a loop in the caller - return null; - } - } - - private void discover(DatagramPacket incomingPacket) { - String host = incomingPacket.getAddress().toString().substring(1); - String reply = new String(incomingPacket.getData(), StandardCharsets.UTF_8); - - logger.trace("Received IDENT from {}: {}", host, reply); - - IdentProtocol.IdentData ident; - - try { - ident = IdentProtocol.decodeResponse(reply); - } catch (JsonParseException e) { - logger.warn("Malformed IDENT reply from {}!", host); - return; - } - - // This check comes from Roomba980-Python - if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) { - logger.warn("Found unsupported iRobot \"{}\" version {} at {}", ident.robotname, ident.ver, host); - return; - } - - if (ident.product.equals(IdentData.PRODUCT_ROOMBA)) { - ThingUID thingUID = new ThingUID(IRobotBindingConstants.THING_TYPE_ROOMBA, host.replace('.', '_')); - DiscoveryResult result = DiscoveryResultBuilder.create(thingUID).withProperty("ipaddress", host) - .withRepresentationProperty("ipaddress").withLabel("iRobot " + ident.robotname).build(); - - thingDiscovered(result); - } - } } diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/IdentProtocol.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/IdentProtocol.java deleted file mode 100644 index fa822fd5abee4..0000000000000 --- a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/IdentProtocol.java +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Copyright (c) 2010-2021 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.irobot.internal.dto; - -import java.io.IOException; -import java.io.StringReader; -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.InetAddress; -import java.nio.charset.StandardCharsets; - -import com.google.gson.Gson; -import com.google.gson.JsonParseException; -import com.google.gson.stream.JsonReader; - -/** - * iRobot discovery and identification protocol - * - * @author Pavel Fedin - Initial contribution - * - */ -public class IdentProtocol { - private static final String UDP_PACKET_CONTENTS = "irobotmcs"; - private static final int REMOTE_UDP_PORT = 5678; - private static final Gson gson = new Gson(); - - public static DatagramSocket sendRequest(InetAddress host) throws IOException { - DatagramSocket socket = new DatagramSocket(); - - socket.setBroadcast(true); - socket.setReuseAddress(true); - - byte[] packetContents = UDP_PACKET_CONTENTS.getBytes(StandardCharsets.UTF_8); - DatagramPacket packet = new DatagramPacket(packetContents, packetContents.length, host, REMOTE_UDP_PORT); - - socket.send(packet); - return socket; - } - - public static DatagramPacket receiveResponse(DatagramSocket socket) throws IOException { - byte[] buffer = new byte[1024]; - DatagramPacket incomingPacket = new DatagramPacket(buffer, buffer.length); - - socket.setSoTimeout(1000 /* one second */); - socket.receive(incomingPacket); - - return incomingPacket; - } - - public static IdentData decodeResponse(DatagramPacket packet) throws JsonParseException { - return decodeResponse(new String(packet.getData(), StandardCharsets.UTF_8)); - } - - public static IdentData decodeResponse(String reply) throws JsonParseException { - /* - * packet is a JSON of the following contents (addresses are undisclosed): - * @formatter:off - * { - * "ver":"3", - * "hostname":"Roomba-3168820480607740", - * "robotname":"Roomba", - * "ip":"XXX.XXX.XXX.XXX", - * "mac":"XX:XX:XX:XX:XX:XX", - * "sw":"v2.4.6-3", - * "sku":"R981040", - * "nc":0, - * "proto":"mqtt", - * "cap":{ - * "pose":1, - * "ota":2, - * "multiPass":2, - * "carpetBoost":1, - * "pp":1, - * "binFullDetect":1, - * "langOta":1, - * "maps":1, - * "edge":1, - * "eco":1, - * "svcConf":1 - * } - * } - * @formatter:on - */ - // We are not consuming all the fields, so we have to create the reader explicitly - // If we use fromJson(String) or fromJson(java.util.reader), it will throw - // "JSON not fully consumed" exception, because not all the reader's content has been - // used up. We want to avoid that for compatibility reasons because newer iRobot versions - // may add fields. - JsonReader jsonReader = new JsonReader(new StringReader(reply)); - IdentData data = gson.fromJson(jsonReader, IdentData.class); - - data.postParse(); - return data; - } - - public static class IdentData { - public static int MIN_SUPPORTED_VERSION = 2; - public static String PRODUCT_ROOMBA = "Roomba"; - - public int ver; - private String hostname; - public String robotname; - - // These two fields are synthetic, they are not contained in JSON - public String product; - public String blid; - - public void postParse() { - // Synthesize missing properties. - String[] hostparts = hostname.split("-"); - - // This also comes from Roomba980-Python. Comments there say that "iRobot" - // prefix is used by i7. We assume for other robots it would be product - // name, e. g. "Scooba" - if (hostparts[0].equals("iRobot")) { - product = "Roomba"; - } else { - product = hostparts[0]; - } - - blid = hostparts[1]; - } - } -} diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/MQTTProtocol.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/MQTTProtocol.java index db71ca99cf89d..fd9103590756f 100644 --- a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/MQTTProtocol.java +++ b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/dto/MQTTProtocol.java @@ -17,6 +17,7 @@ import java.util.stream.Collectors; import com.google.gson.JsonElement; +import com.google.gson.annotations.SerializedName; /** * iRobot MQTT protocol messages @@ -32,22 +33,24 @@ public interface Request { public static class CleanRoomsRequest extends CommandRequest { public int ordered; - public String pmap_id; + @SerializedName("pmap_id") + public String pmapId; public List regions; public CleanRoomsRequest(String cmd, String mapId, String[] regions) { super(cmd); ordered = 1; - pmap_id = mapId; + pmapId = mapId; this.regions = Arrays.stream(regions).map(i -> new Region(i)).collect(Collectors.toList()); } public static class Region { - public String region_id; + @SerializedName("region_id") + public String regionId; public String type; public Region(String id) { - this.region_id = id; + this.regionId = id; this.type = "rid"; } } @@ -262,4 +265,69 @@ public static class ReportedState { public static class StateMessage { public ReportedState state; } + + // DISCOVERY + public static class RobotCapabilities { + public Integer pose; + public Integer ota; + public Integer multiPass; + public Integer carpetBoost; + public Integer pp; + public Integer binFullDetect; + public Integer langOta; + public Integer maps; + public Integer edge; + public Integer eco; + public Integer scvConf; + } + + /* + * JSON of the following contents (addresses are undisclosed): + * @formatter:off + * { + * "ver":"3", + * "hostname":"Roomba-", + * "robotname":"Roomba", + * "robotid":"", --> available on some models only + * "ip":"XXX.XXX.XXX.XXX", + * "mac":"XX:XX:XX:XX:XX:XX", + * "sw":"v2.4.6-3", + * "sku":"R981040", + * "nc":0, + * "proto":"mqtt", + * "cap":{ + * "pose":1, + * "ota":2, + * "multiPass":2, + * "carpetBoost":1, + * "pp":1, + * "binFullDetect":1, + * "langOta":1, + * "maps":1, + * "edge":1, + * "eco":1, + * "svcConf":1 + * } + * } + * @formatter:on + */ + public static class DiscoveryResponse { + public String ver; + public String hostname; + public String robotname; + public String robotid; + public String ip; + public String mac; + public String sw; + public String sku; + public String nc; + public String proto; + public RobotCapabilities cap; + } + + // LoginRequester + public static class BlidResponse { + public String robotid; + public String hostname; + } }; diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/IRobotConnectionHandler.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/IRobotConnectionHandler.java new file mode 100644 index 0000000000000..253610355599d --- /dev/null +++ b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/IRobotConnectionHandler.java @@ -0,0 +1,178 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.irobot.internal.handler; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.MQTT_PORT; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.TRUST_MANAGERS; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.io.transport.mqtt.MqttBrokerConnection; +import org.openhab.core.io.transport.mqtt.MqttConnectionObserver; +import org.openhab.core.io.transport.mqtt.MqttConnectionState; +import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber; +import org.openhab.core.io.transport.mqtt.reconnect.PeriodicReconnectStrategy; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link IRobotConnectionHandler} is responsible for handling iRobot MQTT connection. + * + * @author hkuhn42 - Initial contribution + * @author Pavel Fedin - Rewrite for 900 series + * @author Alexander Falkenstern - Add support for I7 series + */ +@NonNullByDefault +public abstract class IRobotConnectionHandler implements MqttConnectionObserver, MqttMessageSubscriber { + private final Logger logger = LoggerFactory.getLogger(IRobotConnectionHandler.class); + + private static final int RECONNECT_DELAY = 10000; // In milliseconds + private @Nullable Future reconnect; + private @Nullable MqttBrokerConnection connection; + + public IRobotConnectionHandler() { + } + + public synchronized void connect(final String ip, final String blid, final String password) { + InetAddress host = null; + try { + host = InetAddress.getByName(ip); + } catch (UnknownHostException exception) { + connectionStateChanged(MqttConnectionState.DISCONNECTED, exception); + return; + } + + try { + boolean reachable = host.isReachable(1000); + if (logger.isTraceEnabled()) { + logger.trace("Connection to {} can be established {}", ip, reachable); + } + } catch (IOException exception) { + connectionStateChanged(MqttConnectionState.DISCONNECTED, exception); + return; + } + + // BLID is used as both client ID and username. The name of BLID also came from Roomba980-python + MqttBrokerConnection connection = new MqttBrokerConnection(ip, MQTT_PORT, true, blid); + + // Disable sending UNSUBSCRIBE request before disconnecting because Roomba doesn't like it. + // It just swallows the request and never sends any response, so stop() method never completes. + connection.setUnsubscribeOnStop(false); + connection.setCredentials(blid, password); + connection.setTrustManagers(TRUST_MANAGERS); + + // Roomba accepts MQTT qos 0 (AT_MOST_ONCE) only. + connection.setQos(0); + + // MQTT connection reconnects itself, so we don't have to reconnect, when it breaks + connection.setReconnectStrategy(new PeriodicReconnectStrategy(RECONNECT_DELAY, RECONNECT_DELAY)); + + connection.start().exceptionally(exception -> { + connectionStateChanged(MqttConnectionState.DISCONNECTED, exception); + return false; + }).thenAccept(successful -> { + MqttConnectionState state = successful ? MqttConnectionState.CONNECTED : MqttConnectionState.DISCONNECTED; + connectionStateChanged(state, successful ? null : new TimeoutException("Timeout")); + }); + + this.connection = connection; + } + + public synchronized void disconnect() { + Future reconnect = this.reconnect; + if (reconnect != null) { + reconnect.cancel(false); + this.reconnect = null; + } + + MqttBrokerConnection connection = this.connection; + if (connection != null) { + connection.unsubscribe("#", this); + CompletableFuture future = connection.stop(); + try { + future.get(10, TimeUnit.SECONDS); + if (logger.isTraceEnabled()) { + logger.trace("MQTT disconnect successful"); + } + } catch (InterruptedException | ExecutionException | TimeoutException exception) { + logger.warn("MQTT disconnect failed: {}", exception.getMessage()); + } + this.connection = null; + } + } + + @Override + public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) { + if (state == MqttConnectionState.CONNECTED) { + MqttBrokerConnection connection = this.connection; + + // This would be very strange, but Eclipse forces us to do the check + if (connection != null) { + reconnect = null; + + // Roomba sends us two topics: + // "wifistat" - reports signal strength and current robot position + // "$aws/things//shadow/update" - the rest of messages + // Subscribe to everything since we're interested in both + connection.subscribe("#", this).exceptionally(exception -> { + logger.warn("MQTT subscription failed: {}", exception.getMessage()); + return false; + }).thenAccept(successful -> { + if (successful && logger.isTraceEnabled()) { + logger.trace("MQTT subscription successful"); + } else { + logger.warn("MQTT subscription failed: Timeout"); + } + }); + } else { + logger.warn("Established connection without broker pointer"); + } + } else { + String message = (error != null) ? error.getMessage() : "Unknown reason"; + logger.warn("MQTT connection failed: {}", message); + } + } + + @Override + public void processMessage(String topic, byte[] payload) { + // Report raw JSON reply + final String json = new String(payload, UTF_8); + if (logger.isTraceEnabled()) { + logger.trace("Got topic {} data {}", topic, json); + } + + receive(topic, json); + } + + public abstract void receive(final String topic, final String json); + + public void send(final String topic, final String payload) { + MqttBrokerConnection connection = this.connection; + if (connection != null) { + if (logger.isTraceEnabled()) { + logger.trace("Sending {}: {}", topic, payload); + } + connection.publish(topic, payload.getBytes(UTF_8), connection.getQos(), false); + } + } +} diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/RoombaHandler.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/RoombaHandler.java index 7ef18956b9843..78d7f4e1ba75b 100644 --- a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/RoombaHandler.java +++ b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/handler/RoombaHandler.java @@ -12,34 +12,59 @@ */ package org.openhab.binding.irobot.internal.handler; -import static org.openhab.binding.irobot.internal.IRobotBindingConstants.*; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BIN_FULL; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BIN_OK; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BIN_REMOVED; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BOOST_AUTO; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BOOST_ECO; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BOOST_PERFORMANCE; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_ALWAYS_FINISH; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_BATTERY; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_BIN; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_CLEAN_PASSES; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_COMMAND; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_CYCLE; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_EDGE_CLEAN; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_ERROR; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_LAST_COMMAND; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_MAP_UPLOAD; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_PHASE; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_POWER_BOOST; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_RSSI; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SCHEDULE; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SCHED_SWITCH; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SCHED_SWITCH_PREFIX; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SNR; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_CLEAN; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_CLEAN_REGIONS; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_DOCK; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_PAUSE; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_STOP; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.PASSES_1; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.PASSES_2; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.PASSES_AUTO; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.ROBOT_BLID; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.ROBOT_PASSWORD; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.UNKNOWN; +import static org.openhab.core.thing.ThingStatus.INITIALIZING; +import static org.openhab.core.thing.ThingStatus.OFFLINE; +import static org.openhab.core.thing.ThingStatus.UNINITIALIZED; import java.io.IOException; import java.io.StringReader; -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.InetAddress; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import java.util.Hashtable; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; -import org.openhab.binding.irobot.internal.RawMQTT; -import org.openhab.binding.irobot.internal.RoombaConfiguration; -import org.openhab.binding.irobot.internal.dto.IdentProtocol; -import org.openhab.binding.irobot.internal.dto.IdentProtocol.IdentData; +import org.openhab.binding.irobot.internal.config.IRobotConfiguration; import org.openhab.binding.irobot.internal.dto.MQTTProtocol; -import org.openhab.core.config.core.Configuration; -import org.openhab.core.io.transport.mqtt.MqttBrokerConnection; -import org.openhab.core.io.transport.mqtt.MqttConnectionObserver; +import org.openhab.binding.irobot.internal.utils.LoginRequester; import org.openhab.core.io.transport.mqtt.MqttConnectionState; -import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber; -import org.openhab.core.io.transport.mqtt.reconnect.PeriodicReconnectStrategy; import org.openhab.core.library.types.DecimalType; import org.openhab.core.library.types.OnOffType; import org.openhab.core.library.types.StringType; @@ -67,18 +92,14 @@ * @author hkuhn42 - Initial contribution * @author Pavel Fedin - Rewrite for 900 series * @author Florian Binder - added cleanRegions command and lastCommand channel + * @author Alexander Falkenstern - Add support for I7 series */ @NonNullByDefault -public class RoombaHandler extends BaseThingHandler implements MqttConnectionObserver, MqttMessageSubscriber { +public class RoombaHandler extends BaseThingHandler { private final Logger logger = LoggerFactory.getLogger(RoombaHandler.class); + private final Gson gson = new Gson(); - private static final int RECONNECT_DELAY_SEC = 5; // In seconds - private @Nullable Future reconnectReq; - // Dummy RoombaConfiguration object in order to shut up Eclipse warnings - // The real one is set in initialize() - private RoombaConfiguration config = new RoombaConfiguration(); - private @Nullable String blid = null; - private @Nullable MqttBrokerConnection connection; + private Hashtable lastState = new Hashtable<>(); private MQTTProtocol.@Nullable Schedule lastSchedule = null; private boolean autoPasses = true; @@ -87,20 +108,51 @@ public class RoombaHandler extends BaseThingHandler implements MqttConnectionObs private @Nullable Boolean vacHigh = null; private boolean isPaused = false; + private @Nullable Future credentialRequester; + protected IRobotConnectionHandler connection = new IRobotConnectionHandler() { + @Override + public void receive(final String topic, final String json) { + RoombaHandler.this.receive(topic, json); + } + + @Override + public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) { + super.connectionStateChanged(state, error); + if (state == MqttConnectionState.CONNECTED) { + updateStatus(ThingStatus.ONLINE); + } else { + String message = (error != null) ? error.getMessage() : "Unknown reason"; + updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message); + } + } + }; + public RoombaHandler(Thing thing) { super(thing); } @Override public void initialize() { - config = getConfigAs(RoombaConfiguration.class); - updateStatus(ThingStatus.UNKNOWN); - scheduler.execute(this::connect); + IRobotConfiguration config = getConfigAs(IRobotConfiguration.class); + + if (UNKNOWN.equals(config.getPassword()) || UNKNOWN.equals(config.getBlid())) { + final String message = "Robot authentication is required"; + updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message); + scheduler.execute(this::getCredentials); + } else { + scheduler.execute(this::connect); + } } @Override public void dispose() { - scheduler.execute(this::disconnect); + Future requester = credentialRequester; + if (requester != null) { + requester.cancel(false); + credentialRequester = null; + } + + scheduler.execute(connection::disconnect); } // lastState.get() can return null if the key is not found according @@ -139,15 +191,16 @@ public void handleCommand(ChannelUID channelUID, Command command) { String mapId = params[0]; String[] regionIds = params[1].split(","); - sendRequest(new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds)); + MQTTProtocol.Request request = new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds); + connection.send(request.getTopic(), gson.toJson(request)); } else { logger.warn("Invalid request: {}", cmd); logger.warn("Correct format: cleanRegions:;,,...>"); } } else { - sendRequest(new MQTTProtocol.CommandRequest(cmd)); + MQTTProtocol.Request request = new MQTTProtocol.CommandRequest(cmd); + connection.send(request.getTopic(), gson.toJson(request)); } - } } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) { MQTTProtocol.Schedule schedule = lastSchedule; @@ -205,166 +258,82 @@ private void sendSchedule(MQTTProtocol.Schedule schedule) { } private void sendDelta(MQTTProtocol.StateValue state) { - sendRequest(new MQTTProtocol.DeltaRequest(state)); + MQTTProtocol.Request request = new MQTTProtocol.DeltaRequest(state); + connection.send(request.getTopic(), gson.toJson(request)); } - private void sendRequest(MQTTProtocol.Request request) { - MqttBrokerConnection conn = connection; - - if (conn != null) { - String json = gson.toJson(request); - logger.trace("Sending {}: {}", request.getTopic(), json); - // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted - // by Roomba, others just cause it to reject the command and drop the connection. - conn.publish(request.getTopic(), json.getBytes(), 1, false); - } - } - - // In order not to mess up our connection state we need to make sure - // that connect() and disconnect() are never running concurrently, so - // they are synchronized - private synchronized void connect() { - logger.debug("Connecting to {}", config.ipaddress); - - try { - InetAddress host = InetAddress.getByName(config.ipaddress); - String blid = this.blid; - - if (blid == null) { - DatagramSocket identSocket = IdentProtocol.sendRequest(host); - DatagramPacket identPacket = IdentProtocol.receiveResponse(identSocket); - IdentProtocol.IdentData ident; - - identSocket.close(); - + private synchronized void getCredentials() { + ThingStatus status = thing.getStatusInfo().getStatus(); + IRobotConfiguration config = getConfigAs(IRobotConfiguration.class); + if (UNINITIALIZED.equals(status) || INITIALIZING.equals(status) || OFFLINE.equals(status)) { + if (UNKNOWN.equals(config.getBlid())) { + @Nullable + String blid = null; try { - ident = IdentProtocol.decodeResponse(identPacket); - } catch (JsonParseException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - "Malformed IDENT response"); - return; - } - - if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Unsupported version " + ident.ver); - return; + blid = LoginRequester.getBlid(config.getIpAddress()); + } catch (IOException exception) { + updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString()); } - if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Not a Roomba: " + ident.product); - return; + if (blid != null) { + org.openhab.core.config.core.Configuration configuration = editConfiguration(); + configuration.put(ROBOT_BLID, blid); + updateConfiguration(configuration); } - - blid = ident.blid; - this.blid = blid; } - logger.debug("BLID is: {}", blid); - - if (config.password.isEmpty()) { - RawMQTT mqtt; - + if (UNKNOWN.equals(config.getPassword())) { + @Nullable + String password = null; try { - mqtt = new RawMQTT(host, 8883); - } catch (KeyManagementException | NoSuchAlgorithmException e1) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.toString()); + password = LoginRequester.getPassword(config.getIpAddress()); + } catch (KeyManagementException | NoSuchAlgorithmException exception) { + updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, exception.toString()); return; // This is internal system error, no retry + } catch (IOException exception) { + updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString()); } - mqtt.requestPassword(); - RawMQTT.Packet response = mqtt.readPacket(); - mqtt.close(); - - if (response != null && response.isValidPasswdPacket()) { - RawMQTT.PasswdPacket passwdPacket = new RawMQTT.PasswdPacket(response); - String password = passwdPacket.getPassword(); - - if (password != null) { - config.password = password; - - Configuration configuration = editConfiguration(); - - configuration.put("password", password); - updateConfiguration(configuration); - - logger.debug("Password successfully retrieved"); - } + if (password != null) { + org.openhab.core.config.core.Configuration configuration = editConfiguration(); + configuration.put(ROBOT_PASSWORD, password.trim()); + updateConfiguration(configuration); } } - - if (config.password.isEmpty()) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING, - "Authentication on the robot is required"); - scheduleReconnect(); - return; - } - - // BLID is used as both client ID and username. The name of BLID also came from Roomba980-python - MqttBrokerConnection connection = new MqttBrokerConnection(config.ipaddress, RawMQTT.ROOMBA_MQTT_PORT, true, - blid); - - this.connection = connection; - - // Disable sending UNSUBSCRIBE request before disconnecting becuase Roomba doesn't like it. - // It just swallows the request and never sends any response, so stop() method never completes. - connection.setUnsubscribeOnStop(false); - connection.setCredentials(blid, config.password); - connection.setTrustManagers(RawMQTT.getTrustManagers()); - // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted - // by Roomba, others just cause it to reject the command and drop the connection. - connection.setQos(1); - // MQTT connection reconnects itself, so we don't have to call scheduleReconnect() - // when it breaks. Just set the period in ms. - connection.setReconnectStrategy( - new PeriodicReconnectStrategy(RECONNECT_DELAY_SEC * 1000, RECONNECT_DELAY_SEC * 1000)); - connection.start().exceptionally(e -> { - connectionStateChanged(MqttConnectionState.DISCONNECTED, e); - return false; - }).thenAccept(v -> { - if (!v) { - connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout")); - } else { - connectionStateChanged(MqttConnectionState.CONNECTED, null); - } - }); - } catch (IOException e) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); - scheduleReconnect(); - } - } - - private synchronized void disconnect() { - Future reconnectReq = this.reconnectReq; - MqttBrokerConnection connection = this.connection; - - if (reconnectReq != null) { - reconnectReq.cancel(false); - this.reconnectReq = null; } - if (connection != null) { - connection.stop(); - logger.trace("Closed connection to {}", config.ipaddress); - this.connection = null; + credentialRequester = null; + if (UNKNOWN.equals(config.getBlid()) || UNKNOWN.equals(config.getPassword())) { + credentialRequester = scheduler.schedule(this::getCredentials, 10000, TimeUnit.MILLISECONDS); + } else { + scheduler.execute(this::connect); } } - private void scheduleReconnect() { - reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS); - } - - public void onConnected() { - updateStatus(ThingStatus.ONLINE); + // In order not to mess up our connection state we need to make sure that connect() + // and disconnect() are never running concurrently, so they are synchronized + private synchronized void connect() { + IRobotConfiguration config = getConfigAs(IRobotConfiguration.class); + final String address = config.getIpAddress(); + logger.debug("Connecting to {}", address); + + final String blid = config.getBlid(); + final String password = config.getPassword(); + if (UNKNOWN.equals(blid) || UNKNOWN.equals(password)) { + final String message = "Robot authentication is required"; + updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message); + scheduler.execute(this::getCredentials); + } else { + final String message = "Robot authentication is successful"; + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, message); + connection.connect(address, blid, password); + } } - @Override - public void processMessage(String topic, byte[] payload) { - String jsonStr = new String(payload); + public void receive(final String topic, final String json) { MQTTProtocol.StateMessage msg; - logger.trace("Got topic {} data {}", topic, jsonStr); + logger.trace("Got topic {} data {}", topic, json); try { // We are not consuming all the fields, so we have to create the reader explicitly @@ -372,11 +341,11 @@ public void processMessage(String topic, byte[] payload) { // "JSON not fully consumed" exception, because not all the reader's content has been // used up. We want to avoid that also for compatibility reasons because newer iRobot // versions may add fields. - JsonReader jsonReader = new JsonReader(new StringReader(jsonStr)); + JsonReader jsonReader = new JsonReader(new StringReader(json)); msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class); - } catch (JsonParseException e) { - logger.warn("Failed to parse JSON message from {}: {}", config.ipaddress, e.toString()); - logger.warn("Raw contents: {}", payload); + } catch (JsonParseException exception) { + logger.warn("Failed to parse JSON message for {}: {}", thing.getLabel(), exception.toString()); + logger.warn("Raw contents: {}", json); return; } @@ -393,7 +362,7 @@ public void processMessage(String topic, byte[] payload) { String phase = reported.cleanMissionStatus.phase; String command; - if (cycle.equals("none")) { + if ("none".equals(cycle)) { command = CMD_STOP; } else { switch (phase) { @@ -572,47 +541,4 @@ private void reportProperty(String property, @Nullable String value) { updateProperty(property, value); } } - - @Override - public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) { - if (state == MqttConnectionState.CONNECTED) { - MqttBrokerConnection connection = this.connection; - - if (connection == null) { - // This would be very strange, but Eclipse forces us to do the check - logger.warn("Established connection without broker pointer"); - return; - } - - updateStatus(ThingStatus.ONLINE); - - // Roomba sends us two topics: - // "wifistat" - reports singnal strength and current robot position - // "$aws/things//shadow/update" - the rest of messages - // Subscribe to everything since we're interested in both - connection.subscribe("#", this).exceptionally(e -> { - logger.warn("MQTT subscription failed: {}", e.getMessage()); - return false; - }).thenAccept(v -> { - if (!v) { - logger.warn("Subscription timeout"); - } else { - logger.trace("Subscription done"); - } - }); - - } else { - String message; - - if (error != null) { - message = error.getMessage(); - logger.warn("MQTT connection failed: {}", message); - } else { - message = null; - logger.warn("MQTT connection failed for unspecified reason"); - } - - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message); - } - } } diff --git a/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/utils/LoginRequester.java b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/utils/LoginRequester.java new file mode 100644 index 0000000000000..7e0246f091250 --- /dev/null +++ b/bundles/org.openhab.binding.irobot/src/main/java/org/openhab/binding/irobot/internal/utils/LoginRequester.java @@ -0,0 +1,125 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.irobot.internal.utils; + +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.MQTT_PORT; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.TRUST_MANAGERS; +import static org.openhab.binding.irobot.internal.IRobotBindingConstants.UDP_PORT; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.StringReader; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.net.ssl.SSLContext; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.irobot.internal.discovery.IRobotDiscoveryService; +import org.openhab.binding.irobot.internal.dto.MQTTProtocol.BlidResponse; + +import com.google.gson.Gson; +import com.google.gson.stream.JsonReader; + +/** + * Helper functions to get blid and password. Seems pretty much reinventing a bicycle, + * but it looks like HiveMq doesn't provide for sending and receiving custom packets. + * The {@link LoginRequester#getBlid} and {@link IRobotDiscoveryService} are heavily + * related to each other. + * + * @author Pavel Fedin - Initial contribution + * @author Alexander Falkenstern - Fix password fetching + * + */ +@NonNullByDefault +public class LoginRequester { + private static final Gson GSON = new Gson(); + + public static @Nullable String getBlid(final String ip) throws IOException { + DatagramSocket socket = new DatagramSocket(); + socket.setSoTimeout(1000); // One second + socket.setReuseAddress(true); + + final byte[] bRequest = "irobotmcs".getBytes(StandardCharsets.UTF_8); + DatagramPacket request = new DatagramPacket(bRequest, bRequest.length, InetAddress.getByName(ip), UDP_PORT); + socket.send(request); + + byte[] reply = new byte[1024]; + try { + DatagramPacket packet = new DatagramPacket(reply, reply.length); + socket.receive(packet); + reply = Arrays.copyOfRange(packet.getData(), packet.getOffset(), packet.getLength()); + } finally { + socket.close(); + } + + final String json = new String(reply, 0, reply.length, StandardCharsets.UTF_8); + JsonReader jsonReader = new JsonReader(new StringReader(json)); + BlidResponse msg = GSON.fromJson(jsonReader, BlidResponse.class); + + @Nullable + String blid = msg.robotid; + if (((blid == null) || blid.isEmpty()) && ((msg.hostname != null) && !msg.hostname.isEmpty())) { + String[] parts = msg.hostname.split("-"); + if (parts.length == 2) { + blid = parts[1]; + } + } + + return blid; + } + + public static @Nullable String getPassword(final String ip) + throws KeyManagementException, NoSuchAlgorithmException, IOException { + String password = null; + + SSLContext context = SSLContext.getInstance("SSL"); + context.init(null, TRUST_MANAGERS, new java.security.SecureRandom()); + + Socket socket = context.getSocketFactory().createSocket(ip, MQTT_PORT); + socket.setSoTimeout(3000); + + // 1st byte: MQTT reserved message: 0xF0 + // 2nd byte: Data length: 0x05 + // from 3d byte magic packet data: 0xEFCC3B2900 + final byte[] request = { (byte) 0xF0, (byte) 0x05, (byte) 0xEF, (byte) 0xCC, (byte) 0x3B, (byte) 0x29, 0x00 }; + socket.getOutputStream().write(request); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try { + socket.getInputStream().transferTo(buffer); + } catch (IOException exception) { + // Roomba 980 send no properly EOF, so eat the exception + } finally { + socket.close(); + buffer.flush(); + } + + final byte[] reply = buffer.toByteArray(); + if ((reply.length > request.length) && (reply.length == reply[1] + 2)) { // Add 2 bytes, see request doc above + reply[1] = request[1]; // Hack, that we can find request packet in reply + if (Arrays.equals(request, 0, request.length, reply, 0, request.length)) { + password = new String(Arrays.copyOfRange(reply, request.length, reply.length), StandardCharsets.UTF_8); + } + } + + return password; + } +} diff --git a/bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/config/thing.xml b/bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/config/thing.xml new file mode 100644 index 0000000000000..162d79d6a91a6 --- /dev/null +++ b/bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/config/thing.xml @@ -0,0 +1,24 @@ + + + + + + network-address + + Network address of the robot + true + + + + ID of the robot + + + password + + Password of the robot + + + diff --git a/bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/thing/thing.xml similarity index 96% rename from bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/thing/thing-types.xml rename to bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/thing/thing.xml index 99972d7fec9e6..20f85b153f613 100644 --- a/bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.irobot/src/main/resources/OH-INF/thing/thing.xml @@ -7,6 +7,7 @@ A Roomba vacuum robot + CleaningRobot @@ -53,17 +54,9 @@ - - - - IP Address or host name of your Roomba - network-address - - - - - + mac + diff --git a/bundles/org.openhab.binding.irobot/src/test/java/org/openhab/binding/irobot/internal/handler/RoombaHandlerTest.java b/bundles/org.openhab.binding.irobot/src/test/java/org/openhab/binding/irobot/internal/handler/RoombaHandlerTest.java index afb9b8250b689..395768ff4820c 100644 --- a/bundles/org.openhab.binding.irobot/src/test/java/org/openhab/binding/irobot/internal/handler/RoombaHandlerTest.java +++ b/bundles/org.openhab.binding.irobot/src/test/java/org/openhab/binding/irobot/internal/handler/RoombaHandlerTest.java @@ -12,22 +12,32 @@ */ package org.openhab.binding.irobot.internal.handler; +import static org.junit.jupiter.api.Assertions.assertEquals; + import java.io.IOException; import java.lang.reflect.Field; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; -import org.junit.jupiter.api.TestInstance.Lifecycle; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import org.openhab.binding.irobot.internal.config.IRobotConfiguration; import org.openhab.core.config.core.Configuration; import org.openhab.core.library.types.StringType; import org.openhab.core.thing.ChannelUID; import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.ThingTypeUID; import org.openhab.core.thing.ThingUID; import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.thing.internal.ThingImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.spi.LocationAwareLogger; @@ -39,82 +49,80 @@ * @author Florian Binder - Initial contribution */ +@NonNullByDefault @ExtendWith(MockitoExtension.class) -@TestInstance(Lifecycle.PER_CLASS) -class RoombaHandlerTest { +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class RoombaHandlerTest { private static final String IP_ADDRESS = ""; private static final String PASSWORD = ""; + @Nullable private RoombaHandler handler; - private @Mock Thing myThing; + // We have to initialize it to avoid compile errors + private @Mock Thing thing = new ThingImpl(new ThingTypeUID("AA:BB"), ""); + @Nullable private ThingHandlerCallback callback; @BeforeEach void setUp() throws Exception { - Logger l = LoggerFactory.getLogger(RoombaHandler.class); - Field f = l.getClass().getDeclaredField("currentLogLevel"); - f.setAccessible(true); - f.set(l, LocationAwareLogger.TRACE_INT); + Logger logger = LoggerFactory.getLogger(RoombaHandler.class); + Field logLevelField = logger.getClass().getDeclaredField("currentLogLevel"); + logLevelField.setAccessible(true); + logLevelField.set(logger, LocationAwareLogger.TRACE_INT); Configuration config = new Configuration(); config.put("ipaddress", RoombaHandlerTest.IP_ADDRESS); config.put("password", RoombaHandlerTest.PASSWORD); - - Mockito.when(myThing.getConfiguration()).thenReturn(config); - Mockito.when(myThing.getUID()).thenReturn(new ThingUID("mocked", "irobot", "uid")); + Mockito.when(thing.getConfiguration()).thenReturn(config); + Mockito.when(thing.getStatusInfo()) + .thenReturn(new ThingStatusInfo(ThingStatus.UNINITIALIZED, ThingStatusDetail.NONE, "mocked")); + Mockito.lenient().when(thing.getUID()).thenReturn(new ThingUID("mocked", "irobot", "uid")); callback = Mockito.mock(ThingHandlerCallback.class); - handler = new RoombaHandler(myThing); + handler = new RoombaHandler(thing); handler.setCallback(callback); } - // @Test - void testInit() throws InterruptedException, IOException { + @Test + void testConfiguration() throws InterruptedException, IOException { handler.initialize(); - Mockito.verify(myThing, Mockito.times(1)).getConfiguration(); - System.in.read(); + IRobotConfiguration config = thing.getConfiguration().as(IRobotConfiguration.class); + assertEquals(config.getIpAddress(), IP_ADDRESS); + assertEquals(config.getPassword(), PASSWORD); + handler.dispose(); } - // @Test - void testCleanRegion() throws IOException, InterruptedException { + @Test + void testCleanRegion() throws InterruptedException, IOException { handler.initialize(); - System.in.read(); - - ChannelUID cmd = new ChannelUID("my:thi:blabla:command"); + ChannelUID cmd = new ChannelUID(thing.getUID(), "command"); handler.handleCommand(cmd, new StringType("cleanRegions:AABBCCDDEEFFGGHH;2,3")); - System.in.read(); handler.dispose(); } - // @Test - void testDock() throws IOException, InterruptedException { + @Test + void testDock() throws InterruptedException, IOException { handler.initialize(); - System.in.read(); - - ChannelUID cmd = new ChannelUID("my:thi:blabla:command"); + ChannelUID cmd = new ChannelUID(thing.getUID(), "command"); handler.handleCommand(cmd, new StringType("dock")); - System.in.read(); handler.dispose(); } - // @Test - void testStop() throws IOException, InterruptedException { + @Test + void testStop() throws InterruptedException, IOException { handler.initialize(); - System.in.read(); - - ChannelUID cmd = new ChannelUID("my:thi:blabla:command"); + ChannelUID cmd = new ChannelUID(thing.getUID(), "command"); handler.handleCommand(cmd, new StringType("stop")); - System.in.read(); handler.dispose(); } }