[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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = "";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user