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)));
191 } else if (ch.equals(CHANNEL_MAP_UPLOAD)) {
192 if (command instanceof OnOffType) {
193 sendDelta(new MQTTProtocol.MapUploadAllowed(command.equals(OnOffType.ON)));
198 private void enableCycle(JsonArray cycle, int i, boolean enable) {
199 JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
203 private void sendSchedule(MQTTProtocol.Schedule schedule) {
204 sendDelta(new MQTTProtocol.CleanSchedule(schedule));
207 private void sendDelta(MQTTProtocol.StateValue state) {
208 sendRequest(new MQTTProtocol.DeltaRequest(state));
211 private void sendRequest(MQTTProtocol.Request request) {
212 MqttBrokerConnection conn = connection;
215 String json = gson.toJson(request);
216 logger.trace("Sending {}: {}", request.getTopic(), json);
217 // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
218 // by Roomba, others just cause it to reject the command and drop the connection.
219 conn.publish(request.getTopic(), json.getBytes(), 1, false);
223 // In order not to mess up our connection state we need to make sure
224 // that connect() and disconnect() are never running concurrently, so
225 // they are synchronized
226 private synchronized void connect() {
227 logger.debug("Connecting to {}", config.ipaddress);
230 InetAddress host = InetAddress.getByName(config.ipaddress);
231 String blid = this.blid;
234 DatagramSocket identSocket = IdentProtocol.sendRequest(host);
235 DatagramPacket identPacket = IdentProtocol.receiveResponse(identSocket);
236 IdentProtocol.IdentData ident;
241 ident = IdentProtocol.decodeResponse(identPacket);
242 } catch (JsonParseException e) {
243 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
244 "Malformed IDENT response");
248 if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
249 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
250 "Unsupported version " + ident.ver);
254 if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) {
255 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
256 "Not a Roomba: " + ident.product);
264 logger.debug("BLID is: {}", blid);
266 if (config.password.isEmpty()) {
270 mqtt = new RawMQTT(host, 8883);
271 } catch (KeyManagementException | NoSuchAlgorithmException e1) {
272 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.toString());
273 return; // This is internal system error, no retry
276 mqtt.requestPassword();
277 RawMQTT.Packet response = mqtt.readPacket();
280 if (response != null && response.isValidPasswdPacket()) {
281 RawMQTT.PasswdPacket passwdPacket = new RawMQTT.PasswdPacket(response);
282 String password = passwdPacket.getPassword();
284 if (password != null) {
285 config.password = password;
287 Configuration configuration = editConfiguration();
289 configuration.put("password", password);
290 updateConfiguration(configuration);
292 logger.debug("Password successfully retrieved");
297 if (config.password.isEmpty()) {
298 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
299 "Authentication on the robot is required");
304 // BLID is used as both client ID and username. The name of BLID also came from Roomba980-python
305 MqttBrokerConnection connection = new MqttBrokerConnection(config.ipaddress, RawMQTT.ROOMBA_MQTT_PORT, true,
308 this.connection = connection;
310 // Disable sending UNSUBSCRIBE request before disconnecting becuase Roomba doesn't like it.
311 // It just swallows the request and never sends any response, so stop() method never completes.
312 connection.setUnsubscribeOnStop(false);
313 connection.setCredentials(blid, config.password);
314 connection.setTrustManagers(RawMQTT.getTrustManagers());
315 // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
316 // by Roomba, others just cause it to reject the command and drop the connection.
317 connection.setQos(1);
318 // MQTT connection reconnects itself, so we don't have to call scheduleReconnect()
319 // when it breaks. Just set the period in ms.
320 connection.setReconnectStrategy(
321 new PeriodicReconnectStrategy(RECONNECT_DELAY_SEC * 1000, RECONNECT_DELAY_SEC * 1000));
322 connection.start().exceptionally(e -> {
323 connectionStateChanged(MqttConnectionState.DISCONNECTED, e);
327 connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout"));
329 connectionStateChanged(MqttConnectionState.CONNECTED, null);
332 } catch (IOException e) {
333 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
338 private synchronized void disconnect() {
339 Future<?> reconnectReq = this.reconnectReq;
340 MqttBrokerConnection connection = this.connection;
342 if (reconnectReq != null) {
343 reconnectReq.cancel(false);
344 this.reconnectReq = null;
347 if (connection != null) {
349 logger.trace("Closed connection to {}", config.ipaddress);
350 this.connection = null;
354 private void scheduleReconnect() {
355 reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS);
358 public void onConnected() {
359 updateStatus(ThingStatus.ONLINE);
363 public void processMessage(String topic, byte[] payload) {
364 String jsonStr = new String(payload);
365 MQTTProtocol.StateMessage msg;
367 logger.trace("Got topic {} data {}", topic, jsonStr);
370 // We are not consuming all the fields, so we have to create the reader explicitly
371 // If we use fromJson(String) or fromJson(java.util.reader), it will throw
372 // "JSON not fully consumed" exception, because not all the reader's content has been
373 // used up. We want to avoid that also for compatibility reasons because newer iRobot
374 // versions may add fields.
375 JsonReader jsonReader = new JsonReader(new StringReader(jsonStr));
376 msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
377 } catch (JsonParseException e) {
378 logger.warn("Failed to parse JSON message from {}: {}", config.ipaddress, e.toString());
379 logger.warn("Raw contents: {}", payload);
383 // Since all the fields are in fact optional, and a single message never
384 // contains all of them, we have to check presence of each individually
385 if (msg.state == null || msg.state.reported == null) {
389 MQTTProtocol.GenericState reported = msg.state.reported;
391 if (reported.cleanMissionStatus != null) {
392 String cycle = reported.cleanMissionStatus.cycle;
393 String phase = reported.cleanMissionStatus.phase;
396 if (cycle.equals("none")) {
401 case "stuck": // CHECKME: could also be equivalent to "stop" command
402 case "pause": // Never observed in Roomba 930
406 case "dock": // Never observed in Roomba 930
410 command = cycle; // "clean" or "spot"
415 isPaused = command.equals(CMD_PAUSE);
417 reportString(CHANNEL_CYCLE, cycle);
418 reportString(CHANNEL_PHASE, phase);
419 reportString(CHANNEL_COMMAND, command);
420 reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
423 if (reported.batPct != null) {
424 reportInt(CHANNEL_BATTERY, reported.batPct);
427 if (reported.bin != null) {
430 // The bin cannot be both full and removed simultaneously, so let's
431 // encode it as a single value
432 if (!reported.bin.present) {
433 binStatus = BIN_REMOVED;
434 } else if (reported.bin.full) {
435 binStatus = BIN_FULL;
440 reportString(CHANNEL_BIN, binStatus);
443 if (reported.signal != null) {
444 reportInt(CHANNEL_RSSI, reported.signal.rssi);
445 reportInt(CHANNEL_SNR, reported.signal.snr);
448 if (reported.cleanSchedule != null) {
449 MQTTProtocol.Schedule schedule = reported.cleanSchedule;
451 if (schedule.cycle != null) {
454 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
455 boolean on = schedule.cycleEnabled(i);
457 reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
463 reportInt(CHANNEL_SCHEDULE, binary);
466 lastSchedule = schedule;
469 if (reported.openOnly != null) {
470 reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
473 if (reported.binPause != null) {
474 reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
477 // To make the life more interesting, paired values may not appear together in the
478 // same message, so we have to keep track of current values.
479 if (reported.carpetBoost != null) {
480 carpetBoost = reported.carpetBoost;
481 if (reported.carpetBoost) {
482 // When set to true, overrides vacHigh
483 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
484 } else if (vacHigh != null) {
489 if (reported.vacHigh != null) {
490 vacHigh = reported.vacHigh;
492 // Can be overridden by "carpetBoost":true
497 if (reported.noAutoPasses != null) {
498 autoPasses = !reported.noAutoPasses;
499 if (!reported.noAutoPasses) {
500 // When set to false, overrides twoPass
501 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
502 } else if (twoPasses != null) {
507 if (reported.twoPass != null) {
508 twoPasses = reported.twoPass;
510 // Can be overridden by "noAutoPasses":false
515 if (reported.lastCommand != null) {
516 reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
519 if (reported.mapUploadAllowed != null) {
520 reportSwitch(CHANNEL_MAP_UPLOAD, reported.mapUploadAllowed);
523 reportProperty(Thing.PROPERTY_FIRMWARE_VERSION, reported.softwareVer);
524 reportProperty("navSwVer", reported.navSwVer);
525 reportProperty("wifiSwVer", reported.wifiSwVer);
526 reportProperty("mobilityVer", reported.mobilityVer);
527 reportProperty("bootloaderVer", reported.bootloaderVer);
528 reportProperty("umiVer", reported.umiVer);
529 reportProperty("sku", reported.sku);
530 reportProperty("batteryType", reported.batteryType);
532 if (reported.subModSwVer != null) {
533 // This is used by i7 model. It has more capabilities, perhaps a dedicated
534 // handler should be written by someone who owns it.
535 reportProperty("subModSwVer.nav", reported.subModSwVer.nav);
536 reportProperty("subModSwVer.mob", reported.subModSwVer.mob);
537 reportProperty("subModSwVer.pwr", reported.subModSwVer.pwr);
538 reportProperty("subModSwVer.sft", reported.subModSwVer.sft);
539 reportProperty("subModSwVer.mobBtl", reported.subModSwVer.mobBtl);
540 reportProperty("subModSwVer.linux", reported.subModSwVer.linux);
541 reportProperty("subModSwVer.con", reported.subModSwVer.con);
545 private void reportVacHigh() {
546 reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
549 private void reportTwoPasses() {
550 reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
553 private void reportString(String channel, String str) {
554 reportState(channel, StringType.valueOf(str));
557 private void reportInt(String channel, int n) {
558 reportState(channel, new DecimalType(n));
561 private void reportSwitch(String channel, boolean s) {
562 reportState(channel, OnOffType.from(s));
565 private void reportState(String channel, State value) {
566 lastState.put(channel, value);
567 updateState(channel, value);
570 private void reportProperty(String property, @Nullable String value) {
572 updateProperty(property, value);
577 public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
578 if (state == MqttConnectionState.CONNECTED) {
579 MqttBrokerConnection connection = this.connection;
581 if (connection == null) {
582 // This would be very strange, but Eclipse forces us to do the check
583 logger.warn("Established connection without broker pointer");
587 updateStatus(ThingStatus.ONLINE);
589 // Roomba sends us two topics:
590 // "wifistat" - reports singnal strength and current robot position
591 // "$aws/things/<BLID>/shadow/update" - the rest of messages
592 // Subscribe to everything since we're interested in both
593 connection.subscribe("#", this).exceptionally(e -> {
594 logger.warn("MQTT subscription failed: {}", e.getMessage());
598 logger.warn("Subscription timeout");
600 logger.trace("Subscription done");
608 message = error.getMessage();
609 logger.warn("MQTT connection failed: {}", message);
612 logger.warn("MQTT connection failed for unspecified reason");
615 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);