]> git.basschouten.com Git - openhab-addons.git/blob
128afa340900ed500c3758251bbd6165463e1292
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.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;
111
112 /**
113  * The {@link EcovacsVacuumHandler} is responsible for handling data and commands from/to vacuum cleaners.
114  *
115  * @author Danny Baumann - Initial contribution
116  */
117 @NonNullByDefault
118 public class EcovacsVacuumHandler extends BaseThingHandler implements EcovacsDevice.EventListener {
119
120     private final Logger logger = LoggerFactory.getLogger(EcovacsVacuumHandler.class);
121
122     private final TranslationProvider i18Provider;
123     private final LocaleProvider localeProvider;
124     private final EcovacsDynamicStateDescriptionProvider stateDescriptionProvider;
125     private final Bundle bundle;
126
127     private final SchedulerTask initTask;
128     private final SchedulerTask reconnectTask;
129     private final SchedulerTask pollTask;
130     private @Nullable EcovacsDevice device;
131
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>";
139
140     public EcovacsVacuumHandler(Thing thing, TranslationProvider i18Provider, LocaleProvider localeProvider,
141             EcovacsDynamicStateDescriptionProvider stateDescriptionProvider) {
142         super(thing);
143         this.i18Provider = i18Provider;
144         this.localeProvider = localeProvider;
145         this.stateDescriptionProvider = stateDescriptionProvider;
146         bundle = FrameworkUtil.getBundle(getClass());
147
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);
151     }
152
153     @Override
154     public Collection<Class<? extends ThingHandlerService>> getServices() {
155         return Set.of(EcovacsVacuumActions.class);
156     }
157
158     @Override
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);
163             return;
164         }
165         String channel = channelUID.getId();
166
167         try {
168             if (channel.equals(CHANNEL_ID_COMMAND) && command instanceof StringType) {
169                 AbstractNoResponseCommand cmd = determineDeviceCommand(device, command.toString());
170                 if (cmd != null) {
171                     device.sendCommand(cmd);
172                     return;
173                 }
174             } else if (channel.equals(CHANNEL_ID_VOICE_VOLUME) && command instanceof DecimalType volume) {
175                 int volumePercent = volume.intValue();
176                 device.sendCommand(new SetVolumeCommand((volumePercent + 5) / 10));
177                 return;
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()));
182                     return;
183                 }
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()));
188                     return;
189                 }
190             } else if (channel.equals(CHANNEL_ID_AUTO_EMPTY)) {
191                 if (command instanceof OnOffType) {
192                     device.sendCommand(new SetDustbinAutoEmptyCommand(command == OnOffType.ON));
193                     return;
194                 } else if (command instanceof StringType && "trigger".equals(command.toString())) {
195                     device.sendCommand(new EmptyDustbinCommand());
196                     return;
197                 }
198             } else if (channel.equals(CHANNEL_ID_TRUE_DETECT_3D) && command instanceof OnOffType) {
199                 device.sendCommand(new SetTrueDetectCommand(command == OnOffType.ON));
200                 return;
201             } else if (channel.equals(CHANNEL_ID_CONTINUOUS_CLEANING) && command instanceof OnOffType) {
202                 device.sendCommand(new SetContinuousCleaningCommand(command == OnOffType.ON));
203                 return;
204             } else if (channel.equals(CHANNEL_ID_CLEANING_PASSES) && command instanceof DecimalType type) {
205                 int passes = type.intValue();
206                 device.sendCommand(new SetDefaultCleanPassesCommand(passes));
207                 lastDefaultCleaningPasses = passes; // if we get here, the command was executed successfully
208                 return;
209             }
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);
215         }
216     }
217
218     @Override
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");
224         } else {
225             logger.debug("{}: Initializing handler", serialNumber);
226             updateStatus(ThingStatus.UNKNOWN);
227             initTask.setNamePrefix(serialNumber);
228             reconnectTask.setNamePrefix(serialNumber);
229             pollTask.setNamePrefix(serialNumber);
230             initTask.submit();
231         }
232     }
233
234     @Override
235     public void dispose() {
236         logger.debug("{}: Disposing handler", serialNumber);
237         teardown(false);
238     }
239
240     @Override
241     public void channelLinked(ChannelUID channelUID) {
242         EcovacsDevice device = this.device;
243         if (device == null) {
244             return;
245         }
246
247         try {
248             switch (channelUID.getId()) {
249                 case CHANNEL_ID_BATTERY_LEVEL:
250                     fetchInitialBatteryStatus(device);
251                     break;
252                 case CHANNEL_ID_STATE:
253                 case CHANNEL_ID_COMMAND:
254                 case CHANNEL_ID_CLEANING_MODE:
255                     fetchInitialStateAndCommandValues(device);
256                     break;
257                 case CHANNEL_ID_WATER_PLATE_PRESENT:
258                     fetchInitialWaterSystemPresentState(device);
259                     break;
260                 case CHANNEL_ID_ERROR_CODE:
261                 case CHANNEL_ID_ERROR_DESCRIPTION:
262                     fetchInitialErrorCode(device);
263                 default:
264                     scheduleNextPoll(5); // add some delay in case multiple channels are linked at once
265                     break;
266             }
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);
271         }
272     }
273
274     @Override
275     public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
276         logger.debug("{}: Bridge status changed to {}", serialNumber, bridgeStatusInfo);
277         if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
278             initTask.submit();
279         } else if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
280             teardown(false);
281             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
282         }
283     }
284
285     @Override
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));
290     }
291
292     @Override
293     public void onChargingStateUpdated(EcovacsDevice device, boolean charging) {
294         lastWasCharging = charging;
295         updateStateAndCommandChannels();
296     }
297
298     @Override
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;
305         }
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 -> {
311                     try {
312                         int index = Integer.parseInt(item);
313                         return String.valueOf((char) ('A' + index));
314                     } catch (NumberFormatException e) {
315                         return item;
316                     }
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(',', ';');
321             }
322             return new StringType(def);
323         });
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);
330         }
331     }
332
333     @Override
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));
337     }
338
339     @Override
340     public void onWaterSystemPresentUpdated(EcovacsDevice device, boolean present) {
341         updateState(CHANNEL_ID_WATER_PLATE_PRESENT, OnOffType.from(present));
342     }
343
344     @Override
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);
351         }
352         updateState(CHANNEL_ID_ERROR_DESCRIPTION, new StringType(errorDesc));
353     }
354
355     @Override
356     public void onEventStreamFailure(final EcovacsDevice device, Throwable error) {
357         logger.debug("{}: Device connection failed, reconnecting", serialNumber, error);
358         teardownAndScheduleReconnection();
359     }
360
361     @Override
362     public void onFirmwareVersionChanged(EcovacsDevice device, String fwVersion) {
363         updateProperty(Thing.PROPERTY_FIRMWARE_VERSION, fwVersion);
364     }
365
366     public void playSound(PlaySoundCommand command) {
367         doWithDevice(device -> {
368             if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
369                 device.sendCommand(command);
370             } else {
371                 logger.info("{}: Device does not support voice reporting, ignoring sound action", serialNumber);
372             }
373         });
374     }
375
376     private void fetchInitialBatteryStatus(EcovacsDevice device) throws EcovacsApiException, InterruptedException {
377         Integer batteryPercent = device.sendCommand(new GetBatteryInfoCommand());
378         onBatteryLevelUpdated(device, batteryPercent);
379     }
380
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;
387         }
388         lastCleanMode = mode;
389         updateStateAndCommandChannels();
390     }
391
392     private void fetchInitialWaterSystemPresentState(EcovacsDevice device)
393             throws EcovacsApiException, InterruptedException {
394         if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
395             return;
396         }
397         boolean present = device.sendCommand(new GetWaterSystemPresentCommand());
398         onWaterSystemPresentUpdated(device, present);
399     }
400
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());
405         }
406     }
407
408     private void removeUnsupportedChannels(EcovacsDevice device) {
409         ThingBuilder builder = editThing();
410         boolean hasChanges = false;
411
412         if (!device.hasCapability(DeviceCapability.MOPPING_SYSTEM)) {
413             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_AMOUNT);
414             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WATER_PLATE_PRESENT);
415         }
416         if (!device.hasCapability(DeviceCapability.CLEAN_SPEED_CONTROL)) {
417             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_SUCTION_POWER);
418         }
419         if (!device.hasCapability(DeviceCapability.MAIN_BRUSH)) {
420             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_MAIN_BRUSH_LIFETIME);
421         }
422         if (!device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
423             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_VOICE_VOLUME);
424         }
425         if (!device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
426             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MODE);
427         }
428         if (!device.hasCapability(DeviceCapability.MAPPING)
429                 || !device.hasCapability(DeviceCapability.EXTENDED_CLEAN_LOG_RECORD)) {
430             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_LAST_CLEAN_MAP);
431         }
432         if (!device.hasCapability(DeviceCapability.READ_NETWORK_INFO)) {
433             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_WIFI_RSSI);
434         }
435         if (!device.hasCapability(DeviceCapability.AUTO_EMPTY_STATION)) {
436             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_AUTO_EMPTY);
437         }
438         if (!device.hasCapability(DeviceCapability.TRUE_DETECT_3D)) {
439             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_TRUE_DETECT_3D);
440         }
441         if (!device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
442             hasChanges |= removeUnsupportedChannel(builder, CHANNEL_ID_CLEANING_PASSES);
443         }
444
445         if (hasChanges) {
446             updateThing(builder.build());
447         }
448     }
449
450     private boolean removeUnsupportedChannel(ThingBuilder builder, String channelId) {
451         ChannelUID channelUID = new ChannelUID(getThing().getUID(), channelId);
452         if (getThing().getChannel(channelUID) == null) {
453             return false;
454         }
455         logger.debug("{}: Removing unsupported channel {}", serialNumber, channelId);
456         builder.withoutChannel(channelUID);
457         return true;
458     }
459
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();
464
465         stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_CLEANING_MODE),
466                 modeChannelOptions);
467         stateDescriptionProvider.setStateOptions(new ChannelUID(thingUID, CHANNEL_ID_LAST_CLEAN_MODE),
468                 modeChannelOptions);
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));
473     }
474
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());
486     }
487
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);
496         } else {
497             delayUntilNextPoll = initialDelaySeconds;
498         }
499         logger.debug("{}: Scheduling next poll in {}s, refresh interval {}min", serialNumber, delayUntilNextPoll,
500                 config.refresh);
501         pollTask.cancel();
502         pollTask.schedule(delayUntilNextPoll);
503     }
504
505     private void initDevice() {
506         final EcovacsApiHandler handler = getApiHandler();
507         if (handler == null) {
508             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED);
509             return;
510         }
511
512         try {
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);
524                 connectToDevice();
525             } else {
526                 logger.info("{}: Device not found in device list, setting offline", serialNumber);
527                 updateStatus(ThingStatus.OFFLINE);
528             }
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());
535         }
536     }
537
538     private void teardownAndScheduleReconnection() {
539         teardown(true);
540     }
541
542     private synchronized void teardown(boolean scheduleReconnection) {
543         EcovacsDevice device = this.device;
544         if (device != null) {
545             device.disconnect(scheduler);
546         }
547
548         pollTask.cancel();
549
550         reconnectTask.cancel();
551         initTask.cancel();
552
553         if (scheduleReconnection) {
554             SchedulerTask connectTask = device != null ? reconnectTask : initTask;
555             connectTask.schedule(5);
556         }
557     }
558
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);
569         });
570     }
571
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));
579
580             boolean continuousCleaningEnabled = device.sendCommand(new GetContinuousCleaningCommand());
581             updateState(CHANNEL_ID_CONTINUOUS_CLEANING, continuousCleaningEnabled ? OnOffType.ON : OnOffType.OFF);
582
583             List<CleanLogRecord> cleanLogRecords = device.getCleanLogs();
584             if (!cleanLogRecords.isEmpty()) {
585                 CleanLogRecord record = cleanLogRecords.get(0);
586
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));
594
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
600                             @Nullable
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;
605                             } else {
606                                 logger.debug("{}: Downloading cleaning map {} failed", serialNumber, url);
607                             }
608                             return Optional.ofNullable((State) mapData);
609                         }).orElse(UnDefType.NULL));
610                     }
611                 }
612             }
613
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)));
617             }
618
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)));
622             }
623
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));
628                 }
629             }
630
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);
634             }
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);
638             }
639             if (device.hasCapability(DeviceCapability.DEFAULT_CLEAN_COUNT_SETTING)) {
640                 lastDefaultCleaningPasses = device.sendCommand(new GetDefaultCleanPassesCommand());
641                 updateState(CHANNEL_ID_CLEANING_PASSES, new DecimalType(lastDefaultCleaningPasses));
642             }
643
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));
648
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));
652             }
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));
656             }
657             if (device.hasCapability(DeviceCapability.VOICE_REPORTING)) {
658                 int level = device.sendCommand(new GetVolumeCommand());
659                 updateState(CHANNEL_ID_VOICE_VOLUME, new PercentType(level * 10));
660             }
661
662             lastSuccessfulPollTimestamp = System.currentTimeMillis();
663             scheduleNextPoll(-1);
664         });
665         logger.debug("{}: Data polling completed", serialNumber);
666     }
667
668     private void updateStateAndCommandChannels() {
669         Boolean charging = this.lastWasCharging;
670         CleanMode cleanMode = this.lastCleanMode;
671         if (charging == null || cleanMode == null) {
672             return;
673         }
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));
679     }
680
681     private String determineStateChannelValue(boolean charging, CleanMode cleanMode) {
682         if (charging) {
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) {
687                 return "charging";
688             }
689         }
690         if (cleanMode.isActive()) {
691             return "cleaning";
692         }
693         StateOptionEntry<CleanMode> result = CLEAN_MODE_MAPPING.get(cleanMode);
694         return result != null ? result.value : "idle";
695     }
696
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;
700     }
701
702     private @Nullable String determineCommandChannelValue(boolean charging, CleanMode cleanMode) {
703         if (charging) {
704             return CMD_CHARGE;
705         }
706         switch (cleanMode) {
707             case AUTO:
708                 return CMD_AUTO_CLEAN;
709             case SPOT_AREA:
710                 return CMD_SPOT_AREA;
711             case PAUSE:
712                 return CMD_PAUSE;
713             case STOP:
714                 return CMD_STOP;
715             case RETURNING:
716                 return CMD_CHARGE;
717             default:
718                 break;
719         }
720         return null;
721     }
722
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);
726     }
727
728     private @Nullable AbstractNoResponseCommand determineDeviceCommand(EcovacsDevice device, String command) {
729         CleanMode mode = lastActiveCleanMode;
730
731         switch (command) {
732             case CMD_AUTO_CLEAN:
733                 return new StartAutoCleaningCommand();
734             case CMD_PAUSE:
735                 if (mode != null) {
736                     return new PauseCleaningCommand(mode);
737                 }
738                 break;
739             case CMD_RESUME:
740                 if (mode != null) {
741                     return new ResumeCleaningCommand(mode);
742                 }
743                 break;
744             case CMD_STOP:
745                 return new StopCleaningCommand();
746             case CMD_CHARGE:
747                 return new GoChargingCommand();
748         }
749
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'));
759                     } else {
760                         logger.info("{}: Found invalid spot area room ID '{}', ignoring.", serialNumber, id);
761                     }
762                 }
763                 if (!roomIds.isEmpty()) {
764                     return new SpotAreaCleaningCommand(roomIds, passes);
765                 }
766             } else {
767                 logger.info("{}: spotArea command needs to have the form spotArea:<room1>[;<room2>][;<...roomX>][:x2]",
768                         serialNumber);
769             }
770         }
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);
779                 }
780             }
781             logger.info("{}: customArea command needs to have the form customArea:<x1>;<y1>;<x2>;<y2>[:x2]",
782                     serialNumber);
783         }
784
785         return null;
786     }
787
788     private interface WithDeviceAction {
789         void run(EcovacsDevice device) throws EcovacsApiException, InterruptedException;
790     }
791
792     private void doWithDevice(WithDeviceAction action) {
793         EcovacsDevice device = this.device;
794         if (device == null) {
795             return;
796         }
797         try {
798             action.run(device);
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();
808                 }
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);
812                 this.device = null;
813             }
814             teardownAndScheduleReconnection();
815         }
816     }
817
818     private @Nullable EcovacsApiHandler getApiHandler() {
819         final Bridge bridge = getBridge();
820         return bridge != null ? (EcovacsApiHandler) bridge.getHandler() : null;
821     }
822 }