[irobot] iRobot binding (#8723)
* [irobot] iRobot binding Supports iRobot Roomba and probably some other iRobots (in parts where they are compatible) * [irobot] Drop custom hivemq wrapper setUnsubscribeOnStop() has been introduced in org.openhab.core.io.transport.mqtt.MqttBrokerConnection, so it can now handle iRobot's quirks. The custom wrapper is no more needed, remove it Signed-off-by: Pavel Fedin <pavel_fedin@mail.ru> Co-authored-by: Fabian Wolter <github@fabian-wolter.de>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
Copyright (c) 2010-2020 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
|
||||
|
||||
-->
|
||||
<features name="org.openhab.binding.irobot-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-binding-irobot" description="iRobot Binding" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<feature>openhab-transport-mqtt</feature>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.irobot/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
|
||||
/**
|
||||
* The {@link IRobotBindingConstants} class defines common constants, which are
|
||||
* used across the whole binding.
|
||||
*
|
||||
* @author hkuhn42 - Initial contribution
|
||||
* @author Pavel Fedin - rename and update
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class IRobotBindingConstants {
|
||||
|
||||
public static final String BINDING_ID = "irobot";
|
||||
|
||||
// List of all Thing Type UIDs
|
||||
public static final ThingTypeUID THING_TYPE_ROOMBA = new ThingTypeUID(BINDING_ID, "roomba");
|
||||
|
||||
// List of all Channel ids
|
||||
public static final String CHANNEL_COMMAND = "command";
|
||||
public static final String CHANNEL_CYCLE = "cycle";
|
||||
public static final String CHANNEL_PHASE = "phase";
|
||||
public static final String CHANNEL_BIN = "bin";
|
||||
public static final String CHANNEL_BATTERY = "battery";
|
||||
public static final String CHANNEL_ERROR = "error";
|
||||
public static final String CHANNEL_RSSI = "rssi";
|
||||
public static final String CHANNEL_SNR = "snr";
|
||||
// iRobot's JSON lists weekdays starting from Saturday
|
||||
public static final String CHANNEL_SCHED_SWITCH_PREFIX = "sched_";
|
||||
public static final String[] CHANNEL_SCHED_SWITCH = { "sched_sun", "sched_mon", "sched_tue", "sched_wed",
|
||||
"sched_thu", "sched_fri", "sched_sat" };
|
||||
public static final String CHANNEL_SCHEDULE = "schedule";
|
||||
public static final String CHANNEL_EDGE_CLEAN = "edge_clean";
|
||||
public static final String CHANNEL_ALWAYS_FINISH = "always_finish";
|
||||
public static final String CHANNEL_POWER_BOOST = "power_boost";
|
||||
public static final String CHANNEL_CLEAN_PASSES = "clean_passes";
|
||||
|
||||
public static final String CMD_CLEAN = "clean";
|
||||
public static final String CMD_SPOT = "spot";
|
||||
public static final String CMD_DOCK = "dock";
|
||||
public static final String CMD_PAUSE = "pause";
|
||||
public static final String CMD_STOP = "stop";
|
||||
|
||||
public static final String BIN_OK = "ok";
|
||||
public static final String BIN_FULL = "full";
|
||||
public static final String BIN_REMOVED = "removed";
|
||||
|
||||
public static final String BOOST_AUTO = "auto";
|
||||
public static final String BOOST_PERFORMANCE = "performance";
|
||||
public static final String BOOST_ECO = "eco";
|
||||
|
||||
public static final String PASSES_AUTO = "auto";
|
||||
public static final String PASSES_1 = "1";
|
||||
public static final String PASSES_2 = "2";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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 static org.openhab.binding.irobot.internal.IRobotBindingConstants.THING_TYPE_ROOMBA;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.openhab.binding.irobot.internal.handler.RoombaHandler;
|
||||
import org.openhab.core.thing.Thing;
|
||||
import org.openhab.core.thing.ThingTypeUID;
|
||||
import org.openhab.core.thing.binding.BaseThingHandlerFactory;
|
||||
import org.openhab.core.thing.binding.ThingHandler;
|
||||
import org.openhab.core.thing.binding.ThingHandlerFactory;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
|
||||
/**
|
||||
* The {@link IRobotHandlerFactory} is responsible for creating things and thing
|
||||
* handlers.
|
||||
*
|
||||
* @author hkuhn42 - Initial contribution
|
||||
* @author Pavel Fedin - rename and update
|
||||
*/
|
||||
@Component(configurationPid = "binding.irobot", service = ThingHandlerFactory.class)
|
||||
@NonNullByDefault
|
||||
public class IRobotHandlerFactory extends BaseThingHandlerFactory {
|
||||
|
||||
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_ROOMBA);
|
||||
|
||||
@Override
|
||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected @Nullable ThingHandler createHandler(Thing thing) {
|
||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||
|
||||
if (thingTypeUID.equals(THING_TYPE_ROOMBA)) {
|
||||
return new RoombaHandler(thing);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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,159 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.discovery;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.DatagramPacket;
|
||||
import java.net.DatagramSocket;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
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.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;
|
||||
import org.openhab.core.thing.ThingUID;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.JsonParseException;
|
||||
|
||||
/**
|
||||
* Discovery service for iRobots
|
||||
*
|
||||
* @author Pavel Fedin - Initial contribution
|
||||
*
|
||||
*/
|
||||
@Component(service = DiscoveryService.class, configurationPid = "discovery.irobot")
|
||||
@NonNullByDefault
|
||||
public class IRobotDiscoveryService extends AbstractDiscoveryService {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(IRobotDiscoveryService.class);
|
||||
private final Runnable scanner;
|
||||
private @Nullable ScheduledFuture<?> backgroundFuture;
|
||||
|
||||
public IRobotDiscoveryService() {
|
||||
super(Collections.singleton(IRobotBindingConstants.THING_TYPE_ROOMBA), 30, true);
|
||||
scanner = createScanner();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startBackgroundDiscovery() {
|
||||
stopBackgroundScan();
|
||||
backgroundFuture = scheduler.scheduleWithFixedDelay(scanner, 0, 60, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void stopBackgroundDiscovery() {
|
||||
stopBackgroundScan();
|
||||
super.stopBackgroundDiscovery();
|
||||
}
|
||||
|
||||
private void stopBackgroundScan() {
|
||||
ScheduledFuture<?> scan = backgroundFuture;
|
||||
|
||||
if (scan != null) {
|
||||
scan.cancel(true);
|
||||
backgroundFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void startScan() {
|
||||
scheduler.execute(scanner);
|
||||
}
|
||||
|
||||
private Runnable createScanner() {
|
||||
return () -> {
|
||||
long timestampOfLastScan = getTimestampOfLastScan();
|
||||
for (InetAddress broadcastAddress : getBroadcastAddresses()) {
|
||||
logger.debug("Starting broadcast for {}", broadcastAddress.toString());
|
||||
|
||||
try (DatagramSocket socket = IdentProtocol.sendRequest(broadcastAddress)) {
|
||||
while (receivePacketAndDiscover(socket)) {
|
||||
}
|
||||
} catch (IOException e) {
|
||||
logger.warn("Error sending broadcast: {}", e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
removeOlderResults(timestampOfLastScan);
|
||||
};
|
||||
}
|
||||
|
||||
private List<InetAddress> getBroadcastAddresses() {
|
||||
ArrayList<InetAddress> addresses = new ArrayList<>();
|
||||
|
||||
for (String broadcastAddress : NetUtil.getAllBroadcastAddresses()) {
|
||||
try {
|
||||
addresses.add(InetAddress.getByName(broadcastAddress));
|
||||
} catch (UnknownHostException e) {
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
|
||||
return addresses;
|
||||
}
|
||||
|
||||
private boolean receivePacketAndDiscover(DatagramSocket socket) {
|
||||
DatagramPacket incomingPacket;
|
||||
|
||||
try {
|
||||
incomingPacket = 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 false;
|
||||
}
|
||||
|
||||
String host = incomingPacket.getAddress().toString().substring(1);
|
||||
IdentProtocol.IdentData ident;
|
||||
|
||||
try {
|
||||
ident = IdentProtocol.decodeResponse(incomingPacket);
|
||||
} catch (JsonParseException e) {
|
||||
logger.warn("Malformed IDENT reply from {}!", host);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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 true;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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 {
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
String reply = new String(packet.getData(), StandardCharsets.UTF_8);
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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;
|
||||
|
||||
/**
|
||||
* iRobot MQTT protocol messages
|
||||
*
|
||||
* @author Pavel Fedin - Initial contribution
|
||||
*
|
||||
*/
|
||||
public class MQTTProtocol {
|
||||
public interface Request {
|
||||
public String getTopic();
|
||||
}
|
||||
|
||||
public static class CommandRequest implements Request {
|
||||
public String command;
|
||||
public long time;
|
||||
public String initiator;
|
||||
|
||||
public CommandRequest(String cmd) {
|
||||
command = cmd;
|
||||
time = System.currentTimeMillis() / 1000;
|
||||
initiator = "localApp";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return "cmd";
|
||||
}
|
||||
}
|
||||
|
||||
public static class DeltaRequest implements Request {
|
||||
public StateValue state;
|
||||
|
||||
public DeltaRequest(StateValue state) {
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTopic() {
|
||||
return "delta";
|
||||
}
|
||||
}
|
||||
|
||||
public static class CleanMissionStatus {
|
||||
public String cycle;
|
||||
public String phase;
|
||||
public int error;
|
||||
}
|
||||
|
||||
public static class BinStatus {
|
||||
public boolean present;
|
||||
public boolean full;
|
||||
}
|
||||
|
||||
public static class SignalStrength {
|
||||
public int rssi;
|
||||
public int snr;
|
||||
}
|
||||
|
||||
public static class Schedule {
|
||||
public String[] cycle;
|
||||
public int[] h;
|
||||
public int[] m;
|
||||
|
||||
public static final int NUM_WEEK_DAYS = 7;
|
||||
|
||||
public Schedule(int cycles_bitmask) {
|
||||
cycle = new String[NUM_WEEK_DAYS];
|
||||
for (int i = 0; i < NUM_WEEK_DAYS; i++) {
|
||||
enableCycle(i, (cycles_bitmask & (1 << i)) != 0);
|
||||
}
|
||||
}
|
||||
|
||||
public Schedule(String[] cycle) {
|
||||
this.cycle = cycle;
|
||||
}
|
||||
|
||||
public boolean cycleEnabled(int i) {
|
||||
return cycle[i].equals("start");
|
||||
}
|
||||
|
||||
public void enableCycle(int i, boolean enable) {
|
||||
cycle[i] = enable ? "start" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
public static class StateValue {
|
||||
// Just some common type, nothing to do here
|
||||
protected StateValue() {
|
||||
}
|
||||
}
|
||||
|
||||
public static class OpenOnly extends StateValue {
|
||||
public boolean openOnly;
|
||||
|
||||
public OpenOnly(boolean openOnly) {
|
||||
this.openOnly = openOnly;
|
||||
}
|
||||
}
|
||||
|
||||
public static class BinPause extends StateValue {
|
||||
public boolean binPause;
|
||||
|
||||
public BinPause(boolean binPause) {
|
||||
this.binPause = binPause;
|
||||
}
|
||||
}
|
||||
|
||||
public static class PowerBoost extends StateValue {
|
||||
public boolean carpetBoost;
|
||||
public boolean vacHigh;
|
||||
|
||||
public PowerBoost(boolean carpetBoost, boolean vacHigh) {
|
||||
this.carpetBoost = carpetBoost;
|
||||
this.vacHigh = vacHigh;
|
||||
}
|
||||
}
|
||||
|
||||
public static class CleanPasses extends StateValue {
|
||||
public boolean noAutoPasses;
|
||||
public boolean twoPass;
|
||||
|
||||
public CleanPasses(boolean noAutoPasses, boolean twoPass) {
|
||||
this.noAutoPasses = noAutoPasses;
|
||||
this.twoPass = twoPass;
|
||||
}
|
||||
}
|
||||
|
||||
public static class CleanSchedule extends StateValue {
|
||||
public Schedule cleanSchedule;
|
||||
|
||||
public CleanSchedule(Schedule schedule) {
|
||||
cleanSchedule = schedule;
|
||||
}
|
||||
}
|
||||
|
||||
// "reported" messages never contain the full state, only a part.
|
||||
// Therefore all the fields in this class are nullable
|
||||
public static class GenericState extends StateValue {
|
||||
// "cleanMissionStatus":{"cycle":"clean","phase":"hmUsrDock","expireM":0,"rechrgM":0,"error":0,"notReady":0,"mssnM":1,"sqft":7,"initiator":"rmtApp","nMssn":39}
|
||||
public CleanMissionStatus cleanMissionStatus;
|
||||
// "batPct":100
|
||||
public Integer batPct;
|
||||
// "bin":{"present":true,"full":false}
|
||||
public BinStatus bin;
|
||||
// "signal":{"rssi":-55,"snr":33}
|
||||
public SignalStrength signal;
|
||||
// "cleanSchedule":{"cycle":["none","start","start","start","start","none","none"],"h":[9,12,12,12,12,12,9],"m":[0,0,0,0,0,0,0]}
|
||||
public Schedule cleanSchedule;
|
||||
// "openOnly":false
|
||||
public Boolean openOnly;
|
||||
// "binPause":true
|
||||
public Boolean binPause;
|
||||
// "carpetBoost":true
|
||||
public Boolean carpetBoost;
|
||||
// "vacHigh":false
|
||||
public Boolean vacHigh;
|
||||
// "noAutoPasses":true
|
||||
public Boolean noAutoPasses;
|
||||
// "twoPass":true
|
||||
public Boolean twoPass;
|
||||
// "softwareVer":"v2.4.6-3"
|
||||
public String softwareVer;
|
||||
// "navSwVer":"01.12.01#1"
|
||||
public String navSwVer;
|
||||
// "wifiSwVer":"20992"
|
||||
public String wifiSwVer;
|
||||
// "mobilityVer":"5806"
|
||||
public String mobilityVer;
|
||||
// "bootloaderVer":"4042"
|
||||
public String bootloaderVer;
|
||||
// "umiVer":"6",
|
||||
public String umiVer;
|
||||
}
|
||||
|
||||
// Data comes as JSON string: {"state":{"reported":<Actual content here>}}
|
||||
// or: {"state":{"desired":<Some content here>}}
|
||||
// Of the second form i've so far observed only: {"state":{"desired":{"echo":null}}}
|
||||
// I don't know what it is, so let's ignore it.
|
||||
public static class ReportedState {
|
||||
public GenericState reported;
|
||||
}
|
||||
|
||||
public static class StateMessage {
|
||||
public ReportedState state;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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 org.openhab.binding.irobot.internal.IRobotBindingConstants.*;
|
||||
|
||||
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 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.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.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;
|
||||
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.binding.BaseThingHandler;
|
||||
import org.openhab.core.types.Command;
|
||||
import org.openhab.core.types.RefreshType;
|
||||
import org.openhab.core.types.State;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
|
||||
/**
|
||||
* The {@link RoombaHandler} is responsible for handling commands, which are
|
||||
* sent to one of the channels.
|
||||
*
|
||||
* @author hkuhn42 - Initial contribution
|
||||
* @author Pavel Fedin - Rewrite for 900 series
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public class RoombaHandler extends BaseThingHandler implements MqttConnectionObserver, MqttMessageSubscriber {
|
||||
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;
|
||||
private @Nullable Boolean twoPasses = null;
|
||||
private boolean carpetBoost = true;
|
||||
private @Nullable Boolean vacHigh = null;
|
||||
private boolean isPaused = false;
|
||||
|
||||
public RoombaHandler(Thing thing) {
|
||||
super(thing);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize() {
|
||||
config = getConfigAs(RoombaConfiguration.class);
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
scheduler.execute(this::connect);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dispose() {
|
||||
scheduler.execute(this::disconnect);
|
||||
}
|
||||
|
||||
// lastState.get() can return null if the key is not found according
|
||||
// to the documentation
|
||||
@SuppressWarnings("null")
|
||||
private void handleRefresh(String ch) {
|
||||
State value = lastState.get(ch);
|
||||
|
||||
if (value != null) {
|
||||
updateState(ch, value);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handleCommand(ChannelUID channelUID, Command command) {
|
||||
String ch = channelUID.getId();
|
||||
if (command instanceof RefreshType) {
|
||||
handleRefresh(ch);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ch.equals(CHANNEL_COMMAND)) {
|
||||
if (command instanceof StringType) {
|
||||
String cmd = command.toString();
|
||||
|
||||
if (cmd.equals(CMD_CLEAN)) {
|
||||
cmd = isPaused ? "resume" : "start";
|
||||
}
|
||||
|
||||
sendRequest(new MQTTProtocol.CommandRequest(cmd));
|
||||
}
|
||||
} else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
|
||||
MQTTProtocol.Schedule schedule = lastSchedule;
|
||||
|
||||
// Schedule can only be updated in a bulk, so we have to store current
|
||||
// schedule and modify components.
|
||||
if (command instanceof OnOffType && schedule != null && schedule.cycle != null) {
|
||||
for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
|
||||
if (ch.equals(CHANNEL_SCHED_SWITCH[i])) {
|
||||
MQTTProtocol.Schedule newSchedule = new MQTTProtocol.Schedule(schedule.cycle);
|
||||
|
||||
newSchedule.enableCycle(i, command.equals(OnOffType.ON));
|
||||
sendSchedule(newSchedule);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ch.equals(CHANNEL_SCHEDULE)) {
|
||||
if (command instanceof DecimalType) {
|
||||
int bitmask = ((DecimalType) command).intValue();
|
||||
JsonArray cycle = new JsonArray();
|
||||
|
||||
for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
|
||||
enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
|
||||
}
|
||||
|
||||
sendSchedule(new MQTTProtocol.Schedule(bitmask));
|
||||
}
|
||||
} else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
|
||||
if (command instanceof OnOffType) {
|
||||
sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
|
||||
}
|
||||
} else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
|
||||
if (command instanceof OnOffType) {
|
||||
sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
|
||||
}
|
||||
} else if (ch.equals(CHANNEL_POWER_BOOST)) {
|
||||
sendDelta(new MQTTProtocol.PowerBoost(command.equals(BOOST_AUTO), command.equals(BOOST_PERFORMANCE)));
|
||||
} else if (ch.equals(CHANNEL_CLEAN_PASSES)) {
|
||||
sendDelta(new MQTTProtocol.CleanPasses(!command.equals(PASSES_AUTO), command.equals(PASSES_2)));
|
||||
}
|
||||
}
|
||||
|
||||
private void enableCycle(JsonArray cycle, int i, boolean enable) {
|
||||
JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
|
||||
cycle.set(i, value);
|
||||
}
|
||||
|
||||
private void sendSchedule(MQTTProtocol.Schedule schedule) {
|
||||
sendDelta(new MQTTProtocol.CleanSchedule(schedule));
|
||||
}
|
||||
|
||||
private void sendDelta(MQTTProtocol.StateValue state) {
|
||||
sendRequest(new MQTTProtocol.DeltaRequest(state));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
try {
|
||||
ident = IdentProtocol.decodeResponse(identPacket);
|
||||
} catch (JsonParseException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
||||
"Malformed IDENT response");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"Unsupported version " + ident.ver);
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
mqtt = new RawMQTT(host, 8883);
|
||||
} catch (KeyManagementException | NoSuchAlgorithmException e1) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.toString());
|
||||
return; // This is internal system error, no retry
|
||||
}
|
||||
|
||||
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 (config.password.isEmpty()) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
|
||||
"Authentication on the robot is required");
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
// BLID is used as both client ID and username. The name of BLID also came from Roomba980-python
|
||||
MqttBrokerConnection connection = new MqttBrokerConnection(config.ipaddress, RawMQTT.ROOMBA_MQTT_PORT, true,
|
||||
blid);
|
||||
|
||||
this.connection = connection;
|
||||
|
||||
// Disable sending UNSUBSCRIBE request before disconnecting becuase Roomba doesn't like it.
|
||||
// It just swallows the request and never sends any response, so stop() method never completes.
|
||||
connection.setUnsubscribeOnStop(false);
|
||||
connection.setCredentials(blid, config.password);
|
||||
connection.setTrustManagers(RawMQTT.getTrustManagers());
|
||||
// 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
|
||||
// by Roomba, others just cause it to reject the command and drop the connection.
|
||||
connection.setQos(1);
|
||||
// MQTT connection reconnects itself, so we don't have to call scheduleReconnect()
|
||||
// when it breaks. Just set the period in ms.
|
||||
connection.setReconnectStrategy(
|
||||
new PeriodicReconnectStrategy(RECONNECT_DELAY_SEC * 1000, RECONNECT_DELAY_SEC * 1000));
|
||||
connection.start().exceptionally(e -> {
|
||||
connectionStateChanged(MqttConnectionState.DISCONNECTED, e);
|
||||
return false;
|
||||
}).thenAccept(v -> {
|
||||
if (!v) {
|
||||
connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout"));
|
||||
} else {
|
||||
connectionStateChanged(MqttConnectionState.CONNECTED, null);
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void disconnect() {
|
||||
Future<?> reconnectReq = this.reconnectReq;
|
||||
MqttBrokerConnection connection = this.connection;
|
||||
|
||||
if (reconnectReq != null) {
|
||||
reconnectReq.cancel(false);
|
||||
this.reconnectReq = null;
|
||||
}
|
||||
|
||||
if (connection != null) {
|
||||
connection.stop();
|
||||
logger.trace("Closed connection to {}", config.ipaddress);
|
||||
this.connection = null;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
MQTTProtocol.StateMessage msg;
|
||||
|
||||
logger.trace("Got topic {} data {}", topic, jsonStr);
|
||||
|
||||
try {
|
||||
// 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 also for compatibility reasons because newer iRobot
|
||||
// versions may add fields.
|
||||
JsonReader jsonReader = new JsonReader(new StringReader(jsonStr));
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
||||
// Since all the fields are in fact optional, and a single message never
|
||||
// contains all of them, we have to check presence of each individually
|
||||
if (msg.state == null || msg.state.reported == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MQTTProtocol.GenericState reported = msg.state.reported;
|
||||
|
||||
if (reported.cleanMissionStatus != null) {
|
||||
String cycle = reported.cleanMissionStatus.cycle;
|
||||
String phase = reported.cleanMissionStatus.phase;
|
||||
String command;
|
||||
|
||||
if (cycle.equals("none")) {
|
||||
command = CMD_STOP;
|
||||
} else {
|
||||
switch (phase) {
|
||||
case "stop":
|
||||
case "stuck": // CHECKME: could also be equivalent to "stop" command
|
||||
case "pause": // Never observed in Roomba 930
|
||||
command = CMD_PAUSE;
|
||||
break;
|
||||
case "hmUsrDock":
|
||||
case "dock": // Never observed in Roomba 930
|
||||
command = CMD_DOCK;
|
||||
break;
|
||||
default:
|
||||
command = cycle; // "clean" or "spot"
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
isPaused = command.equals(CMD_PAUSE);
|
||||
|
||||
reportString(CHANNEL_CYCLE, cycle);
|
||||
reportString(CHANNEL_PHASE, phase);
|
||||
reportString(CHANNEL_COMMAND, command);
|
||||
reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
|
||||
}
|
||||
|
||||
if (reported.batPct != null) {
|
||||
reportInt(CHANNEL_BATTERY, reported.batPct);
|
||||
}
|
||||
|
||||
if (reported.bin != null) {
|
||||
String binStatus;
|
||||
|
||||
// The bin cannot be both full and removed simultaneously, so let's
|
||||
// encode it as a single value
|
||||
if (!reported.bin.present) {
|
||||
binStatus = BIN_REMOVED;
|
||||
} else if (reported.bin.full) {
|
||||
binStatus = BIN_FULL;
|
||||
} else {
|
||||
binStatus = BIN_OK;
|
||||
}
|
||||
|
||||
reportString(CHANNEL_BIN, binStatus);
|
||||
}
|
||||
|
||||
if (reported.signal != null) {
|
||||
reportInt(CHANNEL_RSSI, reported.signal.rssi);
|
||||
reportInt(CHANNEL_SNR, reported.signal.snr);
|
||||
}
|
||||
|
||||
if (reported.cleanSchedule != null) {
|
||||
MQTTProtocol.Schedule schedule = reported.cleanSchedule;
|
||||
|
||||
if (schedule.cycle != null) {
|
||||
int binary = 0;
|
||||
|
||||
for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
|
||||
boolean on = schedule.cycleEnabled(i);
|
||||
|
||||
reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
|
||||
if (on) {
|
||||
binary |= (1 << i);
|
||||
}
|
||||
}
|
||||
|
||||
reportInt(CHANNEL_SCHEDULE, binary);
|
||||
}
|
||||
|
||||
lastSchedule = schedule;
|
||||
}
|
||||
|
||||
if (reported.openOnly != null) {
|
||||
reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
|
||||
}
|
||||
|
||||
if (reported.binPause != null) {
|
||||
reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
|
||||
}
|
||||
|
||||
// To make the life more interesting, paired values may not appear together in the
|
||||
// same message, so we have to keep track of current values.
|
||||
if (reported.carpetBoost != null) {
|
||||
carpetBoost = reported.carpetBoost;
|
||||
if (reported.carpetBoost) {
|
||||
// When set to true, overrides vacHigh
|
||||
reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
|
||||
} else if (vacHigh != null) {
|
||||
reportVacHigh();
|
||||
}
|
||||
}
|
||||
|
||||
if (reported.vacHigh != null) {
|
||||
vacHigh = reported.vacHigh;
|
||||
if (!carpetBoost) {
|
||||
// Can be overridden by "carpetBoost":true
|
||||
reportVacHigh();
|
||||
}
|
||||
}
|
||||
|
||||
if (reported.noAutoPasses != null) {
|
||||
autoPasses = !reported.noAutoPasses;
|
||||
if (!reported.noAutoPasses) {
|
||||
// When set to false, overrides twoPass
|
||||
reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
|
||||
} else if (twoPasses != null) {
|
||||
reportTwoPasses();
|
||||
}
|
||||
}
|
||||
|
||||
if (reported.twoPass != null) {
|
||||
twoPasses = reported.twoPass;
|
||||
if (!autoPasses) {
|
||||
// Can be overridden by "noAutoPasses":false
|
||||
reportTwoPasses();
|
||||
}
|
||||
}
|
||||
|
||||
reportProperty(Thing.PROPERTY_FIRMWARE_VERSION, reported.softwareVer);
|
||||
reportProperty("navSwVer", reported.navSwVer);
|
||||
reportProperty("wifiSwVer", reported.wifiSwVer);
|
||||
reportProperty("mobilityVer", reported.mobilityVer);
|
||||
reportProperty("bootloaderVer", reported.bootloaderVer);
|
||||
reportProperty("umiVer", reported.umiVer);
|
||||
}
|
||||
|
||||
private void reportVacHigh() {
|
||||
reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
|
||||
}
|
||||
|
||||
private void reportTwoPasses() {
|
||||
reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
|
||||
}
|
||||
|
||||
private void reportString(String channel, String str) {
|
||||
reportState(channel, StringType.valueOf(str));
|
||||
}
|
||||
|
||||
private void reportInt(String channel, int n) {
|
||||
reportState(channel, new DecimalType(n));
|
||||
}
|
||||
|
||||
private void reportSwitch(String channel, boolean s) {
|
||||
reportState(channel, OnOffType.from(s));
|
||||
}
|
||||
|
||||
private void reportState(String channel, State value) {
|
||||
lastState.put(channel, value);
|
||||
updateState(channel, value);
|
||||
}
|
||||
|
||||
private void reportProperty(String property, @Nullable String value) {
|
||||
if (value != null) {
|
||||
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,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<binding:binding id="irobot" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:binding="https://openhab.org/schemas/binding/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/binding/v1.0.0 https://openhab.org/schemas/binding-1.0.0.xsd">
|
||||
|
||||
<name>iRobot Binding</name>
|
||||
<description>This is the binding for iRobot vacuum robots.</description>
|
||||
|
||||
</binding:binding>
|
||||
@@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<thing:thing-descriptions bindingId="irobot"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
|
||||
|
||||
<thing-type id="roomba">
|
||||
<label>Roomba</label>
|
||||
<description>A Roomba vacuum robot</description>
|
||||
|
||||
<channels>
|
||||
<channel id="command" typeId="command"/>
|
||||
<channel id="cycle" typeId="cycle"/>
|
||||
<channel id="phase" typeId="phase"/>
|
||||
<channel id="battery" typeId="battery"/>
|
||||
<channel id="bin" typeId="bin"/>
|
||||
<channel id="error" typeId="error"/>
|
||||
<channel id="rssi" typeId="rssi"/>
|
||||
<channel id="snr" typeId="snr"/>
|
||||
<channel id="sched_mon" typeId="sched_switch">
|
||||
<label>Schedule Mon</label>
|
||||
<description>Monday schedule active</description>
|
||||
</channel>
|
||||
<channel id="sched_tue" typeId="sched_switch">
|
||||
<label>Schedule Tue</label>
|
||||
<description>Tuesday schedule active</description>
|
||||
</channel>
|
||||
<channel id="sched_wed" typeId="sched_switch">
|
||||
<label>Schedule Wed</label>
|
||||
<description>Wednesday schedule active</description>
|
||||
</channel>
|
||||
<channel id="sched_thu" typeId="sched_switch">
|
||||
<label>Schedule Thu</label>
|
||||
<description>Thirsday schedule active</description>
|
||||
</channel>
|
||||
<channel id="sched_fri" typeId="sched_switch">
|
||||
<label>Schedule Fri</label>
|
||||
<description>Friday schedule active</description>
|
||||
</channel>
|
||||
<channel id="sched_sat" typeId="sched_switch">
|
||||
<label>Schedule Sat</label>
|
||||
<description>Saturday schedule active</description>
|
||||
</channel>
|
||||
<channel id="sched_sun" typeId="sched_switch">
|
||||
<label>Schedule Sun</label>
|
||||
<description>Sunday schedule active</description>
|
||||
</channel>
|
||||
<channel id="schedule" typeId="schedule"/>
|
||||
<channel id="edge_clean" typeId="edge_clean"/>
|
||||
<channel id="always_finish" typeId="always_finish"/>
|
||||
<channel id="power_boost" typeId="power_boost"/>
|
||||
<channel id="clean_passes" typeId="clean_passes"/>
|
||||
</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>
|
||||
</thing-type>
|
||||
|
||||
<channel-type id="command">
|
||||
<item-type>String</item-type>
|
||||
<label>Command</label>
|
||||
<description>Command to execute</description>
|
||||
<state>
|
||||
<options>
|
||||
<option value="clean">Clean</option>
|
||||
<option value="spot">Spot</option>
|
||||
<option value="dock">Dock</option>
|
||||
<option value="pause">Pause</option>
|
||||
<option value="stop">Stop</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="cycle">
|
||||
<item-type>String</item-type>
|
||||
<label>Mission</label>
|
||||
<description>Current mission</description>
|
||||
<state readOnly="true">
|
||||
<options>
|
||||
<option value="none">None</option>
|
||||
<option value="clean">Clean</option>
|
||||
<option value="spot">Spot</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="phase">
|
||||
<item-type>String</item-type>
|
||||
<label>State</label>
|
||||
<description>Current state</description>
|
||||
<state readOnly="true">
|
||||
<options>
|
||||
<option value="charge">Charging</option>
|
||||
<option value="new">New Mission</option>
|
||||
<option value="run">Running</option>
|
||||
<option value="resume">Resumed</option>
|
||||
<option value="hmMidMsn">Going for recharge in mission</option>
|
||||
<option value="recharge">Recharging</option>
|
||||
<option value="stuck">Stuck</option>
|
||||
<option value="hmUsrDock">Going home</option>
|
||||
<option value="dock">Docking</option>
|
||||
<option value="dockend">Docking - End Mission</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="stop">Stopped</option>
|
||||
<option value="pause">Paused</option>
|
||||
<option value="hmPostMsn">Going home after mission</option>
|
||||
<option value="">None</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="battery">
|
||||
<item-type>Number</item-type>
|
||||
<label>Battery</label>
|
||||
<description>Battery charge percentage</description>
|
||||
<state readOnly="true"></state>
|
||||
</channel-type>
|
||||
<channel-type id="bin">
|
||||
<item-type>String</item-type>
|
||||
<label>Bin</label>
|
||||
<description>Bin status</description>
|
||||
<state readOnly="true">
|
||||
<options>
|
||||
<option value="ok">OK</option>
|
||||
<option value="full">Full</option>
|
||||
<option value="removed">Removed</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="error">
|
||||
<item-type>String</item-type>
|
||||
<label>Error</label>
|
||||
<description>Error code</description>
|
||||
<state readOnly="true">
|
||||
<options>
|
||||
<!-- Taken from Roomba980-Python, originally reverse engineered from phone app -->
|
||||
<option value="0">None</option>
|
||||
<option value="1">Left wheel off floor</option>
|
||||
<option value="2">Main Brushes stuck</option>
|
||||
<option value="3">Right wheel off floor</option>
|
||||
<option value="4">Left wheel stuck</option>
|
||||
<option value="5">Right wheel stuck</option>
|
||||
<option value="6">Stuck near a cliff</option>
|
||||
<option value="7">Left wheel error</option>
|
||||
<option value="8">Bin error</option>
|
||||
<option value="9">Bumper stuck</option>
|
||||
<option value="10">Right wheel error</option>
|
||||
<option value="11">Bin error</option>
|
||||
<option value="12">Cliff sensor issue</option>
|
||||
<option value="13">Both wheels off floor</option>
|
||||
<option value="14">Bin missing</option>
|
||||
<option value="15">Reboot required</option>
|
||||
<option value="16">Bumped unexpectedly</option>
|
||||
<option value="17">Path blocked</option>
|
||||
<option value="18">Docking issue</option>
|
||||
<option value="19">Undocking issue</option>
|
||||
<option value="20">Docking issue</option>
|
||||
<option value="21">Navigation problem</option>
|
||||
<option value="22">Navigation problem</option>
|
||||
<option value="23">Battery issue</option>
|
||||
<option value="24">Navigation problem</option>
|
||||
<option value="25">Reboot required</option>
|
||||
<option value="26">Vacuum problem</option>
|
||||
<option value="27">Vacuum problem</option>
|
||||
<option value="29">Software update needed</option>
|
||||
<option value="30">Vacuum problem</option>
|
||||
<option value="31">Reboot required</option>
|
||||
<option value="32">Smart map problem</option>
|
||||
<option value="33">Path blocked</option>
|
||||
<option value="34">Reboot required</option>
|
||||
<option value="35">Unrecognized cleaning pad</option>
|
||||
<option value="36">Bin full</option>
|
||||
<option value="37">Tank needed refilling</option>
|
||||
<option value="38">Vacuum problem</option>
|
||||
<option value="39">Reboot required</option>
|
||||
<option value="40">Navigation problem</option>
|
||||
<option value="41">Timed out</option>
|
||||
<option value="42">Localization problem</option>
|
||||
<option value="43">Navigation problem</option>
|
||||
<option value="44">Pump issue</option>
|
||||
<option value="45">Lid open</option>
|
||||
<option value="46">Low battery</option>
|
||||
<option value="47">Reboot required</option>
|
||||
<option value="48">Path blocked</option>
|
||||
<option value="52">Pad required attention</option>
|
||||
<option value="65">Hardware problem detected</option>
|
||||
<option value="66">Low memory</option>
|
||||
<option value="68">Hardware problem detected</option>
|
||||
<option value="73">Pad type changed</option>
|
||||
<option value="74">Max area reached</option>
|
||||
<option value="75">Navigation problem</option>
|
||||
<option value="76">Hardware problem detected</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="sched_switch">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Schedule</label>
|
||||
</channel-type>
|
||||
<channel-type id="schedule" advanced="true">
|
||||
<item-type>Number</item-type>
|
||||
<label>Schedule</label>
|
||||
<description>Schedule bitmask for use in scripts: Sun Mon Tue Wed Thu Fri Sat</description>
|
||||
<state min="0" max="127"/>
|
||||
</channel-type>
|
||||
<channel-type id="rssi" advanced="true">
|
||||
<item-type>Number</item-type>
|
||||
<label>RSSI</label>
|
||||
<description>Wi-Fi signal strength</description>
|
||||
<state readOnly="true"></state>
|
||||
</channel-type>
|
||||
<channel-type id="snr" advanced="true">
|
||||
<item-type>Number</item-type>
|
||||
<label>SNR</label>
|
||||
<description>Wi-Fi signal to noise ratio</description>
|
||||
<state readOnly="true"></state>
|
||||
</channel-type>
|
||||
<channel-type id="edge_clean" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Edge clean</label>
|
||||
<description>Seek out and clean along walls and furniture legs</description>
|
||||
</channel-type>
|
||||
<channel-type id="always_finish" advanced="true">
|
||||
<item-type>Switch</item-type>
|
||||
<label>Always finish</label>
|
||||
<description>Do not pause current mission if the bin is full</description>
|
||||
</channel-type>
|
||||
<channel-type id="power_boost" advanced="true">
|
||||
<item-type>String</item-type>
|
||||
<label>Power boost</label>
|
||||
<description>Carpet boost mode</description>
|
||||
<state>
|
||||
<options>
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="performance">Performance mode</option>
|
||||
<option value="eco">Eco mode</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
<channel-type id="clean_passes" advanced="true">
|
||||
<item-type>String</item-type>
|
||||
<label>Cleaning passes</label>
|
||||
<description>Number of cleaning passes to make</description>
|
||||
<state>
|
||||
<options>
|
||||
<option value="auto">Automatic</option>
|
||||
<option value="1">One pass</option>
|
||||
<option value="2">Two passes</option>
|
||||
</options>
|
||||
</state>
|
||||
</channel-type>
|
||||
|
||||
</thing:thing-descriptions>
|
||||
Reference in New Issue
Block a user