2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.miio.internal.handler;
15 import static org.openhab.binding.miio.internal.MiIoBindingConstants.*;
17 import java.io.ByteArrayInputStream;
18 import java.io.ByteArrayOutputStream;
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;
31 import java.util.concurrent.ConcurrentHashMap;
32 import java.util.concurrent.TimeUnit;
33 import java.util.stream.Collectors;
34 import java.util.stream.Stream;
36 import javax.imageio.ImageIO;
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;
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;
92 * The {@link MiIoVacuumHandler} is responsible for handling commands, which are
93 * sent to one of the channels.
95 * @author Marcel Verpaalen - Initial contribution
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;
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()));
111 private ExpiringCache<String> status;
112 private ExpiringCache<String> consumables;
113 private ExpiringCache<String> dnd;
114 private ExpiringCache<String> history;
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();
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, () -> {
132 int ret = sendCommand(MiIoCommand.GET_STATUS);
136 } catch (Exception e) {
137 logger.debug("Error during status refresh: {}", e.getMessage(), e);
141 consumables = new ExpiringCache<>(CACHE_EXPIRY, () -> {
143 int ret = sendCommand(MiIoCommand.CONSUMABLES_GET);
147 } catch (Exception e) {
148 logger.debug("Error during consumables refresh: {}", e.getMessage(), e);
152 dnd = new ExpiringCache<>(CACHE_EXPIRY, () -> {
154 int ret = sendCommand(MiIoCommand.DND_GET);
158 } catch (Exception e) {
159 logger.debug("Error during dnd refresh: {}", e.getMessage(), e);
163 history = new ExpiringCache<>(CACHE_EXPIRY, () -> {
165 int ret = sendCommand(MiIoCommand.CLEAN_SUMMARY_GET);
169 } catch (Exception e) {
170 logger.debug("Error during cleaning data refresh: {}", e.getMessage(), e);
174 map = new ExpiringCache<>(CACHE_EXPIRY, () -> {
176 int ret = sendCommand(MiIoCommand.GET_MAP);
180 } catch (Exception e) {
181 logger.debug("Error during dnd refresh: {}", e.getMessage(), e);
188 public void handleCommand(ChannelUID channelUID, Command command) {
189 if (getConnection() == null) {
190 logger.debug("Vacuum {} not online. Command {} ignored", getThing().getUID(), command.toString());
193 if (command == RefreshType.REFRESH) {
194 logger.debug("Refreshing {}", channelUID);
197 if (channelUID.getId().equals(CHANNEL_VACUUM_MAP)) {
198 sendCommand(MiIoCommand.GET_MAP);
202 if (handleCommandsChannels(channelUID, command)) {
206 if (channelUID.getId().equals(CHANNEL_VACUUM)) {
207 if (command instanceof OnOffType) {
208 if (command.equals(OnOffType.ON)) {
209 sendCommand(MiIoCommand.START_VACUUM);
213 sendCommand(MiIoCommand.STOP_VACUUM);
214 miIoScheduler.schedule(() -> {
215 sendCommand(MiIoCommand.CHARGE);
217 }, 2000, TimeUnit.MILLISECONDS);
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);
234 }, 2000, TimeUnit.MILLISECONDS);
237 logger.info("Command {} not recognised", command.toString());
242 if (channelUID.getId().equals(CHANNEL_FAN_POWER)) {
243 sendCommand(MiIoCommand.SET_MODE, "[" + command.toString() + "]");
248 if (channelUID.getId().equals(RobotCababilities.WATERBOX_MODE.getChannel())) {
249 sendCommand(MiIoCommand.SET_WATERBOX_MODE, "[" + command.toString() + "]");
253 if (channelUID.getId().equals(RobotCababilities.MOP_MODE.getChannel())) {
254 sendCommand(MiIoCommand.SET_MOP_MODE, "[" + command.toString() + "]");
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("-"));
265 if (channelUID.getId().equals(CHANNEL_FAN_CONTROL)) {
266 if (Integer.valueOf(command.toString()) > 0) {
267 sendCommand(MiIoCommand.SET_MODE, "[" + command.toString() + "]");
272 if (channelUID.getId().equals(CHANNEL_CONSUMABLE_RESET)) {
273 sendCommand(MiIoCommand.CONSUMABLES_RESET, "[" + command.toString() + "]");
274 updateState(CHANNEL_CONSUMABLE_RESET, new StringType("none"));
277 if (channelUID.getId().equals(RobotCababilities.COLLECT_DUST.getChannel()) && !command.toString().isEmpty()
278 && !command.toString().contentEquals("-")) {
279 sendCommand(MiIoCommand.SET_COLLECT_DUST);
284 if (channelUID.getId().equals(RobotCababilities.CLEAN_MOP_START.getChannel()) && !command.toString().isEmpty()
285 && !command.toString().contentEquals("-")) {
286 sendCommand(MiIoCommand.SET_CLEAN_MOP_START);
290 if (channelUID.getId().equals(RobotCababilities.CLEAN_MOP_STOP.getChannel()) && !command.toString().isEmpty()
291 && !command.toString().contentEquals("-")) {
292 sendCommand(MiIoCommand.SET_CLEAN_MOP_STOP);
298 private void forceStatusUpdate() {
299 status.invalidateValue();
300 miIoScheduler.schedule(() -> {
302 }, 3000, TimeUnit.MILLISECONDS);
305 private void safeUpdateState(String channelID, @Nullable Integer state) {
307 updateState(channelID, new DecimalType(state));
309 logger.debug("Channel {} not update. value not available.", channelID);
313 private boolean updateVacuumStatus(JsonObject statusData) {
314 StatusDTO statusInfo = GSON.fromJson(statusData, StatusDTO.class);
315 if (statusInfo == null) {
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));
323 if (statusInfo.getCleanTime() != null) {
324 updateState(CHANNEL_CLEAN_TIME,
325 new QuantityType<>(TimeUnit.SECONDS.toMinutes(statusInfo.getCleanTime()), Units.MINUTE));
327 safeUpdateState(CHANNEL_DND_ENABLED, statusInfo.getDndEnabled());
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());
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()));
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()));
347 State vacuum = OnOffType.OFF;
355 vacuum = OnOffType.ON;
370 vacuum = OnOffType.ON;
376 if ("undef".equals(control)) {
377 updateState(CHANNEL_CONTROL, UnDefType.UNDEF);
379 updateState(CHANNEL_CONTROL, new StringType(control));
381 updateState(CHANNEL_VACUUM, vacuum);
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()));
388 if (deviceCapabilities.containsKey(RobotCababilities.WATERBOX_MODE)) {
389 safeUpdateState(RobotCababilities.WATERBOX_MODE.getChannel(), statusInfo.getWaterBoxMode());
391 if (deviceCapabilities.containsKey(RobotCababilities.MOP_MODE)) {
392 safeUpdateState(RobotCababilities.MOP_MODE.getChannel(), statusInfo.getMopMode());
394 if (deviceCapabilities.containsKey(RobotCababilities.WATERBOX_STATUS)) {
395 safeUpdateState(RobotCababilities.WATERBOX_STATUS.getChannel(), statusInfo.getWaterBoxStatus());
397 if (deviceCapabilities.containsKey(RobotCababilities.WATERBOX_CARRIAGE)) {
398 safeUpdateState(RobotCababilities.WATERBOX_CARRIAGE.getChannel(), statusInfo.getWaterBoxCarriageStatus());
400 if (deviceCapabilities.containsKey(RobotCababilities.LOCKSTATUS)) {
401 safeUpdateState(RobotCababilities.LOCKSTATUS.getChannel(), statusInfo.getLockStatus());
403 if (deviceCapabilities.containsKey(RobotCababilities.MOP_FORBIDDEN)) {
404 safeUpdateState(RobotCababilities.MOP_FORBIDDEN.getChannel(), statusInfo.getMopForbiddenEnable());
406 if (deviceCapabilities.containsKey(RobotCababilities.LOCATING)) {
407 safeUpdateState(RobotCababilities.LOCATING.getChannel(), statusInfo.getIsLocating());
409 if (deviceCapabilities.containsKey(RobotCababilities.CLEAN_MOP_START)) {
410 safeUpdateState(RobotCababilities.CLEAN_MOP_START.getChannel(), 0);
412 if (deviceCapabilities.containsKey(RobotCababilities.CLEAN_MOP_STOP)) {
413 safeUpdateState(RobotCababilities.CLEAN_MOP_STOP.getChannel(), 0);
415 if (deviceCapabilities.containsKey(RobotCababilities.COLLECT_DUST)) {
416 safeUpdateState(RobotCababilities.COLLECT_DUST.getChannel(), 0);
418 if (deviceCapabilities.containsKey(RobotCababilities.MOP_DRYING)) {
419 safeUpdateState(RobotCababilities.MOP_DRYING.getChannel(), statusInfo.getIsMopDryingActive());
421 if (deviceCapabilities.containsKey(RobotCababilities.MOP_DRYING_REMAINING_TIME)) {
422 updateState(CHANNEL_MOP_TOTALDRYTIME,
423 new QuantityType<>(TimeUnit.SECONDS.toMinutes(statusInfo.getMopDryTime()), Units.MINUTE));
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)));
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())));
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 + "]");
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));
485 if (historyData.has("clean_area")) {
486 updateState(CHANNEL_HISTORY_TOTALAREA,
487 new QuantityType<>(historyData.get("clean_area").getAsDouble() / 1000000D, SIUnits.SQUARE_METRE));
489 if (historyData.has("clean_count")) {
490 updateState(CHANNEL_HISTORY_COUNT, new DecimalType(historyData.get("clean_count").getAsLong()));
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 + "]");
505 private void updateHistoryRecordLegacy(JsonArray historyData) {
506 HistoryRecordDTO historyRecord = new HistoryRecordDTO();
507 for (int i = 0; i < historyData.size(); ++i) {
509 BigInteger value = historyData.get(i).getAsBigInteger();
512 historyRecord.setStart(ZonedDateTime
513 .ofInstant(Instant.ofEpochSecond(value.longValue()), ZoneId.systemDefault())
517 historyRecord.setEnd(ZonedDateTime
518 .ofInstant(Instant.ofEpochSecond(value.longValue()), ZoneId.systemDefault())
522 historyRecord.setDuration(value.intValue());
525 historyRecord.setArea(new BigDecimal(value).divide(BigDecimal.valueOf(1000000)));
528 historyRecord.setError(value.intValue());
531 historyRecord.setFinished(value.intValue());
534 historyRecord.setStartType(value.intValue());
537 historyRecord.setCleanType(value.intValue());
540 historyRecord.setFinishReason(value.intValue());
543 } catch (ClassCastException | NumberFormatException | IllegalStateException e) {
546 updateHistoryRecord(historyRecord);
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]));
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]));
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));
566 if (historyRecordDTO.getArea() != null) {
567 historyRecord.addProperty("area", historyRecordDTO.getArea());
568 updateState(CHANNEL_HISTORY_AREA, new QuantityType<>(historyRecordDTO.getArea(), SIUnits.SQUARE_METRE));
570 if (historyRecordDTO.getError() != null) {
571 historyRecord.addProperty("error", historyRecordDTO.getError());
572 updateState(CHANNEL_HISTORY_ERROR, new DecimalType(historyRecordDTO.getError()));
574 if (historyRecordDTO.getFinished() != null) {
575 historyRecord.addProperty("finished", historyRecordDTO.getFinished());
576 updateState(CHANNEL_HISTORY_FINISH, new DecimalType(historyRecordDTO.getFinished()));
578 if (historyRecordDTO.getFinishReason() != null) {
579 historyRecord.addProperty("finish_reason", historyRecordDTO.getFinishReason());
580 updateState(CHANNEL_HISTORY_FINISHREASON, new DecimalType(historyRecordDTO.getFinishReason()));
582 if (historyRecordDTO.getDustCollectionStatus() != null) {
583 historyRecord.addProperty("dust_collection_status", historyRecordDTO.getDustCollectionStatus());
584 updateState(CHANNEL_HISTORY_DUSTCOLLECTION, new DecimalType(historyRecordDTO.getDustCollectionStatus()));
586 updateState(CHANNEL_HISTORY_RECORD, new StringType(historyRecord.toString()));
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());
601 room.add("not found");
603 mappedRoom.add(room);
605 updateState(cmd.getChannel(), new StringType(mappedRoom.toString()));
607 updateState(cmd.getChannel(), new StringType(response.getResult().toString()));
615 protected boolean skipUpdate() {
616 if (!hasConnection()) {
617 logger.debug("Skipping periodic update for '{}'. No Connection", getThing().getUID().toString());
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());
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());
636 protected synchronized void updateData() {
637 if (!hasConnection() || skipUpdate()) {
640 logger.debug("Periodic update for '{}' ({})", getThing().getUID().toString(), getThing().getThingTypeUID());
646 consumables.getValue();
647 if (lastMap.isEmpty() || stateId != 8) {
648 if (isLinked(mapChannelUid)) {
652 for (RobotCababilities cmd : FEATURES_CHANNELS) {
653 if (isLinked(cmd.getChannel()) && !cmd.getCommand().isBlank()) {
654 sendCommand(cmd.getCommand());
657 } catch (Exception e) {
658 logger.debug("Error while updating '{}': '{}", getThing().getUID().toString(), e.getLocalizedMessage());
663 public void 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();
673 protected boolean initializeData() {
674 updateState(CHANNEL_CONSUMABLE_RESET, new StringType("none"));
675 return super.initializeData();
679 public void onMessageReceived(MiIoSendCommand response) {
680 super.onMessageReceived(response);
681 if (response.isError()) {
684 switch (response.getCommand()) {
686 if (response.getResult().isJsonArray()) {
687 JsonObject statusResponse = response.getResult().getAsJsonArray().get(0).getAsJsonObject();
688 if (!hasChannelStructure) {
689 setCapabilities(statusResponse);
690 createCapabilityChannels();
692 updateVacuumStatus(statusResponse);
695 case CONSUMABLES_GET:
696 if (response.getResult().isJsonArray()) {
697 updateConsumables(response.getResult().getAsJsonArray().get(0).getAsJsonObject());
701 if (response.getResult().isJsonArray()) {
702 updateDnD(response.getResult().getAsJsonArray().get(0).getAsJsonObject());
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());
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);
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);
730 logger.debug("Could not extract cleaning history record from: {}", response.getResult());
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)));
743 case GET_SEGMENT_STATUS:
745 updateNumericChannel(response);
747 case GET_ROOM_MAPPING:
748 updateRoomMapping(response);
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()));
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;
777 if (capabilityChannel != null) {
778 if (response.getResult().isJsonArray() && response.getResult().getAsJsonArray().get(0).isJsonPrimitive()) {
780 Integer stat = response.getResult().getAsJsonArray().get(0).getAsInt();
781 updateState(capabilityChannel.getChannel(), new DecimalType(stat));
783 } catch (ClassCastException | IllegalStateException e) {
784 logger.debug("Could not update numeric channel {} with '{}': {}", capabilityChannel.getChannel(),
785 response.getResult(), e.getMessage());
788 logger.debug("Could not update numeric channel {} with '{}': Not in expected format",
789 capabilityChannel.getChannel(), response.getResult());
791 updateState(capabilityChannel.getChannel(), UnDefType.UNDEF);
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);
804 private void createCapabilityChannels() {
805 ThingBuilder thingBuilder = editThing();
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);
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);
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());
838 updateThing(thingBuilder.build());
840 hasChannelStructure = true;
843 private State getMap(String map) {
844 final MiIoBindingConfiguration configuration = this.configuration;
845 if (configuration != null && cloudConnector.isConnected()) {
847 final @Nullable RawType mapDl = cloudConnector.getMap(map, configuration.cloudServer);
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);
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");
864 logger.debug("Mapdata empty removing image");
865 return UnDefType.UNDEF;
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());
874 logger.debug("Not connected to Xiaomi cloud. Cannot retreive new map: {}", map);
876 return UnDefType.UNDEF;