]> git.basschouten.com Git - openhab-addons.git/blob
0a24b2eaa58a56d79cca24a96c85532f70a27e4f
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.ecovacs.internal.handler;
14
15 import static org.openhab.binding.ecovacs.internal.EcovacsBindingConstants.*;
16
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;
24 import java.util.Set;
25 import java.util.function.Predicate;
26 import java.util.stream.Collectors;
27
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;
110
111 /**
112  * The {@link EcovacsVacuumHandler} is responsible for handling data and commands from/to vacuum cleaners.
113  *
114  * @author Danny Baumann - Initial contribution
115  */
116 @NonNullByDefault
117 public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDevice.EventListener {
118
119     private final Logger logger = LoggerFactory.getLogger(EcovacsVacuumHandler.class);
120
121     private final TranslationProvider i18Provider;
122     private final LocaleProvider localeProvider;
123     private final EcovacsDynamicStateDescriptionProvider stateDescriptionProvider;
124     private final Bundle bundle;
125
126     private final SchedulerTask initTask;
127     private final SchedulerTask reconnectTask;
128     private final SchedulerTask pollTask;
129     private @Nullable EcovacsDevice device;
130
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>";
138
139     public EcovacsVacuumHandler(Thing thing, TranslationProvider i18Provider, LocaleProvider localeProvider,
140             EcovacsDynamicStateDescriptionProvider stateDescriptionProvider) {
141         super(thing);
142         this.i18Provider = i18Provider;
143         this.localeProvider = localeProvider;
144         this.stateDescriptionProvider = stateDescriptionProvider;
145         bundle = FrameworkUtil.getBundle(getClass());
146
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);
150     }
151
152     @Override
153     public Collection<Class<? extends ThingHandlerService>> getServices() {
154         return Set.of(EcovacsVacuumActions.class);
155     }
156
157     @Override
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);
162             return;
163         }
164         String channel = channelUID.getId();
165
166         try {
167             if (channel.equals(CHANNEL_ID_COMMAND) && command instanceof StringType) {
168                 AbstractNoResponseCommand cmd = determineDeviceCommand(device, command.toString());
169                 if (cmd != null) {
170                     device.sendCommand(cmd);
171                     return;
172                 }
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));
176                 return;
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()));
181                     return;
182                 }
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()));
187                     return;
188                 }
189             } else if (channel.equals(CHANNEL_ID_AUTO_EMPTY)) {
190                 if (command instanceof OnOffType) {
191                     device.sendCommand(new SetDustbinAutoEmptyCommand(command == OnOffType.ON));
192                     return;
193                 } else if (command instanceof StringType && "trigger".equals(command.toString())) {
194                     device.sendCommand(new EmptyDustbinCommand());
195                     return;
196                 }
197             } else if (channel.equals(CHANNEL_ID_TRUE_DETECT_3D) && command instanceof OnOffType) {
198                 device.sendCommand(new SetTrueDetectCommand(command == OnOffType.ON));
199                 return;
200             } else if (channel.equals(CHANNEL_ID_CONTINUOUS_CLEANING) && command instanceof OnOffType) {
201                 device.sendCommand(new SetContinuousCleaningCommand(command == OnOffType.ON));
202                 return;
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
207                 return;
208             }
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);
214         }
215     }
216
217     @Override
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");
223         } else {
224             logger.debug("{}: Initializing handler", serialNumber);
225             updateStatus(ThingStatus.UNKNOWN);
226             initTask.setNamePrefix(serialNumber);
227             reconnectTask.setNamePrefix(serialNumber);
228             pollTask.setNamePrefix(serialNumber);
229             initTask.submit();
230         }
231     }
232
233     @Override
234     public void dispose() {
235         logger.debug("{}: Disposing handler", serialNumber);
236         teardown(false);
237     }
238
239     @Override
240     public void channelLinked(ChannelUID channelUID) {
241         EcovacsDevice device = this.device;
242         if (device == null) {
243             return;
244         }
245
246         try {
247             switch (channelUID.getId()) {
248                 case CHANNEL_ID_BATTERY_LEVEL:
249                     fetchInitialBatteryStatus(device);
250                     break;
251                 case CHANNEL_ID_STATE:
252                 case CHANNEL_ID_COMMAND:
253                 case CHANNEL_ID_CLEANING_MODE:
254                     fetchInitialStateAndCommandValues(device);
255                     break;
256                 case CHANNEL_ID_WATER_PLATE_PRESENT:
257                     fetchInitialWaterSystemPresentState(device);
258                     break;
259                 case CHANNEL_ID_ERROR_CODE:
260                 case CHANNEL_ID_ERROR_DESCRIPTION:
261                     fetchInitialErrorCode(device);
262                 default:
263                     scheduleNextPoll(5); // add some delay in case multiple channels are linked at once
264                     break;
265             }
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);
270         }
271     }
272
273     @Override
274     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
275         logger.debug("{}: Bridge status changed to {}", serialNumber, bridgeStatusInfo);
276         if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
277             initTask.submit();
278         } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
279             teardown(false);
280             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
281         }
282     }
283
284     @Override
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));
289     }
290
291     @Override
292     public void onChargingStateUpdated(EcovacsDevice device, boolean charging) {
293         lastWasCharging = charging;
294         updateStateAndCommandChannels();
295     }
296
297     @Override
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;
304         }
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 -> {
310                     try {
311                         int index = Integer.parseInt(item);
312                         return String.valueOf((char) ('A' + index));
313                     } catch (NumberFormatException e) {
314                         return item;
315                     }
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(',', ';');
320             }
321             return new StringType(def);
322         });
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);
329         }
330     }
331
332     @Override
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));
336     }
337
338     @Override
339     public void onWaterSystemPresentUpdated(EcovacsDevice device, boolean present) {
340         updateState(CHANNEL_ID_WATER_PLATE_PRESENT, OnOffType.from(present));
341     }
342
343     @Override
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);
350         }
351         updateState(CHANNEL_ID_ERROR_DESCRIPTION, new StringType(errorDesc));
352     }
353
354     @Override
355     public void onEventStreamFailure(final EcovacsDevice device, Throwable error) {
356         logger.debug("{}: Device connection failed, reconnecting", serialNumber, error);
357         teardownAndScheduleReconnection();
358     }
359
360     @Override
361     public void onFirmwareVersionChanged(EcovacsDevice device, String fwVersion) {
362         updateProperty(Thing.PROPERTY_FIRMWARE_VERSION, fwVersion);
363     }
364
365     public void playSound(PlaySoundCommand command) {
366         doWithDevice(device -> {
367             if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
368                 device.sendCommand(command);
369             } else {
370                 logger.info("{}: Device does not support voice reporting, ignoring sound action", serialNumber);
371             }
372         });
373     }
374
375     private void fetchInitialBatteryStatus(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
376         Integer batteryPercent = device.sendCommand(new GetBatteryInfoCommand());
377         onBatteryLevelUpdated(device, batteryPercent);
378     }
379
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;
386         }
387         lastCleanMode = mode;
388         updateStateAndCommandChannels();
389     }
390
391     private void fetchInitialWaterSystemPresentState(EcovacsDevice device)
392             throws EcovacsApiException, InterruptedException {
393         if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
394             return;
395         }
396         boolean present = device.sendCommand(new GetWaterSystemPresentCommand());
397         onWaterSystemPresentUpdated(device, present);
398     }
399
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());
404         }
405     }
406
407     private void removeUnsupportedChannels(EcovacsDevice device) {
408         ThingBuilder builder = editThing();
409         boolean hasChanges = false;
410
411         if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
412             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT);
413             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_PLATE_PRESENT);
414         }
415         if (!device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
416             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_SUCTION_POWER);
417         }
418         if (!device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
419             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_MAIN_BRUSH_LIFETIME);
420         }
421         if (!device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
422             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_VOICE_VOLUME);
423         }
424         if (!device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
425             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MODE);
426         }
427         if (!device.hasCapability(DeviceCapability.MAPPING)
428                 || !device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
429             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MAP);
430         }
431         if (!device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
432             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WIFI_RSSI);
433         }
434         if (!device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
435             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_AUTO_EMPTY);
436         }
437         if (!device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
438             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_TRUE_DETECT_3D);
439         }
440         if (!device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
441             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_CLEANING_PASSES);
442         }
443
444         if (hasChanges) {
445             updateThing(builder.build());
446         }
447     }
448
449     private boolean removeUnsupportedChannel(ThingBuilder builder, String channelId) {
450         ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
451         if (getThing().getChannel(channelUID) == null) {
452             return false;
453         }
454         logger.debug("{}: Removing unsupported channel {}", serialNumber, channelId);
455         builder.withoutChannel(channelUID);
456         return true;
457     }
458
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();
463
464         stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_CLEANING_MODE),
465                 modeChannelOptions);
466         stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_LAST_CLEAN_MODE),
467                 modeChannelOptions);
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));
472     }
473
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());
485     }
486
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);
495         } else {
496             delayUntilNextPoll = initialDelaySeconds;
497         }
498         logger.debug("{}: Scheduling next poll in {}s, refresh interval {}min", serialNumber, delayUntilNextPoll,
499                 config.refresh);
500         pollTask.cancel();
501         pollTask.schedule(delayUntilNextPoll);
502     }
503
504     private void initDevice() {
505         final EcovacsApiHandler handler = getApiHandler();
506         if (handler == null) {
507             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
508             return;
509         }
510
511         try {
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);
523                 connectToDevice();
524             } else {
525                 logger.info("{}: Device not found in device list, setting offline", serialNumber);
526                 updateStatus(ThingStatus.OFFLINE);
527             }
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());
534         }
535     }
536
537     private void teardownAndScheduleReconnection() {
538         teardown(true);
539     }
540
541     private synchronized void teardown(boolean scheduleReconnection) {
542         EcovacsDevice device = this.device;
543         if (device != null) {
544             device.disconnect(scheduler);
545         }
546
547         pollTask.cancel();
548
549         reconnectTask.cancel();
550         initTask.cancel();
551
552         if (scheduleReconnection) {
553             SchedulerTask connectTask = device != null ? reconnectTask : initTask;
554             connectTask.schedule(5);
555         }
556     }
557
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);
568         });
569     }
570
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));
578
579             boolean continuousCleaningEnabled = device.sendCommand(new GetContinuousCleaningCommand());
580             updateState(CHANNEL_ID_CONTINUOUS_CLEANING, OnOffType.from(continuousCleaningEnabled));
581
582             List<CleanLogRecord> cleanLogRecords = device.getCleanLogs();
583             if (!cleanLogRecords.isEmpty()) {
584                 CleanLogRecord record = cleanLogRecords.get(0);
585
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));
593
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");
599                         });
600                         updateState(CHANNEL_ID_LAST_CLEAN_MAP, content.orElse(UnDefType.NULL));
601                     }
602                 }
603             }
604
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)));
608             }
609
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)));
613             }
614
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));
619                 }
620             }
621
622             if (device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
623                 boolean autoEmptyEnabled = device.sendCommand(new GetDustbinAutoEmptyCommand());
624                 updateState(CHANNEL_ID_AUTO_EMPTY, OnOffType.from(autoEmptyEnabled));
625             }
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));
629             }
630             if (device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
631                 lastDefaultCleaningPasses = device.sendCommand(new GetDefaultCleanPassesCommand());
632                 updateState(CHANNEL_ID_CLEANING_PASSES, new DecimalType(lastDefaultCleaningPasses));
633             }
634
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));
639
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));
643             }
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));
647             }
648             if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
649                 int level = device.sendCommand(new GetVolumeCommand());
650                 updateState(CHANNEL_ID_VOICE_VOLUME, new PercentType(level * 10));
651             }
652
653             lastSuccessfulPollTimestamp = System.currentTimeMillis();
654             scheduleNextPoll(-1);
655         });
656         logger.debug("{}: Data polling completed", serialNumber);
657     }
658
659     private void updateStateAndCommandChannels() {
660         Boolean charging = this.lastWasCharging;
661         CleanMode cleanMode = this.lastCleanMode;
662         if (charging == null || cleanMode == null) {
663             return;
664         }
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));
670     }
671
672     private String determineStateChannelValue(boolean charging, CleanMode cleanMode) {
673         if (charging) {
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) {
678                 return "charging";
679             }
680         }
681         if (cleanMode.isActive()) {
682             return "cleaning";
683         }
684         StateOptionEntry<CleanMode> result = CLEAN_MODE_MAPPING.get(cleanMode);
685         return result != null ? result.value : "idle";
686     }
687
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;
691     }
692
693     private @Nullable String determineCommandChannelValue(boolean charging, CleanMode cleanMode) {
694         if (charging) {
695             return CMD_CHARGE;
696         }
697         switch (cleanMode) {
698             case AUTO:
699                 return CMD_AUTO_CLEAN;
700             case SPOT_AREA:
701                 return CMD_SPOT_AREA;
702             case PAUSE:
703                 return CMD_PAUSE;
704             case STOP:
705                 return CMD_STOP;
706             case RETURNING:
707                 return CMD_CHARGE;
708             default:
709                 break;
710         }
711         return null;
712     }
713
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);
717     }
718
719     private @Nullable AbstractNoResponseCommand determineDeviceCommand(EcovacsDevice device, String command) {
720         CleanMode mode = lastActiveCleanMode;
721
722         switch (command) {
723             case CMD_AUTO_CLEAN:
724                 return new StartAutoCleaningCommand();
725             case CMD_PAUSE:
726                 if (mode != null) {
727                     return new PauseCleaningCommand(mode);
728                 }
729                 break;
730             case CMD_RESUME:
731                 if (mode != null) {
732                     return new ResumeCleaningCommand(mode);
733                 }
734                 break;
735             case CMD_STOP:
736                 return new StopCleaningCommand();
737             case CMD_CHARGE:
738                 return new GoChargingCommand();
739         }
740
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'));
750                     } else {
751                         logger.info("{}: Found invalid spot area room ID '{}', ignoring.", serialNumber, id);
752                     }
753                 }
754                 if (!roomIds.isEmpty()) {
755                     return new SpotAreaCleaningCommand(roomIds, passes,
756                             device.hasCapability(DeviceCapability.FREE_CLEAN_FOR_SPOT_AREA));
757                 }
758             } else {
759                 logger.info("{}: spotArea command needs to have the form spotArea:<room1>[;<room2>][;<...roomX>][:x2]",
760                         serialNumber);
761             }
762         }
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);
771                 }
772             }
773             logger.info("{}: customArea command needs to have the form customArea:<x1>;<y1>;<x2>;<y2>[:x2]",
774                     serialNumber);
775         }
776
777         return null;
778     }
779
780     private interface WithDeviceAction {
781         void run(EcovacsDevice device) throws EcovacsApiException, InterruptedException;
782     }
783
784     private void doWithDevice(WithDeviceAction action) {
785         EcovacsDevice device = this.device;
786         if (device == null) {
787             return;
788         }
789         try {
790             action.run(device);
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();
800                 }
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);
804                 this.device = null;
805             }
806             teardownAndScheduleReconnection();
807         }
808     }
809
810     private @Nullable EcovacsApiHandler getApiHandler() {
811         final Bridge bridge = getBridge();
812         return bridge != null ? (EcovacsApiHandler) bridge.getHandler() : null;
813     }
814 }