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.ecovacs.internal.handler;
15 import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
17 import java.time.ZoneId;
18 import java.util.ArrayList;
19 import java.util.Arrays;
20 import java.util.Collection;
21 import java.util.List;
22 import java.util.Locale;
23 import java.util.Optional;
25 import java.util.function.Predicate;
26 import java.util.stream.Collectors;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.binding.ecovacs.internal.EcovacsDynamicStateDescriptionProvider;
31 import org.openhab.binding.ecovacs.internal.action.EcovacsVacuumActions;
32 import org.openhab.binding.ecovacs.internal.api.EcovacsApi;
33 import org.openhab.binding.ecovacs.internal.api.EcovacsApiException;
34 import org.openhab.binding.ecovacs.internal.api.EcovacsDevice;
35 import org.openhab.binding.ecovacs.internal.api.commands.AbstractNoResponseCommand;
36 import org.openhab.binding.ecovacs.internal.api.commands.CustomAreaCleaningCommand;
37 import org.openhab.binding.ecovacs.internal.api.commands.EmptyDustbinCommand;
38 import org.openhab.binding.ecovacs.internal.api.commands.GetBatteryInfoCommand;
39 import org.openhab.binding.ecovacs.internal.api.commands.GetChargeStateCommand;
40 import org.openhab.binding.ecovacs.internal.api.commands.GetCleanStateCommand;
41 import org.openhab.binding.ecovacs.internal.api.commands.GetComponentLifeSpanCommand;
42 import org.openhab.binding.ecovacs.internal.api.commands.GetContinuousCleaningCommand;
43 import org.openhab.binding.ecovacs.internal.api.commands.GetDefaultCleanPassesCommand;
44 import org.openhab.binding.ecovacs.internal.api.commands.GetDustbinAutoEmptyCommand;
45 import org.openhab.binding.ecovacs.internal.api.commands.GetErrorCommand;
46 import org.openhab.binding.ecovacs.internal.api.commands.GetMoppingWaterAmountCommand;
47 import org.openhab.binding.ecovacs.internal.api.commands.GetNetworkInfoCommand;
48 import org.openhab.binding.ecovacs.internal.api.commands.GetSuctionPowerCommand;
49 import org.openhab.binding.ecovacs.internal.api.commands.GetTotalStatsCommand;
50 import org.openhab.binding.ecovacs.internal.api.commands.GetTotalStatsCommand.TotalStats;
51 import org.openhab.binding.ecovacs.internal.api.commands.GetTrueDetectCommand;
52 import org.openhab.binding.ecovacs.internal.api.commands.GetVolumeCommand;
53 import org.openhab.binding.ecovacs.internal.api.commands.GetWaterSystemPresentCommand;
54 import org.openhab.binding.ecovacs.internal.api.commands.GoChargingCommand;
55 import org.openhab.binding.ecovacs.internal.api.commands.PauseCleaningCommand;
56 import org.openhab.binding.ecovacs.internal.api.commands.PlaySoundCommand;
57 import org.openhab.binding.ecovacs.internal.api.commands.ResumeCleaningCommand;
58 import org.openhab.binding.ecovacs.internal.api.commands.SetContinuousCleaningCommand;
59 import org.openhab.binding.ecovacs.internal.api.commands.SetDefaultCleanPassesCommand;
60 import org.openhab.binding.ecovacs.internal.api.commands.SetDustbinAutoEmptyCommand;
61 import org.openhab.binding.ecovacs.internal.api.commands.SetMoppingWaterAmountCommand;
62 import org.openhab.binding.ecovacs.internal.api.commands.SetSuctionPowerCommand;
63 import org.openhab.binding.ecovacs.internal.api.commands.SetTrueDetectCommand;
64 import org.openhab.binding.ecovacs.internal.api.commands.SetVolumeCommand;
65 import org.openhab.binding.ecovacs.internal.api.commands.SpotAreaCleaningCommand;
66 import org.openhab.binding.ecovacs.internal.api.commands.StartAutoCleaningCommand;
67 import org.openhab.binding.ecovacs.internal.api.commands.StopCleaningCommand;
68 import org.openhab.binding.ecovacs.internal.api.model.ChargeMode;
69 import org.openhab.binding.ecovacs.internal.api.model.CleanLogRecord;
70 import org.openhab.binding.ecovacs.internal.api.model.CleanMode;
71 import org.openhab.binding.ecovacs.internal.api.model.Component;
72 import org.openhab.binding.ecovacs.internal.api.model.DeviceCapability;
73 import org.openhab.binding.ecovacs.internal.api.model.MoppingWaterAmount;
74 import org.openhab.binding.ecovacs.internal.api.model.NetworkInfo;
75 import org.openhab.binding.ecovacs.internal.api.model.SuctionPower;
76 import org.openhab.binding.ecovacs.internal.api.util.SchedulerTask;
77 import org.openhab.binding.ecovacs.internal.config.EcovacsVacuumConfiguration;
78 import org.openhab.binding.ecovacs.internal.util.StateOptionEntry;
79 import org.openhab.binding.ecovacs.internal.util.StateOptionMapping;
80 import org.openhab.core.i18n.ConfigurationException;
81 import org.openhab.core.i18n.LocaleProvider;
82 import org.openhab.core.i18n.TranslationProvider;
83 import org.openhab.core.library.types.DateTimeType;
84 import org.openhab.core.library.types.DecimalType;
85 import org.openhab.core.library.types.OnOffType;
86 import org.openhab.core.library.types.PercentType;
87 import org.openhab.core.library.types.QuantityType;
88 import org.openhab.core.library.types.RawType;
89 import org.openhab.core.library.types.StringType;
90 import org.openhab.core.library.unit.SIUnits;
91 import org.openhab.core.library.unit.Units;
92 import org.openhab.core.thing.Bridge;
93 import org.openhab.core.thing.ChannelUID;
94 import org.openhab.core.thing.Thing;
95 import org.openhab.core.thing.ThingStatus;
96 import org.openhab.core.thing.ThingStatusDetail;
97 import org.openhab.core.thing.ThingStatusInfo;
98 import org.openhab.core.thing.ThingUID;
99 import org.openhab.core.thing.binding.BaseThingHandler;
100 import org.openhab.core.thing.binding.ThingHandlerService;
101 import org.openhab.core.thing.binding.builder.ThingBuilder;
102 import org.openhab.core.types.Command;
103 import org.openhab.core.types.State;
104 import org.openhab.core.types.StateOption;
105 import org.openhab.core.types.UnDefType;
106 import org.osgi.framework.Bundle;
107 import org.osgi.framework.FrameworkUtil;
108 import org.slf4j.Logger;
109 import org.slf4j.LoggerFactory;
112 * The {@link EcovacsVacuumHandler} is responsible for handling data and commands from/to vacuum cleaners.
114 * @author Danny Baumann - Initial contribution
117 public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDevice.EventListener {
119 private final Logger logger = LoggerFactory.getLogger(EcovacsVacuumHandler.class);
121 private final TranslationProvider i18Provider;
122 private final LocaleProvider localeProvider;
123 private final EcovacsDynamicStateDescriptionProvider stateDescriptionProvider;
124 private final Bundle bundle;
126 private final SchedulerTask initTask;
127 private final SchedulerTask reconnectTask;
128 private final SchedulerTask pollTask;
129 private @Nullable EcovacsDevice device;
131 private @Nullable Boolean lastWasCharging;
132 private @Nullable CleanMode lastCleanMode;
133 private @Nullable CleanMode lastActiveCleanMode;
134 private Optional<String> lastDownloadedCleanMapUrl = Optional.empty();
135 private long lastSuccessfulPollTimestamp;
136 private int lastDefaultCleaningPasses = 1;
137 private String serialNumber = "<unset>";
139 public EcovacsVacuumHandler(Thing thing, TranslationProvider i18Provider, LocaleProvider localeProvider,
140 EcovacsDynamicStateDescriptionProvider stateDescriptionProvider) {
142 this.i18Provider = i18Provider;
143 this.localeProvider = localeProvider;
144 this.stateDescriptionProvider = stateDescriptionProvider;
145 bundle = FrameworkUtil.getBundle(getClass());
147 initTask = new SchedulerTask(scheduler, logger, "Init", this::initDevice);
148 reconnectTask = new SchedulerTask(scheduler, logger, "Connection", this::connectToDevice);
149 pollTask = new SchedulerTask(scheduler, logger, "Poll", this::pollData);
153 public Collection<Class<? extends ThingHandlerService>> getServices() {
154 return Set.of(EcovacsVacuumActions.class);
158 public void handleCommand(ChannelUID channelUID, Command command) {
159 final EcovacsDevice device = this.device;
160 if (device == null) {
161 logger.debug("{}: Ignoring command {}, no active connection", serialNumber, command);
164 String channel = channelUID.getId();
167 if (channel.equals(CHANNEL_ID_COMMAND) && command instanceof StringType) {
168 AbstractNoResponseCommand cmd = determineDeviceCommand(device, command.toString());
170 device.sendCommand(cmd);
173 } else if (channel.equals(CHANNEL_ID_VOICE_VOLUME) && command instanceof DecimalType volume) {
174 int volumePercent = volume.intValue();
175 device.sendCommand(new SetVolumeCommand((volumePercent + 5) / 10));
177 } else if (channel.equals(CHANNEL_ID_SUCTION_POWER) && command instanceof StringType) {
178 Optional<SuctionPower> power = SUCTION_POWER_MAPPING.findMappedEnumValue(command.toString());
179 if (power.isPresent()) {
180 device.sendCommand(new SetSuctionPowerCommand(power.get()));
183 } else if (channel.equals(CHANNEL_ID_WATER_AMOUNT) && command instanceof StringType) {
184 Optional<MoppingWaterAmount> amount = WATER_AMOUNT_MAPPING.findMappedEnumValue(command.toString());
185 if (amount.isPresent()) {
186 device.sendCommand(new SetMoppingWaterAmountCommand(amount.get()));
189 } else if (channel.equals(CHANNEL_ID_AUTO_EMPTY)) {
190 if (command instanceof OnOffType) {
191 device.sendCommand(new SetDustbinAutoEmptyCommand(command == OnOffType.ON));
193 } else if (command instanceof StringType && "trigger".equals(command.toString())) {
194 device.sendCommand(new EmptyDustbinCommand());
197 } else if (channel.equals(CHANNEL_ID_TRUE_DETECT_3D) && command instanceof OnOffType) {
198 device.sendCommand(new SetTrueDetectCommand(command == OnOffType.ON));
200 } else if (channel.equals(CHANNEL_ID_CONTINUOUS_CLEANING) && command instanceof OnOffType) {
201 device.sendCommand(new SetContinuousCleaningCommand(command == OnOffType.ON));
203 } else if (channel.equals(CHANNEL_ID_CLEANING_PASSES) && command instanceof DecimalType type) {
204 int passes = type.intValue();
205 device.sendCommand(new SetDefaultCleanPassesCommand(passes));
206 lastDefaultCleaningPasses = passes; // if we get here, the command was executed successfully
209 logger.debug("{}: Ignoring unsupported device command {} for channel {}", serialNumber, command, channel);
210 } catch (InterruptedException e) {
211 Thread.currentThread().interrupt();
212 } catch (EcovacsApiException e) {
213 logger.debug("{}: Handling device command {} failed", serialNumber, command, e);
218 public void initialize() {
219 serialNumber = getConfigAs(EcovacsVacuumConfiguration.class).serialNumber;
220 if (serialNumber.isEmpty()) {
221 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
222 "@text/offline.config-error-no-serial");
224 logger.debug("{}: Initializing handler", serialNumber);
225 updateStatus(ThingStatus.UNKNOWN);
226 initTask.setNamePrefix(serialNumber);
227 reconnectTask.setNamePrefix(serialNumber);
228 pollTask.setNamePrefix(serialNumber);
234 public void dispose() {
235 logger.debug("{}: Disposing handler", serialNumber);
240 public void channelLinked(ChannelUID channelUID) {
241 EcovacsDevice device = this.device;
242 if (device == null) {
247 switch (channelUID.getId()) {
248 case CHANNEL_ID_BATTERY_LEVEL:
249 fetchInitialBatteryStatus(device);
251 case CHANNEL_ID_STATE:
252 case CHANNEL_ID_COMMAND:
253 case CHANNEL_ID_CLEANING_MODE:
254 fetchInitialStateAndCommandValues(device);
256 case CHANNEL_ID_WATER_PLATE_PRESENT:
257 fetchInitialWaterSystemPresentState(device);
259 case CHANNEL_ID_ERROR_CODE:
260 case CHANNEL_ID_ERROR_DESCRIPTION:
261 fetchInitialErrorCode(device);
263 scheduleNextPoll(5); // add some delay in case multiple channels are linked at once
266 } catch (InterruptedException e) {
267 Thread.currentThread().interrupt();
268 } catch (EcovacsApiException e) {
269 logger.debug("{}: Fetching initial data for channel {} failed", serialNumber, channelUID.getId(), e);
274 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
275 logger.debug("{}: Bridge status changed to {}", serialNumber, bridgeStatusInfo);
276 if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
278 } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
280 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
285 public void onBatteryLevelUpdated(EcovacsDevice device, int newLevelPercent) {
286 // Some devices report weird values (> 100%), so better clamp to supported range
287 int actualPercent = Math.max(0, Math.min(newLevelPercent, 100));
288 updateState(CHANNEL_ID_BATTERY_LEVEL, new DecimalType(actualPercent));
292 public void onChargingStateUpdated(EcovacsDevice device, boolean charging) {
293 lastWasCharging = charging;
294 updateStateAndCommandChannels();
298 public void onCleaningModeUpdated(EcovacsDevice device, CleanMode newMode, Optional<String> areaDefinition) {
299 lastCleanMode = newMode;
300 if (newMode.isActive()) {
301 lastActiveCleanMode = newMode;
302 } else if (newMode.isIdle()) {
303 lastActiveCleanMode = null;
305 updateStateAndCommandChannels();
306 Optional<State> areaDefState = areaDefinition.map(def -> {
307 if (newMode == CleanMode.SPOT_AREA) {
308 // Map indices back to letters as shown in the app
309 def = Arrays.stream(def.split(",")).map(item -> {
311 int index = Integer.parseInt(item);
312 return String.valueOf((char) ('A' + index));
313 } catch (NumberFormatException e) {
316 }).collect(Collectors.joining(";"));
317 } else if (newMode == CleanMode.CUSTOM_AREA) {
318 // Map the separator from comma to semicolon to allow using the output as command input
319 def = def.replace(',', ';');
321 return new StringType(def);
323 updateState(CHANNEL_ID_CLEANING_SPOT_DEFINITION, areaDefState.orElse(UnDefType.UNDEF));
324 if (newMode == CleanMode.RETURNING) {
325 scheduleNextPoll(30);
326 } else if (newMode.isIdle()) {
327 updateState(CHANNEL_ID_CLEANED_AREA, UnDefType.UNDEF);
328 updateState(CHANNEL_ID_CLEANING_TIME, UnDefType.UNDEF);
333 public void onCleaningStatsUpdated(EcovacsDevice device, int cleanedArea, int cleaningTimeSeconds) {
334 updateState(CHANNEL_ID_CLEANED_AREA, new QuantityType<>(cleanedArea, SIUnits.SQUARE_METRE));
335 updateState(CHANNEL_ID_CLEANING_TIME, new QuantityType<>(cleaningTimeSeconds, Units.SECOND));
339 public void onWaterSystemPresentUpdated(EcovacsDevice device, boolean present) {
340 updateState(CHANNEL_ID_WATER_PLATE_PRESENT, OnOffType.from(present));
344 public void onErrorReported(EcovacsDevice device, int errorCode) {
345 updateState(CHANNEL_ID_ERROR_CODE, new DecimalType(errorCode));
346 final Locale locale = localeProvider.getLocale();
347 String errorDesc = i18Provider.getText(bundle, "ecovacs.vacuum.error-code." + errorCode, null, locale);
348 if (errorDesc == null) {
349 errorDesc = i18Provider.getText(bundle, "ecovacs.vacuum.error-code.unknown", "", locale, errorCode);
351 updateState(CHANNEL_ID_ERROR_DESCRIPTION, new StringType(errorDesc));
355 public void onEventStreamFailure(final EcovacsDevice device, Throwable error) {
356 logger.debug("{}: Device connection failed, reconnecting", serialNumber, error);
357 teardownAndScheduleReconnection();
361 public void onFirmwareVersionChanged(EcovacsDevice device, String fwVersion) {
362 updateProperty(Thing.PROPERTY_FIRMWARE_VERSION, fwVersion);
365 public void playSound(PlaySoundCommand command) {
366 doWithDevice(device -> {
367 if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
368 device.sendCommand(command);
370 logger.info("{}: Device does not support voice reporting, ignoring sound action", serialNumber);
375 private void fetchInitialBatteryStatus(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
376 Integer batteryPercent = device.sendCommand(new GetBatteryInfoCommand());
377 onBatteryLevelUpdated(device, batteryPercent);
380 private void fetchInitialStateAndCommandValues(EcovacsDevice device)
381 throws EcovacsApiException, InterruptedException {
382 lastWasCharging = device.sendCommand(new GetChargeStateCommand()) == ChargeMode.CHARGING;
383 CleanMode mode = device.sendCommand(new GetCleanStateCommand());
384 if (mode.isActive()) {
385 lastActiveCleanMode = mode;
387 lastCleanMode = mode;
388 updateStateAndCommandChannels();
391 private void fetchInitialWaterSystemPresentState(EcovacsDevice device)
392 throws EcovacsApiException, InterruptedException {
393 if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
396 boolean present = device.sendCommand(new GetWaterSystemPresentCommand());
397 onWaterSystemPresentUpdated(device, present);
400 private void fetchInitialErrorCode(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
401 Optional<Integer> errorOpt = device.sendCommand(new GetErrorCommand());
402 if (errorOpt.isPresent()) {
403 onErrorReported(device, errorOpt.get());
407 private void removeUnsupportedChannels(EcovacsDevice device) {
408 ThingBuilder builder = editThing();
409 boolean hasChanges = false;
411 if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
412 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT);
413 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_PLATE_PRESENT);
415 if (!device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
416 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_SUCTION_POWER);
418 if (!device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
419 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_MAIN_BRUSH_LIFETIME);
421 if (!device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
422 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_VOICE_VOLUME);
424 if (!device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
425 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MODE);
427 if (!device.hasCapability(DeviceCapability.MAPPING)
428 || !device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
429 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MAP);
431 if (!device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
432 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WIFI_RSSI);
434 if (!device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
435 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_AUTO_EMPTY);
437 if (!device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
438 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_TRUE_DETECT_3D);
440 if (!device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
441 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_CLEANING_PASSES);
445 updateThing(builder.build());
449 private boolean removeUnsupportedChannel(ThingBuilder builder, String channelId) {
450 ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
451 if (getThing().getChannel(channelUID) == null) {
454 logger.debug("{}: Removing unsupported channel {}", serialNumber, channelId);
455 builder.withoutChannel(channelUID);
459 private void updateStateOptions(EcovacsDevice device) {
460 List<StateOption> modeChannelOptions = createChannelOptions(device, CleanMode.values(), CLEAN_MODE_MAPPING,
461 m -> m.enumValue.isActive());
462 ThingUID thingUID = getThing().getUID();
464 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_CLEANING_MODE),
466 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_LAST_CLEAN_MODE),
468 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_SUCTION_POWER),
469 createChannelOptions(device, SuctionPower.values(), SUCTION_POWER_MAPPING, null));
470 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_WATER_AMOUNT),
471 createChannelOptions(device, MoppingWaterAmount.values(), WATER_AMOUNT_MAPPING, null));
474 private <T extends Enum<T>> List<StateOption> createChannelOptions(EcovacsDevice device, T[] values,
475 StateOptionMapping<T> mapping, @Nullable Predicate<StateOptionEntry<T>> filter) {
476 return Arrays.stream(values).map(v -> Optional.ofNullable(mapping.get(v)))
477 // ensure we have a mapping (should always be the case)
478 .filter(Optional::isPresent).map(opt -> opt.get())
479 // apply supplied filter
480 .filter(mv -> filter == null || filter.test(mv))
481 // apply capability filter
482 .filter(mv -> mv.capability.isEmpty() || device.hasCapability(mv.capability.get()))
483 // map to actual option
484 .map(mv -> new StateOption(mv.value, mv.value)).collect(Collectors.toList());
487 private synchronized void scheduleNextPoll(long initialDelaySeconds) {
488 final EcovacsVacuumConfiguration config = getConfigAs(EcovacsVacuumConfiguration.class);
489 final long delayUntilNextPoll;
490 if (initialDelaySeconds < 0) {
491 long intervalSeconds = config.refresh * 60;
492 long secondsSinceLastPoll = (System.currentTimeMillis() - lastSuccessfulPollTimestamp) / 1000;
493 long deltaRemaining = intervalSeconds - secondsSinceLastPoll;
494 delayUntilNextPoll = Math.max(0, deltaRemaining);
496 delayUntilNextPoll = initialDelaySeconds;
498 logger.debug("{}: Scheduling next poll in {}s, refresh interval {}min", serialNumber, delayUntilNextPoll,
501 pollTask.schedule(delayUntilNextPoll);
504 private void initDevice() {
505 final EcovacsApiHandler handler = getApiHandler();
506 if (handler == null) {
507 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
512 final EcovacsApi api = handler.createApiForDevice(serialNumber);
513 api.loginAndGetAccessToken();
514 Optional<EcovacsDevice> deviceOpt = api.getDevices().stream()
515 .filter(d -> serialNumber.equals(d.getSerialNumber())).findFirst();
516 if (deviceOpt.isPresent()) {
517 EcovacsDevice device = deviceOpt.get();
518 this.device = device;
519 updateProperty(Thing.PROPERTY_MODEL_ID, device.getModelName());
520 updateProperty(Thing.PROPERTY_SERIAL_NUMBER, device.getSerialNumber());
521 updateStateOptions(device);
522 removeUnsupportedChannels(device);
525 logger.info("{}: Device not found in device list, setting offline", serialNumber);
526 updateStatus(ThingStatus.OFFLINE);
528 } catch (InterruptedException e) {
529 Thread.currentThread().interrupt();
530 } catch (ConfigurationException e) {
531 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
532 } catch (EcovacsApiException e) {
533 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
537 private void teardownAndScheduleReconnection() {
541 private synchronized void teardown(boolean scheduleReconnection) {
542 EcovacsDevice device = this.device;
543 if (device != null) {
544 device.disconnect(scheduler);
549 reconnectTask.cancel();
552 if (scheduleReconnection) {
553 SchedulerTask connectTask = device != null ? reconnectTask : initTask;
554 connectTask.schedule(5);
558 private void connectToDevice() {
559 doWithDevice(device -> {
560 device.connect(this, scheduler);
561 fetchInitialBatteryStatus(device);
562 fetchInitialStateAndCommandValues(device);
563 fetchInitialWaterSystemPresentState(device); // nop if unsupported
564 fetchInitialErrorCode(device);
565 scheduleNextPoll(-1);
566 logger.debug("{}: Device connected", serialNumber);
567 updateStatus(ThingStatus.ONLINE);
571 private void pollData() {
572 logger.debug("{}: Polling data", serialNumber);
573 doWithDevice(device -> {
574 TotalStats totalStats = device.sendCommand(new GetTotalStatsCommand());
575 updateState(CHANNEL_ID_TOTAL_CLEANED_AREA, new QuantityType<>(totalStats.totalArea, SIUnits.SQUARE_METRE));
576 updateState(CHANNEL_ID_TOTAL_CLEANING_TIME, new QuantityType<>(totalStats.totalRuntime, Units.SECOND));
577 updateState(CHANNEL_ID_TOTAL_CLEAN_RUNS, new DecimalType(totalStats.cleanRuns));
579 boolean continuousCleaningEnabled = device.sendCommand(new GetContinuousCleaningCommand());
580 updateState(CHANNEL_ID_CONTINUOUS_CLEANING, OnOffType.from(continuousCleaningEnabled));
582 List<CleanLogRecord> cleanLogRecords = device.getCleanLogs();
583 if (!cleanLogRecords.isEmpty()) {
584 CleanLogRecord record = cleanLogRecords.get(0);
586 updateState(CHANNEL_ID_LAST_CLEAN_START,
587 new DateTimeType(record.timestamp.toInstant().atZone(ZoneId.systemDefault())));
588 updateState(CHANNEL_ID_LAST_CLEAN_DURATION, new QuantityType<>(record.cleaningDuration, Units.SECOND));
589 updateState(CHANNEL_ID_LAST_CLEAN_AREA, new QuantityType<>(record.cleanedArea, SIUnits.SQUARE_METRE));
590 if (device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
591 StateOptionEntry<CleanMode> mode = CLEAN_MODE_MAPPING.get(record.mode);
592 updateState(CHANNEL_ID_LAST_CLEAN_MODE, stringToState(mode != null ? mode.value : null));
594 if (device.hasCapability(DeviceCapability.MAPPING)
595 && !lastDownloadedCleanMapUrl.equals(record.mapImageUrl)) {
596 Optional<State> content = device.downloadCleanMapImage(record).map(bytes -> {
597 lastDownloadedCleanMapUrl = record.mapImageUrl;
598 return new RawType(bytes, "image/png");
600 updateState(CHANNEL_ID_LAST_CLEAN_MAP, content.orElse(UnDefType.NULL));
605 if (device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
606 SuctionPower power = device.sendCommand(new GetSuctionPowerCommand());
607 updateState(CHANNEL_ID_SUCTION_POWER, new StringType(SUCTION_POWER_MAPPING.getMappedValue(power)));
610 if (device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
611 MoppingWaterAmount waterAmount = device.sendCommand(new GetMoppingWaterAmountCommand());
612 updateState(CHANNEL_ID_WATER_AMOUNT, new StringType(WATER_AMOUNT_MAPPING.getMappedValue(waterAmount)));
615 if (device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
616 NetworkInfo netInfo = device.sendCommand(new GetNetworkInfoCommand());
617 if (netInfo.wifiRssi != 0) {
618 updateState(CHANNEL_ID_WIFI_RSSI, new QuantityType<>(netInfo.wifiRssi, Units.DECIBEL_MILLIWATTS));
622 if (device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
623 boolean autoEmptyEnabled = device.sendCommand(new GetDustbinAutoEmptyCommand());
624 updateState(CHANNEL_ID_AUTO_EMPTY, OnOffType.from(autoEmptyEnabled));
626 if (device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
627 boolean trueDetectEnabled = device.sendCommand(new GetTrueDetectCommand());
628 updateState(CHANNEL_ID_TRUE_DETECT_3D, OnOffType.from(trueDetectEnabled));
630 if (device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
631 lastDefaultCleaningPasses = device.sendCommand(new GetDefaultCleanPassesCommand());
632 updateState(CHANNEL_ID_CLEANING_PASSES, new DecimalType(lastDefaultCleaningPasses));
635 int sideBrushPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.SIDE_BRUSH));
636 updateState(CHANNEL_ID_SIDE_BRUSH_LIFETIME, new QuantityType<>(sideBrushPercent, Units.PERCENT));
637 int filterPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.DUST_CASE_HEAP));
638 updateState(CHANNEL_ID_DUST_FILTER_LIFETIME, new QuantityType<>(filterPercent, Units.PERCENT));
640 if (device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
641 int mainBrushPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.BRUSH));
642 updateState(CHANNEL_ID_MAIN_BRUSH_LIFETIME, new QuantityType<>(mainBrushPercent, Units.PERCENT));
644 if (device.hasCapability(DeviceCapability.UNIT_CARE_LIFESPAN)) {
645 int unitCarePercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.UNIT_CARE));
646 updateState(CHANNEL_ID_OTHER_COMPONENT_LIFETIME, new QuantityType<>(unitCarePercent, Units.PERCENT));
648 if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
649 int level = device.sendCommand(new GetVolumeCommand());
650 updateState(CHANNEL_ID_VOICE_VOLUME, new PercentType(level * 10));
653 lastSuccessfulPollTimestamp = System.currentTimeMillis();
654 scheduleNextPoll(-1);
656 logger.debug("{}: Data polling completed", serialNumber);
659 private void updateStateAndCommandChannels() {
660 Boolean charging = this.lastWasCharging;
661 CleanMode cleanMode = this.lastCleanMode;
662 if (charging == null || cleanMode == null) {
665 String commandState = determineCommandChannelValue(charging, cleanMode);
666 String currentMode = determineCleaningModeChannelValue(cleanMode.isActive() ? cleanMode : lastActiveCleanMode);
667 updateState(CHANNEL_ID_STATE, StringType.valueOf(determineStateChannelValue(charging, cleanMode)));
668 updateState(CHANNEL_ID_CLEANING_MODE, stringToState(currentMode));
669 updateState(CHANNEL_ID_COMMAND, stringToState(commandState));
672 private String determineStateChannelValue(boolean charging, CleanMode cleanMode) {
674 // Some devices already report charging state while returning to charging station, make sure to not report
675 // charging in that case. The same applies for models with pad washing/drying station, as those states imply
676 // the device being charging.
677 if (cleanMode != CleanMode.RETURNING && cleanMode != CleanMode.WASHING && cleanMode != CleanMode.DRYING) {
681 if (cleanMode.isActive()) {
684 StateOptionEntry<CleanMode> result = CLEAN_MODE_MAPPING.get(cleanMode);
685 return result != null ? result.value : "idle";
688 private @Nullable String determineCleaningModeChannelValue(@Nullable CleanMode activeCleanMode) {
689 StateOptionEntry<CleanMode> result = activeCleanMode != null ? CLEAN_MODE_MAPPING.get(activeCleanMode) : null;
690 return result != null ? result.value : null;
693 private @Nullable String determineCommandChannelValue(boolean charging, CleanMode cleanMode) {
699 return CMD_AUTO_CLEAN;
701 return CMD_SPOT_AREA;
714 private State stringToState(@Nullable String value) {
715 Optional<State> stateOpt = Optional.ofNullable(value).map(v -> StringType.valueOf(v));
716 return stateOpt.orElse(UnDefType.UNDEF);
719 private @Nullable AbstractNoResponseCommand determineDeviceCommand(EcovacsDevice device, String command) {
720 CleanMode mode = lastActiveCleanMode;
724 return new StartAutoCleaningCommand();
727 return new PauseCleaningCommand(mode);
732 return new ResumeCleaningCommand(mode);
736 return new StopCleaningCommand();
738 return new GoChargingCommand();
741 if (command.startsWith(CMD_SPOT_AREA) && device.hasCapability(DeviceCapability.SPOT_AREA_CLEANING)) {
742 String[] splitted = command.split(":");
743 if (splitted.length == 2 || splitted.length == 3) {
744 int passes = splitted.length == 3 && "x2".equals(splitted[2]) ? 2 : lastDefaultCleaningPasses;
745 List<String> roomIds = new ArrayList<>();
746 for (String id : splitted[1].split(";")) {
747 // We let the user pass in letters as in Ecovacs' app, but the API wants indices
748 if (id.length() == 1 && id.charAt(0) >= 'A' && id.charAt(0) <= 'Z') {
749 roomIds.add(String.valueOf(id.charAt(0) - 'A'));
751 logger.info("{}: Found invalid spot area room ID '{}', ignoring.", serialNumber, id);
754 if (!roomIds.isEmpty()) {
755 return new SpotAreaCleaningCommand(roomIds, passes,
756 device.hasCapability(DeviceCapability.FREE_CLEAN_FOR_SPOT_AREA));
759 logger.info("{}: spotArea command needs to have the form spotArea:<room1>[;<room2>][;<...roomX>][:x2]",
763 if (command.startsWith(CMD_CUSTOM_AREA) && device.hasCapability(DeviceCapability.CUSTOM_AREA_CLEANING)) {
764 String[] splitted = command.split(":");
765 if (splitted.length == 2 || splitted.length == 3) {
766 String coords = splitted[1];
767 int passes = splitted.length == 3 && "x2".equals(splitted[2]) ? 2 : lastDefaultCleaningPasses;
768 String[] splittedAreaDef = coords.split(";");
769 if (splittedAreaDef.length == 4) {
770 return new CustomAreaCleaningCommand(String.join(",", splittedAreaDef), passes);
773 logger.info("{}: customArea command needs to have the form customArea:<x1>;<y1>;<x2>;<y2>[:x2]",
780 private interface WithDeviceAction {
781 void run(EcovacsDevice device) throws EcovacsApiException, InterruptedException;
784 private void doWithDevice(WithDeviceAction action) {
785 EcovacsDevice device = this.device;
786 if (device == null) {
791 } catch (InterruptedException e) {
792 Thread.currentThread().interrupt();
793 } catch (EcovacsApiException e) {
794 logger.debug("{}: Failed communicating to device, reconnecting", serialNumber, e);
795 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
796 if (e.isAuthFailure) {
797 EcovacsApiHandler apiHandler = getApiHandler();
798 if (apiHandler != null) {
799 apiHandler.onLoginExpired();
801 // Drop our device instance to make sure we run a full init cycle,
802 // including an API re-login, on reconnection
803 device.disconnect(scheduler);
806 teardownAndScheduleReconnection();
810 private @Nullable EcovacsApiHandler getApiHandler() {
811 final Bridge bridge = getBridge();
812 return bridge != null ? (EcovacsApiHandler) bridge.getHandler() : null;