[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:
Sonic-Amiga 2020-12-03 23:28:17 +03:00 committed by GitHub
parent a736e64402
commit 7a821a6c40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 1909 additions and 0 deletions

View File

@ -104,6 +104,7 @@
/bundles/org.openhab.binding.intesis/ @hmerk /bundles/org.openhab.binding.intesis/ @hmerk
/bundles/org.openhab.binding.ipcamera/ @Skinah /bundles/org.openhab.binding.ipcamera/ @Skinah
/bundles/org.openhab.binding.ipp/ @peuter /bundles/org.openhab.binding.ipp/ @peuter
/bundles/org.openhab.binding.irobot/ @Sonic-Amiga
/bundles/org.openhab.binding.irtrans/ @kgoderis /bundles/org.openhab.binding.irtrans/ @kgoderis
/bundles/org.openhab.binding.ism8/ @hans-reiner /bundles/org.openhab.binding.ism8/ @hans-reiner
/bundles/org.openhab.binding.jablotron/ @octa22 /bundles/org.openhab.binding.jablotron/ @octa22

View File

@ -511,6 +511,11 @@
<artifactId>org.openhab.binding.ipp</artifactId> <artifactId>org.openhab.binding.ipp</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.irobot</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.openhab.addons.bundles</groupId> <groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.irtrans</artifactId> <artifactId>org.openhab.binding.irtrans</artifactId>

View File

@ -0,0 +1,13 @@
This content is produced and maintained by the openHAB project.
* Project home: https://www.openhab.org
== Declared Project Licenses
This program and the accompanying materials are made available under the terms
of the Eclipse Public License 2.0 which is available at
https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons

View File

@ -0,0 +1,184 @@
# iRobot Binding
This binding provides integration of products by iRobot company (http://www.irobot.com/). It is currently developed to support Roomba 900
series robotic vacuum cleaner with built-in Wi-Fi module. The binding interfaces to the robot directly without any need for a dedicated MQTT server.
## Supported Things
- iRobot Roomba robotic vacuum cleaner (https://www.irobot.com/roomba). The binding has been developed and tested with Roomba 930.
- iRobot Braava has also been reported to (partially) work. Automatic configuration and password retrieval does not work. Add the robot manually as Roomba and use external tools (like Dorita980) in order to retrieve the password.
## Discovery
Roombas on the same network will be discovered automatically, however in order to connect to them a password is needed. The
password is a machine-generated string, which is unfortunately not exposed by the original iRobot smartphone application, but
it can be downloaded from the robot itself. If no password is configured, the Thing enters "CONFIGURATION PENDING" state.
Now you need to perform authorization by pressing and holding the HOME button on your robot until it plays series of tones
(approximately 2 seconds). The Wi-Fi indicator on the robot will flash for 30 seconds, the binding should automatically
receive the password and go ONLINE.
After you've done this procedure you can write the password somewhere in case if you need to reconfigure your binding. It's not
known, however, whether the password is eternal or can change during factory reset.
## Thing Configuration
| Parameter | Meaning |
|-----------|----------------------------------------|
| ipaddress | IP address (or hostname) of your robot |
| password | Password for the robot |
## Channels
| channel | type | description | Read-only |
|---------------|--------|----------------------------------------------------|-----------|
| command | String | Command to execute: clean, spot, dock, pause, stop | N |
| cycle | String | Current mission: none, clean, spot | Y |
| phase | String | Current phase of the mission; see below. | Y |
| battery | Number | Battery charge in percents | Y |
| bin | String | Bin status: ok, removed, full | Y |
| error | String | Error code; see below | Y |
| rssi | Number | Wi-Fi Received Signal Strength indicator in db | Y |
| snr | Number | Wi-Fi Signal to noise ratio | Y |
| sched_mon | Switch | Scheduled clean enabled for Monday | N |
| sched_tue | Switch | Scheduled clean enabled for Tuesday | N |
| sched_wed | Switch | Scheduled clean enabled for Wednesday | N |
| sched_thu | Switch | Scheduled clean enabled for Thursday | N |
| sched_fri | Switch | Scheduled clean enabled for Friday | N |
| sched_sat | Switch | Scheduled clean enabled for Saturday | N |
| sched_sun | Switch | Scheduled clean enabled for Sunday | N |
| schedule | Number | Schedule bitmask for use in scripts. 7 bits, bit #0 corresponds to Sunday | N |
| edge_clean | Switch | Seek out and clean along walls and furniture legs | N |
| always_finish | Switch | Whether to keep cleaning if the bin becomes full | N |
| power_boost | String | Power boost mode: "auto", "performance", "eco" | N |
| clean_passes | String | Number of cleaning passes: "auto", "1", "2" | N |
Known phase strings and their meanings:
| phase | Meaning |
|-----------|-----------------------------------|
| charge | Charging |
| new | New Mission (*) |
| run | Running |
| resume | Resumed (*) |
| hmMidMsn | Going for recharge during mission |
| recharge | Recharging |
| stuck | Stuck |
| mUsrDock | Going home (on user command) |
| dock | Docking (*) |
| dockend | Docking - End Mission (*) |
| cancelled | Cancelled (*) |
| stop | Stopped |
| pause | Paused (*) |
| hmPostMsn | Going home after mission |
| "" (empty string) | None (*) |
Phases, marked with asterisk (*), have not been seen being reported by Roomba 930. All the definitions
are taken from Roomba980-Python.
Error codes. Data type is string in order to be able to utilize mapping to human-readable strings.
| Code | Meaning |
|------|----------------------------|
| 0 | None |
| 1 | Left wheel off floor |
| 2 | Main Brushes stuck |
| 3 | Right wheel off floor |
| 4 | Left wheel stuck |
| 5 | Right wheel stuck |
| 6 | Stuck near a cliff |
| 7 | Left wheel error |
| 8 | Bin error |
| 9 | Bumper stuck |
| 10 | Right wheel error |
| 11 | Bin error |
| 12 | Cliff sensor issue |
| 13 | Both wheels off floor |
| 14 | Bin missing |
| 15 | Reboot required |
| 16 | Bumped unexpectedly |
| 17 | Path blocked |
| 18 | Docking issue |
| 19 | Undocking issue |
| 20 | Docking issue |
| 21 | Navigation problem |
| 22 | Navigation problem |
| 23 | Battery issue |
| 24 | Navigation problem |
| 25 | Reboot required |
| 26 | Vacuum problem |
| 27 | Vacuum problem |
| 29 | Software update needed |
| 30 | Vacuum problem |
| 31 | Reboot required |
| 32 | Smart map problem |
| 33 | Path blocked |
| 34 | Reboot required |
| 35 | Unrecognized cleaning pad |
| 36 | Bin full |
| 37 | Tank needed refilling |
| 38 | Vacuum problem |
| 39 | Reboot required |
| 40 | Navigation problem |
| 41 | Timed out |
| 42 | Localization problem |
| 43 | Navigation problem |
| 44 | Pump issue |
| 45 | Lid open |
| 46 | Low battery |
| 47 | Reboot required |
| 48 | Path blocked |
| 52 | Pad required attention |
| 65 | Hardware problem detected |
| 66 | Low memory |
| 68 | Hardware problem detected |
| 73 | Pad type changed |
| 74 | Max area reached |
| 75 | Navigation problem |
| 76 | Hardware problem detected |
## Known Problems / Caveats
1. Sending "pause" command during missions other than "clean" is equivalent to sending "stop"
2. Switching to "spot" mission is possible only in "stop" state. Attempt to do it otherwise causes error: the command is rejected and error tones are played.
3. Roomba's built-in MQTT server, used for communication, supports only a single local connection at a time. Bear this in mind when you want to do something that requires local connection from your phone, like reconfiguring the network. Disable openHAB Thing before doing this.
4. Sometimes during intensive testing Roomba just stopped communicating over the local connection. If this happens, try rebooting it. On my robot it's done by holding "Clean" button for about 10 seconds until all the LEDs come on. Release the button and the reboot tone will be played. It looks like there are some bugs in the firmware.
## Example
irobot.things:
```
irobot:roomba:my_roomba [ ipaddress="192.168.0.5", password="xxxxxxxx" ]
```
irobot.items:
```
String Roomba_Command { channel="irobot:roomba:my_roomba:command" }
String Roomba_Cycle { channel="irobot:roomba:my_roomba:cycle" }
String Roomba_Phase { channel="irobot:roomba:my_roomba:phase" }
Number Roomba_Battery { channel="irobot:roomba:my_roomba:battery" }
String Roomba_Bin { channel="irobot:roomba:my_roomba:bin" }
String Roomba_Error { channel="irobot:roomba:my_roomba:error" }
```
irobot.sitemap:
```
Selection item=Roomba_Command mappings=["clean"="Clean", "spot"="Spot", dock="Dock", pause="Pause", stop="Stop"]
Text item=Roomba_Cycle label="Current cycle"
Text item=Roomba_Phase label="Current phase"
Text item=Roomba_Battery label="Battery charge [%d %%]"
Text item=Roomba_Bin label="Bin status"
Text item=Roomba_Error label="Error"
```
## Credits
This code is a result of development of an abandoned draft by hkunh42 (http://github.com/hkuhn42/openhab2.roomba)
and heavily uses the following projects as a reference:
- Roomba980-Python by Nick Waterton (http://github.com/NickWaterton/Roomba980-Python)
- Dorita980 by Facu ZAK (https://github.com/koalazak/dorita980)

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.0.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.irobot</artifactId>
<name>openHAB Add-ons :: Bundles :: iRobot Binding</name>
</project>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -136,6 +136,7 @@
<module>org.openhab.binding.ipcamera</module> <module>org.openhab.binding.ipcamera</module>
<module>org.openhab.binding.intesis</module> <module>org.openhab.binding.intesis</module>
<module>org.openhab.binding.ipp</module> <module>org.openhab.binding.ipp</module>
<module>org.openhab.binding.irobot</module>
<module>org.openhab.binding.irtrans</module> <module>org.openhab.binding.irtrans</module>
<module>org.openhab.binding.ism8</module> <module>org.openhab.binding.ism8</module>
<module>org.openhab.binding.jablotron</module> <module>org.openhab.binding.jablotron</module>