[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
14 changed files with 741 additions and 672 deletions

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();
}
}