]> git.basschouten.com Git - openhab-addons.git/blob
d748fc3f092c511d26b5324aa78b9a9a05a5529b
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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 userPmapvId;
193                         if (params.length >= 3) {
194                             userPmapvId = params[2];
195                         } else {
196                             userPmapvId = null;
197                         }
198
199                         String[] regions = params[1].split(",");
200                         String regionIds[] = new String[regions.length];
201                         String regionTypes[] = new String[regions.length];
202
203                         for (int i = 0; i < regions.length; i++) {
204                             String[] regionDetails = regions[i].split("=");
205
206                             if (regionDetails.length >= 2) {
207                                 if (regionDetails[0].equals("r")) {
208                                     regionIds[i] = regionDetails[1];
209                                     regionTypes[i] = "rid";
210                                 } else if (regionDetails[0].equals("z")) {
211                                     regionIds[i] = regionDetails[1];
212                                     regionTypes[i] = "zid";
213                                 } else {
214                                     regionIds[i] = regionDetails[0];
215                                     regionTypes[i] = "rid";
216                                 }
217                             } else {
218                                 regionIds[i] = regionDetails[0];
219                                 regionTypes[i] = "rid";
220                             }
221                         }
222                         MQTTProtocol.Request request = new MQTTProtocol.CleanRoomsRequest("start", mapId, regionIds,
223                                 regionTypes, userPmapvId);
224                         connection.send(request.getTopic(), gson.toJson(request));
225                     } else {
226                         logger.warn("Invalid request: {}", cmd);
227                         logger.warn("Correct format: cleanRegions:<pmid>;<region_id1>,<region_id2>,...>");
228                     }
229                 } else {
230                     MQTTProtocol.Request request = new MQTTProtocol.CommandRequest(cmd);
231                     connection.send(request.getTopic(), gson.toJson(request));
232                 }
233             }
234         } else if (ch.startsWith(CHANNEL_SCHED_SWITCH_PREFIX)) {
235             MQTTProtocol.Schedule schedule = lastSchedule;
236
237             // Schedule can only be updated in a bulk, so we have to store current
238             // schedule and modify components.
239             if (command instanceof OnOffType && schedule != null && schedule.cycle != null) {
240                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
241                     if (ch.equals(CHANNEL_SCHED_SWITCH[i])) {
242                         MQTTProtocol.Schedule newSchedule = new MQTTProtocol.Schedule(schedule.cycle);
243
244                         newSchedule.enableCycle(i, command.equals(OnOffType.ON));
245                         sendSchedule(newSchedule);
246                         break;
247                     }
248                 }
249             }
250         } else if (ch.equals(CHANNEL_SCHEDULE)) {
251             if (command instanceof DecimalType) {
252                 int bitmask = ((DecimalType) command).intValue();
253                 JsonArray cycle = new JsonArray();
254
255                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
256                     enableCycle(cycle, i, (bitmask & (1 << i)) != 0);
257                 }
258
259                 sendSchedule(new MQTTProtocol.Schedule(bitmask));
260             }
261         } else if (ch.equals(CHANNEL_EDGE_CLEAN)) {
262             if (command instanceof OnOffType) {
263                 sendDelta(new MQTTProtocol.OpenOnly(command.equals(OnOffType.OFF)));
264             }
265         } else if (ch.equals(CHANNEL_ALWAYS_FINISH)) {
266             if (command instanceof OnOffType) {
267                 sendDelta(new MQTTProtocol.BinPause(command.equals(OnOffType.OFF)));
268             }
269         } else if (ch.equals(CHANNEL_POWER_BOOST)) {
270             sendDelta(new MQTTProtocol.PowerBoost(command.equals(BOOST_AUTO), command.equals(BOOST_PERFORMANCE)));
271         } else if (ch.equals(CHANNEL_CLEAN_PASSES)) {
272             sendDelta(new MQTTProtocol.CleanPasses(!command.equals(PASSES_AUTO), command.equals(PASSES_2)));
273         } else if (ch.equals(CHANNEL_MAP_UPLOAD)) {
274             if (command instanceof OnOffType) {
275                 sendDelta(new MQTTProtocol.MapUploadAllowed(command.equals(OnOffType.ON)));
276             }
277         }
278     }
279
280     private void enableCycle(JsonArray cycle, int i, boolean enable) {
281         JsonPrimitive value = new JsonPrimitive(enable ? "start" : "none");
282         cycle.set(i, value);
283     }
284
285     private void sendSchedule(MQTTProtocol.Schedule schedule) {
286         sendDelta(new MQTTProtocol.CleanSchedule(schedule));
287     }
288
289     private void sendDelta(MQTTProtocol.StateValue state) {
290         MQTTProtocol.Request request = new MQTTProtocol.DeltaRequest(state);
291         connection.send(request.getTopic(), gson.toJson(request));
292     }
293
294     private synchronized void getCredentials() {
295         ThingStatus status = thing.getStatusInfo().getStatus();
296         IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
297         if (UNINITIALIZED.equals(status) || INITIALIZING.equals(status) || OFFLINE.equals(status)) {
298             if (UNKNOWN.equals(config.getBlid())) {
299                 @Nullable
300                 String blid = null;
301                 try {
302                     blid = LoginRequester.getBlid(config.getIpAddress());
303                 } catch (IOException exception) {
304                     updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
305                 }
306
307                 if (blid != null) {
308                     org.openhab.core.config.core.Configuration configuration = editConfiguration();
309                     configuration.put(ROBOT_BLID, blid);
310                     updateConfiguration(configuration);
311                 }
312             }
313
314             if (UNKNOWN.equals(config.getPassword())) {
315                 @Nullable
316                 String password = null;
317                 try {
318                     password = LoginRequester.getPassword(config.getIpAddress());
319                 } catch (KeyManagementException | NoSuchAlgorithmException exception) {
320                     updateStatus(OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, exception.toString());
321                     return; // This is internal system error, no retry
322                 } catch (IOException exception) {
323                     updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, exception.toString());
324                 }
325
326                 if (password != null) {
327                     org.openhab.core.config.core.Configuration configuration = editConfiguration();
328                     configuration.put(ROBOT_PASSWORD, password.trim());
329                     updateConfiguration(configuration);
330                 }
331             }
332         }
333
334         credentialRequester = null;
335         if (UNKNOWN.equals(config.getBlid()) || UNKNOWN.equals(config.getPassword())) {
336             credentialRequester = scheduler.schedule(this::getCredentials, 10000, TimeUnit.MILLISECONDS);
337         } else {
338             scheduler.execute(this::connect);
339         }
340     }
341
342     // In order not to mess up our connection state we need to make sure that connect()
343     // and disconnect() are never running concurrently, so they are synchronized
344     private synchronized void connect() {
345         IRobotConfiguration config = getConfigAs(IRobotConfiguration.class);
346         final String address = config.getIpAddress();
347         logger.debug("Connecting to {}", address);
348
349         final String blid = config.getBlid();
350         final String password = config.getPassword();
351         if (UNKNOWN.equals(blid) || UNKNOWN.equals(password)) {
352             final String message = "Robot authentication is required";
353             updateStatus(OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
354             scheduler.execute(this::getCredentials);
355         } else {
356             final String message = "Robot authentication is successful";
357             updateStatus(ThingStatus.ONLINE, ThingStatusDetail.CONFIGURATION_PENDING, message);
358             connection.connect(address, blid, password);
359         }
360     }
361
362     public void receive(final String topic, final String json) {
363         MQTTProtocol.StateMessage msg;
364
365         logger.trace("Got topic {} data {}", topic, json);
366
367         try {
368             // We are not consuming all the fields, so we have to create the reader explicitly
369             // If we use fromJson(String) or fromJson(java.util.reader), it will throw
370             // "JSON not fully consumed" exception, because not all the reader's content has been
371             // used up. We want to avoid that also for compatibility reasons because newer iRobot
372             // versions may add fields.
373             JsonReader jsonReader = new JsonReader(new StringReader(json));
374             msg = gson.fromJson(jsonReader, MQTTProtocol.StateMessage.class);
375         } catch (JsonParseException exception) {
376             logger.warn("Failed to parse JSON message for {}: {}", thing.getLabel(), exception.toString());
377             logger.warn("Raw contents: {}", json);
378             return;
379         }
380
381         // Since all the fields are in fact optional, and a single message never
382         // contains all of them, we have to check presence of each individually
383         if (msg.state == null || msg.state.reported == null) {
384             return;
385         }
386
387         MQTTProtocol.GenericState reported = msg.state.reported;
388
389         if (reported.cleanMissionStatus != null) {
390             String cycle = reported.cleanMissionStatus.cycle;
391             String phase = reported.cleanMissionStatus.phase;
392             String command;
393
394             if ("none".equals(cycle)) {
395                 command = CMD_STOP;
396             } else {
397                 switch (phase) {
398                     case "stop":
399                     case "stuck": // CHECKME: could also be equivalent to "stop" command
400                     case "pause": // Never observed in Roomba 930
401                         command = CMD_PAUSE;
402                         break;
403                     case "hmUsrDock":
404                     case "dock": // Never observed in Roomba 930
405                         command = CMD_DOCK;
406                         break;
407                     default:
408                         command = cycle; // "clean" or "spot"
409                         break;
410                 }
411             }
412
413             isPaused = command.equals(CMD_PAUSE);
414
415             reportString(CHANNEL_CYCLE, cycle);
416             reportString(CHANNEL_PHASE, phase);
417             reportString(CHANNEL_COMMAND, command);
418             reportString(CHANNEL_ERROR, String.valueOf(reported.cleanMissionStatus.error));
419         }
420
421         if (reported.batPct != null) {
422             reportInt(CHANNEL_BATTERY, reported.batPct);
423         }
424
425         if (reported.bin != null) {
426             String binStatus;
427
428             // The bin cannot be both full and removed simultaneously, so let's
429             // encode it as a single value
430             if (!reported.bin.present) {
431                 binStatus = BIN_REMOVED;
432             } else if (reported.bin.full) {
433                 binStatus = BIN_FULL;
434             } else {
435                 binStatus = BIN_OK;
436             }
437
438             reportString(CHANNEL_BIN, binStatus);
439         }
440
441         if (reported.signal != null) {
442             reportInt(CHANNEL_RSSI, reported.signal.rssi);
443             reportInt(CHANNEL_SNR, reported.signal.snr);
444         }
445
446         if (reported.cleanSchedule != null) {
447             MQTTProtocol.Schedule schedule = reported.cleanSchedule;
448
449             if (schedule.cycle != null) {
450                 int binary = 0;
451
452                 for (int i = 0; i < CHANNEL_SCHED_SWITCH.length; i++) {
453                     boolean on = schedule.cycleEnabled(i);
454
455                     reportSwitch(CHANNEL_SCHED_SWITCH[i], on);
456                     if (on) {
457                         binary |= (1 << i);
458                     }
459                 }
460
461                 reportInt(CHANNEL_SCHEDULE, binary);
462             }
463
464             lastSchedule = schedule;
465         }
466
467         if (reported.openOnly != null) {
468             reportSwitch(CHANNEL_EDGE_CLEAN, !reported.openOnly);
469         }
470
471         if (reported.binPause != null) {
472             reportSwitch(CHANNEL_ALWAYS_FINISH, !reported.binPause);
473         }
474
475         // To make the life more interesting, paired values may not appear together in the
476         // same message, so we have to keep track of current values.
477         if (reported.carpetBoost != null) {
478             carpetBoost = reported.carpetBoost;
479             if (reported.carpetBoost) {
480                 // When set to true, overrides vacHigh
481                 reportString(CHANNEL_POWER_BOOST, BOOST_AUTO);
482             } else if (vacHigh != null) {
483                 reportVacHigh();
484             }
485         }
486
487         if (reported.vacHigh != null) {
488             vacHigh = reported.vacHigh;
489             if (!carpetBoost) {
490                 // Can be overridden by "carpetBoost":true
491                 reportVacHigh();
492             }
493         }
494
495         if (reported.noAutoPasses != null) {
496             autoPasses = !reported.noAutoPasses;
497             if (!reported.noAutoPasses) {
498                 // When set to false, overrides twoPass
499                 reportString(CHANNEL_CLEAN_PASSES, PASSES_AUTO);
500             } else if (twoPasses != null) {
501                 reportTwoPasses();
502             }
503         }
504
505         if (reported.twoPass != null) {
506             twoPasses = reported.twoPass;
507             if (!autoPasses) {
508                 // Can be overridden by "noAutoPasses":false
509                 reportTwoPasses();
510             }
511         }
512
513         if (reported.lastCommand != null) {
514             reportString(CHANNEL_LAST_COMMAND, reported.lastCommand.toString());
515         }
516
517         if (reported.mapUploadAllowed != null) {
518             reportSwitch(CHANNEL_MAP_UPLOAD, reported.mapUploadAllowed);
519         }
520
521         reportProperty(Thing.PROPERTY_FIRMWARE_VERSION, reported.softwareVer);
522         reportProperty("navSwVer", reported.navSwVer);
523         reportProperty("wifiSwVer", reported.wifiSwVer);
524         reportProperty("mobilityVer", reported.mobilityVer);
525         reportProperty("bootloaderVer", reported.bootloaderVer);
526         reportProperty("umiVer", reported.umiVer);
527         reportProperty("sku", reported.sku);
528         reportProperty("batteryType", reported.batteryType);
529
530         if (reported.subModSwVer != null) {
531             // This is used by i7 model. It has more capabilities, perhaps a dedicated
532             // handler should be written by someone who owns it.
533             reportProperty("subModSwVer.nav", reported.subModSwVer.nav);
534             reportProperty("subModSwVer.mob", reported.subModSwVer.mob);
535             reportProperty("subModSwVer.pwr", reported.subModSwVer.pwr);
536             reportProperty("subModSwVer.sft", reported.subModSwVer.sft);
537             reportProperty("subModSwVer.mobBtl", reported.subModSwVer.mobBtl);
538             reportProperty("subModSwVer.linux", reported.subModSwVer.linux);
539             reportProperty("subModSwVer.con", reported.subModSwVer.con);
540         }
541     }
542
543     private void reportVacHigh() {
544         reportString(CHANNEL_POWER_BOOST, vacHigh ? BOOST_PERFORMANCE : BOOST_ECO);
545     }
546
547     private void reportTwoPasses() {
548         reportString(CHANNEL_CLEAN_PASSES, twoPasses ? PASSES_2 : PASSES_1);
549     }
550
551     private void reportString(String channel, String str) {
552         reportState(channel, StringType.valueOf(str));
553     }
554
555     private void reportInt(String channel, int n) {
556         reportState(channel, new DecimalType(n));
557     }
558
559     private void reportSwitch(String channel, boolean s) {
560         reportState(channel, OnOffType.from(s));
561     }
562
563     private void reportState(String channel, State value) {
564         lastState.put(channel, value);
565         updateState(channel, value);
566     }
567
568     private void reportProperty(String property, @Nullable String value) {
569         if (value != null) {
570             updateProperty(property, value);
571         }
572     }
573 }