2 * Copyright (c) 2010-2020 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;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.irobot.internal.RawMQTT;
32 import org.openhab.binding.irobot.internal.RoombaConfiguration;
33 import org.openhab.binding.irobot.internal.dto.IdentProtocol;
34 import org.openhab.binding.irobot.internal.dto.IdentProtocol.IdentData;
35 import org.openhab.binding.irobot.internal.dto.MQTTProtocol;
36 import org.openhab.core.config.core.Configuration;
37 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
38 import org.openhab.core.io.transport.mqtt.MqttConnectionObserver;
39 import org.openhab.core.io.transport.mqtt.MqttConnectionState;
40 import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
41 import org.openhab.core.io.transport.mqtt.reconnect.PeriodicReconnectStrategy;
42 import org.openhab.core.library.types.DecimalType;
43 import org.openhab.core.library.types.OnOffType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.State;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
56 import com.google.gson.Gson;
57 import com.google.gson.JsonArray;
58 import com.google.gson.JsonParseException;
59 import com.google.gson.JsonPrimitive;
60 import com.google.gson.stream.JsonReader;
63 * The {@link RoombaHandler} is responsible for handling commands, which are
64 * sent to one of the channels.
66 * @author hkuhn42 - Initial contribution
67 * @author Pavel Fedin - Rewrite for 900 series
70 public class RoombaHandler extends BaseThingHandler implements MqttConnectionObserver, MqttMessageSubscriber {
71 private final Logger logger = LoggerFactory.getLogger(RoombaHandler.class);
72 private final Gson gson = new Gson();
73 private static final int RECONNECT_DELAY_SEC = 5; // In seconds
74 private @Nullable Future<?> reconnectReq;
75 // Dummy RoombaConfiguration object in order to shut up Eclipse warnings
76 // The real one is set in initialize()
77 private RoombaConfiguration config = new RoombaConfiguration();
78 private @Nullable String blid = null;
79 private @Nullable MqttBrokerConnection connection;
80 private Hashtable<String, State> lastState = new Hashtable<>();
81 private MQTTProtocol.@Nullable Schedule lastSchedule = null;
82 private boolean autoPasses = true;
83 private @Nullable Boolean twoPasses = null;
84 private boolean carpetBoost = true;
85 private @Nullable Boolean vacHigh = null;
86 private boolean isPaused = false;
88 public RoombaHandler(Thing thing) {
93 public void initialize() {
94 config = getConfigAs(RoombaConfiguration.class);
95 updateStatus(ThingStatus.UNKNOWN);
96 scheduler.execute(this::connect);
100 public void dispose() {
101 scheduler.execute(this::disconnect);
104 // lastState.get() can return null if the key is not found according
105 // to the documentation
106 @SuppressWarnings("null")
107 private void handleRefresh(String ch) {
108 State value = lastState.get(ch);
111 updateState(ch, value);
116 public void handleCommand(ChannelUID channelUID, Command command) {
117 String ch = channelUID.getId();
118 if (command instanceof RefreshType) {
123 if (ch.equals(CHANNEL_COMMAND)) {
124 if (command instanceof StringType) {
125 String cmd = command.toString();
127 if (cmd.equals(CMD_CLEAN)) {
128 cmd = isPaused ? "resume" : "start";
131 sendRequest(new MQTTProtocol.CommandRequest(cmd));
133 } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
134 MQTTProtocol.Schedule schedule = lastSchedule;
136 // Schedule can only be updated in a bulk, so we have to store current
137 // schedule and modify components.
138 if (command instanceof OnOffType && schedule != null && schedule.cycle != null) {
139 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
140 if (ch.equals(CHANNEL_SCHED_SWITCH[i])) {
141 MQTTProtocol.Schedule newSchedule = new MQTTProtocol.Schedule(schedule.cycle);
143 newSchedule.enableCycle(i, command.equals(OnOffType.ON));
144 sendSchedule(newSchedule);
149 } else if (ch.equals(CHANNEL_SCHEDULE)) {
150 if (command instanceof DecimalType) {
151 int bitmask = ((DecimalType) command).intValue();
152 JsonArray cycle = new JsonArray();
154 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
155 enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
158 sendSchedule(new MQTTProtocol.Schedule(bitmask));
160 } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
161 if (command instanceof OnOffType) {
162 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
164 } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
165 if (command instanceof OnOffType) {
166 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
168 } else if (ch.equals(CHANNEL_POWER_BOOST)) {
169 sendDelta(new MQTTProtocol.PowerBoost(command.equals(BOOST_AUTO), command.equals(BOOST_PERFORMANCE)));
170 } else if (ch.equals(CHANNEL_CLEAN_PASSES)) {
171 sendDelta(new MQTTProtocol.CleanPasses(!command.equals(PASSES_AUTO), command.equals(PASSES_2)));
175 private void enableCycle(JsonArray cycle, int i, boolean enable) {
176 JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
180 private void sendSchedule(MQTTProtocol.Schedule schedule) {
181 sendDelta(new MQTTProtocol.CleanSchedule(schedule));
184 private void sendDelta(MQTTProtocol.StateValue state) {
185 sendRequest(new MQTTProtocol.DeltaRequest(state));
188 private void sendRequest(MQTTProtocol.Request request) {
189 MqttBrokerConnection conn = connection;
192 String json = gson.toJson(request);
193 logger.trace("Sending {}: {}", request.getTopic(), json);
194 // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
195 // by Roomba, others just cause it to reject the command and drop the connection.
196 conn.publish(request.getTopic(), json.getBytes(), 1, false);
200 // In order not to mess up our connection state we need to make sure
201 // that connect() and disconnect() are never running concurrently, so
202 // they are synchronized
203 private synchronized void connect() {
204 logger.debug("Connecting to {}", config.ipaddress);
207 InetAddress host = InetAddress.getByName(config.ipaddress);
208 String blid = this.blid;
211 DatagramSocket identSocket = IdentProtocol.sendRequest(host);
212 DatagramPacket identPacket = IdentProtocol.receiveResponse(identSocket);
213 IdentProtocol.IdentData ident;
218 ident = IdentProtocol.decodeResponse(identPacket);
219 } catch (JsonParseException e) {
220 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
221 "Malformed IDENT response");
225 if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
226 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
227 "Unsupported version " + ident.ver);
231 if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) {
232 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
233 "Not a Roomba: " + ident.product);
241 logger.debug("BLID is: {}", blid);
243 if (config.password.isEmpty()) {
247 mqtt = new RawMQTT(host, 8883);
248 } catch (KeyManagementException | NoSuchAlgorithmException e1) {
249 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.toString());
250 return; // This is internal system error, no retry
253 mqtt.requestPassword();
254 RawMQTT.Packet response = mqtt.readPacket();
257 if (response != null && response.isValidPasswdPacket()) {
258 RawMQTT.PasswdPacket passwdPacket = new RawMQTT.PasswdPacket(response);
259 String password = passwdPacket.getPassword();
261 if (password != null) {
262 config.password = password;
264 Configuration configuration = editConfiguration();
266 configuration.put("password", password);
267 updateConfiguration(configuration);
269 logger.debug("Password successfully retrieved");
274 if (config.password.isEmpty()) {
275 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
276 "Authentication on the robot is required");
281 // BLID is used as both client ID and username. The name of BLID also came from Roomba980-python
282 MqttBrokerConnection connection = new MqttBrokerConnection(config.ipaddress, RawMQTT.ROOMBA_MQTT_PORT, true,
285 this.connection = connection;
287 // Disable sending UNSUBSCRIBE request before disconnecting becuase Roomba doesn't like it.
288 // It just swallows the request and never sends any response, so stop() method never completes.
289 connection.setUnsubscribeOnStop(false);
290 connection.setCredentials(blid, config.password);
291 connection.setTrustManagers(RawMQTT.getTrustManagers());
292 // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
293 // by Roomba, others just cause it to reject the command and drop the connection.
294 connection.setQos(1);
295 // MQTT connection reconnects itself, so we don't have to call scheduleReconnect()
296 // when it breaks. Just set the period in ms.
297 connection.setReconnectStrategy(
298 new PeriodicReconnectStrategy(RECONNECT_DELAY_SEC * 1000, RECONNECT_DELAY_SEC * 1000));
299 connection.start().exceptionally(e -> {
300 connectionStateChanged(MqttConnectionState.DISCONNECTED, e);
304 connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout"));
306 connectionStateChanged(MqttConnectionState.CONNECTED, null);
309 } catch (IOException e) {
310 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
315 private synchronized void disconnect() {
316 Future<?> reconnectReq = this.reconnectReq;
317 MqttBrokerConnection connection = this.connection;
319 if (reconnectReq != null) {
320 reconnectReq.cancel(false);
321 this.reconnectReq = null;
324 if (connection != null) {
326 logger.trace("Closed connection to {}", config.ipaddress);
327 this.connection = null;
331 private void scheduleReconnect() {
332 reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS);
335 public void onConnected() {
336 updateStatus(ThingStatus.ONLINE);
340 public void processMessage(String topic, byte[] payload) {
341 String jsonStr = new String(payload);
342 MQTTProtocol.StateMessage msg;
344 logger.trace("Got topic {} data {}", topic, jsonStr);
347 // We are not consuming all the fields, so we have to create the reader explicitly
348 // If we use fromJson(String) or fromJson(java.util.reader), it will throw
349 // "JSON not fully consumed" exception, because not all the reader's content has been
350 // used up. We want to avoid that also for compatibility reasons because newer iRobot
351 // versions may add fields.
352 JsonReader jsonReader = new JsonReader(new StringReader(jsonStr));
353 msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
354 } catch (JsonParseException e) {
355 logger.warn("Failed to parse JSON message from {}: {}", config.ipaddress, e.toString());
356 logger.warn("Raw contents: {}", payload);
360 // Since all the fields are in fact optional, and a single message never
361 // contains all of them, we have to check presence of each individually
362 if (msg.state == null || msg.state.reported == null) {
366 MQTTProtocol.GenericState reported = msg.state.reported;
368 if (reported.cleanMissionStatus != null) {
369 String cycle = reported.cleanMissionStatus.cycle;
370 String phase = reported.cleanMissionStatus.phase;
373 if (cycle.equals("none")) {
378 case "stuck": // CHECKME: could also be equivalent to "stop" command
379 case "pause": // Never observed in Roomba 930
383 case "dock": // Never observed in Roomba 930
387 command = cycle; // "clean" or "spot"
392 isPaused = command.equals(CMD_PAUSE);
394 reportString(CHANNEL_CYCLE, cycle);
395 reportString(CHANNEL_PHASE, phase);
396 reportString(CHANNEL_COMMAND, command);
397 reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
400 if (reported.batPct != null) {
401 reportInt(CHANNEL_BATTERY, reported.batPct);
404 if (reported.bin != null) {
407 // The bin cannot be both full and removed simultaneously, so let's
408 // encode it as a single value
409 if (!reported.bin.present) {
410 binStatus = BIN_REMOVED;
411 } else if (reported.bin.full) {
412 binStatus = BIN_FULL;
417 reportString(CHANNEL_BIN, binStatus);
420 if (reported.signal != null) {
421 reportInt(CHANNEL_RSSI, reported.signal.rssi);
422 reportInt(CHANNEL_SNR, reported.signal.snr);
425 if (reported.cleanSchedule != null) {
426 MQTTProtocol.Schedule schedule = reported.cleanSchedule;
428 if (schedule.cycle != null) {
431 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
432 boolean on = schedule.cycleEnabled(i);
434 reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
440 reportInt(CHANNEL_SCHEDULE, binary);
443 lastSchedule = schedule;
446 if (reported.openOnly != null) {
447 reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
450 if (reported.binPause != null) {
451 reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
454 // To make the life more interesting, paired values may not appear together in the
455 // same message, so we have to keep track of current values.
456 if (reported.carpetBoost != null) {
457 carpetBoost = reported.carpetBoost;
458 if (reported.carpetBoost) {
459 // When set to true, overrides vacHigh
460 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
461 } else if (vacHigh != null) {
466 if (reported.vacHigh != null) {
467 vacHigh = reported.vacHigh;
469 // Can be overridden by "carpetBoost":true
474 if (reported.noAutoPasses != null) {
475 autoPasses = !reported.noAutoPasses;
476 if (!reported.noAutoPasses) {
477 // When set to false, overrides twoPass
478 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
479 } else if (twoPasses != null) {
484 if (reported.twoPass != null) {
485 twoPasses = reported.twoPass;
487 // Can be overridden by "noAutoPasses":false
492 reportProperty(Thing.PROPERTY_FIRMWARE_VERSION, reported.softwareVer);
493 reportProperty("navSwVer", reported.navSwVer);
494 reportProperty("wifiSwVer", reported.wifiSwVer);
495 reportProperty("mobilityVer", reported.mobilityVer);
496 reportProperty("bootloaderVer", reported.bootloaderVer);
497 reportProperty("umiVer", reported.umiVer);
500 private void reportVacHigh() {
501 reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
504 private void reportTwoPasses() {
505 reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
508 private void reportString(String channel, String str) {
509 reportState(channel, StringType.valueOf(str));
512 private void reportInt(String channel, int n) {
513 reportState(channel, new DecimalType(n));
516 private void reportSwitch(String channel, boolean s) {
517 reportState(channel, OnOffType.from(s));
520 private void reportState(String channel, State value) {
521 lastState.put(channel, value);
522 updateState(channel, value);
525 private void reportProperty(String property, @Nullable String value) {
527 updateProperty(property, value);
532 public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
533 if (state == MqttConnectionState.CONNECTED) {
534 MqttBrokerConnection connection = this.connection;
536 if (connection == null) {
537 // This would be very strange, but Eclipse forces us to do the check
538 logger.warn("Established connection without broker pointer");
542 updateStatus(ThingStatus.ONLINE);
544 // Roomba sends us two topics:
545 // "wifistat" - reports singnal strength and current robot position
546 // "$aws/things/<BLID>/shadow/update" - the rest of messages
547 // Subscribe to everything since we're interested in both
548 connection.subscribe("#", this).exceptionally(e -> {
549 logger.warn("MQTT subscription failed: {}", e.getMessage());
553 logger.warn("Subscription timeout");
555 logger.trace("Subscription done");
563 message = error.getMessage();
564 logger.warn("MQTT connection failed: {}", message);
567 logger.warn("MQTT connection failed for unspecified reason");
570 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);