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.BIN_FULL;
16 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BIN_OK;
17 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BIN_REMOVED;
18 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BOOST_AUTO;
19 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BOOST_ECO;
20 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.BOOST_PERFORMANCE;
21 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_ALWAYS_FINISH;
22 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_BATTERY;
23 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_BIN;
24 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_CLEAN_PASSES;
25 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_COMMAND;
26 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_CYCLE;
27 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_EDGE_CLEAN;
28 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_ERROR;
29 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_LAST_COMMAND;
30 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_MAP_UPLOAD;
31 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_PHASE;
32 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_POWER_BOOST;
33 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_RSSI;
34 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SCHEDULE;
35 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SCHED_SWITCH;
36 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SCHED_SWITCH_PREFIX;
37 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CHANNEL_SNR;
38 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_CLEAN;
39 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_CLEAN_REGIONS;
40 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_DOCK;
41 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_PAUSE;
42 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.CMD_STOP;
43 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.PASSES_1;
44 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.PASSES_2;
45 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.PASSES_AUTO;
46 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.ROBOT_BLID;
47 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.ROBOT_PASSWORD;
48 import static org.openhab.binding.irobot.internal.IRobotBindingConstants.UNKNOWN;
49 import static org.openhab.core.thing.ThingStatus.INITIALIZING;
50 import static org.openhab.core.thing.ThingStatus.OFFLINE;
51 import static org.openhab.core.thing.ThingStatus.UNINITIALIZED;
53 import java.io.IOException;
54 import java.io.StringReader;
55 import java.security.KeyManagementException;
56 import java.security.NoSuchAlgorithmException;
57 import java.util.Hashtable;
58 import java.util.concurrent.Future;
59 import java.util.concurrent.TimeUnit;
60 import java.util.regex.Pattern;
62 import org.eclipse.jdt.annotation.NonNullByDefault;
63 import org.eclipse.jdt.annotation.Nullable;
64 import org.openhab.binding.irobot.internal.config.IRobotConfiguration;
65 import org.openhab.binding.irobot.internal.dto.MQTTProtocol;
66 import org.openhab.binding.irobot.internal.utils.LoginRequester;
67 import org.openhab.core.io.transport.mqtt.MqttConnectionState;
68 import org.openhab.core.library.types.DecimalType;
69 import org.openhab.core.library.types.OnOffType;
70 import org.openhab.core.library.types.StringType;
71 import org.openhab.core.thing.ChannelUID;
72 import org.openhab.core.thing.Thing;
73 import org.openhab.core.thing.ThingStatus;
74 import org.openhab.core.thing.ThingStatusDetail;
75 import org.openhab.core.thing.binding.BaseThingHandler;
76 import org.openhab.core.types.Command;
77 import org.openhab.core.types.RefreshType;
78 import org.openhab.core.types.State;
79 import org.slf4j.Logger;
80 import org.slf4j.LoggerFactory;
82 import com.google.gson.Gson;
83 import com.google.gson.JsonArray;
84 import com.google.gson.JsonParseException;
85 import com.google.gson.JsonPrimitive;
86 import com.google.gson.stream.JsonReader;
89 * The {@link RoombaHandler} is responsible for handling commands, which are
90 * sent to one of the channels.
92 * @author hkuhn42 - Initial contribution
93 * @author Pavel Fedin - Rewrite for 900 series
94 * @author Florian Binder - added cleanRegions command and lastCommand channel
95 * @author Alexander Falkenstern - Add support for I7 series
98 public class RoombaHandler extends BaseThingHandler {
99 private final Logger logger = LoggerFactory.getLogger(RoombaHandler.class);
101 private final Gson gson = new Gson();
103 private Hashtable<String, State> lastState = new Hashtable<>();
104 private MQTTProtocol.@Nullable Schedule lastSchedule = null;
105 private boolean autoPasses = true;
106 private @Nullable Boolean twoPasses = null;
107 private boolean carpetBoost = true;
108 private @Nullable Boolean vacHigh = null;
109 private boolean isPaused = false;
111 private @Nullable Future<?> credentialRequester;
112 protected IRobotConnectionHandler connection = new IRobotConnectionHandler() {
114 public void receive(final String topic, final String json) {
115 RoombaHandler.this.receive(topic, json);
119 public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
120 super.connectionStateChanged(state, error);
121 if (state == MqttConnectionState.CONNECTED) {
122 updateStatus(ThingStatus.ONLINE);
124 String message = (error != null) ? error.getMessage() : "Unknown reason";
125 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
130 public RoombaHandler(Thing thing) {
135 public void initialize() {
136 IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
138 if (UNKNOWN.equals(config.getPassword()) || UNKNOWN.equals(config.getBlid())) {
139 final String message = "Robot authentication is required";
140 updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
141 scheduler.execute(this::getCredentials);
143 scheduler.execute(this::connect);
148 public void dispose() {
149 Future<?> requester = credentialRequester;
150 if (requester != null) {
151 requester.cancel(false);
152 credentialRequester = null;
155 scheduler.execute(connection::disconnect);
158 // lastState.get() can return null if the key is not found according
159 // to the documentation
160 @SuppressWarnings("null")
161 private void handleRefresh(String ch) {
162 State value = lastState.get(ch);
165 updateState(ch, value);
170 public void handleCommand(ChannelUID channelUID, Command command) {
171 String ch = channelUID.getId();
172 if (command instanceof RefreshType) {
177 if (ch.equals(CHANNEL_COMMAND)) {
178 if (command instanceof StringType) {
179 String cmd = command.toString();
181 if (cmd.equals(CMD_CLEAN)) {
182 cmd = isPaused ? "resume" : "start";
185 if (cmd.startsWith(CMD_CLEAN_REGIONS)) {
186 // format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...
187 if (Pattern.matches("cleanRegions:[^:;,]+;.+(,[^:;,]+)*", cmd)) {
188 String[] cmds = cmd.split(":");
189 String[] params = cmds[1].split(";");
191 String mapId = params[0];
192 String[] regionIds = params[1].split(",");
194 MQTTProtocol.Request request = new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds);
195 connection.send(request.getTopic(), gson.toJson(request));
197 logger.warn("Invalid request: {}", cmd);
198 logger.warn("Correct format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...>");
201 MQTTProtocol.Request request = new MQTTProtocol.CommandRequest(cmd);
202 connection.send(request.getTopic(), gson.toJson(request));
205 } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
206 MQTTProtocol.Schedule schedule = lastSchedule;
208 // Schedule can only be updated in a bulk, so we have to store current
209 // schedule and modify components.
210 if (command instanceof OnOffType && schedule != null && schedule.cycle != null) {
211 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
212 if (ch.equals(CHANNEL_SCHED_SWITCH[i])) {
213 MQTTProtocol.Schedule newSchedule = new MQTTProtocol.Schedule(schedule.cycle);
215 newSchedule.enableCycle(i, command.equals(OnOffType.ON));
216 sendSchedule(newSchedule);
221 } else if (ch.equals(CHANNEL_SCHEDULE)) {
222 if (command instanceof DecimalType) {
223 int bitmask = ((DecimalType) command).intValue();
224 JsonArray cycle = new JsonArray();
226 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
227 enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
230 sendSchedule(new MQTTProtocol.Schedule(bitmask));
232 } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
233 if (command instanceof OnOffType) {
234 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
236 } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
237 if (command instanceof OnOffType) {
238 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
240 } else if (ch.equals(CHANNEL_POWER_BOOST)) {
241 sendDelta(new MQTTProtocol.PowerBoost(command.equals(BOOST_AUTO), command.equals(BOOST_PERFORMANCE)));
242 } else if (ch.equals(CHANNEL_CLEAN_PASSES)) {
243 sendDelta(new MQTTProtocol.CleanPasses(!command.equals(PASSES_AUTO), command.equals(PASSES_2)));
244 } else if (ch.equals(CHANNEL_MAP_UPLOAD)) {
245 if (command instanceof OnOffType) {
246 sendDelta(new MQTTProtocol.MapUploadAllowed(command.equals(OnOffType.ON)));
251 private void enableCycle(JsonArray cycle, int i, boolean enable) {
252 JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
256 private void sendSchedule(MQTTProtocol.Schedule schedule) {
257 sendDelta(new MQTTProtocol.CleanSchedule(schedule));
260 private void sendDelta(MQTTProtocol.StateValue state) {
261 MQTTProtocol.Request request = new MQTTProtocol.DeltaRequest(state);
262 connection.send(request.getTopic(), gson.toJson(request));
265 private synchronized void getCredentials() {
266 ThingStatus status = thing.getStatusInfo().getStatus();
267 IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
268 if (UNINITIALIZED.equals(status) || INITIALIZING.equals(status) || OFFLINE.equals(status)) {
269 if (UNKNOWN.equals(config.getBlid())) {
273 blid = LoginRequester.getBlid(config.getIpAddress());
274 } catch (IOException exception) {
275 updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
279 org.openhab.core.config.core.Configuration configuration = editConfiguration();
280 configuration.put(ROBOT_BLID, blid);
281 updateConfiguration(configuration);
285 if (UNKNOWN.equals(config.getPassword())) {
287 String password = null;
289 password = LoginRequester.getPassword(config.getIpAddress());
290 } catch (KeyManagementException | NoSuchAlgorithmException exception) {
291 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, exception.toString());
292 return; // This is internal system error, no retry
293 } catch (IOException exception) {
294 updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
297 if (password != null) {
298 org.openhab.core.config.core.Configuration configuration = editConfiguration();
299 configuration.put(ROBOT_PASSWORD, password.trim());
300 updateConfiguration(configuration);
305 credentialRequester = null;
306 if (UNKNOWN.equals(config.getBlid()) || UNKNOWN.equals(config.getPassword())) {
307 credentialRequester = scheduler.schedule(this::getCredentials, 10000, TimeUnit.MILLISECONDS);
309 scheduler.execute(this::connect);
313 // In order not to mess up our connection state we need to make sure that connect()
314 // and disconnect() are never running concurrently, so they are synchronized
315 private synchronized void connect() {
316 IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
317 final String address = config.getIpAddress();
318 logger.debug("Connecting to {}", address);
320 final String blid = config.getBlid();
321 final String password = config.getPassword();
322 if (UNKNOWN.equals(blid) || UNKNOWN.equals(password)) {
323 final String message = "Robot authentication is required";
324 updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
325 scheduler.execute(this::getCredentials);
327 final String message = "Robot authentication is successful";
328 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, message);
329 connection.connect(address, blid, password);
333 public void receive(final String topic, final String json) {
334 MQTTProtocol.StateMessage msg;
336 logger.trace("Got topic {} data {}", topic, json);
339 // We are not consuming all the fields, so we have to create the reader explicitly
340 // If we use fromJson(String) or fromJson(java.util.reader), it will throw
341 // "JSON not fully consumed" exception, because not all the reader's content has been
342 // used up. We want to avoid that also for compatibility reasons because newer iRobot
343 // versions may add fields.
344 JsonReader jsonReader = new JsonReader(new StringReader(json));
345 msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
346 } catch (JsonParseException exception) {
347 logger.warn("Failed to parse JSON message for {}: {}", thing.getLabel(), exception.toString());
348 logger.warn("Raw contents: {}", json);
352 // Since all the fields are in fact optional, and a single message never
353 // contains all of them, we have to check presence of each individually
354 if (msg.state == null || msg.state.reported == null) {
358 MQTTProtocol.GenericState reported = msg.state.reported;
360 if (reported.cleanMissionStatus != null) {
361 String cycle = reported.cleanMissionStatus.cycle;
362 String phase = reported.cleanMissionStatus.phase;
365 if ("none".equals(cycle)) {
370 case "stuck": // CHECKME: could also be equivalent to "stop" command
371 case "pause": // Never observed in Roomba 930
375 case "dock": // Never observed in Roomba 930
379 command = cycle; // "clean" or "spot"
384 isPaused = command.equals(CMD_PAUSE);
386 reportString(CHANNEL_CYCLE, cycle);
387 reportString(CHANNEL_PHASE, phase);
388 reportString(CHANNEL_COMMAND, command);
389 reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
392 if (reported.batPct != null) {
393 reportInt(CHANNEL_BATTERY, reported.batPct);
396 if (reported.bin != null) {
399 // The bin cannot be both full and removed simultaneously, so let's
400 // encode it as a single value
401 if (!reported.bin.present) {
402 binStatus = BIN_REMOVED;
403 } else if (reported.bin.full) {
404 binStatus = BIN_FULL;
409 reportString(CHANNEL_BIN, binStatus);
412 if (reported.signal != null) {
413 reportInt(CHANNEL_RSSI, reported.signal.rssi);
414 reportInt(CHANNEL_SNR, reported.signal.snr);
417 if (reported.cleanSchedule != null) {
418 MQTTProtocol.Schedule schedule = reported.cleanSchedule;
420 if (schedule.cycle != null) {
423 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
424 boolean on = schedule.cycleEnabled(i);
426 reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
432 reportInt(CHANNEL_SCHEDULE, binary);
435 lastSchedule = schedule;
438 if (reported.openOnly != null) {
439 reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
442 if (reported.binPause != null) {
443 reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
446 // To make the life more interesting, paired values may not appear together in the
447 // same message, so we have to keep track of current values.
448 if (reported.carpetBoost != null) {
449 carpetBoost = reported.carpetBoost;
450 if (reported.carpetBoost) {
451 // When set to true, overrides vacHigh
452 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
453 } else if (vacHigh != null) {
458 if (reported.vacHigh != null) {
459 vacHigh = reported.vacHigh;
461 // Can be overridden by "carpetBoost":true
466 if (reported.noAutoPasses != null) {
467 autoPasses = !reported.noAutoPasses;
468 if (!reported.noAutoPasses) {
469 // When set to false, overrides twoPass
470 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
471 } else if (twoPasses != null) {
476 if (reported.twoPass != null) {
477 twoPasses = reported.twoPass;
479 // Can be overridden by "noAutoPasses":false
484 if (reported.lastCommand != null) {
485 reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
488 if (reported.mapUploadAllowed != null) {
489 reportSwitch(CHANNEL_MAP_UPLOAD, reported.mapUploadAllowed);
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);
498 reportProperty("sku", reported.sku);
499 reportProperty("batteryType", reported.batteryType);
501 if (reported.subModSwVer != null) {
502 // This is used by i7 model. It has more capabilities, perhaps a dedicated
503 // handler should be written by someone who owns it.
504 reportProperty("subModSwVer.nav", reported.subModSwVer.nav);
505 reportProperty("subModSwVer.mob", reported.subModSwVer.mob);
506 reportProperty("subModSwVer.pwr", reported.subModSwVer.pwr);
507 reportProperty("subModSwVer.sft", reported.subModSwVer.sft);
508 reportProperty("subModSwVer.mobBtl", reported.subModSwVer.mobBtl);
509 reportProperty("subModSwVer.linux", reported.subModSwVer.linux);
510 reportProperty("subModSwVer.con", reported.subModSwVer.con);
514 private void reportVacHigh() {
515 reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
518 private void reportTwoPasses() {
519 reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
522 private void reportString(String channel, String str) {
523 reportState(channel, StringType.valueOf(str));
526 private void reportInt(String channel, int n) {
527 reportState(channel, new DecimalType(n));
530 private void reportSwitch(String channel, boolean s) {
531 reportState(channel, OnOffType.from(s));
534 private void reportState(String channel, State value) {
535 lastState.put(channel, value);
536 updateState(channel, value);
539 private void reportProperty(String property, @Nullable String value) {
541 updateProperty(property, value);