]> git.basschouten.com Git - openhab-addons.git/blob
7ef18956b9843d0443db0c8cc18dcd4198ebf832
[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.*;
16
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;
29
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;
56
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;
62
63 /**
64  * The {@link RoombaHandler} is responsible for handling commands, which are
65  * sent to one of the channels.
66  *
67  * @author hkuhn42 - Initial contribution
68  * @author Pavel Fedin - Rewrite for 900 series
69  * @author Florian Binder - added cleanRegions command and lastCommand channel
70  */
71 @NonNullByDefault
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;
89
90     public RoombaHandler(Thing thing) {
91         super(thing);
92     }
93
94     @Override
95     public void initialize() {
96         config = getConfigAs(RoombaConfiguration.class);
97         updateStatus(ThingStatus.UNKNOWN);
98         scheduler.execute(this::connect);
99     }
100
101     @Override
102     public void dispose() {
103         scheduler.execute(this::disconnect);
104     }
105
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);
111
112         if (value != null) {
113             updateState(ch, value);
114         }
115     }
116
117     @Override
118     public void handleCommand(ChannelUID channelUID, Command command) {
119         String ch = channelUID.getId();
120         if (command instanceof RefreshType) {
121             handleRefresh(ch);
122             return;
123         }
124
125         if (ch.equals(CHANNEL_COMMAND)) {
126             if (command instanceof StringType) {
127                 String cmd = command.toString();
128
129                 if (cmd.equals(CMD_CLEAN)) {
130                     cmd = isPaused ? "resume" : "start";
131                 }
132
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(";");
138
139                         String mapId = params[0];
140                         String[] regionIds = params[1].split(",");
141
142                         sendRequest(new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds));
143                     } else {
144                         logger.warn("Invalid request: {}", cmd);
145                         logger.warn("Correct format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...>");
146                     }
147                 } else {
148                     sendRequest(new MQTTProtocol.CommandRequest(cmd));
149                 }
150
151             }
152         } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
153             MQTTProtocol.Schedule schedule = lastSchedule;
154
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);
161
162                         newSchedule.enableCycle(i, command.equals(OnOffType.ON));
163                         sendSchedule(newSchedule);
164                         break;
165                     }
166                 }
167             }
168         } else if (ch.equals(CHANNEL_SCHEDULE)) {
169             if (command instanceof DecimalType) {
170                 int bitmask = ((DecimalType) command).intValue();
171                 JsonArray cycle = new JsonArray();
172
173                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
174                     enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
175                 }
176
177                 sendSchedule(new MQTTProtocol.Schedule(bitmask));
178             }
179         } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
180             if (command instanceof OnOffType) {
181                 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
182             }
183         } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
184             if (command instanceof OnOffType) {
185                 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
186             }
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)));
194             }
195         }
196     }
197
198     private void enableCycle(JsonArray cycle, int i, boolean enable) {
199         JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
200         cycle.set(i, value);
201     }
202
203     private void sendSchedule(MQTTProtocol.Schedule schedule) {
204         sendDelta(new MQTTProtocol.CleanSchedule(schedule));
205     }
206
207     private void sendDelta(MQTTProtocol.StateValue state) {
208         sendRequest(new MQTTProtocol.DeltaRequest(state));
209     }
210
211     private void sendRequest(MQTTProtocol.Request request) {
212         MqttBrokerConnection conn = connection;
213
214         if (conn != null) {
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);
220         }
221     }
222
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);
228
229         try {
230             InetAddress host = InetAddress.getByName(config.ipaddress);
231             String blid = this.blid;
232
233             if (blid == null) {
234                 DatagramSocket identSocket = IdentProtocol.sendRequest(host);
235                 DatagramPacket identPacket = IdentProtocol.receiveResponse(identSocket);
236                 IdentProtocol.IdentData ident;
237
238                 identSocket.close();
239
240                 try {
241                     ident = IdentProtocol.decodeResponse(identPacket);
242                 } catch (JsonParseException e) {
243                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
244                             "Malformed IDENT response");
245                     return;
246                 }
247
248                 if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
249                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
250                             "Unsupported version " + ident.ver);
251                     return;
252                 }
253
254                 if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) {
255                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
256                             "Not a Roomba: " + ident.product);
257                     return;
258                 }
259
260                 blid = ident.blid;
261                 this.blid = blid;
262             }
263
264             logger.debug("BLID is: {}", blid);
265
266             if (config.password.isEmpty()) {
267                 RawMQTT mqtt;
268
269                 try {
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
274                 }
275
276                 mqtt.requestPassword();
277                 RawMQTT.Packet response = mqtt.readPacket();
278                 mqtt.close();
279
280                 if (response != null && response.isValidPasswdPacket()) {
281                     RawMQTT.PasswdPacket passwdPacket = new RawMQTT.PasswdPacket(response);
282                     String password = passwdPacket.getPassword();
283
284                     if (password != null) {
285                         config.password = password;
286
287                         Configuration configuration = editConfiguration();
288
289                         configuration.put("password", password);
290                         updateConfiguration(configuration);
291
292                         logger.debug("Password successfully retrieved");
293                     }
294                 }
295             }
296
297             if (config.password.isEmpty()) {
298                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
299                         "Authentication on the robot is required");
300                 scheduleReconnect();
301                 return;
302             }
303
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,
306                     blid);
307
308             this.connection = connection;
309
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);
324                 return false;
325             }).thenAccept(v -> {
326                 if (!v) {
327                     connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout"));
328                 } else {
329                     connectionStateChanged(MqttConnectionState.CONNECTED, null);
330                 }
331             });
332         } catch (IOException e) {
333             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
334             scheduleReconnect();
335         }
336     }
337
338     private synchronized void disconnect() {
339         Future<?> reconnectReq = this.reconnectReq;
340         MqttBrokerConnection connection = this.connection;
341
342         if (reconnectReq != null) {
343             reconnectReq.cancel(false);
344             this.reconnectReq = null;
345         }
346
347         if (connection != null) {
348             connection.stop();
349             logger.trace("Closed connection to {}", config.ipaddress);
350             this.connection = null;
351         }
352     }
353
354     private void scheduleReconnect() {
355         reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS);
356     }
357
358     public void onConnected() {
359         updateStatus(ThingStatus.ONLINE);
360     }
361
362     @Override
363     public void processMessage(String topic, byte[] payload) {
364         String jsonStr = new String(payload);
365         MQTTProtocol.StateMessage msg;
366
367         logger.trace("Got topic {} data {}", topic, jsonStr);
368
369         try {
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);
380             return;
381         }
382
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) {
386             return;
387         }
388
389         MQTTProtocol.GenericState reported = msg.state.reported;
390
391         if (reported.cleanMissionStatus != null) {
392             String cycle = reported.cleanMissionStatus.cycle;
393             String phase = reported.cleanMissionStatus.phase;
394             String command;
395
396             if (cycle.equals("none")) {
397                 command = CMD_STOP;
398             } else {
399                 switch (phase) {
400                     case "stop":
401                     case "stuck": // CHECKME: could also be equivalent to "stop" command
402                     case "pause": // Never observed in Roomba 930
403                         command = CMD_PAUSE;
404                         break;
405                     case "hmUsrDock":
406                     case "dock": // Never observed in Roomba 930
407                         command = CMD_DOCK;
408                         break;
409                     default:
410                         command = cycle; // "clean" or "spot"
411                         break;
412                 }
413             }
414
415             isPaused = command.equals(CMD_PAUSE);
416
417             reportString(CHANNEL_CYCLE, cycle);
418             reportString(CHANNEL_PHASE, phase);
419             reportString(CHANNEL_COMMAND, command);
420             reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
421         }
422
423         if (reported.batPct != null) {
424             reportInt(CHANNEL_BATTERY, reported.batPct);
425         }
426
427         if (reported.bin != null) {
428             String binStatus;
429
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;
436             } else {
437                 binStatus = BIN_OK;
438             }
439
440             reportString(CHANNEL_BIN, binStatus);
441         }
442
443         if (reported.signal != null) {
444             reportInt(CHANNEL_RSSI, reported.signal.rssi);
445             reportInt(CHANNEL_SNR, reported.signal.snr);
446         }
447
448         if (reported.cleanSchedule != null) {
449             MQTTProtocol.Schedule schedule = reported.cleanSchedule;
450
451             if (schedule.cycle != null) {
452                 int binary = 0;
453
454                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
455                     boolean on = schedule.cycleEnabled(i);
456
457                     reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
458                     if (on) {
459                         binary |= (1 << i);
460                     }
461                 }
462
463                 reportInt(CHANNEL_SCHEDULE, binary);
464             }
465
466             lastSchedule = schedule;
467         }
468
469         if (reported.openOnly != null) {
470             reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
471         }
472
473         if (reported.binPause != null) {
474             reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
475         }
476
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) {
485                 reportVacHigh();
486             }
487         }
488
489         if (reported.vacHigh != null) {
490             vacHigh = reported.vacHigh;
491             if (!carpetBoost) {
492                 // Can be overridden by "carpetBoost":true
493                 reportVacHigh();
494             }
495         }
496
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) {
503                 reportTwoPasses();
504             }
505         }
506
507         if (reported.twoPass != null) {
508             twoPasses = reported.twoPass;
509             if (!autoPasses) {
510                 // Can be overridden by "noAutoPasses":false
511                 reportTwoPasses();
512             }
513         }
514
515         if (reported.lastCommand != null) {
516             reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
517         }
518
519         if (reported.mapUploadAllowed != null) {
520             reportSwitch(CHANNEL_MAP_UPLOAD, reported.mapUploadAllowed);
521         }
522
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);
531
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);
542         }
543     }
544
545     private void reportVacHigh() {
546         reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
547     }
548
549     private void reportTwoPasses() {
550         reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
551     }
552
553     private void reportString(String channel, String str) {
554         reportState(channel, StringType.valueOf(str));
555     }
556
557     private void reportInt(String channel, int n) {
558         reportState(channel, new DecimalType(n));
559     }
560
561     private void reportSwitch(String channel, boolean s) {
562         reportState(channel, OnOffType.from(s));
563     }
564
565     private void reportState(String channel, State value) {
566         lastState.put(channel, value);
567         updateState(channel, value);
568     }
569
570     private void reportProperty(String property, @Nullable String value) {
571         if (value != null) {
572             updateProperty(property, value);
573         }
574     }
575
576     @Override
577     public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
578         if (state == MqttConnectionState.CONNECTED) {
579             MqttBrokerConnection connection = this.connection;
580
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");
584                 return;
585             }
586
587             updateStatus(ThingStatus.ONLINE);
588
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());
595                 return false;
596             }).thenAccept(v -> {
597                 if (!v) {
598                     logger.warn("Subscription timeout");
599                 } else {
600                     logger.trace("Subscription done");
601                 }
602             });
603
604         } else {
605             String message;
606
607             if (error != null) {
608                 message = error.getMessage();
609                 logger.warn("MQTT connection failed: {}", message);
610             } else {
611                 message = null;
612                 logger.warn("MQTT connection failed for unspecified reason");
613             }
614
615             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
616         }
617     }
618 }