mirror of
https://github.com/openhab/openhab-addons.git
synced 2025-01-10 23:22:02 +01:00
[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:
parent
a736e64402
commit
7a821a6c40
@ -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
|
||||||
|
@ -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>
|
||||||
|
13
bundles/org.openhab.binding.irobot/NOTICE
Normal file
13
bundles/org.openhab.binding.irobot/NOTICE
Normal 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
|
184
bundles/org.openhab.binding.irobot/README.md
Normal file
184
bundles/org.openhab.binding.irobot/README.md
Normal 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)
|
16
bundles/org.openhab.binding.irobot/pom.xml
Normal file
16
bundles/org.openhab.binding.irobot/pom.xml
Normal 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>
|
@ -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>
|
@ -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>
|
||||||
|
Loading…
Reference in New Issue
Block a user