2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.irobot.internal.handler;
15 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.*;
17 import java.io.IOException;
18 import java.io.StringReader;
19 import java.net.DatagramPacket;
20 import java.net.DatagramSocket;
21 import java.net.InetAddress;
22 import java.security.KeyManagementException;
23 import java.security.NoSuchAlgorithmException;
24 import java.util.Hashtable;
25 import java.util.concurrent.Future;
26 import java.util.concurrent.TimeUnit;
27 import java.util.concurrent.TimeoutException;
28 import java.util.regex.Pattern;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.irobot.internal.RawMQTT;
33 import org.openhab.binding.irobot.internal.RoombaConfiguration;
34 import org.openhab.binding.irobot.internal.dto.IdentProtocol;
35 import org.openhab.binding.irobot.internal.dto.IdentProtocol.IdentData;
36 import org.openhab.binding.irobot.internal.dto.MQTTProtocol;
37 import org.openhab.core.config.core.Configuration;
38 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
39 import org.openhab.core.io.transport.mqtt.MqttConnectionObserver;
40 import org.openhab.core.io.transport.mqtt.MqttConnectionState;
41 import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
42 import org.openhab.core.io.transport.mqtt.reconnect.PeriodicReconnectStrategy;
43 import org.openhab.core.library.types.DecimalType;
44 import org.openhab.core.library.types.OnOffType;
45 import org.openhab.core.library.types.StringType;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseThingHandler;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.State;
54 import org.slf4j.Logger;
55 import org.slf4j.LoggerFactory;
57 import com.google.gson.Gson;
58 import com.google.gson.JsonArray;
59 import com.google.gson.JsonParseException;
60 import com.google.gson.JsonPrimitive;
61 import com.google.gson.stream.JsonReader;
64 * The {@link RoombaHandler} is responsible for handling commands, which are
65 * sent to one of the channels.
67 * @author hkuhn42 - Initial contribution
68 * @author Pavel Fedin - Rewrite for 900 series
69 * @author Florian Binder - added cleanRegions command and lastCommand channel
72 public class RoombaHandler extends BaseThingHandler implements MqttConnectionObserver, MqttMessageSubscriber {
73 private final Logger logger = LoggerFactory.getLogger(RoombaHandler.class);
74 private final Gson gson = new Gson();
75 private static final int RECONNECT_DELAY_SEC = 5; // In seconds
76 private @Nullable Future<?> reconnectReq;
77 // Dummy RoombaConfiguration object in order to shut up Eclipse warnings
78 // The real one is set in initialize()
79 private RoombaConfiguration config = new RoombaConfiguration();
80 private @Nullable String blid = null;
81 private @Nullable MqttBrokerConnection connection;
82 private Hashtable<String, State> lastState = new Hashtable<>();
83 private MQTTProtocol.@Nullable Schedule lastSchedule = null;
84 private boolean autoPasses = true;
85 private @Nullable Boolean twoPasses = null;
86 private boolean carpetBoost = true;
87 private @Nullable Boolean vacHigh = null;
88 private boolean isPaused = false;
90 public RoombaHandler(Thing thing) {
95 public void initialize() {
96 config = getConfigAs(RoombaConfiguration.class);
97 updateStatus(ThingStatus.UNKNOWN);
98 scheduler.execute(this::connect);
102 public void dispose() {
103 scheduler.execute(this::disconnect);
106 // lastState.get() can return null if the key is not found according
107 // to the documentation
108 @SuppressWarnings("null")
109 private void handleRefresh(String ch) {
110 State value = lastState.get(ch);
113 updateState(ch, value);
118 public void handleCommand(ChannelUID channelUID, Command command) {
119 String ch = channelUID.getId();
120 if (command instanceof RefreshType) {
125 if (ch.equals(CHANNEL_COMMAND)) {
126 if (command instanceof StringType) {
127 String cmd = command.toString();
129 if (cmd.equals(CMD_CLEAN)) {
130 cmd = isPaused ? "resume" : "start";
133 if (cmd.startsWith(CMD_CLEAN_REGIONS)) {
134 // format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...
135 if (Pattern.matches("cleanRegions:[^:;,]+;.+(,[^:;,]+)*", cmd)) {
136 String[] cmds = cmd.split(":");
137 String[] params = cmds[1].split(";");
139 String mapId = params[0];
140 String[] regionIds = params[1].split(",");
142 sendRequest(new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds));
144 logger.warn("Invalid request: {}", cmd);
145 logger.warn("Correct format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...>");
148 sendRequest(new MQTTProtocol.CommandRequest(cmd));
152 } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
153 MQTTProtocol.Schedule schedule = lastSchedule;
155 // Schedule can only be updated in a bulk, so we have to store current
156 // schedule and modify components.
157 if (command instanceof OnOffType && schedule != null && schedule.cycle != null) {
158 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
159 if (ch.equals(CHANNEL_SCHED_SWITCH[i])) {
160 MQTTProtocol.Schedule newSchedule = new MQTTProtocol.Schedule(schedule.cycle);
162 newSchedule.enableCycle(i, command.equals(OnOffType.ON));
163 sendSchedule(newSchedule);
168 } else if (ch.equals(CHANNEL_SCHEDULE)) {
169 if (command instanceof DecimalType) {
170 int bitmask = ((DecimalType) command).intValue();
171 JsonArray cycle = new JsonArray();
173 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
174 enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
177 sendSchedule(new MQTTProtocol.Schedule(bitmask));
179 } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
180 if (command instanceof OnOffType) {
181 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
183 } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
184 if (command instanceof OnOffType) {
185 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
187 } else if (ch.equals(CHANNEL_POWER_BOOST)) {
188 sendDelta(new MQTTProtocol.PowerBoost(command.equals(BOOST_AUTO), command.equals(BOOST_PERFORMANCE)));
189 } else if (ch.equals(CHANNEL_CLEAN_PASSES)) {
190 sendDelta(new MQTTProtocol.CleanPasses(!command.equals(PASSES_AUTO), command.equals(PASSES_2)));
194 private void enableCycle(JsonArray cycle, int i, boolean enable) {
195 JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
199 private void sendSchedule(MQTTProtocol.Schedule schedule) {
200 sendDelta(new MQTTProtocol.CleanSchedule(schedule));
203 private void sendDelta(MQTTProtocol.StateValue state) {
204 sendRequest(new MQTTProtocol.DeltaRequest(state));
207 private void sendRequest(MQTTProtocol.Request request) {
208 MqttBrokerConnection conn = connection;
211 String json = gson.toJson(request);
212 logger.trace("Sending {}: {}", request.getTopic(), json);
213 // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
214 // by Roomba, others just cause it to reject the command and drop the connection.
215 conn.publish(request.getTopic(), json.getBytes(), 1, false);
219 // In order not to mess up our connection state we need to make sure
220 // that connect() and disconnect() are never running concurrently, so
221 // they are synchronized
222 private synchronized void connect() {
223 logger.debug("Connecting to {}", config.ipaddress);
226 InetAddress host = InetAddress.getByName(config.ipaddress);
227 String blid = this.blid;
230 DatagramSocket identSocket = IdentProtocol.sendRequest(host);
231 DatagramPacket identPacket = IdentProtocol.receiveResponse(identSocket);
232 IdentProtocol.IdentData ident;
237 ident = IdentProtocol.decodeResponse(identPacket);
238 } catch (JsonParseException e) {
239 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
240 "Malformed IDENT response");
244 if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
245 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
246 "Unsupported version " + ident.ver);
250 if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) {
251 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
252 "Not a Roomba: " + ident.product);
260 logger.debug("BLID is: {}", blid);
262 if (config.password.isEmpty()) {
266 mqtt = new RawMQTT(host, 8883);
267 } catch (KeyManagementException | NoSuchAlgorithmException e1) {
268 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.toString());
269 return; // This is internal system error, no retry
272 mqtt.requestPassword();
273 RawMQTT.Packet response = mqtt.readPacket();
276 if (response != null && response.isValidPasswdPacket()) {
277 RawMQTT.PasswdPacket passwdPacket = new RawMQTT.PasswdPacket(response);
278 String password = passwdPacket.getPassword();
280 if (password != null) {
281 config.password = password;
283 Configuration configuration = editConfiguration();
285 configuration.put("password", password);
286 updateConfiguration(configuration);
288 logger.debug("Password successfully retrieved");
293 if (config.password.isEmpty()) {
294 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
295 "Authentication on the robot is required");
300 // BLID is used as both client ID and username. The name of BLID also came from Roomba980-python
301 MqttBrokerConnection connection = new MqttBrokerConnection(config.ipaddress, RawMQTT.ROOMBA_MQTT_PORT, true,
304 this.connection = connection;
306 // Disable sending UNSUBSCRIBE request before disconnecting becuase Roomba doesn't like it.
307 // It just swallows the request and never sends any response, so stop() method never completes.
308 connection.setUnsubscribeOnStop(false);
309 connection.setCredentials(blid, config.password);
310 connection.setTrustManagers(RawMQTT.getTrustManagers());
311 // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
312 // by Roomba, others just cause it to reject the command and drop the connection.
313 connection.setQos(1);
314 // MQTT connection reconnects itself, so we don't have to call scheduleReconnect()
315 // when it breaks. Just set the period in ms.
316 connection.setReconnectStrategy(
317 new PeriodicReconnectStrategy(RECONNECT_DELAY_SEC * 1000, RECONNECT_DELAY_SEC * 1000));
318 connection.start().exceptionally(e -> {
319 connectionStateChanged(MqttConnectionState.DISCONNECTED, e);
323 connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout"));
325 connectionStateChanged(MqttConnectionState.CONNECTED, null);
328 } catch (IOException e) {
329 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
334 private synchronized void disconnect() {
335 Future<?> reconnectReq = this.reconnectReq;
336 MqttBrokerConnection connection = this.connection;
338 if (reconnectReq != null) {
339 reconnectReq.cancel(false);
340 this.reconnectReq = null;
343 if (connection != null) {
345 logger.trace("Closed connection to {}", config.ipaddress);
346 this.connection = null;
350 private void scheduleReconnect() {
351 reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS);
354 public void onConnected() {
355 updateStatus(ThingStatus.ONLINE);
359 public void processMessage(String topic, byte[] payload) {
360 String jsonStr = new String(payload);
361 MQTTProtocol.StateMessage msg;
363 logger.trace("Got topic {} data {}", topic, jsonStr);
366 // We are not consuming all the fields, so we have to create the reader explicitly
367 // If we use fromJson(String) or fromJson(java.util.reader), it will throw
368 // "JSON not fully consumed" exception, because not all the reader's content has been
369 // used up. We want to avoid that also for compatibility reasons because newer iRobot
370 // versions may add fields.
371 JsonReader jsonReader = new JsonReader(new StringReader(jsonStr));
372 msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
373 } catch (JsonParseException e) {
374 logger.warn("Failed to parse JSON message from {}: {}", config.ipaddress, e.toString());
375 logger.warn("Raw contents: {}", payload);
379 // Since all the fields are in fact optional, and a single message never
380 // contains all of them, we have to check presence of each individually
381 if (msg.state == null || msg.state.reported == null) {
385 MQTTProtocol.GenericState reported = msg.state.reported;
387 if (reported.cleanMissionStatus != null) {
388 String cycle = reported.cleanMissionStatus.cycle;
389 String phase = reported.cleanMissionStatus.phase;
392 if (cycle.equals("none")) {
397 case "stuck": // CHECKME: could also be equivalent to "stop" command
398 case "pause": // Never observed in Roomba 930
402 case "dock": // Never observed in Roomba 930
406 command = cycle; // "clean" or "spot"
411 isPaused = command.equals(CMD_PAUSE);
413 reportString(CHANNEL_CYCLE, cycle);
414 reportString(CHANNEL_PHASE, phase);
415 reportString(CHANNEL_COMMAND, command);
416 reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
419 if (reported.batPct != null) {
420 reportInt(CHANNEL_BATTERY, reported.batPct);
423 if (reported.bin != null) {
426 // The bin cannot be both full and removed simultaneously, so let's
427 // encode it as a single value
428 if (!reported.bin.present) {
429 binStatus = BIN_REMOVED;
430 } else if (reported.bin.full) {
431 binStatus = BIN_FULL;
436 reportString(CHANNEL_BIN, binStatus);
439 if (reported.signal != null) {
440 reportInt(CHANNEL_RSSI, reported.signal.rssi);
441 reportInt(CHANNEL_SNR, reported.signal.snr);
444 if (reported.cleanSchedule != null) {
445 MQTTProtocol.Schedule schedule = reported.cleanSchedule;
447 if (schedule.cycle != null) {
450 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
451 boolean on = schedule.cycleEnabled(i);
453 reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
459 reportInt(CHANNEL_SCHEDULE, binary);
462 lastSchedule = schedule;
465 if (reported.openOnly != null) {
466 reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
469 if (reported.binPause != null) {
470 reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
473 // To make the life more interesting, paired values may not appear together in the
474 // same message, so we have to keep track of current values.
475 if (reported.carpetBoost != null) {
476 carpetBoost = reported.carpetBoost;
477 if (reported.carpetBoost) {
478 // When set to true, overrides vacHigh
479 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
480 } else if (vacHigh != null) {
485 if (reported.vacHigh != null) {
486 vacHigh = reported.vacHigh;
488 // Can be overridden by "carpetBoost":true
493 if (reported.noAutoPasses != null) {
494 autoPasses = !reported.noAutoPasses;
495 if (!reported.noAutoPasses) {
496 // When set to false, overrides twoPass
497 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
498 } else if (twoPasses != null) {
503 if (reported.twoPass != null) {
504 twoPasses = reported.twoPass;
506 // Can be overridden by "noAutoPasses":false
511 if (reported.lastCommand != null) {
512 reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
515 reportProperty(Thing.PROPERTY_FIRMWARE_VERSION, reported.softwareVer);
516 reportProperty("navSwVer", reported.navSwVer);
517 reportProperty("wifiSwVer", reported.wifiSwVer);
518 reportProperty("mobilityVer", reported.mobilityVer);
519 reportProperty("bootloaderVer", reported.bootloaderVer);
520 reportProperty("umiVer", reported.umiVer);
523 private void reportVacHigh() {
524 reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
527 private void reportTwoPasses() {
528 reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
531 private void reportString(String channel, String str) {
532 reportState(channel, StringType.valueOf(str));
535 private void reportInt(String channel, int n) {
536 reportState(channel, new DecimalType(n));
539 private void reportSwitch(String channel, boolean s) {
540 reportState(channel, OnOffType.from(s));
543 private void reportState(String channel, State value) {
544 lastState.put(channel, value);
545 updateState(channel, value);
548 private void reportProperty(String property, @Nullable String value) {
550 updateProperty(property, value);
555 public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
556 if (state == MqttConnectionState.CONNECTED) {
557 MqttBrokerConnection connection = this.connection;
559 if (connection == null) {
560 // This would be very strange, but Eclipse forces us to do the check
561 logger.warn("Established connection without broker pointer");
565 updateStatus(ThingStatus.ONLINE);
567 // Roomba sends us two topics:
568 // "wifistat" - reports singnal strength and current robot position
569 // "$aws/things/<BLID>/shadow/update" - the rest of messages
570 // Subscribe to everything since we're interested in both
571 connection.subscribe("#", this).exceptionally(e -> {
572 logger.warn("MQTT subscription failed: {}", e.getMessage());
576 logger.warn("Subscription timeout");
578 logger.trace("Subscription done");
586 message = error.getMessage();
587 logger.warn("MQTT connection failed: {}", message);
590 logger.warn("MQTT connection failed for unspecified reason");
593 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);