2 * Copyright (c) 2010-2023 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.Collections;
22 import java.util.List;
23 import java.util.Locale;
24 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.io.net.http.HttpUtil;
84 import org.openhab.core.library.types.DateTimeType;
85 import org.openhab.core.library.types.DecimalType;
86 import org.openhab.core.library.types.OnOffType;
87 import org.openhab.core.library.types.PercentType;
88 import org.openhab.core.library.types.QuantityType;
89 import org.openhab.core.library.types.RawType;
90 import org.openhab.core.library.types.StringType;
91 import org.openhab.core.library.unit.SIUnits;
92 import org.openhab.core.library.unit.Units;
93 import org.openhab.core.thing.Bridge;
94 import org.openhab.core.thing.ChannelUID;
95 import org.openhab.core.thing.Thing;
96 import org.openhab.core.thing.ThingStatus;
97 import org.openhab.core.thing.ThingStatusDetail;
98 import org.openhab.core.thing.ThingStatusInfo;
99 import org.openhab.core.thing.ThingUID;
100 import org.openhab.core.thing.binding.BaseThingHandler;
101 import org.openhab.core.thing.binding.ThingHandlerService;
102 import org.openhab.core.thing.binding.builder.ThingBuilder;
103 import org.openhab.core.types.Command;
104 import org.openhab.core.types.State;
105 import org.openhab.core.types.StateOption;
106 import org.openhab.core.types.UnDefType;
107 import org.osgi.framework.Bundle;
108 import org.osgi.framework.FrameworkUtil;
109 import org.slf4j.Logger;
110 import org.slf4j.LoggerFactory;
113 * The {@link EcovacsVacuumHandler} is responsible for handling data and commands from/to vacuum cleaners.
115 * @author Danny Baumann - Initial contribution
118 public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDevice.EventListener {
120 private final Logger logger = LoggerFactory.getLogger(EcovacsVacuumHandler.class);
122 private final TranslationProvider i18Provider;
123 private final LocaleProvider localeProvider;
124 private final EcovacsDynamicStateDescriptionProvider stateDescriptionProvider;
125 private final Bundle bundle;
127 private final SchedulerTask initTask;
128 private final SchedulerTask reconnectTask;
129 private final SchedulerTask pollTask;
130 private @Nullable EcovacsDevice device;
132 private @Nullable Boolean lastWasCharging;
133 private @Nullable CleanMode lastCleanMode;
134 private @Nullable CleanMode lastActiveCleanMode;
135 private Optional<String> lastDownloadedCleanMapUrl = Optional.empty();
136 private long lastSuccessfulPollTimestamp;
137 private int lastDefaultCleaningPasses = 1;
138 private String serialNumber = "<unset>";
140 public EcovacsVacuumHandler(Thing thing, TranslationProvider i18Provider, LocaleProvider localeProvider,
141 EcovacsDynamicStateDescriptionProvider stateDescriptionProvider) {
143 this.i18Provider = i18Provider;
144 this.localeProvider = localeProvider;
145 this.stateDescriptionProvider = stateDescriptionProvider;
146 bundle = FrameworkUtil.getBundle(getClass());
148 initTask = new SchedulerTask(scheduler, logger, "Init", this::initDevice);
149 reconnectTask = new SchedulerTask(scheduler, logger, "Connection", this::connectToDevice);
150 pollTask = new SchedulerTask(scheduler, logger, "Poll", this::pollData);
154 public Collection<Class<? extends ThingHandlerService>> getServices() {
155 return Collections.singleton(EcovacsVacuumActions.class);
159 public void handleCommand(ChannelUID channelUID, Command command) {
160 final EcovacsDevice device = this.device;
161 if (device == null) {
162 logger.debug("{}: Ignoring command {}, no active connection", serialNumber, command);
165 String channel = channelUID.getId();
168 if (channel.equals(CHANNEL_ID_COMMAND) && command instanceof StringType) {
169 AbstractNoResponseCommand cmd = determineDeviceCommand(device, command.toString());
171 device.sendCommand(cmd);
174 } else if (channel.equals(CHANNEL_ID_VOICE_VOLUME) && command instanceof DecimalType) {
175 int volumePercent = ((DecimalType) command).intValue();
176 device.sendCommand(new SetVolumeCommand((volumePercent + 5) / 10));
178 } else if (channel.equals(CHANNEL_ID_SUCTION_POWER) && command instanceof StringType) {
179 Optional<SuctionPower> power = SUCTION_POWER_MAPPING.findMappedEnumValue(command.toString());
180 if (power.isPresent()) {
181 device.sendCommand(new SetSuctionPowerCommand(power.get()));
184 } else if (channel.equals(CHANNEL_ID_WATER_AMOUNT) && command instanceof StringType) {
185 Optional<MoppingWaterAmount> amount = WATER_AMOUNT_MAPPING.findMappedEnumValue(command.toString());
186 if (amount.isPresent()) {
187 device.sendCommand(new SetMoppingWaterAmountCommand(amount.get()));
190 } else if (channel.equals(CHANNEL_ID_AUTO_EMPTY)) {
191 if (command instanceof OnOffType) {
192 device.sendCommand(new SetDustbinAutoEmptyCommand(command == OnOffType.ON));
194 } else if (command instanceof StringType && command.toString().equals("trigger")) {
195 device.sendCommand(new EmptyDustbinCommand());
198 } else if (channel.equals(CHANNEL_ID_TRUE_DETECT_3D) && command instanceof OnOffType) {
199 device.sendCommand(new SetTrueDetectCommand(command == OnOffType.ON));
201 } else if (channel.equals(CHANNEL_ID_CONTINUOUS_CLEANING) && command instanceof OnOffType) {
202 device.sendCommand(new SetContinuousCleaningCommand(command == OnOffType.ON));
204 } else if (channel.equals(CHANNEL_ID_CLEANING_PASSES) && command instanceof DecimalType) {
205 int passes = ((DecimalType) command).intValue();
206 device.sendCommand(new SetDefaultCleanPassesCommand(passes));
207 lastDefaultCleaningPasses = passes; // if we get here, the command was executed successfully
210 logger.debug("{}: Ignoring unsupported device command {} for channel {}", serialNumber, command, channel);
211 } catch (InterruptedException e) {
212 Thread.currentThread().interrupt();
213 } catch (EcovacsApiException e) {
214 logger.debug("{}: Handling device command {} failed", serialNumber, command, e);
219 public void initialize() {
220 serialNumber = getConfigAs(EcovacsVacuumConfiguration.class).serialNumber;
221 if (serialNumber.isEmpty()) {
222 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
223 "@text/offline.config-error-no-serial");
225 logger.debug("{}: Initializing handler", serialNumber);
226 updateStatus(ThingStatus.UNKNOWN);
227 initTask.setNamePrefix(serialNumber);
228 reconnectTask.setNamePrefix(serialNumber);
229 pollTask.setNamePrefix(serialNumber);
235 public void dispose() {
236 logger.debug("{}: Disposing handler", serialNumber);
241 public void channelLinked(ChannelUID channelUID) {
242 EcovacsDevice device = this.device;
243 if (device == null) {
248 switch (channelUID.getId()) {
249 case CHANNEL_ID_BATTERY_LEVEL:
250 fetchInitialBatteryStatus(device);
252 case CHANNEL_ID_STATE:
253 case CHANNEL_ID_COMMAND:
254 case CHANNEL_ID_CLEANING_MODE:
255 fetchInitialStateAndCommandValues(device);
257 case CHANNEL_ID_WATER_PLATE_PRESENT:
258 fetchInitialWaterSystemPresentState(device);
260 case CHANNEL_ID_ERROR_CODE:
261 case CHANNEL_ID_ERROR_DESCRIPTION:
262 fetchInitialErrorCode(device);
264 scheduleNextPoll(5); // add some delay in case multiple channels are linked at once
267 } catch (InterruptedException e) {
268 Thread.currentThread().interrupt();
269 } catch (EcovacsApiException e) {
270 logger.debug("{}: Fetching initial data for channel {} failed", serialNumber, channelUID.getId(), e);
275 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
276 logger.debug("{}: Bridge status changed to {}", serialNumber, bridgeStatusInfo);
277 if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
279 } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
286 public void onBatteryLevelUpdated(EcovacsDevice device, int newLevelPercent) {
287 // Some devices report weird values (> 100%), so better clamp to supported range
288 int actualPercent = Math.max(0, Math.min(newLevelPercent, 100));
289 updateState(CHANNEL_ID_BATTERY_LEVEL, new DecimalType(actualPercent));
293 public void onChargingStateUpdated(EcovacsDevice device, boolean charging) {
294 lastWasCharging = charging;
295 updateStateAndCommandChannels();
299 public void onCleaningModeUpdated(EcovacsDevice device, CleanMode newMode, Optional<String> areaDefinition) {
300 lastCleanMode = newMode;
301 if (newMode.isActive()) {
302 lastActiveCleanMode = newMode;
303 } else if (newMode.isIdle()) {
304 lastActiveCleanMode = null;
306 updateStateAndCommandChannels();
307 Optional<State> areaDefState = areaDefinition.map(def -> {
308 if (newMode == CleanMode.SPOT_AREA) {
309 // Map indices back to letters as shown in the app
310 def = Arrays.stream(def.split(",")).map(item -> {
312 int index = Integer.parseInt(item);
313 return String.valueOf((char) ('A' + index));
314 } catch (NumberFormatException e) {
317 }).collect(Collectors.joining(";"));
318 } else if (newMode == CleanMode.CUSTOM_AREA) {
319 // Map the separator from comma to semicolon to allow using the output as command input
320 def = def.replace(',', ';');
322 return new StringType(def);
324 updateState(CHANNEL_ID_CLEANING_SPOT_DEFINITION, areaDefState.orElse(UnDefType.UNDEF));
325 if (newMode == CleanMode.RETURNING) {
326 scheduleNextPoll(30);
327 } else if (newMode.isIdle()) {
328 updateState(CHANNEL_ID_CLEANED_AREA, UnDefType.UNDEF);
329 updateState(CHANNEL_ID_CLEANING_TIME, UnDefType.UNDEF);
334 public void onCleaningStatsUpdated(EcovacsDevice device, int cleanedArea, int cleaningTimeSeconds) {
335 updateState(CHANNEL_ID_CLEANED_AREA, new QuantityType<>(cleanedArea, SIUnits.SQUARE_METRE));
336 updateState(CHANNEL_ID_CLEANING_TIME, new QuantityType<>(cleaningTimeSeconds, Units.SECOND));
340 public void onWaterSystemPresentUpdated(EcovacsDevice device, boolean present) {
341 updateState(CHANNEL_ID_WATER_PLATE_PRESENT, OnOffType.from(present));
345 public void onErrorReported(EcovacsDevice device, int errorCode) {
346 updateState(CHANNEL_ID_ERROR_CODE, new DecimalType(errorCode));
347 final Locale locale = localeProvider.getLocale();
348 String errorDesc = i18Provider.getText(bundle, "ecovacs.vacuum.error-code." + errorCode, null, locale);
349 if (errorDesc == null) {
350 errorDesc = i18Provider.getText(bundle, "ecovacs.vacuum.error-code.unknown", "", locale, errorCode);
352 updateState(CHANNEL_ID_ERROR_DESCRIPTION, new StringType(errorDesc));
356 public void onEventStreamFailure(final EcovacsDevice device, Throwable error) {
357 logger.debug("{}: Device connection failed, reconnecting", serialNumber, error);
358 teardownAndScheduleReconnection();
362 public void onFirmwareVersionChanged(EcovacsDevice device, String fwVersion) {
363 updateProperty(Thing.PROPERTY_FIRMWARE_VERSION, fwVersion);
366 public void playSound(PlaySoundCommand command) {
367 doWithDevice(device -> {
368 if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
369 device.sendCommand(command);
371 logger.info("{}: Device does not support voice reporting, ignoring sound action", serialNumber);
376 private void fetchInitialBatteryStatus(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
377 Integer batteryPercent = device.sendCommand(new GetBatteryInfoCommand());
378 onBatteryLevelUpdated(device, batteryPercent);
381 private void fetchInitialStateAndCommandValues(EcovacsDevice device)
382 throws EcovacsApiException, InterruptedException {
383 lastWasCharging = device.sendCommand(new GetChargeStateCommand()) == ChargeMode.CHARGING;
384 CleanMode mode = device.sendCommand(new GetCleanStateCommand());
385 if (mode.isActive()) {
386 lastActiveCleanMode = mode;
388 lastCleanMode = mode;
389 updateStateAndCommandChannels();
392 private void fetchInitialWaterSystemPresentState(EcovacsDevice device)
393 throws EcovacsApiException, InterruptedException {
394 if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
397 boolean present = device.sendCommand(new GetWaterSystemPresentCommand());
398 onWaterSystemPresentUpdated(device, present);
401 private void fetchInitialErrorCode(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
402 Optional<Integer> errorOpt = device.sendCommand(new GetErrorCommand());
403 if (errorOpt.isPresent()) {
404 onErrorReported(device, errorOpt.get());
408 private void removeUnsupportedChannels(EcovacsDevice device) {
409 ThingBuilder builder = editThing();
410 boolean hasChanges = false;
412 if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
413 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT);
414 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_PLATE_PRESENT);
416 if (!device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
417 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_SUCTION_POWER);
419 if (!device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
420 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_MAIN_BRUSH_LIFETIME);
422 if (!device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
423 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_VOICE_VOLUME);
425 if (!device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
426 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MODE);
428 if (!device.hasCapability(DeviceCapability.MAPPING)
429 || !device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
430 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MAP);
432 if (!device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
433 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WIFI_RSSI);
435 if (!device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
436 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_AUTO_EMPTY);
438 if (!device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
439 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_TRUE_DETECT_3D);
441 if (!device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
442 hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_CLEANING_PASSES);
446 updateThing(builder.build());
450 private boolean removeUnsupportedChannel(ThingBuilder builder, String channelId) {
451 ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
452 if (getThing().getChannel(channelUID) == null) {
455 logger.debug("{}: Removing unsupported channel {}", serialNumber, channelId);
456 builder.withoutChannel(channelUID);
460 private void updateStateOptions(EcovacsDevice device) {
461 List<StateOption> modeChannelOptions = createChannelOptions(device, CleanMode.values(), CLEAN_MODE_MAPPING,
462 m -> m.enumValue.isActive());
463 ThingUID thingUID = getThing().getUID();
465 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_CLEANING_MODE),
467 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_LAST_CLEAN_MODE),
469 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_SUCTION_POWER),
470 createChannelOptions(device, SuctionPower.values(), SUCTION_POWER_MAPPING, null));
471 stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_WATER_AMOUNT),
472 createChannelOptions(device, MoppingWaterAmount.values(), WATER_AMOUNT_MAPPING, null));
475 private <T extends Enum<T>> List<StateOption> createChannelOptions(EcovacsDevice device, T[] values,
476 StateOptionMapping<T> mapping, @Nullable Predicate<StateOptionEntry<T>> filter) {
477 return Arrays.stream(values).map(v -> Optional.ofNullable(mapping.get(v)))
478 // ensure we have a mapping (should always be the case)
479 .filter(Optional::isPresent).map(opt -> opt.get())
480 // apply supplied filter
481 .filter(mv -> filter == null || filter.test(mv))
482 // apply capability filter
483 .filter(mv -> mv.capability.isEmpty() || device.hasCapability(mv.capability.get()))
484 // map to actual option
485 .map(mv -> new StateOption(mv.value, mv.value)).collect(Collectors.toList());
488 private synchronized void scheduleNextPoll(long initialDelaySeconds) {
489 final EcovacsVacuumConfiguration config = getConfigAs(EcovacsVacuumConfiguration.class);
490 final long delayUntilNextPoll;
491 if (initialDelaySeconds < 0) {
492 long intervalSeconds = config.refresh * 60;
493 long secondsSinceLastPoll = (System.currentTimeMillis() - lastSuccessfulPollTimestamp) / 1000;
494 long deltaRemaining = intervalSeconds - secondsSinceLastPoll;
495 delayUntilNextPoll = Math.max(0, deltaRemaining);
497 delayUntilNextPoll = initialDelaySeconds;
499 logger.debug("{}: Scheduling next poll in {}s, refresh interval {}min", serialNumber, delayUntilNextPoll,
502 pollTask.schedule(delayUntilNextPoll);
505 private void initDevice() {
506 final EcovacsApiHandler handler = getApiHandler();
507 if (handler == null) {
508 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
513 final EcovacsApi api = handler.createApiForDevice(serialNumber);
514 api.loginAndGetAccessToken();
515 Optional<EcovacsDevice> deviceOpt = api.getDevices().stream()
516 .filter(d -> serialNumber.equals(d.getSerialNumber())).findFirst();
517 if (deviceOpt.isPresent()) {
518 EcovacsDevice device = deviceOpt.get();
519 this.device = device;
520 updateProperty(Thing.PROPERTY_MODEL_ID, device.getModelName());
521 updateProperty(Thing.PROPERTY_SERIAL_NUMBER, device.getSerialNumber());
522 updateStateOptions(device);
523 removeUnsupportedChannels(device);
526 logger.info("{}: Device not found in device list, setting offline", serialNumber);
527 updateStatus(ThingStatus.OFFLINE);
529 } catch (InterruptedException e) {
530 Thread.currentThread().interrupt();
531 } catch (ConfigurationException e) {
532 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
533 } catch (EcovacsApiException e) {
534 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
538 private void teardownAndScheduleReconnection() {
542 private synchronized void teardown(boolean scheduleReconnection) {
543 EcovacsDevice device = this.device;
544 if (device != null) {
545 device.disconnect(scheduler);
550 reconnectTask.cancel();
553 if (scheduleReconnection) {
554 SchedulerTask connectTask = device != null ? reconnectTask : initTask;
555 connectTask.schedule(5);
559 private void connectToDevice() {
560 doWithDevice(device -> {
561 device.connect(this, scheduler);
562 fetchInitialBatteryStatus(device);
563 fetchInitialStateAndCommandValues(device);
564 fetchInitialWaterSystemPresentState(device); // nop if unsupported
565 fetchInitialErrorCode(device);
566 scheduleNextPoll(-1);
567 logger.debug("{}: Device connected", serialNumber);
568 updateStatus(ThingStatus.ONLINE);
572 private void pollData() {
573 logger.debug("{}: Polling data", serialNumber);
574 doWithDevice(device -> {
575 TotalStats totalStats = device.sendCommand(new GetTotalStatsCommand());
576 updateState(CHANNEL_ID_TOTAL_CLEANED_AREA, new QuantityType<>(totalStats.totalArea, SIUnits.SQUARE_METRE));
577 updateState(CHANNEL_ID_TOTAL_CLEANING_TIME, new QuantityType<>(totalStats.totalRuntime, Units.SECOND));
578 updateState(CHANNEL_ID_TOTAL_CLEAN_RUNS, new DecimalType(totalStats.cleanRuns));
580 boolean continuousCleaningEnabled = device.sendCommand(new GetContinuousCleaningCommand());
581 updateState(CHANNEL_ID_CONTINUOUS_CLEANING, continuousCleaningEnabled ? OnOffType.ON : OnOffType.OFF);
583 List<CleanLogRecord> cleanLogRecords = device.getCleanLogs();
584 if (!cleanLogRecords.isEmpty()) {
585 CleanLogRecord record = cleanLogRecords.get(0);
587 updateState(CHANNEL_ID_LAST_CLEAN_START,
588 new DateTimeType(record.timestamp.toInstant().atZone(ZoneId.systemDefault())));
589 updateState(CHANNEL_ID_LAST_CLEAN_DURATION, new QuantityType<>(record.cleaningDuration, Units.SECOND));
590 updateState(CHANNEL_ID_LAST_CLEAN_AREA, new QuantityType<>(record.cleanedArea, SIUnits.SQUARE_METRE));
591 if (device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
592 StateOptionEntry<CleanMode> mode = CLEAN_MODE_MAPPING.get(record.mode);
593 updateState(CHANNEL_ID_LAST_CLEAN_MODE, stringToState(mode != null ? mode.value : null));
595 if (device.hasCapability(DeviceCapability.MAPPING)
596 && !lastDownloadedCleanMapUrl.equals(record.mapImageUrl)) {
597 updateState(CHANNEL_ID_LAST_CLEAN_MAP, record.mapImageUrl.flatMap(url -> {
598 // HttpUtil expects the server to return the correct MIME type, but Ecovacs' server sends
599 // 'application/octet-stream', so we have to set the correct MIME type by ourselves
601 RawType mapData = HttpUtil.downloadData(url, null, false, -1);
602 if (mapData != null) {
603 mapData = new RawType(mapData.getBytes(), "image/png");
604 lastDownloadedCleanMapUrl = record.mapImageUrl;
606 logger.debug("{}: Downloading cleaning map {} failed", serialNumber, url);
608 return Optional.ofNullable((State) mapData);
609 }).orElse(UnDefType.NULL));
614 if (device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
615 SuctionPower power = device.sendCommand(new GetSuctionPowerCommand());
616 updateState(CHANNEL_ID_SUCTION_POWER, new StringType(SUCTION_POWER_MAPPING.getMappedValue(power)));
619 if (device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
620 MoppingWaterAmount waterAmount = device.sendCommand(new GetMoppingWaterAmountCommand());
621 updateState(CHANNEL_ID_WATER_AMOUNT, new StringType(WATER_AMOUNT_MAPPING.getMappedValue(waterAmount)));
624 if (device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
625 NetworkInfo netInfo = device.sendCommand(new GetNetworkInfoCommand());
626 if (netInfo.wifiRssi != 0) {
627 updateState(CHANNEL_ID_WIFI_RSSI, new QuantityType<>(netInfo.wifiRssi, Units.DECIBEL_MILLIWATTS));
631 if (device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
632 boolean autoEmptyEnabled = device.sendCommand(new GetDustbinAutoEmptyCommand());
633 updateState(CHANNEL_ID_AUTO_EMPTY, autoEmptyEnabled ? OnOffType.ON : OnOffType.OFF);
635 if (device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
636 boolean trueDetectEnabled = device.sendCommand(new GetTrueDetectCommand());
637 updateState(CHANNEL_ID_TRUE_DETECT_3D, trueDetectEnabled ? OnOffType.ON : OnOffType.OFF);
639 if (device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
640 lastDefaultCleaningPasses = device.sendCommand(new GetDefaultCleanPassesCommand());
641 updateState(CHANNEL_ID_CLEANING_PASSES, new DecimalType(lastDefaultCleaningPasses));
644 int sideBrushPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.SIDE_BRUSH));
645 updateState(CHANNEL_ID_SIDE_BRUSH_LIFETIME, new QuantityType<>(sideBrushPercent, Units.PERCENT));
646 int filterPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.DUST_CASE_HEAP));
647 updateState(CHANNEL_ID_DUST_FILTER_LIFETIME, new QuantityType<>(filterPercent, Units.PERCENT));
649 if (device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
650 int mainBrushPercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.BRUSH));
651 updateState(CHANNEL_ID_MAIN_BRUSH_LIFETIME, new QuantityType<>(mainBrushPercent, Units.PERCENT));
653 if (device.hasCapability(DeviceCapability.UNIT_CARE_LIFESPAN)) {
654 int unitCarePercent = device.sendCommand(new GetComponentLifeSpanCommand(Component.UNIT_CARE));
655 updateState(CHANNEL_ID_OTHER_COMPONENT_LIFETIME, new QuantityType<>(unitCarePercent, Units.PERCENT));
657 if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
658 int level = device.sendCommand(new GetVolumeCommand());
659 updateState(CHANNEL_ID_VOICE_VOLUME, new PercentType(level * 10));
662 lastSuccessfulPollTimestamp = System.currentTimeMillis();
663 scheduleNextPoll(-1);
665 logger.debug("{}: Data polling completed", serialNumber);
668 private void updateStateAndCommandChannels() {
669 Boolean charging = this.lastWasCharging;
670 CleanMode cleanMode = this.lastCleanMode;
671 if (charging == null || cleanMode == null) {
674 String commandState = determineCommandChannelValue(charging, cleanMode);
675 String currentMode = determineCleaningModeChannelValue(cleanMode.isActive() ? cleanMode : lastActiveCleanMode);
676 updateState(CHANNEL_ID_STATE, StringType.valueOf(determineStateChannelValue(charging, cleanMode)));
677 updateState(CHANNEL_ID_CLEANING_MODE, stringToState(currentMode));
678 updateState(CHANNEL_ID_COMMAND, stringToState(commandState));
681 private String determineStateChannelValue(boolean charging, CleanMode cleanMode) {
683 // Some devices already report charging state while returning to charging station, make sure to not report
684 // charging in that case. The same applies for models with pad washing/drying station, as those states imply
685 // the device being charging.
686 if (cleanMode != CleanMode.RETURNING && cleanMode != CleanMode.WASHING && cleanMode != CleanMode.DRYING) {
690 if (cleanMode.isActive()) {
693 StateOptionEntry<CleanMode> result = CLEAN_MODE_MAPPING.get(cleanMode);
694 return result != null ? result.value : "idle";
697 private @Nullable String determineCleaningModeChannelValue(@Nullable CleanMode activeCleanMode) {
698 StateOptionEntry<CleanMode> result = activeCleanMode != null ? CLEAN_MODE_MAPPING.get(activeCleanMode) : null;
699 return result != null ? result.value : null;
702 private @Nullable String determineCommandChannelValue(boolean charging, CleanMode cleanMode) {
708 return CMD_AUTO_CLEAN;
710 return CMD_SPOT_AREA;
723 private State stringToState(@Nullable String value) {
724 Optional<State> stateOpt = Optional.ofNullable(value).map(v -> StringType.valueOf(v));
725 return stateOpt.orElse(UnDefType.UNDEF);
728 private @Nullable AbstractNoResponseCommand determineDeviceCommand(EcovacsDevice device, String command) {
729 CleanMode mode = lastActiveCleanMode;
733 return new StartAutoCleaningCommand();
736 return new PauseCleaningCommand(mode);
741 return new ResumeCleaningCommand(mode);
745 return new StopCleaningCommand();
747 return new GoChargingCommand();
750 if (command.startsWith(CMD_SPOT_AREA) && device.hasCapability(DeviceCapability.SPOT_AREA_CLEANING)) {
751 String[] splitted = command.split(":");
752 if (splitted.length == 2 || splitted.length == 3) {
753 int passes = splitted.length == 3 && "x2".equals(splitted[2]) ? 2 : lastDefaultCleaningPasses;
754 List<String> roomIds = new ArrayList<>();
755 for (String id : splitted[1].split(";")) {
756 // We let the user pass in letters as in Ecovacs' app, but the API wants indices
757 if (id.length() == 1 && id.charAt(0) >= 'A' && id.charAt(0) <= 'Z') {
758 roomIds.add(String.valueOf(id.charAt(0) - 'A'));
760 logger.info("{}: Found invalid spot area room ID '{}', ignoring.", serialNumber, id);
763 if (!roomIds.isEmpty()) {
764 return new SpotAreaCleaningCommand(roomIds, passes);
767 logger.info("{}: spotArea command needs to have the form spotArea:<room1>[;<room2>][;<...roomX>][:x2]",
771 if (command.startsWith(CMD_CUSTOM_AREA) && device.hasCapability(DeviceCapability.CUSTOM_AREA_CLEANING)) {
772 String[] splitted = command.split(":");
773 if (splitted.length == 2 || splitted.length == 3) {
774 String coords = splitted[1];
775 int passes = splitted.length == 3 && "x2".equals(splitted[2]) ? 2 : lastDefaultCleaningPasses;
776 String[] splittedAreaDef = coords.split(";");
777 if (splittedAreaDef.length == 4) {
778 return new CustomAreaCleaningCommand(String.join(",", splittedAreaDef), passes);
781 logger.info("{}: customArea command needs to have the form customArea:<x1>;<y1>;<x2>;<y2>[:x2]",
788 private interface WithDeviceAction {
789 void run(EcovacsDevice device) throws EcovacsApiException, InterruptedException;
792 private void doWithDevice(WithDeviceAction action) {
793 EcovacsDevice device = this.device;
794 if (device == null) {
799 } catch (InterruptedException e) {
800 Thread.currentThread().interrupt();
801 } catch (EcovacsApiException e) {
802 logger.debug("{}: Failed communicating to device, reconnecting", serialNumber, e);
803 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
804 if (e.isAuthFailure) {
805 EcovacsApiHandler apiHandler = getApiHandler();
806 if (apiHandler != null) {
807 apiHandler.onLoginExpired();
809 // Drop our device instance to make sure we run a full init cycle,
810 // including an API re-login, on reconnection
811 device.disconnect(scheduler);
814 teardownAndScheduleReconnection();
818 private @Nullable EcovacsApiHandler getApiHandler() {
819 final Bridge bridge = getBridge();
820 return bridge != null ? (EcovacsApiHandler) bridge.getHandler() : null;