]> git.basschouten.com Git - openhab-addons.git/blob
943f4de955a272036b6cea2df4246c9d7f58d64e
[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
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.irobot.internal.RawMQTT;
32 import org.openhab.binding.irobot.internal.RoombaConfiguration;
33 import org.openhab.binding.irobot.internal.dto.IdentProtocol;
34 import org.openhab.binding.irobot.internal.dto.IdentProtocol.IdentData;
35 import org.openhab.binding.irobot.internal.dto.MQTTProtocol;
36 import org.openhab.core.config.core.Configuration;
37 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
38 import org.openhab.core.io.transport.mqtt.MqttConnectionObserver;
39 import org.openhab.core.io.transport.mqtt.MqttConnectionState;
40 import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
41 import org.openhab.core.io.transport.mqtt.reconnect.PeriodicReconnectStrategy;
42 import org.openhab.core.library.types.DecimalType;
43 import org.openhab.core.library.types.OnOffType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.thing.ChannelUID;
46 import org.openhab.core.thing.Thing;
47 import org.openhab.core.thing.ThingStatus;
48 import org.openhab.core.thing.ThingStatusDetail;
49 import org.openhab.core.thing.binding.BaseThingHandler;
50 import org.openhab.core.types.Command;
51 import org.openhab.core.types.RefreshType;
52 import org.openhab.core.types.State;
53 import org.slf4j.Logger;
54 import org.slf4j.LoggerFactory;
55
56 import com.google.gson.Gson;
57 import com.google.gson.JsonArray;
58 import com.google.gson.JsonParseException;
59 import com.google.gson.JsonPrimitive;
60 import com.google.gson.stream.JsonReader;
61
62 /**
63  * The {@link RoombaHandler} is responsible for handling commands, which are
64  * sent to one of the channels.
65  *
66  * @author hkuhn42 - Initial contribution
67  * @author Pavel Fedin - Rewrite for 900 series
68  */
69 @NonNullByDefault
70 public class RoombaHandler extends BaseThingHandler implements MqttConnectionObserver, MqttMessageSubscriber {
71     private final Logger logger = LoggerFactory.getLogger(RoombaHandler.class);
72     private final Gson gson = new Gson();
73     private static final int RECONNECT_DELAY_SEC = 5; // In seconds
74     private @Nullable Future<?> reconnectReq;
75     // Dummy RoombaConfiguration object in order to shut up Eclipse warnings
76     // The real one is set in initialize()
77     private RoombaConfiguration config = new RoombaConfiguration();
78     private @Nullable String blid = null;
79     private @Nullable MqttBrokerConnection connection;
80     private Hashtable<String, State> lastState = new Hashtable<>();
81     private MQTTProtocol.@Nullable Schedule lastSchedule = null;
82     private boolean autoPasses = true;
83     private @Nullable Boolean twoPasses = null;
84     private boolean carpetBoost = true;
85     private @Nullable Boolean vacHigh = null;
86     private boolean isPaused = false;
87
88     public RoombaHandler(Thing thing) {
89         super(thing);
90     }
91
92     @Override
93     public void initialize() {
94         config = getConfigAs(RoombaConfiguration.class);
95         updateStatus(ThingStatus.UNKNOWN);
96         scheduler.execute(this::connect);
97     }
98
99     @Override
100     public void dispose() {
101         scheduler.execute(this::disconnect);
102     }
103
104     // lastState.get() can return null if the key is not found according
105     // to the documentation
106     @SuppressWarnings("null")
107     private void handleRefresh(String ch) {
108         State value = lastState.get(ch);
109
110         if (value != null) {
111             updateState(ch, value);
112         }
113     }
114
115     @Override
116     public void handleCommand(ChannelUID channelUID, Command command) {
117         String ch = channelUID.getId();
118         if (command instanceof RefreshType) {
119             handleRefresh(ch);
120             return;
121         }
122
123         if (ch.equals(CHANNEL_COMMAND)) {
124             if (command instanceof StringType) {
125                 String cmd = command.toString();
126
127                 if (cmd.equals(CMD_CLEAN)) {
128                     cmd = isPaused ? "resume" : "start";
129                 }
130
131                 sendRequest(new MQTTProtocol.CommandRequest(cmd));
132             }
133         } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
134             MQTTProtocol.Schedule schedule = lastSchedule;
135
136             // Schedule can only be updated in a bulk, so we have to store current
137             // schedule and modify components.
138             if (command instanceof OnOffType && schedule != null && schedule.cycle != null) {
139                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
140                     if (ch.equals(CHANNEL_SCHED_SWITCH[i])) {
141                         MQTTProtocol.Schedule newSchedule = new MQTTProtocol.Schedule(schedule.cycle);
142
143                         newSchedule.enableCycle(i, command.equals(OnOffType.ON));
144                         sendSchedule(newSchedule);
145                         break;
146                     }
147                 }
148             }
149         } else if (ch.equals(CHANNEL_SCHEDULE)) {
150             if (command instanceof DecimalType) {
151                 int bitmask = ((DecimalType) command).intValue();
152                 JsonArray cycle = new JsonArray();
153
154                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
155                     enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
156                 }
157
158                 sendSchedule(new MQTTProtocol.Schedule(bitmask));
159             }
160         } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
161             if (command instanceof OnOffType) {
162                 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
163             }
164         } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
165             if (command instanceof OnOffType) {
166                 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
167             }
168         } else if (ch.equals(CHANNEL_POWER_BOOST)) {
169             sendDelta(new MQTTProtocol.PowerBoost(command.equals(BOOST_AUTO), command.equals(BOOST_PERFORMANCE)));
170         } else if (ch.equals(CHANNEL_CLEAN_PASSES)) {
171             sendDelta(new MQTTProtocol.CleanPasses(!command.equals(PASSES_AUTO), command.equals(PASSES_2)));
172         }
173     }
174
175     private void enableCycle(JsonArray cycle, int i, boolean enable) {
176         JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
177         cycle.set(i, value);
178     }
179
180     private void sendSchedule(MQTTProtocol.Schedule schedule) {
181         sendDelta(new MQTTProtocol.CleanSchedule(schedule));
182     }
183
184     private void sendDelta(MQTTProtocol.StateValue state) {
185         sendRequest(new MQTTProtocol.DeltaRequest(state));
186     }
187
188     private void sendRequest(MQTTProtocol.Request request) {
189         MqttBrokerConnection conn = connection;
190
191         if (conn != null) {
192             String json = gson.toJson(request);
193             logger.trace("Sending {}: {}", request.getTopic(), json);
194             // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
195             // by Roomba, others just cause it to reject the command and drop the connection.
196             conn.publish(request.getTopic(), json.getBytes(), 1, false);
197         }
198     }
199
200     // In order not to mess up our connection state we need to make sure
201     // that connect() and disconnect() are never running concurrently, so
202     // they are synchronized
203     private synchronized void connect() {
204         logger.debug("Connecting to {}", config.ipaddress);
205
206         try {
207             InetAddress host = InetAddress.getByName(config.ipaddress);
208             String blid = this.blid;
209
210             if (blid == null) {
211                 DatagramSocket identSocket = IdentProtocol.sendRequest(host);
212                 DatagramPacket identPacket = IdentProtocol.receiveResponse(identSocket);
213                 IdentProtocol.IdentData ident;
214
215                 identSocket.close();
216
217                 try {
218                     ident = IdentProtocol.decodeResponse(identPacket);
219                 } catch (JsonParseException e) {
220                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
221                             "Malformed IDENT response");
222                     return;
223                 }
224
225                 if (ident.ver < IdentData.MIN_SUPPORTED_VERSION) {
226                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
227                             "Unsupported version " + ident.ver);
228                     return;
229                 }
230
231                 if (!ident.product.equals(IdentData.PRODUCT_ROOMBA)) {
232                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
233                             "Not a Roomba: " + ident.product);
234                     return;
235                 }
236
237                 blid = ident.blid;
238                 this.blid = blid;
239             }
240
241             logger.debug("BLID is: {}", blid);
242
243             if (config.password.isEmpty()) {
244                 RawMQTT mqtt;
245
246                 try {
247                     mqtt = new RawMQTT(host, 8883);
248                 } catch (KeyManagementException | NoSuchAlgorithmException e1) {
249                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e1.toString());
250                     return; // This is internal system error, no retry
251                 }
252
253                 mqtt.requestPassword();
254                 RawMQTT.Packet response = mqtt.readPacket();
255                 mqtt.close();
256
257                 if (response != null && response.isValidPasswdPacket()) {
258                     RawMQTT.PasswdPacket passwdPacket = new RawMQTT.PasswdPacket(response);
259                     String password = passwdPacket.getPassword();
260
261                     if (password != null) {
262                         config.password = password;
263
264                         Configuration configuration = editConfiguration();
265
266                         configuration.put("password", password);
267                         updateConfiguration(configuration);
268
269                         logger.debug("Password successfully retrieved");
270                     }
271                 }
272             }
273
274             if (config.password.isEmpty()) {
275                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
276                         "Authentication on the robot is required");
277                 scheduleReconnect();
278                 return;
279             }
280
281             // BLID is used as both client ID and username. The name of BLID also came from Roomba980-python
282             MqttBrokerConnection connection = new MqttBrokerConnection(config.ipaddress, RawMQTT.ROOMBA_MQTT_PORT, true,
283                     blid);
284
285             this.connection = connection;
286
287             // Disable sending UNSUBSCRIBE request before disconnecting becuase Roomba doesn't like it.
288             // It just swallows the request and never sends any response, so stop() method never completes.
289             connection.setUnsubscribeOnStop(false);
290             connection.setCredentials(blid, config.password);
291             connection.setTrustManagers(RawMQTT.getTrustManagers());
292             // 1 here actually corresponds to MQTT qos 0 (AT_MOST_ONCE). Only this value is accepted
293             // by Roomba, others just cause it to reject the command and drop the connection.
294             connection.setQos(1);
295             // MQTT connection reconnects itself, so we don't have to call scheduleReconnect()
296             // when it breaks. Just set the period in ms.
297             connection.setReconnectStrategy(
298                     new PeriodicReconnectStrategy(RECONNECT_DELAY_SEC * 1000, RECONNECT_DELAY_SEC * 1000));
299             connection.start().exceptionally(e -> {
300                 connectionStateChanged(MqttConnectionState.DISCONNECTED, e);
301                 return false;
302             }).thenAccept(v -> {
303                 if (!v) {
304                     connectionStateChanged(MqttConnectionState.DISCONNECTED, new TimeoutException("Timeout"));
305                 } else {
306                     connectionStateChanged(MqttConnectionState.CONNECTED, null);
307                 }
308             });
309         } catch (IOException e) {
310             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
311             scheduleReconnect();
312         }
313     }
314
315     private synchronized void disconnect() {
316         Future<?> reconnectReq = this.reconnectReq;
317         MqttBrokerConnection connection = this.connection;
318
319         if (reconnectReq != null) {
320             reconnectReq.cancel(false);
321             this.reconnectReq = null;
322         }
323
324         if (connection != null) {
325             connection.stop();
326             logger.trace("Closed connection to {}", config.ipaddress);
327             this.connection = null;
328         }
329     }
330
331     private void scheduleReconnect() {
332         reconnectReq = scheduler.schedule(this::connect, RECONNECT_DELAY_SEC, TimeUnit.SECONDS);
333     }
334
335     public void onConnected() {
336         updateStatus(ThingStatus.ONLINE);
337     }
338
339     @Override
340     public void processMessage(String topic, byte[] payload) {
341         String jsonStr = new String(payload);
342         MQTTProtocol.StateMessage msg;
343
344         logger.trace("Got topic {} data {}", topic, jsonStr);
345
346         try {
347             // We are not consuming all the fields, so we have to create the reader explicitly
348             // If we use fromJson(String) or fromJson(java.util.reader), it will throw
349             // "JSON not fully consumed" exception, because not all the reader's content has been
350             // used up. We want to avoid that also for compatibility reasons because newer iRobot
351             // versions may add fields.
352             JsonReader jsonReader = new JsonReader(new StringReader(jsonStr));
353             msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
354         } catch (JsonParseException e) {
355             logger.warn("Failed to parse JSON message from {}: {}", config.ipaddress, e.toString());
356             logger.warn("Raw contents: {}", payload);
357             return;
358         }
359
360         // Since all the fields are in fact optional, and a single message never
361         // contains all of them, we have to check presence of each individually
362         if (msg.state == null || msg.state.reported == null) {
363             return;
364         }
365
366         MQTTProtocol.GenericState reported = msg.state.reported;
367
368         if (reported.cleanMissionStatus != null) {
369             String cycle = reported.cleanMissionStatus.cycle;
370             String phase = reported.cleanMissionStatus.phase;
371             String command;
372
373             if (cycle.equals("none")) {
374                 command = CMD_STOP;
375             } else {
376                 switch (phase) {
377                     case "stop":
378                     case "stuck": // CHECKME: could also be equivalent to "stop" command
379                     case "pause": // Never observed in Roomba 930
380                         command = CMD_PAUSE;
381                         break;
382                     case "hmUsrDock":
383                     case "dock": // Never observed in Roomba 930
384                         command = CMD_DOCK;
385                         break;
386                     default:
387                         command = cycle; // "clean" or "spot"
388                         break;
389                 }
390             }
391
392             isPaused = command.equals(CMD_PAUSE);
393
394             reportString(CHANNEL_CYCLE, cycle);
395             reportString(CHANNEL_PHASE, phase);
396             reportString(CHANNEL_COMMAND, command);
397             reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
398         }
399
400         if (reported.batPct != null) {
401             reportInt(CHANNEL_BATTERY, reported.batPct);
402         }
403
404         if (reported.bin != null) {
405             String binStatus;
406
407             // The bin cannot be both full and removed simultaneously, so let's
408             // encode it as a single value
409             if (!reported.bin.present) {
410                 binStatus = BIN_REMOVED;
411             } else if (reported.bin.full) {
412                 binStatus = BIN_FULL;
413             } else {
414                 binStatus = BIN_OK;
415             }
416
417             reportString(CHANNEL_BIN, binStatus);
418         }
419
420         if (reported.signal != null) {
421             reportInt(CHANNEL_RSSI, reported.signal.rssi);
422             reportInt(CHANNEL_SNR, reported.signal.snr);
423         }
424
425         if (reported.cleanSchedule != null) {
426             MQTTProtocol.Schedule schedule = reported.cleanSchedule;
427
428             if (schedule.cycle != null) {
429                 int binary = 0;
430
431                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
432                     boolean on = schedule.cycleEnabled(i);
433
434                     reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
435                     if (on) {
436                         binary |= (1 << i);
437                     }
438                 }
439
440                 reportInt(CHANNEL_SCHEDULE, binary);
441             }
442
443             lastSchedule = schedule;
444         }
445
446         if (reported.openOnly != null) {
447             reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
448         }
449
450         if (reported.binPause != null) {
451             reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
452         }
453
454         // To make the life more interesting, paired values may not appear together in the
455         // same message, so we have to keep track of current values.
456         if (reported.carpetBoost != null) {
457             carpetBoost = reported.carpetBoost;
458             if (reported.carpetBoost) {
459                 // When set to true, overrides vacHigh
460                 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
461             } else if (vacHigh != null) {
462                 reportVacHigh();
463             }
464         }
465
466         if (reported.vacHigh != null) {
467             vacHigh = reported.vacHigh;
468             if (!carpetBoost) {
469                 // Can be overridden by "carpetBoost":true
470                 reportVacHigh();
471             }
472         }
473
474         if (reported.noAutoPasses != null) {
475             autoPasses = !reported.noAutoPasses;
476             if (!reported.noAutoPasses) {
477                 // When set to false, overrides twoPass
478                 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
479             } else if (twoPasses != null) {
480                 reportTwoPasses();
481             }
482         }
483
484         if (reported.twoPass != null) {
485             twoPasses = reported.twoPass;
486             if (!autoPasses) {
487                 // Can be overridden by "noAutoPasses":false
488                 reportTwoPasses();
489             }
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     }
499
500     private void reportVacHigh() {
501         reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
502     }
503
504     private void reportTwoPasses() {
505         reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
506     }
507
508     private void reportString(String channel, String str) {
509         reportState(channel, StringType.valueOf(str));
510     }
511
512     private void reportInt(String channel, int n) {
513         reportState(channel, new DecimalType(n));
514     }
515
516     private void reportSwitch(String channel, boolean s) {
517         reportState(channel, OnOffType.from(s));
518     }
519
520     private void reportState(String channel, State value) {
521         lastState.put(channel, value);
522         updateState(channel, value);
523     }
524
525     private void reportProperty(String property, @Nullable String value) {
526         if (value != null) {
527             updateProperty(property, value);
528         }
529     }
530
531     @Override
532     public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
533         if (state == MqttConnectionState.CONNECTED) {
534             MqttBrokerConnection connection = this.connection;
535
536             if (connection == null) {
537                 // This would be very strange, but Eclipse forces us to do the check
538                 logger.warn("Established connection without broker pointer");
539                 return;
540             }
541
542             updateStatus(ThingStatus.ONLINE);
543
544             // Roomba sends us two topics:
545             // "wifistat" - reports singnal strength and current robot position
546             // "$aws/things/<BLID>/shadow/update" - the rest of messages
547             // Subscribe to everything since we're interested in both
548             connection.subscribe("#", this).exceptionally(e -> {
549                 logger.warn("MQTT subscription failed: {}", e.getMessage());
550                 return false;
551             }).thenAccept(v -> {
552                 if (!v) {
553                     logger.warn("Subscription timeout");
554                 } else {
555                     logger.trace("Subscription done");
556                 }
557             });
558
559         } else {
560             String message;
561
562             if (error != null) {
563                 message = error.getMessage();
564                 logger.warn("MQTT connection failed: {}", message);
565             } else {
566                 message = null;
567                 logger.warn("MQTT connection failed for unspecified reason");
568             }
569
570             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
571         }
572     }
573 }