]> git.basschouten.com Git - openhab-addons.git/blob
de5da91fa50a9ee545a3bb7b2aba23b22e22afd8
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.miio.internal.handler;
14
15 import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
16
17 import java.io.ByteArrayInputStream;
18 import java.io.ByteArrayOutputStream;
19 import java.io.File;
20 import java.io.IOException;
21 import java.math.BigDecimal;
22 import java.math.BigInteger;
23 import java.time.Instant;
24 import java.time.LocalDateTime;
25 import java.time.ZoneId;
26 import java.time.ZonedDateTime;
27 import java.time.format.DateTimeFormatter;
28 import java.util.Collections;
29 import java.util.Map.Entry;
30 import java.util.Set;
31 import java.util.concurrent.ConcurrentHashMap;
32 import java.util.concurrent.TimeUnit;
33 import java.util.stream.Collectors;
34 import java.util.stream.Stream;
35
36 import javax.imageio.ImageIO;
37
38 import org.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.openhab.binding.miio.internal.MiIoBindingConfiguration;
41 import org.openhab.binding.miio.internal.MiIoCommand;
42 import org.openhab.binding.miio.internal.MiIoSendCommand;
43 import org.openhab.binding.miio.internal.basic.MiIoDatabaseWatchService;
44 import org.openhab.binding.miio.internal.cloud.CloudConnector;
45 import org.openhab.binding.miio.internal.cloud.CloudUtil;
46 import org.openhab.binding.miio.internal.cloud.HomeRoomDTO;
47 import org.openhab.binding.miio.internal.cloud.MiCloudException;
48 import org.openhab.binding.miio.internal.robot.ConsumablesType;
49 import org.openhab.binding.miio.internal.robot.DockStatusType;
50 import org.openhab.binding.miio.internal.robot.FanModeType;
51 import org.openhab.binding.miio.internal.robot.HistoryRecordDTO;
52 import org.openhab.binding.miio.internal.robot.RRMapDraw;
53 import org.openhab.binding.miio.internal.robot.RRMapDrawOptions;
54 import org.openhab.binding.miio.internal.robot.RobotCababilities;
55 import org.openhab.binding.miio.internal.robot.StatusDTO;
56 import org.openhab.binding.miio.internal.robot.StatusType;
57 import org.openhab.binding.miio.internal.robot.VacuumErrorType;
58 import org.openhab.binding.miio.internal.transport.MiIoAsyncCommunication;
59 import org.openhab.core.cache.ExpiringCache;
60 import org.openhab.core.i18n.LocaleProvider;
61 import org.openhab.core.i18n.TranslationProvider;
62 import org.openhab.core.library.types.DateTimeType;
63 import org.openhab.core.library.types.DecimalType;
64 import org.openhab.core.library.types.OnOffType;
65 import org.openhab.core.library.types.QuantityType;
66 import org.openhab.core.library.types.RawType;
67 import org.openhab.core.library.types.StringType;
68 import org.openhab.core.library.unit.SIUnits;
69 import org.openhab.core.library.unit.Units;
70 import org.openhab.core.thing.Channel;
71 import org.openhab.core.thing.ChannelUID;
72 import org.openhab.core.thing.Thing;
73 import org.openhab.core.thing.ThingStatusDetail;
74 import org.openhab.core.thing.binding.builder.ChannelBuilder;
75 import org.openhab.core.thing.binding.builder.ThingBuilder;
76 import org.openhab.core.thing.type.ChannelType;
77 import org.openhab.core.thing.type.ChannelTypeRegistry;
78 import org.openhab.core.types.Command;
79 import org.openhab.core.types.RefreshType;
80 import org.openhab.core.types.State;
81 import org.openhab.core.types.UnDefType;
82 import org.slf4j.Logger;
83 import org.slf4j.LoggerFactory;
84
85 import com.google.gson.Gson;
86 import com.google.gson.GsonBuilder;
87 import com.google.gson.JsonArray;
88 import com.google.gson.JsonElement;
89 import com.google.gson.JsonObject;
90
91 /**
92  * The {@link MiIoVacuumHandler} is responsible for handling commands, which are
93  * sent to one of the channels.
94  *
95  * @author Marcel Verpaalen - Initial contribution
96  */
97 @NonNullByDefault
98 public class MiIoVacuumHandler extends MiIoAbstractHandler {
99     private final Logger logger = LoggerFactory.getLogger(MiIoVacuumHandler.class);
100     private static final DateTimeFormatter DATEFORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss");
101     private static final Gson GSON = new GsonBuilder().serializeNulls().create();
102     private final ChannelUID mapChannelUid;
103
104     private static final Set<RobotCababilities> FEATURES_CHANNELS = Collections.unmodifiableSet(Stream.of(
105             RobotCababilities.SEGMENT_STATUS, RobotCababilities.MAP_STATUS, RobotCababilities.LED_STATUS,
106             RobotCababilities.CARPET_MODE, RobotCababilities.FW_FEATURES, RobotCababilities.ROOM_MAPPING,
107             RobotCababilities.MULTI_MAP_LIST, RobotCababilities.CUSTOMIZE_CLEAN_MODE, RobotCababilities.COLLECT_DUST,
108             RobotCababilities.CLEAN_MOP_START, RobotCababilities.CLEAN_MOP_STOP, RobotCababilities.MOP_DRYING,
109             RobotCababilities.MOP_DRYING_REMAINING_TIME, RobotCababilities.DOCK_STATE_ID).collect(Collectors.toSet()));
110
111     private ExpiringCache<String> status;
112     private ExpiringCache<String> consumables;
113     private ExpiringCache<String> dnd;
114     private ExpiringCache<String> history;
115     private int stateId;
116     private ExpiringCache<String> map;
117     private String lastHistoryId = "";
118     private String lastMap = "";
119     private boolean hasChannelStructure;
120     private ConcurrentHashMap<RobotCababilities, Boolean> deviceCapabilities = new ConcurrentHashMap<>();
121     private ChannelTypeRegistry channelTypeRegistry;
122     private RRMapDrawOptions mapDrawOptions = new RRMapDrawOptions();
123
124     public MiIoVacuumHandler(Thing thing, MiIoDatabaseWatchService miIoDatabaseWatchService,
125             CloudConnector cloudConnector, ChannelTypeRegistry channelTypeRegistry, TranslationProvider i18nProvider,
126             LocaleProvider localeProvider) {
127         super(thing, miIoDatabaseWatchService, cloudConnector, i18nProvider, localeProvider);
128         this.channelTypeRegistry = channelTypeRegistry;
129         mapChannelUid = new ChannelUID(thing.getUID(), CHANNEL_VACUUM_MAP);
130         status = new ExpiringCache<>(CACHE_EXPIRY, () -> {
131             try {
132                 int ret = sendCommand(MiIoCommand.GET_STATUS);
133                 if (ret != 0) {
134                     return "id:" + ret;
135                 }
136             } catch (Exception e) {
137                 logger.debug("Error during status refresh: {}", e.getMessage(), e);
138             }
139             return null;
140         });
141         consumables = new ExpiringCache<>(CACHE_EXPIRY, () -> {
142             try {
143                 int ret = sendCommand(MiIoCommand.CONSUMABLES_GET);
144                 if (ret != 0) {
145                     return "id:" + ret;
146                 }
147             } catch (Exception e) {
148                 logger.debug("Error during consumables refresh: {}", e.getMessage(), e);
149             }
150             return null;
151         });
152         dnd = new ExpiringCache<>(CACHE_EXPIRY, () -> {
153             try {
154                 int ret = sendCommand(MiIoCommand.DND_GET);
155                 if (ret != 0) {
156                     return "id:" + ret;
157                 }
158             } catch (Exception e) {
159                 logger.debug("Error during dnd refresh: {}", e.getMessage(), e);
160             }
161             return null;
162         });
163         history = new ExpiringCache<>(CACHE_EXPIRY, () -> {
164             try {
165                 int ret = sendCommand(MiIoCommand.CLEAN_SUMMARY_GET);
166                 if (ret != 0) {
167                     return "id:" + ret;
168                 }
169             } catch (Exception e) {
170                 logger.debug("Error during cleaning data refresh: {}", e.getMessage(), e);
171             }
172             return null;
173         });
174         map = new ExpiringCache<>(CACHE_EXPIRY, () -> {
175             try {
176                 int ret = sendCommand(MiIoCommand.GET_MAP);
177                 if (ret != 0) {
178                     return "id:" + ret;
179                 }
180             } catch (Exception e) {
181                 logger.debug("Error during dnd refresh: {}", e.getMessage(), e);
182             }
183             return null;
184         });
185     }
186
187     @Override
188     public void handleCommand(ChannelUID channelUID, Command command) {
189         if (getConnection() == null) {
190             logger.debug("Vacuum {} not online. Command {} ignored", getThing().getUID(), command.toString());
191             return;
192         }
193         if (command == RefreshType.REFRESH) {
194             logger.debug("Refreshing {}", channelUID);
195             updateData();
196             lastMap = "";
197             if (channelUID.getId().equals(CHANNEL_VACUUM_MAP)) {
198                 sendCommand(MiIoCommand.GET_MAP);
199             }
200             return;
201         }
202         if (handleCommandsChannels(channelUID, command)) {
203             forceStatusUpdate();
204             return;
205         }
206         if (channelUID.getId().equals(CHANNEL_VACUUM)) {
207             if (command instanceof OnOffType) {
208                 if (command.equals(OnOffType.ON)) {
209                     sendCommand(MiIoCommand.START_VACUUM);
210                     forceStatusUpdate();
211                     return;
212                 } else {
213                     sendCommand(MiIoCommand.STOP_VACUUM);
214                     miIoScheduler.schedule(() -> {
215                         sendCommand(MiIoCommand.CHARGE);
216                         forceStatusUpdate();
217                     }, 2000, TimeUnit.MILLISECONDS);
218                     return;
219                 }
220             }
221         }
222         if (channelUID.getId().equals(CHANNEL_CONTROL)) {
223             if ("vacuum".equals(command.toString())) {
224                 sendCommand(MiIoCommand.START_VACUUM);
225             } else if ("spot".equals(command.toString())) {
226                 sendCommand(MiIoCommand.START_SPOT);
227             } else if ("pause".equals(command.toString())) {
228                 sendCommand(MiIoCommand.PAUSE);
229             } else if ("dock".equals(command.toString())) {
230                 sendCommand(MiIoCommand.STOP_VACUUM);
231                 miIoScheduler.schedule(() -> {
232                     sendCommand(MiIoCommand.CHARGE);
233                     forceStatusUpdate();
234                 }, 2000, TimeUnit.MILLISECONDS);
235                 return;
236             } else {
237                 logger.info("Command {} not recognised", command.toString());
238             }
239             forceStatusUpdate();
240             return;
241         }
242         if (channelUID.getId().equals(CHANNEL_FAN_POWER)) {
243             sendCommand(MiIoCommand.SET_MODE, "[" + command.toString() + "]");
244             forceStatusUpdate();
245             return;
246         }
247
248         if (channelUID.getId().equals(RobotCababilities.WATERBOX_MODE.getChannel())) {
249             sendCommand(MiIoCommand.SET_WATERBOX_MODE, "[" + command.toString() + "]");
250             forceStatusUpdate();
251             return;
252         }
253         if (channelUID.getId().equals(RobotCababilities.MOP_MODE.getChannel())) {
254             sendCommand(MiIoCommand.SET_MOP_MODE, "[" + command.toString() + "]");
255             forceStatusUpdate();
256             return;
257         }
258         if (channelUID.getId().equals(RobotCababilities.SEGMENT_CLEAN.getChannel()) && !command.toString().isEmpty()
259                 && !command.toString().contentEquals("-")) {
260             sendCommand(MiIoCommand.START_SEGMENT, "[" + command.toString() + "]");
261             updateState(RobotCababilities.SEGMENT_CLEAN.getChannel(), new StringType("-"));
262             forceStatusUpdate();
263             return;
264         }
265         if (channelUID.getId().equals(CHANNEL_FAN_CONTROL)) {
266             if (Integer.valueOf(command.toString()) > 0) {
267                 sendCommand(MiIoCommand.SET_MODE, "[" + command.toString() + "]");
268             }
269             forceStatusUpdate();
270             return;
271         }
272         if (channelUID.getId().equals(CHANNEL_CONSUMABLE_RESET)) {
273             sendCommand(MiIoCommand.CONSUMABLES_RESET, "[" + command.toString() + "]");
274             updateState(CHANNEL_CONSUMABLE_RESET, new StringType("none"));
275         }
276
277         if (channelUID.getId().equals(RobotCababilities.COLLECT_DUST.getChannel()) && !command.toString().isEmpty()
278                 && !command.toString().contentEquals("-")) {
279             sendCommand(MiIoCommand.SET_COLLECT_DUST);
280             forceStatusUpdate();
281             return;
282         }
283
284         if (channelUID.getId().equals(RobotCababilities.CLEAN_MOP_START.getChannel()) && !command.toString().isEmpty()
285                 && !command.toString().contentEquals("-")) {
286             sendCommand(MiIoCommand.SET_CLEAN_MOP_START);
287             forceStatusUpdate();
288             return;
289         }
290         if (channelUID.getId().equals(RobotCababilities.CLEAN_MOP_STOP.getChannel()) && !command.toString().isEmpty()
291                 && !command.toString().contentEquals("-")) {
292             sendCommand(MiIoCommand.SET_CLEAN_MOP_STOP);
293             forceStatusUpdate();
294             return;
295         }
296     }
297
298     private void forceStatusUpdate() {
299         status.invalidateValue();
300         miIoScheduler.schedule(() -> {
301             status.getValue();
302         }, 3000, TimeUnit.MILLISECONDS);
303     }
304
305     private void safeUpdateState(String channelID, @Nullable Integer state) {
306         if (state != null) {
307             updateState(channelID, new DecimalType(state));
308         } else {
309             logger.debug("Channel {} not update. value not available.", channelID);
310         }
311     }
312
313     private boolean updateVacuumStatus(JsonObject statusData) {
314         StatusDTO statusInfo = GSON.fromJson(statusData, StatusDTO.class);
315         if (statusInfo == null) {
316             return false;
317         }
318         safeUpdateState(CHANNEL_BATTERY, statusInfo.getBattery());
319         if (statusInfo.getCleanArea() != null) {
320             updateState(CHANNEL_CLEAN_AREA,
321                     new QuantityType<>(statusInfo.getCleanArea() / 1000000.0, SIUnits.SQUARE_METRE));
322         }
323         if (statusInfo.getCleanTime() != null) {
324             updateState(CHANNEL_CLEAN_TIME,
325                     new QuantityType<>(TimeUnit.SECONDS.toMinutes(statusInfo.getCleanTime()), Units.MINUTE));
326         }
327         safeUpdateState(CHANNEL_DND_ENABLED, statusInfo.getDndEnabled());
328
329         if (statusInfo.getErrorCode() != null) {
330             updateState(CHANNEL_ERROR_CODE,
331                     new StringType(VacuumErrorType.getType(statusInfo.getErrorCode()).getDescription()));
332             safeUpdateState(CHANNEL_ERROR_ID, statusInfo.getErrorCode());
333         }
334
335         if (statusInfo.getFanPower() != null) {
336             updateState(CHANNEL_FAN_POWER, new DecimalType(statusInfo.getFanPower()));
337             updateState(CHANNEL_FAN_CONTROL, new DecimalType(FanModeType.getType(statusInfo.getFanPower()).getId()));
338         }
339         safeUpdateState(CHANNEL_IN_CLEANING, statusInfo.getInCleaning());
340         safeUpdateState(CHANNEL_MAP_PRESENT, statusInfo.getMapPresent());
341         if (statusInfo.getState() != null) {
342             stateId = statusInfo.getState();
343             StatusType state = StatusType.getType(statusInfo.getState());
344             updateState(CHANNEL_STATE, new StringType(state.getDescription()));
345             updateState(CHANNEL_STATE_ID, new DecimalType(statusInfo.getState()));
346
347             State vacuum = OnOffType.OFF;
348             String control;
349             switch (state) {
350                 case ZONE:
351                 case ROOM:
352                 case CLEANING:
353                 case RETURNING:
354                     control = "vacuum";
355                     vacuum = OnOffType.ON;
356                     break;
357                 case CHARGING:
358                 case CHARGING_ERROR:
359                 case DOCKING:
360                 case FULL:
361                     control = "dock";
362                     break;
363                 case SLEEPING:
364                 case PAUSED:
365                 case IDLE:
366                     control = "pause";
367                     break;
368                 case SPOTCLEAN:
369                     control = "spot";
370                     vacuum = OnOffType.ON;
371                     break;
372                 default:
373                     control = "undef";
374                     break;
375             }
376             if ("undef".equals(control)) {
377                 updateState(CHANNEL_CONTROL, UnDefType.UNDEF);
378             } else {
379                 updateState(CHANNEL_CONTROL, new StringType(control));
380             }
381             updateState(CHANNEL_VACUUM, vacuum);
382         }
383         if (this.deviceCapabilities.containsKey(RobotCababilities.DOCK_STATE_ID)) {
384             DockStatusType state = DockStatusType.getType(statusInfo.getDockErrorStatus().intValue());
385             updateState(CHANNEL_DOCK_STATE, new StringType(state.getDescription()));
386             updateState(CHANNEL_DOCK_STATE_ID, new DecimalType(state.getId()));
387         }
388         if (deviceCapabilities.containsKey(RobotCababilities.WATERBOX_MODE)) {
389             safeUpdateState(RobotCababilities.WATERBOX_MODE.getChannel(), statusInfo.getWaterBoxMode());
390         }
391         if (deviceCapabilities.containsKey(RobotCababilities.MOP_MODE)) {
392             safeUpdateState(RobotCababilities.MOP_MODE.getChannel(), statusInfo.getMopMode());
393         }
394         if (deviceCapabilities.containsKey(RobotCababilities.WATERBOX_STATUS)) {
395             safeUpdateState(RobotCababilities.WATERBOX_STATUS.getChannel(), statusInfo.getWaterBoxStatus());
396         }
397         if (deviceCapabilities.containsKey(RobotCababilities.WATERBOX_CARRIAGE)) {
398             safeUpdateState(RobotCababilities.WATERBOX_CARRIAGE.getChannel(), statusInfo.getWaterBoxCarriageStatus());
399         }
400         if (deviceCapabilities.containsKey(RobotCababilities.LOCKSTATUS)) {
401             safeUpdateState(RobotCababilities.LOCKSTATUS.getChannel(), statusInfo.getLockStatus());
402         }
403         if (deviceCapabilities.containsKey(RobotCababilities.MOP_FORBIDDEN)) {
404             safeUpdateState(RobotCababilities.MOP_FORBIDDEN.getChannel(), statusInfo.getMopForbiddenEnable());
405         }
406         if (deviceCapabilities.containsKey(RobotCababilities.LOCATING)) {
407             safeUpdateState(RobotCababilities.LOCATING.getChannel(), statusInfo.getIsLocating());
408         }
409         if (deviceCapabilities.containsKey(RobotCababilities.CLEAN_MOP_START)) {
410             safeUpdateState(RobotCababilities.CLEAN_MOP_START.getChannel(), 0);
411         }
412         if (deviceCapabilities.containsKey(RobotCababilities.CLEAN_MOP_STOP)) {
413             safeUpdateState(RobotCababilities.CLEAN_MOP_STOP.getChannel(), 0);
414         }
415         if (deviceCapabilities.containsKey(RobotCababilities.COLLECT_DUST)) {
416             safeUpdateState(RobotCababilities.COLLECT_DUST.getChannel(), 0);
417         }
418         if (deviceCapabilities.containsKey(RobotCababilities.MOP_DRYING)) {
419             safeUpdateState(RobotCababilities.MOP_DRYING.getChannel(), statusInfo.getIsMopDryingActive());
420         }
421         if (deviceCapabilities.containsKey(RobotCababilities.MOP_DRYING_REMAINING_TIME)) {
422             updateState(CHANNEL_MOP_TOTALDRYTIME,
423                     new QuantityType<>(TimeUnit.SECONDS.toMinutes(statusInfo.getMopDryTime()), Units.MINUTE));
424         }
425         return true;
426     }
427
428     private boolean updateConsumables(JsonObject consumablesData) {
429         int mainBrush = consumablesData.get("main_brush_work_time").getAsInt();
430         int sideBrush = consumablesData.get("side_brush_work_time").getAsInt();
431         int filter = consumablesData.get("filter_work_time").getAsInt();
432         int sensor = consumablesData.get("sensor_dirty_time").getAsInt();
433         updateState(CHANNEL_CONSUMABLE_MAIN_TIME,
434                 new QuantityType<>(ConsumablesType.remainingHours(mainBrush, ConsumablesType.MAIN_BRUSH), Units.HOUR));
435         updateState(CHANNEL_CONSUMABLE_MAIN_PERC,
436                 new DecimalType(ConsumablesType.remainingPercent(mainBrush, ConsumablesType.MAIN_BRUSH)));
437         updateState(CHANNEL_CONSUMABLE_SIDE_TIME,
438                 new QuantityType<>(ConsumablesType.remainingHours(sideBrush, ConsumablesType.SIDE_BRUSH), Units.HOUR));
439         updateState(CHANNEL_CONSUMABLE_SIDE_PERC,
440                 new DecimalType(ConsumablesType.remainingPercent(sideBrush, ConsumablesType.SIDE_BRUSH)));
441         updateState(CHANNEL_CONSUMABLE_FILTER_TIME,
442                 new QuantityType<>(ConsumablesType.remainingHours(filter, ConsumablesType.FILTER), Units.HOUR));
443         updateState(CHANNEL_CONSUMABLE_FILTER_PERC,
444                 new DecimalType(ConsumablesType.remainingPercent(filter, ConsumablesType.FILTER)));
445         updateState(CHANNEL_CONSUMABLE_SENSOR_TIME,
446                 new QuantityType<>(ConsumablesType.remainingHours(sensor, ConsumablesType.SENSOR), Units.HOUR));
447         updateState(CHANNEL_CONSUMABLE_SENSOR_PERC,
448                 new DecimalType(ConsumablesType.remainingPercent(sensor, ConsumablesType.SENSOR)));
449         return true;
450     }
451
452     private boolean updateDnD(JsonObject dndData) {
453         logger.trace("Do not disturb data: {}", dndData.toString());
454         updateState(CHANNEL_DND_FUNCTION, new DecimalType(dndData.get("enabled").getAsBigDecimal()));
455         updateState(CHANNEL_DND_START, new StringType(String.format("%02d:%02d", dndData.get("start_hour").getAsInt(),
456                 dndData.get("start_minute").getAsInt())));
457         updateState(CHANNEL_DND_END, new StringType(
458                 String.format("%02d:%02d", dndData.get("end_hour").getAsInt(), dndData.get("end_minute").getAsInt())));
459         return true;
460     }
461
462     private boolean updateHistoryLegacy(JsonArray historyData) {
463         logger.trace("Cleaning history data: {}", historyData.toString());
464         updateState(CHANNEL_HISTORY_TOTALTIME,
465                 new QuantityType<>(TimeUnit.SECONDS.toMinutes(historyData.get(0).getAsLong()), Units.MINUTE));
466         updateState(CHANNEL_HISTORY_TOTALAREA,
467                 new QuantityType<>(historyData.get(1).getAsDouble() / 1000000D, SIUnits.SQUARE_METRE));
468         updateState(CHANNEL_HISTORY_COUNT, new DecimalType(historyData.get(2).toString()));
469         if (historyData.get(3).getAsJsonArray().size() > 0) {
470             String lastClean = historyData.get(3).getAsJsonArray().get(0).getAsString();
471             if (!lastClean.equals(lastHistoryId)) {
472                 lastHistoryId = lastClean;
473                 sendCommand(MiIoCommand.CLEAN_RECORD_GET, "[" + lastClean + "]");
474             }
475         }
476         return true;
477     }
478
479     private boolean updateHistory(JsonObject historyData) {
480         logger.trace("Cleaning history data: {}", historyData);
481         if (historyData.has("clean_time")) {
482             updateState(CHANNEL_HISTORY_TOTALTIME, new QuantityType<>(
483                     TimeUnit.SECONDS.toMinutes(historyData.get("clean_time").getAsLong()), Units.MINUTE));
484         }
485         if (historyData.has("clean_area")) {
486             updateState(CHANNEL_HISTORY_TOTALAREA,
487                     new QuantityType<>(historyData.get("clean_area").getAsDouble() / 1000000D, SIUnits.SQUARE_METRE));
488         }
489         if (historyData.has("clean_count")) {
490             updateState(CHANNEL_HISTORY_COUNT, new DecimalType(historyData.get("clean_count").getAsLong()));
491         }
492         if (historyData.has("records") & historyData.get("records").isJsonArray()) {
493             JsonArray historyRecords = historyData.get("records").getAsJsonArray();
494             if (!historyRecords.isEmpty()) {
495                 String lastClean = historyRecords.get(0).getAsString();
496                 if (!lastClean.equals(lastHistoryId)) {
497                     lastHistoryId = lastClean;
498                     sendCommand(MiIoCommand.CLEAN_RECORD_GET, "[" + lastClean + "]");
499                 }
500             }
501         }
502         return true;
503     }
504
505     private void updateHistoryRecordLegacy(JsonArray historyData) {
506         HistoryRecordDTO historyRecord = new HistoryRecordDTO();
507         for (int i = 0; i < historyData.size(); ++i) {
508             try {
509                 BigInteger value = historyData.get(i).getAsBigInteger();
510                 switch (i) {
511                     case 0:
512                         historyRecord.setStart(ZonedDateTime
513                                 .ofInstant(Instant.ofEpochSecond(value.longValue()), ZoneId.systemDefault())
514                                 .toString());
515                         break;
516                     case 1:
517                         historyRecord.setEnd(ZonedDateTime
518                                 .ofInstant(Instant.ofEpochSecond(value.longValue()), ZoneId.systemDefault())
519                                 .toString());
520                         break;
521                     case 2:
522                         historyRecord.setDuration(value.intValue());
523                         break;
524                     case 3:
525                         historyRecord.setArea(new BigDecimal(value).divide(BigDecimal.valueOf(1000000)));
526                         break;
527                     case 4:
528                         historyRecord.setError(value.intValue());
529                         break;
530                     case 5:
531                         historyRecord.setFinished(value.intValue());
532                         break;
533                     case 6:
534                         historyRecord.setStartType(value.intValue());
535                         break;
536                     case 7:
537                         historyRecord.setCleanType(value.intValue());
538                         break;
539                     case 8:
540                         historyRecord.setFinishReason(value.intValue());
541                         break;
542                 }
543             } catch (ClassCastException | NumberFormatException | IllegalStateException e) {
544             }
545         }
546         updateHistoryRecord(historyRecord);
547     }
548
549     private void updateHistoryRecord(HistoryRecordDTO historyRecordDTO) {
550         JsonObject historyRecord = GSON.toJsonTree(historyRecordDTO).getAsJsonObject();
551         if (historyRecordDTO.getStart() != null) {
552             historyRecord.addProperty("start", historyRecordDTO.getStart().split("\\+")[0].split("\\-")[0]);
553             updateState(CHANNEL_HISTORY_START_TIME,
554                     new DateTimeType(historyRecordDTO.getStart().split("\\+")[0].split("\\-")[0]));
555         }
556         if (historyRecordDTO.getEnd() != null) {
557             historyRecord.addProperty("end", historyRecordDTO.getEnd().split("\\+")[0].split("\\-")[0]);
558             updateState(CHANNEL_HISTORY_END_TIME,
559                     new DateTimeType(historyRecordDTO.getEnd().split("\\+")[0].split("\\-")[0]));
560         }
561         if (historyRecordDTO.getDuration() != null) {
562             long duration = TimeUnit.SECONDS.toMinutes(historyRecordDTO.getDuration().longValue());
563             historyRecord.addProperty("duration", duration);
564             updateState(CHANNEL_HISTORY_DURATION, new QuantityType<>(duration, Units.MINUTE));
565         }
566         if (historyRecordDTO.getArea() != null) {
567             historyRecord.addProperty("area", historyRecordDTO.getArea());
568             updateState(CHANNEL_HISTORY_AREA, new QuantityType<>(historyRecordDTO.getArea(), SIUnits.SQUARE_METRE));
569         }
570         if (historyRecordDTO.getError() != null) {
571             historyRecord.addProperty("error", historyRecordDTO.getError());
572             updateState(CHANNEL_HISTORY_ERROR, new DecimalType(historyRecordDTO.getError()));
573         }
574         if (historyRecordDTO.getFinished() != null) {
575             historyRecord.addProperty("finished", historyRecordDTO.getFinished());
576             updateState(CHANNEL_HISTORY_FINISH, new DecimalType(historyRecordDTO.getFinished()));
577         }
578         if (historyRecordDTO.getFinishReason() != null) {
579             historyRecord.addProperty("finish_reason", historyRecordDTO.getFinishReason());
580             updateState(CHANNEL_HISTORY_FINISHREASON, new DecimalType(historyRecordDTO.getFinishReason()));
581         }
582         if (historyRecordDTO.getDustCollectionStatus() != null) {
583             historyRecord.addProperty("dust_collection_status", historyRecordDTO.getDustCollectionStatus());
584             updateState(CHANNEL_HISTORY_DUSTCOLLECTION, new DecimalType(historyRecordDTO.getDustCollectionStatus()));
585         }
586         updateState(CHANNEL_HISTORY_RECORD, new StringType(historyRecord.toString()));
587     }
588
589     private void updateRoomMapping(MiIoSendCommand response) {
590         for (RobotCababilities cmd : FEATURES_CHANNELS) {
591             if (response.getCommand().getCommand().contentEquals(cmd.getCommand())) {
592                 if (response.getResult().isJsonArray()) {
593                     JsonArray rooms = response.getResult().getAsJsonArray();
594                     JsonArray mappedRoom = new JsonArray();
595                     for (JsonElement roomE : rooms) {
596                         JsonArray room = roomE.getAsJsonArray();
597                         HomeRoomDTO name = cloudConnector.getRoom(room.get(1).getAsString());
598                         if (name != null && name.getName() != null) {
599                             room.add(name.getName());
600                         } else {
601                             room.add("not found");
602                         }
603                         mappedRoom.add(room);
604                     }
605                     updateState(cmd.getChannel(), new StringType(mappedRoom.toString()));
606                 } else {
607                     updateState(cmd.getChannel(), new StringType(response.getResult().toString()));
608                 }
609                 break;
610             }
611         }
612     }
613
614     @Override
615     protected boolean skipUpdate() {
616         if (!hasConnection()) {
617             logger.debug("Skipping periodic update for '{}'. No Connection", getThing().getUID().toString());
618             return true;
619         }
620         if (ThingStatusDetail.CONFIGURATION_ERROR.equals(getThing().getStatusInfo().getStatusDetail())) {
621             logger.debug("Skipping periodic update for '{}' UID '{}'. Thing Status", getThing().getUID().toString(),
622                     getThing().getStatusInfo().getStatusDetail());
623             refreshNetwork();
624             return true;
625         }
626         final MiIoAsyncCommunication mc = miioCom;
627         if (mc != null && mc.getQueueLength() > MAX_QUEUE) {
628             logger.debug("Skipping periodic update for '{}'. {} elements in queue.", getThing().getUID().toString(),
629                     mc.getQueueLength());
630             return true;
631         }
632         return false;
633     }
634
635     @Override
636     protected synchronized void updateData() {
637         if (!hasConnection() || skipUpdate()) {
638             return;
639         }
640         logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
641         try {
642             dnd.getValue();
643             history.getValue();
644             status.getValue();
645             refreshNetwork();
646             consumables.getValue();
647             if (lastMap.isEmpty() || stateId != 8) {
648                 if (isLinked(mapChannelUid)) {
649                     map.getValue();
650                 }
651             }
652             for (RobotCababilities cmd : FEATURES_CHANNELS) {
653                 if (isLinked(cmd.getChannel()) && !cmd.getCommand().isBlank()) {
654                     sendCommand(cmd.getCommand());
655                 }
656             }
657         } catch (Exception e) {
658             logger.debug("Error while updating '{}': '{}", getThing().getUID().toString(), e.getLocalizedMessage());
659         }
660     }
661
662     @Override
663     public void initialize() {
664         super.initialize();
665         hasChannelStructure = false;
666         this.mapDrawOptions = RRMapDrawOptions
667                 .getOptionsFromFile(BINDING_USERDATA_PATH + File.separator + "mapConfig.json", logger);
668         updateState(RobotCababilities.SEGMENT_CLEAN.getChannel(), new StringType("-"));
669         cloudConnector.getHomeLists();
670     }
671
672     @Override
673     protected boolean initializeData() {
674         updateState(CHANNEL_CONSUMABLE_RESET, new StringType("none"));
675         return super.initializeData();
676     }
677
678     @Override
679     public void onMessageReceived(MiIoSendCommand response) {
680         super.onMessageReceived(response);
681         if (response.isError()) {
682             return;
683         }
684         switch (response.getCommand()) {
685             case GET_STATUS:
686                 if (response.getResult().isJsonArray()) {
687                     JsonObject statusResponse = response.getResult().getAsJsonArray().get(0).getAsJsonObject();
688                     if (!hasChannelStructure) {
689                         setCapabilities(statusResponse);
690                         createCapabilityChannels();
691                     }
692                     updateVacuumStatus(statusResponse);
693                 }
694                 break;
695             case CONSUMABLES_GET:
696                 if (response.getResult().isJsonArray()) {
697                     updateConsumables(response.getResult().getAsJsonArray().get(0).getAsJsonObject());
698                 }
699                 break;
700             case DND_GET:
701                 if (response.getResult().isJsonArray()) {
702                     updateDnD(response.getResult().getAsJsonArray().get(0).getAsJsonObject());
703                 }
704                 break;
705             case CLEAN_SUMMARY_GET:
706                 if (response.getResult().isJsonArray()) {
707                     updateHistoryLegacy(response.getResult().getAsJsonArray());
708                 } else if (response.getResult().isJsonObject()) {
709                     updateHistory(response.getResult().getAsJsonObject());
710                 }
711                 break;
712             case CLEAN_RECORD_GET:
713                 if (response.getResult().isJsonArray() && response.getResult().getAsJsonArray().size() > 0
714                         && response.getResult().getAsJsonArray().get(0).isJsonArray()) {
715                     updateHistoryRecordLegacy(response.getResult().getAsJsonArray().get(0).getAsJsonArray());
716                 } else if (response.getResult().isJsonArray() && response.getResult().getAsJsonArray().size() > 0
717                         && response.getResult().getAsJsonArray().get(0).isJsonObject()) {
718                     final HistoryRecordDTO historyRecordDTO = GSON.fromJson(
719                             response.getResult().getAsJsonArray().get(0).getAsJsonObject(), HistoryRecordDTO.class);
720                     if (historyRecordDTO != null) {
721                         updateHistoryRecord(historyRecordDTO);
722                     }
723                 } else if (response.getResult().isJsonObject()) {
724                     final HistoryRecordDTO historyRecordDTO = GSON.fromJson(response.getResult().getAsJsonObject(),
725                             HistoryRecordDTO.class);
726                     if (historyRecordDTO != null) {
727                         updateHistoryRecord(historyRecordDTO);
728                     }
729                 } else {
730                     logger.debug("Could not extract cleaning history record from: {}", response.getResult());
731                 }
732                 break;
733             case GET_MAP:
734                 if (response.getResult().isJsonArray()) {
735                     String mapresponse = response.getResult().getAsJsonArray().get(0).getAsString();
736                     if (!mapresponse.contentEquals("retry") && !mapresponse.contentEquals(lastMap)) {
737                         lastMap = mapresponse;
738                         miIoScheduler.submit(() -> updateState(CHANNEL_VACUUM_MAP, getMap(mapresponse)));
739                     }
740                 }
741                 break;
742             case GET_MAP_STATUS:
743             case GET_SEGMENT_STATUS:
744             case GET_LED_STATUS:
745                 updateNumericChannel(response);
746                 break;
747             case GET_ROOM_MAPPING:
748                 updateRoomMapping(response);
749                 break;
750             case GET_CARPET_MODE:
751             case GET_FW_FEATURES:
752             case GET_CUSTOMIZED_CLEAN_MODE:
753             case GET_MULTI_MAP_LIST:
754             case SET_COLLECT_DUST:
755             case SET_CLEAN_MOP_START:
756             case SET_CLEAN_MOP_STOP:
757                 for (RobotCababilities cmd : FEATURES_CHANNELS) {
758                     if (response.getCommand().getCommand().contentEquals(cmd.getCommand())) {
759                         updateState(cmd.getChannel(), new StringType(response.getResult().toString()));
760                         break;
761                     }
762                 }
763                 break;
764             default:
765                 break;
766         }
767     }
768
769     private void updateNumericChannel(MiIoSendCommand response) {
770         RobotCababilities capabilityChannel = null;
771         for (RobotCababilities cmd : FEATURES_CHANNELS) {
772             if (response.getCommand().getCommand().contentEquals(cmd.getCommand())) {
773                 capabilityChannel = cmd;
774                 break;
775             }
776         }
777         if (capabilityChannel != null) {
778             if (response.getResult().isJsonArray() && response.getResult().getAsJsonArray().get(0).isJsonPrimitive()) {
779                 try {
780                     Integer stat = response.getResult().getAsJsonArray().get(0).getAsInt();
781                     updateState(capabilityChannel.getChannel(), new DecimalType(stat));
782                     return;
783                 } catch (ClassCastException | IllegalStateException e) {
784                     logger.debug("Could not update numeric channel {} with '{}': {}", capabilityChannel.getChannel(),
785                             response.getResult(), e.getMessage());
786                 }
787             } else {
788                 logger.debug("Could not update numeric channel {} with '{}': Not in expected format",
789                         capabilityChannel.getChannel(), response.getResult());
790             }
791             updateState(capabilityChannel.getChannel(), UnDefType.UNDEF);
792         }
793     }
794
795     private void setCapabilities(JsonObject statusResponse) {
796         for (RobotCababilities capability : RobotCababilities.values()) {
797             if (statusResponse.has(capability.getStatusFieldName())) {
798                 deviceCapabilities.putIfAbsent(capability, false);
799                 logger.debug("Setting additional vacuum {}", capability);
800             }
801         }
802     }
803
804     private void createCapabilityChannels() {
805         ThingBuilder thingBuilder = editThing();
806         int cnt = 0;
807
808         for (Entry<RobotCababilities, Boolean> robotCapability : deviceCapabilities.entrySet()) {
809             RobotCababilities capability = robotCapability.getKey();
810             Boolean channelCreated = robotCapability.getValue();
811             if (!channelCreated) {
812                 if (thing.getChannels().stream()
813                         .anyMatch(ch -> ch.getUID().getId().equalsIgnoreCase(capability.getChannel()))) {
814                     logger.debug("Channel already available...skip creation of channel '{}'.", capability.getChannel());
815                     deviceCapabilities.replace(capability, true);
816                     continue;
817                 }
818                 logger.debug("Creating dynamic channel for capability {}", capability);
819                 ChannelType channelType = channelTypeRegistry.getChannelType(capability.getChannelType());
820                 if (channelType != null) {
821                     logger.debug("Found channelType '{}' for capability {}", channelType, capability.name());
822                     ChannelUID channelUID = new ChannelUID(getThing().getUID(), capability.getChannel());
823                     Channel channel = ChannelBuilder.create(channelUID, channelType.getItemType())
824                             .withType(capability.getChannelType()).withLabel(channelType.getLabel()).build();
825                     thingBuilder.withChannel(channel);
826                     cnt++;
827                 } else {
828                     logger.debug("ChannelType {} not found (Unexpected). Available types:",
829                             capability.getChannelType());
830                     for (ChannelType ct : channelTypeRegistry.getChannelTypes()) {
831                         logger.debug("Available channelType: '{}' '{}' '{}'", ct.getUID(), ct.toString(),
832                                 ct.getConfigDescriptionURI());
833                     }
834                 }
835             }
836         }
837         if (cnt > 0) {
838             updateThing(thingBuilder.build());
839         }
840         hasChannelStructure = true;
841     }
842
843     private State getMap(String map) {
844         final MiIoBindingConfiguration configuration = this.configuration;
845         if (configuration != null && cloudConnector.isConnected()) {
846             try {
847                 final @Nullable RawType mapDl = cloudConnector.getMap(map, configuration.cloudServer);
848                 if (mapDl != null) {
849                     byte[] mapData = mapDl.getBytes();
850                     RRMapDraw rrMap = RRMapDraw.loadImage(new ByteArrayInputStream(mapData));
851                     rrMap.setDrawOptions(mapDrawOptions);
852                     ByteArrayOutputStream baos = new ByteArrayOutputStream();
853                     if (logger.isDebugEnabled()) {
854                         final String mapPath = BINDING_USERDATA_PATH + File.separator + map
855                                 + LocalDateTime.now().format(DATEFORMATTER) + ".rrmap";
856                         CloudUtil.writeBytesToFileNio(mapData, mapPath);
857                         logger.debug("Mapdata saved to {}", mapPath);
858                     }
859                     ImageIO.write(rrMap.getImage(), "jpg", baos);
860                     byte[] byteArray = baos.toByteArray();
861                     if (byteArray != null && byteArray.length > 0) {
862                         return new RawType(byteArray, "image/jpeg");
863                     } else {
864                         logger.debug("Mapdata empty removing image");
865                         return UnDefType.UNDEF;
866                     }
867                 }
868             } catch (MiCloudException e) {
869                 logger.debug("Error getting data from Xiaomi cloud. Mapdata could not be updated: {}", e.getMessage());
870             } catch (IOException e) {
871                 logger.debug("Mapdata could not be updated: {}", e.getMessage());
872             }
873         } else {
874             logger.debug("Not connected to Xiaomi cloud. Cannot retreive new map: {}", map);
875         }
876         return UnDefType.UNDEF;
877     }
878 }