2 * Copyright (c) 2010-2023 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];
193 if (params.length >= 3) {
194 userPmapvId = params[2];
199 String[] regions = params[1].split(",");
200 String regionIds[] = new String[regions.length];
201 String regionTypes[] = new String[regions.length];
203 for (int i = 0; i < regions.length; i++) {
204 String[] regionDetails = regions[i].split("=");
206 if (regionDetails.length >= 2) {
207 if (regionDetails[0].equals("r")) {
208 regionIds[i] = regionDetails[1];
209 regionTypes[i] = "rid";
210 } else if (regionDetails[0].equals("z")) {
211 regionIds[i] = regionDetails[1];
212 regionTypes[i] = "zid";
214 regionIds[i] = regionDetails[0];
215 regionTypes[i] = "rid";
218 regionIds[i] = regionDetails[0];
219 regionTypes[i] = "rid";
222 MQTTProtocol.Request request = new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds,
223 regionTypes, userPmapvId);
224 connection.send(request.getTopic(), gson.toJson(request));
226 logger.warn("Invalid request: {}", cmd);
227 logger.warn("Correct format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...>");
230 MQTTProtocol.Request request = new MQTTProtocol.CommandRequest(cmd);
231 connection.send(request.getTopic(), gson.toJson(request));
234 } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
235 MQTTProtocol.Schedule schedule = lastSchedule;
237 // Schedule can only be updated in a bulk, so we have to store current
238 // schedule and modify components.
239 if (command instanceof OnOffType && schedule != null && schedule.cycle != null) {
240 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
241 if (ch.equals(CHANNEL_SCHED_SWITCH[i])) {
242 MQTTProtocol.Schedule newSchedule = new MQTTProtocol.Schedule(schedule.cycle);
244 newSchedule.enableCycle(i, command.equals(OnOffType.ON));
245 sendSchedule(newSchedule);
250 } else if (ch.equals(CHANNEL_SCHEDULE)) {
251 if (command instanceof DecimalType) {
252 int bitmask = ((DecimalType) command).intValue();
253 JsonArray cycle = new JsonArray();
255 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
256 enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
259 sendSchedule(new MQTTProtocol.Schedule(bitmask));
261 } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
262 if (command instanceof OnOffType) {
263 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
265 } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
266 if (command instanceof OnOffType) {
267 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
269 } else if (ch.equals(CHANNEL_POWER_BOOST)) {
270 sendDelta(new MQTTProtocol.PowerBoost(command.equals(BOOST_AUTO), command.equals(BOOST_PERFORMANCE)));
271 } else if (ch.equals(CHANNEL_CLEAN_PASSES)) {
272 sendDelta(new MQTTProtocol.CleanPasses(!command.equals(PASSES_AUTO), command.equals(PASSES_2)));
273 } else if (ch.equals(CHANNEL_MAP_UPLOAD)) {
274 if (command instanceof OnOffType) {
275 sendDelta(new MQTTProtocol.MapUploadAllowed(command.equals(OnOffType.ON)));
280 private void enableCycle(JsonArray cycle, int i, boolean enable) {
281 JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
285 private void sendSchedule(MQTTProtocol.Schedule schedule) {
286 sendDelta(new MQTTProtocol.CleanSchedule(schedule));
289 private void sendDelta(MQTTProtocol.StateValue state) {
290 MQTTProtocol.Request request = new MQTTProtocol.DeltaRequest(state);
291 connection.send(request.getTopic(), gson.toJson(request));
294 private synchronized void getCredentials() {
295 ThingStatus status = thing.getStatusInfo().getStatus();
296 IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
297 if (UNINITIALIZED.equals(status) || INITIALIZING.equals(status) || OFFLINE.equals(status)) {
298 if (UNKNOWN.equals(config.getBlid())) {
302 blid = LoginRequester.getBlid(config.getIpAddress());
303 } catch (IOException exception) {
304 updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
308 org.openhab.core.config.core.Configuration configuration = editConfiguration();
309 configuration.put(ROBOT_BLID, blid);
310 updateConfiguration(configuration);
314 if (UNKNOWN.equals(config.getPassword())) {
316 String password = null;
318 password = LoginRequester.getPassword(config.getIpAddress());
319 } catch (KeyManagementException | NoSuchAlgorithmException exception) {
320 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, exception.toString());
321 return; // This is internal system error, no retry
322 } catch (IOException exception) {
323 updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
326 if (password != null) {
327 org.openhab.core.config.core.Configuration configuration = editConfiguration();
328 configuration.put(ROBOT_PASSWORD, password.trim());
329 updateConfiguration(configuration);
334 credentialRequester = null;
335 if (UNKNOWN.equals(config.getBlid()) || UNKNOWN.equals(config.getPassword())) {
336 credentialRequester = scheduler.schedule(this::getCredentials, 10000, TimeUnit.MILLISECONDS);
338 scheduler.execute(this::connect);
342 // In order not to mess up our connection state we need to make sure that connect()
343 // and disconnect() are never running concurrently, so they are synchronized
344 private synchronized void connect() {
345 IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
346 final String address = config.getIpAddress();
347 logger.debug("Connecting to {}", address);
349 final String blid = config.getBlid();
350 final String password = config.getPassword();
351 if (UNKNOWN.equals(blid) || UNKNOWN.equals(password)) {
352 final String message = "Robot authentication is required";
353 updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
354 scheduler.execute(this::getCredentials);
356 final String message = "Robot authentication is successful";
357 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, message);
358 connection.connect(address, blid, password);
362 public void receive(final String topic, final String json) {
363 MQTTProtocol.StateMessage msg;
365 logger.trace("Got topic {} data {}", topic, json);
368 // We are not consuming all the fields, so we have to create the reader explicitly
369 // If we use fromJson(String) or fromJson(java.util.reader), it will throw
370 // "JSON not fully consumed" exception, because not all the reader's content has been
371 // used up. We want to avoid that also for compatibility reasons because newer iRobot
372 // versions may add fields.
373 JsonReader jsonReader = new JsonReader(new StringReader(json));
374 msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
375 } catch (JsonParseException exception) {
376 logger.warn("Failed to parse JSON message for {}: {}", thing.getLabel(), exception.toString());
377 logger.warn("Raw contents: {}", json);
381 // Since all the fields are in fact optional, and a single message never
382 // contains all of them, we have to check presence of each individually
383 if (msg.state == null || msg.state.reported == null) {
387 MQTTProtocol.GenericState reported = msg.state.reported;
389 if (reported.cleanMissionStatus != null) {
390 String cycle = reported.cleanMissionStatus.cycle;
391 String phase = reported.cleanMissionStatus.phase;
394 if ("none".equals(cycle)) {
399 case "stuck": // CHECKME: could also be equivalent to "stop" command
400 case "pause": // Never observed in Roomba 930
404 case "dock": // Never observed in Roomba 930
408 command = cycle; // "clean" or "spot"
413 isPaused = command.equals(CMD_PAUSE);
415 reportString(CHANNEL_CYCLE, cycle);
416 reportString(CHANNEL_PHASE, phase);
417 reportString(CHANNEL_COMMAND, command);
418 reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
421 if (reported.batPct != null) {
422 reportInt(CHANNEL_BATTERY, reported.batPct);
425 if (reported.bin != null) {
428 // The bin cannot be both full and removed simultaneously, so let's
429 // encode it as a single value
430 if (!reported.bin.present) {
431 binStatus = BIN_REMOVED;
432 } else if (reported.bin.full) {
433 binStatus = BIN_FULL;
438 reportString(CHANNEL_BIN, binStatus);
441 if (reported.signal != null) {
442 reportInt(CHANNEL_RSSI, reported.signal.rssi);
443 reportInt(CHANNEL_SNR, reported.signal.snr);
446 if (reported.cleanSchedule != null) {
447 MQTTProtocol.Schedule schedule = reported.cleanSchedule;
449 if (schedule.cycle != null) {
452 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
453 boolean on = schedule.cycleEnabled(i);
455 reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
461 reportInt(CHANNEL_SCHEDULE, binary);
464 lastSchedule = schedule;
467 if (reported.openOnly != null) {
468 reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
471 if (reported.binPause != null) {
472 reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
475 // To make the life more interesting, paired values may not appear together in the
476 // same message, so we have to keep track of current values.
477 if (reported.carpetBoost != null) {
478 carpetBoost = reported.carpetBoost;
479 if (reported.carpetBoost) {
480 // When set to true, overrides vacHigh
481 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
482 } else if (vacHigh != null) {
487 if (reported.vacHigh != null) {
488 vacHigh = reported.vacHigh;
490 // Can be overridden by "carpetBoost":true
495 if (reported.noAutoPasses != null) {
496 autoPasses = !reported.noAutoPasses;
497 if (!reported.noAutoPasses) {
498 // When set to false, overrides twoPass
499 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
500 } else if (twoPasses != null) {
505 if (reported.twoPass != null) {
506 twoPasses = reported.twoPass;
508 // Can be overridden by "noAutoPasses":false
513 if (reported.lastCommand != null) {
514 reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
517 if (reported.mapUploadAllowed != null) {
518 reportSwitch(CHANNEL_MAP_UPLOAD, reported.mapUploadAllowed);
521 reportProperty(Thing.PROPERTY_FIRMWARE_VERSION, reported.softwareVer);
522 reportProperty("navSwVer", reported.navSwVer);
523 reportProperty("wifiSwVer", reported.wifiSwVer);
524 reportProperty("mobilityVer", reported.mobilityVer);
525 reportProperty("bootloaderVer", reported.bootloaderVer);
526 reportProperty("umiVer", reported.umiVer);
527 reportProperty("sku", reported.sku);
528 reportProperty("batteryType", reported.batteryType);
530 if (reported.subModSwVer != null) {
531 // This is used by i7 model. It has more capabilities, perhaps a dedicated
532 // handler should be written by someone who owns it.
533 reportProperty("subModSwVer.nav", reported.subModSwVer.nav);
534 reportProperty("subModSwVer.mob", reported.subModSwVer.mob);
535 reportProperty("subModSwVer.pwr", reported.subModSwVer.pwr);
536 reportProperty("subModSwVer.sft", reported.subModSwVer.sft);
537 reportProperty("subModSwVer.mobBtl", reported.subModSwVer.mobBtl);
538 reportProperty("subModSwVer.linux", reported.subModSwVer.linux);
539 reportProperty("subModSwVer.con", reported.subModSwVer.con);
543 private void reportVacHigh() {
544 reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
547 private void reportTwoPasses() {
548 reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
551 private void reportString(String channel, String str) {
552 reportState(channel, StringType.valueOf(str));
555 private void reportInt(String channel, int n) {
556 reportState(channel, new DecimalType(n));
559 private void reportSwitch(String channel, boolean s) {
560 reportState(channel, OnOffType.from(s));
563 private void reportState(String channel, State value) {
564 lastState.put(channel, value);
565 updateState(channel, value);
568 private void reportProperty(String property, @Nullable String value) {
570 updateProperty(property, value);