]> git.basschouten.com Git - openhab-addons.git/blob
39a51da8b8e081d22c16d5983058ec2e8d99bc92
[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         }
192     }
193
194     private void enableCycle(JsonArray cycle, int i, boolean enable) {
195         JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
196         cycle.set(i, value);
197     }
198
199     private void sendSchedule(MQTTProtocol.Schedule schedule) {
200         sendDelta(new MQTTProtocol.CleanSchedule(schedule));
201     }
202
203     private void sendDelta(MQTTProtocol.StateValue state) {
204         sendRequest(new MQTTProtocol.DeltaRequest(state));
205     }
206
207     private void sendRequest(MQTTProtocol.Request request) {
208         MqttBrokerConnection conn = connection;
209
210         if (conn != null) {
211             String json = gson.toJson(request);
212             logger.trace("Sending {}: {}", request.getTopic(), json);
213             // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
214             // by Roomba, others just cause it to reject the command and drop the connection.
215             conn.publish(request.getTopic(), json.getBytes(), 1, false);
216         }
217     }
218
219     // In order not to mess up our connection state we need to make sure
220     // that connect() and disconnect() are never running concurrently, so
221     // they are synchronized
222     private synchronized void connect() {
223         logger.debug("Connecting to {}", config.ipaddress);
224
225         try {
226             InetAddress host = InetAddress.getByName(config.ipaddress);
227             String blid = this.blid;
228
229             if (blid == null) {
230                 DatagramSocket identSocket = IdentProtocol.sendRequest(host);
231                 DatagramPacket identPacket = IdentProtocol.receiveResponse(identSocket);
232                 IdentProtocol.IdentData ident;
233
234                 identSocket.close();
235
236                 try {
237                     ident = IdentProtocol.decodeResponse(identPacket);
238                 } catch (JsonParseException e) {
239                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
240                             "Malformed IDENT response");
241                     return;
242                 }
243
244                 if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
245                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
246                             "Unsupported version " + ident.ver);
247                     return;
248                 }
249
250                 if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) {
251                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
252                             "Not a Roomba: " + ident.product);
253                     return;
254                 }
255
256                 blid = ident.blid;
257                 this.blid = blid;
258             }
259
260             logger.debug("BLID is: {}", blid);
261
262             if (config.password.isEmpty()) {
263                 RawMQTT mqtt;
264
265                 try {
266                     mqtt = new RawMQTT(host, 8883);
267                 } catch (KeyManagementException | NoSuchAlgorithmException e1) {
268                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.toString());
269                     return; // This is internal system error, no retry
270                 }
271
272                 mqtt.requestPassword();
273                 RawMQTT.Packet response = mqtt.readPacket();
274                 mqtt.close();
275
276                 if (response != null && response.isValidPasswdPacket()) {
277                     RawMQTT.PasswdPacket passwdPacket = new RawMQTT.PasswdPacket(response);
278                     String password = passwdPacket.getPassword();
279
280                     if (password != null) {
281                         config.password = password;
282
283                         Configuration configuration = editConfiguration();
284
285                         configuration.put("password", password);
286                         updateConfiguration(configuration);
287
288                         logger.debug("Password successfully retrieved");
289                     }
290                 }
291             }
292
293             if (config.password.isEmpty()) {
294                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
295                         "Authentication on the robot is required");
296                 scheduleReconnect();
297                 return;
298             }
299
300             // BLID is used as both client ID and username. The name of BLID also came from Roomba980-python
301             MqttBrokerConnection connection = new MqttBrokerConnection(config.ipaddress, RawMQTT.ROOMBA_MQTT_PORT, true,
302                     blid);
303
304             this.connection = connection;
305
306             // Disable sending UNSUBSCRIBE request before disconnecting becuase Roomba doesn't like it.
307             // It just swallows the request and never sends any response, so stop() method never completes.
308             connection.setUnsubscribeOnStop(false);
309             connection.setCredentials(blid, config.password);
310             connection.setTrustManagers(RawMQTT.getTrustManagers());
311             // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
312             // by Roomba, others just cause it to reject the command and drop the connection.
313             connection.setQos(1);
314             // MQTT connection reconnects itself, so we don't have to call scheduleReconnect()
315             // when it breaks. Just set the period in ms.
316             connection.setReconnectStrategy(
317                     new PeriodicReconnectStrategy(RECONNECT_DELAY_SEC * 1000, RECONNECT_DELAY_SEC * 1000));
318             connection.start().exceptionally(e -> {
319                 connectionStateChanged(MqttConnectionState.DISCONNECTED, e);
320                 return false;
321             }).thenAccept(v -> {
322                 if (!v) {
323                     connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout"));
324                 } else {
325                     connectionStateChanged(MqttConnectionState.CONNECTED, null);
326                 }
327             });
328         } catch (IOException e) {
329             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
330             scheduleReconnect();
331         }
332     }
333
334     private synchronized void disconnect() {
335         Future<?> reconnectReq = this.reconnectReq;
336         MqttBrokerConnection connection = this.connection;
337
338         if (reconnectReq != null) {
339             reconnectReq.cancel(false);
340             this.reconnectReq = null;
341         }
342
343         if (connection != null) {
344             connection.stop();
345             logger.trace("Closed connection to {}", config.ipaddress);
346             this.connection = null;
347         }
348     }
349
350     private void scheduleReconnect() {
351         reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS);
352     }
353
354     public void onConnected() {
355         updateStatus(ThingStatus.ONLINE);
356     }
357
358     @Override
359     public void processMessage(String topic, byte[] payload) {
360         String jsonStr = new String(payload);
361         MQTTProtocol.StateMessage msg;
362
363         logger.trace("Got topic {} data {}", topic, jsonStr);
364
365         try {
366             // We are not consuming all the fields, so we have to create the reader explicitly
367             // If we use fromJson(String) or fromJson(java.util.reader), it will throw
368             // "JSON not fully consumed" exception, because not all the reader's content has been
369             // used up. We want to avoid that also for compatibility reasons because newer iRobot
370             // versions may add fields.
371             JsonReader jsonReader = new JsonReader(new StringReader(jsonStr));
372             msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
373         } catch (JsonParseException e) {
374             logger.warn("Failed to parse JSON message from {}: {}", config.ipaddress, e.toString());
375             logger.warn("Raw contents: {}", payload);
376             return;
377         }
378
379         // Since all the fields are in fact optional, and a single message never
380         // contains all of them, we have to check presence of each individually
381         if (msg.state == null || msg.state.reported == null) {
382             return;
383         }
384
385         MQTTProtocol.GenericState reported = msg.state.reported;
386
387         if (reported.cleanMissionStatus != null) {
388             String cycle = reported.cleanMissionStatus.cycle;
389             String phase = reported.cleanMissionStatus.phase;
390             String command;
391
392             if (cycle.equals("none")) {
393                 command = CMD_STOP;
394             } else {
395                 switch (phase) {
396                     case "stop":
397                     case "stuck": // CHECKME: could also be equivalent to "stop" command
398                     case "pause": // Never observed in Roomba 930
399                         command = CMD_PAUSE;
400                         break;
401                     case "hmUsrDock":
402                     case "dock": // Never observed in Roomba 930
403                         command = CMD_DOCK;
404                         break;
405                     default:
406                         command = cycle; // "clean" or "spot"
407                         break;
408                 }
409             }
410
411             isPaused = command.equals(CMD_PAUSE);
412
413             reportString(CHANNEL_CYCLE, cycle);
414             reportString(CHANNEL_PHASE, phase);
415             reportString(CHANNEL_COMMAND, command);
416             reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
417         }
418
419         if (reported.batPct != null) {
420             reportInt(CHANNEL_BATTERY, reported.batPct);
421         }
422
423         if (reported.bin != null) {
424             String binStatus;
425
426             // The bin cannot be both full and removed simultaneously, so let's
427             // encode it as a single value
428             if (!reported.bin.present) {
429                 binStatus = BIN_REMOVED;
430             } else if (reported.bin.full) {
431                 binStatus = BIN_FULL;
432             } else {
433                 binStatus = BIN_OK;
434             }
435
436             reportString(CHANNEL_BIN, binStatus);
437         }
438
439         if (reported.signal != null) {
440             reportInt(CHANNEL_RSSI, reported.signal.rssi);
441             reportInt(CHANNEL_SNR, reported.signal.snr);
442         }
443
444         if (reported.cleanSchedule != null) {
445             MQTTProtocol.Schedule schedule = reported.cleanSchedule;
446
447             if (schedule.cycle != null) {
448                 int binary = 0;
449
450                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
451                     boolean on = schedule.cycleEnabled(i);
452
453                     reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
454                     if (on) {
455                         binary |= (1 << i);
456                     }
457                 }
458
459                 reportInt(CHANNEL_SCHEDULE, binary);
460             }
461
462             lastSchedule = schedule;
463         }
464
465         if (reported.openOnly != null) {
466             reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
467         }
468
469         if (reported.binPause != null) {
470             reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
471         }
472
473         // To make the life more interesting, paired values may not appear together in the
474         // same message, so we have to keep track of current values.
475         if (reported.carpetBoost != null) {
476             carpetBoost = reported.carpetBoost;
477             if (reported.carpetBoost) {
478                 // When set to true, overrides vacHigh
479                 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
480             } else if (vacHigh != null) {
481                 reportVacHigh();
482             }
483         }
484
485         if (reported.vacHigh != null) {
486             vacHigh = reported.vacHigh;
487             if (!carpetBoost) {
488                 // Can be overridden by "carpetBoost":true
489                 reportVacHigh();
490             }
491         }
492
493         if (reported.noAutoPasses != null) {
494             autoPasses = !reported.noAutoPasses;
495             if (!reported.noAutoPasses) {
496                 // When set to false, overrides twoPass
497                 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
498             } else if (twoPasses != null) {
499                 reportTwoPasses();
500             }
501         }
502
503         if (reported.twoPass != null) {
504             twoPasses = reported.twoPass;
505             if (!autoPasses) {
506                 // Can be overridden by "noAutoPasses":false
507                 reportTwoPasses();
508             }
509         }
510
511         if (reported.lastCommand != null) {
512             reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
513         }
514
515         reportProperty(Thing.PROPERTY_FIRMWARE_VERSION, reported.softwareVer);
516         reportProperty("navSwVer", reported.navSwVer);
517         reportProperty("wifiSwVer", reported.wifiSwVer);
518         reportProperty("mobilityVer", reported.mobilityVer);
519         reportProperty("bootloaderVer", reported.bootloaderVer);
520         reportProperty("umiVer", reported.umiVer);
521     }
522
523     private void reportVacHigh() {
524         reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
525     }
526
527     private void reportTwoPasses() {
528         reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
529     }
530
531     private void reportString(String channel, String str) {
532         reportState(channel, StringType.valueOf(str));
533     }
534
535     private void reportInt(String channel, int n) {
536         reportState(channel, new DecimalType(n));
537     }
538
539     private void reportSwitch(String channel, boolean s) {
540         reportState(channel, OnOffType.from(s));
541     }
542
543     private void reportState(String channel, State value) {
544         lastState.put(channel, value);
545         updateState(channel, value);
546     }
547
548     private void reportProperty(String property, @Nullable String value) {
549         if (value != null) {
550             updateProperty(property, value);
551         }
552     }
553
554     @Override
555     public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
556         if (state == MqttConnectionState.CONNECTED) {
557             MqttBrokerConnection connection = this.connection;
558
559             if (connection == null) {
560                 // This would be very strange, but Eclipse forces us to do the check
561                 logger.warn("Established connection without broker pointer");
562                 return;
563             }
564
565             updateStatus(ThingStatus.ONLINE);
566
567             // Roomba sends us two topics:
568             // "wifistat" - reports singnal strength and current robot position
569             // "$aws/things/<BLID>/shadow/update" - the rest of messages
570             // Subscribe to everything since we're interested in both
571             connection.subscribe("#", this).exceptionally(e -> {
572                 logger.warn("MQTT subscription failed: {}", e.getMessage());
573                 return false;
574             }).thenAccept(v -> {
575                 if (!v) {
576                     logger.warn("Subscription timeout");
577                 } else {
578                     logger.trace("Subscription done");
579                 }
580             });
581
582         } else {
583             String message;
584
585             if (error != null) {
586                 message = error.getMessage();
587                 logger.warn("MQTT connection failed: {}", message);
588             } else {
589                 message = null;
590                 logger.warn("MQTT connection failed for unspecified reason");
591             }
592
593             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
594         }
595     }
596 }