[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 <alexander.falkenstern@gmail.com>

* [irobot] remove json-path dependency (use gson instead)

Signed-off-by: Florian Binder <fb@java4.info>

* [irobot] fix checkstyle warnings, preserve backward compatibility, and
remove unused parameters

Signed-off-by: Florian Binder <fb@java4.info>

Co-authored-by: Alexander Falkenstern <alexander.falkenstern@gmail.com>
This commit is contained in:
rimago 2021-06-18 10:38:51 +02:00 committed by GitHub
parent 36d7dc26b8
commit 1630430705
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 741 additions and 672 deletions

View File

@ -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:<pmapId>;<region_id1>,<region_id2>,..
```
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

View File

@ -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 @@ import org.openhab.core.thing.ThingTypeUID;
*
* @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";
}

View File

@ -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;
}
}

View File

@ -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 = "";
}

View File

@ -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;
}
}

View File

@ -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.osgi.service.component.annotations.Component;
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 @@ public class IRobotDiscoveryService extends AbstractDiscoveryService {
private Runnable createScanner() {
return () -> {
Set<String> 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);
while ((incomingPacket = receivePacket(socket)) != null) {
discover(incomingPacket);
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());
}
}
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 @@ public class IRobotDiscoveryService extends AbstractDiscoveryService {
};
}
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<InetAddress> getBroadcastAddresses() {
ArrayList<InetAddress> 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);
}
}
}

View File

@ -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];
}
}
}

View File

@ -17,6 +17,7 @@ import java.util.List;
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 class MQTTProtocol {
public static class CleanRoomsRequest extends CommandRequest {
public int ordered;
public String pmap_id;
@SerializedName("pmap_id")
public String pmapId;
public List<Region> 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 class MQTTProtocol {
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-<blid>",
* "robotname":"Roomba",
* "robotid":"<blid>", --> 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;
}
};

View File

@ -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<Boolean> 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/<BLID>/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);
}
}
}

View File

@ -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 @@ import com.google.gson.stream.JsonReader;
* @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<String, State> 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 class RoombaHandler extends BaseThingHandler implements MqttConnectionObs
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:<pmid>;<region_id1>,<region_id2>,...>");
}
} 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 @@ public class RoombaHandler extends BaseThingHandler implements MqttConnectionObs
}
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;
blid = LoginRequester.getBlid(config.getIpAddress());
} catch (IOException exception) {
updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
}
if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Unsupported version " + ident.ver);
return;
if (blid != null) {
org.openhab.core.config.core.Configuration configuration = editConfiguration();
configuration.put(ROBOT_BLID, blid);
updateConfiguration(configuration);
}
if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"Not a Roomba: " + ident.product);
return;
}
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();
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 synchronized void disconnect() {
Future<?> reconnectReq = this.reconnectReq;
MqttBrokerConnection connection = this.connection;
// 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);
if (reconnectReq != null) {
reconnectReq.cancel(false);
this.reconnectReq = null;
}
if (connection != null) {
connection.stop();
logger.trace("Closed connection to {}", config.ipaddress);
this.connection = null;
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);
}
}
private void scheduleReconnect() {
reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS);
}
public void onConnected() {
updateStatus(ThingStatus.ONLINE);
}
@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 class RoombaHandler extends BaseThingHandler implements MqttConnectionObs
// "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 class RoombaHandler extends BaseThingHandler implements MqttConnectionObs
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 @@ public class RoombaHandler extends BaseThingHandler implements MqttConnectionObs
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/<BLID>/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);
}
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<config-description:config-descriptions
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0 https://openhab.org/schemas/config-description-1.0.0.xsd">
<config-description uri="thing-type:irobot:thing">
<parameter name="ipaddress" type="text">
<context>network-address</context>
<label>Network Address</label>
<description>Network address of the robot</description>
<required>true</required>
</parameter>
<parameter name="blid" type="text">
<label>Robot ID</label>
<description>ID of the robot</description>
</parameter>
<parameter name="password" type="text">
<context>password</context>
<label>Password</label>
<description>Password of the robot</description>
</parameter>
</config-description>
</config-description:config-descriptions>

View File

@ -7,6 +7,7 @@
<thing-type id="roomba">
<label>Roomba</label>
<description>A Roomba vacuum robot</description>
<category>CleaningRobot</category>
<channels>
<channel id="command" typeId="command"/>
@ -53,17 +54,9 @@
<channel id="clean_passes" typeId="clean_passes"/>
<channel id="map_upload" typeId="map_upload"/>
</channels>
<config-description>
<parameter name="ipaddress" type="text">
<label>IP Address</label>
<description>IP Address or host name of your Roomba</description>
<context>network-address</context>
</parameter>
<parameter name="password" type="text">
<label>Password</label>
</parameter>
</config-description>
<representation-property>mac</representation-property>
<config-description-ref uri="thing-type:irobot:thing"/>
</thing-type>
<channel-type id="command">

View File

@ -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 @@ import org.slf4j.spi.LocationAwareLogger;
* @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 = "<iRobotIP>";
private static final String PASSWORD = "<PasswordForIRobot>";
@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();
}
}