]> git.basschouten.com Git - openhab-addons.git/blob
78d7f4e1ba75bf827d8e94fed8a7d5d20ef041a4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.irobot.internal.handler;
14
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;
52
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;
61
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;
81
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;
87
88 /**
89  * The {@link RoombaHandler} is responsible for handling commands, which are
90  * sent to one of the channels.
91  *
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
96  */
97 @NonNullByDefault
98 public class RoombaHandler extends BaseThingHandler {
99     private final Logger logger = LoggerFactory.getLogger(RoombaHandler.class);
100
101     private final Gson gson = new Gson();
102
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;
110
111     private @Nullable Future<?> credentialRequester;
112     protected IRobotConnectionHandler connection = new IRobotConnectionHandler() {
113         @Override
114         public void receive(final String topic, final String json) {
115             RoombaHandler.this.receive(topic, json);
116         }
117
118         @Override
119         public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
120             super.connectionStateChanged(state, error);
121             if (state == MqttConnectionState.CONNECTED) {
122                 updateStatus(ThingStatus.ONLINE);
123             } else {
124                 String message = (error != null) ? error.getMessage() : "Unknown reason";
125                 updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
126             }
127         }
128     };
129
130     public RoombaHandler(Thing thing) {
131         super(thing);
132     }
133
134     @Override
135     public void initialize() {
136         IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
137
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);
142         } else {
143             scheduler.execute(this::connect);
144         }
145     }
146
147     @Override
148     public void dispose() {
149         Future<?> requester = credentialRequester;
150         if (requester != null) {
151             requester.cancel(false);
152             credentialRequester = null;
153         }
154
155         scheduler.execute(connection::disconnect);
156     }
157
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);
163
164         if (value != null) {
165             updateState(ch, value);
166         }
167     }
168
169     @Override
170     public void handleCommand(ChannelUID channelUID, Command command) {
171         String ch = channelUID.getId();
172         if (command instanceof RefreshType) {
173             handleRefresh(ch);
174             return;
175         }
176
177         if (ch.equals(CHANNEL_COMMAND)) {
178             if (command instanceof StringType) {
179                 String cmd = command.toString();
180
181                 if (cmd.equals(CMD_CLEAN)) {
182                     cmd = isPaused ? "resume" : "start";
183                 }
184
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(";");
190
191                         String mapId = params[0];
192                         String[] regionIds = params[1].split(",");
193
194                         MQTTProtocol.Request request = new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds);
195                         connection.send(request.getTopic(), gson.toJson(request));
196                     } else {
197                         logger.warn("Invalid request: {}", cmd);
198                         logger.warn("Correct format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...>");
199                     }
200                 } else {
201                     MQTTProtocol.Request request = new MQTTProtocol.CommandRequest(cmd);
202                     connection.send(request.getTopic(), gson.toJson(request));
203                 }
204             }
205         } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
206             MQTTProtocol.Schedule schedule = lastSchedule;
207
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);
214
215                         newSchedule.enableCycle(i, command.equals(OnOffType.ON));
216                         sendSchedule(newSchedule);
217                         break;
218                     }
219                 }
220             }
221         } else if (ch.equals(CHANNEL_SCHEDULE)) {
222             if (command instanceof DecimalType) {
223                 int bitmask = ((DecimalType) command).intValue();
224                 JsonArray cycle = new JsonArray();
225
226                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
227                     enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
228                 }
229
230                 sendSchedule(new MQTTProtocol.Schedule(bitmask));
231             }
232         } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
233             if (command instanceof OnOffType) {
234                 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
235             }
236         } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
237             if (command instanceof OnOffType) {
238                 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
239             }
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)));
247             }
248         }
249     }
250
251     private void enableCycle(JsonArray cycle, int i, boolean enable) {
252         JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
253         cycle.set(i, value);
254     }
255
256     private void sendSchedule(MQTTProtocol.Schedule schedule) {
257         sendDelta(new MQTTProtocol.CleanSchedule(schedule));
258     }
259
260     private void sendDelta(MQTTProtocol.StateValue state) {
261         MQTTProtocol.Request request = new MQTTProtocol.DeltaRequest(state);
262         connection.send(request.getTopic(), gson.toJson(request));
263     }
264
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())) {
270                 @Nullable
271                 String blid = null;
272                 try {
273                     blid = LoginRequester.getBlid(config.getIpAddress());
274                 } catch (IOException exception) {
275                     updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
276                 }
277
278                 if (blid != null) {
279                     org.openhab.core.config.core.Configuration configuration = editConfiguration();
280                     configuration.put(ROBOT_BLID, blid);
281                     updateConfiguration(configuration);
282                 }
283             }
284
285             if (UNKNOWN.equals(config.getPassword())) {
286                 @Nullable
287                 String password = null;
288                 try {
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());
295                 }
296
297                 if (password != null) {
298                     org.openhab.core.config.core.Configuration configuration = editConfiguration();
299                     configuration.put(ROBOT_PASSWORD, password.trim());
300                     updateConfiguration(configuration);
301                 }
302             }
303         }
304
305         credentialRequester = null;
306         if (UNKNOWN.equals(config.getBlid()) || UNKNOWN.equals(config.getPassword())) {
307             credentialRequester = scheduler.schedule(this::getCredentials, 10000, TimeUnit.MILLISECONDS);
308         } else {
309             scheduler.execute(this::connect);
310         }
311     }
312
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);
319
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);
326         } else {
327             final String message = "Robot authentication is successful";
328             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, message);
329             connection.connect(address, blid, password);
330         }
331     }
332
333     public void receive(final String topic, final String json) {
334         MQTTProtocol.StateMessage msg;
335
336         logger.trace("Got topic {} data {}", topic, json);
337
338         try {
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);
349             return;
350         }
351
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) {
355             return;
356         }
357
358         MQTTProtocol.GenericState reported = msg.state.reported;
359
360         if (reported.cleanMissionStatus != null) {
361             String cycle = reported.cleanMissionStatus.cycle;
362             String phase = reported.cleanMissionStatus.phase;
363             String command;
364
365             if ("none".equals(cycle)) {
366                 command = CMD_STOP;
367             } else {
368                 switch (phase) {
369                     case "stop":
370                     case "stuck": // CHECKME: could also be equivalent to "stop" command
371                     case "pause": // Never observed in Roomba 930
372                         command = CMD_PAUSE;
373                         break;
374                     case "hmUsrDock":
375                     case "dock": // Never observed in Roomba 930
376                         command = CMD_DOCK;
377                         break;
378                     default:
379                         command = cycle; // "clean" or "spot"
380                         break;
381                 }
382             }
383
384             isPaused = command.equals(CMD_PAUSE);
385
386             reportString(CHANNEL_CYCLE, cycle);
387             reportString(CHANNEL_PHASE, phase);
388             reportString(CHANNEL_COMMAND, command);
389             reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
390         }
391
392         if (reported.batPct != null) {
393             reportInt(CHANNEL_BATTERY, reported.batPct);
394         }
395
396         if (reported.bin != null) {
397             String binStatus;
398
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;
405             } else {
406                 binStatus = BIN_OK;
407             }
408
409             reportString(CHANNEL_BIN, binStatus);
410         }
411
412         if (reported.signal != null) {
413             reportInt(CHANNEL_RSSI, reported.signal.rssi);
414             reportInt(CHANNEL_SNR, reported.signal.snr);
415         }
416
417         if (reported.cleanSchedule != null) {
418             MQTTProtocol.Schedule schedule = reported.cleanSchedule;
419
420             if (schedule.cycle != null) {
421                 int binary = 0;
422
423                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
424                     boolean on = schedule.cycleEnabled(i);
425
426                     reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
427                     if (on) {
428                         binary |= (1 << i);
429                     }
430                 }
431
432                 reportInt(CHANNEL_SCHEDULE, binary);
433             }
434
435             lastSchedule = schedule;
436         }
437
438         if (reported.openOnly != null) {
439             reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
440         }
441
442         if (reported.binPause != null) {
443             reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
444         }
445
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) {
454                 reportVacHigh();
455             }
456         }
457
458         if (reported.vacHigh != null) {
459             vacHigh = reported.vacHigh;
460             if (!carpetBoost) {
461                 // Can be overridden by "carpetBoost":true
462                 reportVacHigh();
463             }
464         }
465
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) {
472                 reportTwoPasses();
473             }
474         }
475
476         if (reported.twoPass != null) {
477             twoPasses = reported.twoPass;
478             if (!autoPasses) {
479                 // Can be overridden by "noAutoPasses":false
480                 reportTwoPasses();
481             }
482         }
483
484         if (reported.lastCommand != null) {
485             reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
486         }
487
488         if (reported.mapUploadAllowed != null) {
489             reportSwitch(CHANNEL_MAP_UPLOAD, reported.mapUploadAllowed);
490         }
491
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);
500
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);
511         }
512     }
513
514     private void reportVacHigh() {
515         reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
516     }
517
518     private void reportTwoPasses() {
519         reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
520     }
521
522     private void reportString(String channel, String str) {
523         reportState(channel, StringType.valueOf(str));
524     }
525
526     private void reportInt(String channel, int n) {
527         reportState(channel, new DecimalType(n));
528     }
529
530     private void reportSwitch(String channel, boolean s) {
531         reportState(channel, OnOffType.from(s));
532     }
533
534     private void reportState(String channel, State value) {
535         lastState.put(channel, value);
536         updateState(channel, value);
537     }
538
539     private void reportProperty(String property, @Nullable String value) {
540         if (value != null) {
541             updateProperty(property, value);
542         }
543     }
544 }