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.vesync.internal.handlers;
15 import static org.openhab.binding.vesync.internal.VeSyncConstants.*;
16 import static org.openhab.binding.vesync.internal.dto.requests.VeSyncProtocolConstants.*;
18 import java.time.LocalDateTime;
19 import java.time.temporal.ChronoUnit;
20 import java.util.Arrays;
21 import java.util.Collections;
22 import java.util.List;
25 import javax.validation.constraints.NotNull;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.openhab.binding.vesync.internal.VeSyncBridgeConfiguration;
29 import org.openhab.binding.vesync.internal.VeSyncConstants;
30 import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestManagedDeviceBypassV2;
31 import org.openhab.binding.vesync.internal.dto.requests.VeSyncRequestV1ManagedDeviceDetails;
32 import org.openhab.binding.vesync.internal.dto.responses.VeSyncV2BypassPurifierStatus;
33 import org.openhab.binding.vesync.internal.dto.responses.v1.VeSyncV1AirPurifierDeviceDetailsResponse;
34 import org.openhab.core.cache.ExpiringCache;
35 import org.openhab.core.library.items.DateTimeItem;
36 import org.openhab.core.library.types.DateTimeType;
37 import org.openhab.core.library.types.DecimalType;
38 import org.openhab.core.library.types.OnOffType;
39 import org.openhab.core.library.types.QuantityType;
40 import org.openhab.core.library.types.StringType;
41 import org.openhab.core.library.unit.Units;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingTypeUID;
46 import org.openhab.core.types.Command;
47 import org.openhab.core.types.RefreshType;
48 import org.slf4j.Logger;
49 import org.slf4j.LoggerFactory;
52 * The {@link VeSyncDeviceAirPurifierHandler} is responsible for handling commands, which are
53 * sent to one of the channels.
55 * @author David Goodyear - Initial contribution
58 public class VeSyncDeviceAirPurifierHandler extends VeSyncBaseDeviceHandler {
60 public static final String DEV_TYPE_FAMILY_AIR_PURIFIER = "LAP";
62 public static final int DEFAULT_AIR_PURIFIER_POLL_RATE = 120;
64 public static final String DEV_FAMILY_CORE_200S = "200S";
65 public static final String DEV_FAMILY_CORE_300S = "300S";
66 public static final String DEV_FAMILY_CORE_400S = "400S";
67 public static final String DEV_FAMILY_CORE_600S = "600S";
69 public static final String DEV_FAMILY_PUR_131S = "131S";
71 public static final VeSyncDeviceMetadata CORE200S = new VeSyncDeviceMetadata(DEV_FAMILY_CORE_200S,
72 Arrays.asList("C201S", "C202S"), List.of("Core200S"));
74 public static final VeSyncDeviceMetadata CORE300S = new VeSyncDeviceMetadata(DEV_FAMILY_CORE_300S,
75 List.of("C301S", "C302S"), List.of("Core300S"));
77 public static final VeSyncDeviceMetadata CORE400S = new VeSyncDeviceMetadata(DEV_FAMILY_CORE_400S, List.of("C401S"),
80 public static final VeSyncDeviceMetadata CORE600S = new VeSyncDeviceMetadata(DEV_FAMILY_CORE_600S, List.of("C601S"),
83 public static final VeSyncDeviceMetadata PUR131S = new VeSyncDeviceMetadata(DEV_FAMILY_PUR_131S,
84 Collections.emptyList(), Arrays.asList("LV-PUR131S", "LV-RH131S"));
86 public static final List<VeSyncDeviceMetadata> SUPPORTED_MODEL_FAMILIES = Arrays.asList(CORE600S, CORE400S,
87 CORE300S, CORE200S, PUR131S);
89 private static final List<String> CORE_400S600S_FAN_MODES = Arrays.asList(MODE_AUTO, MODE_MANUAL, MODE_SLEEP);
90 private static final List<String> CORE_200S300S_FAN_MODES = Arrays.asList(MODE_MANUAL, MODE_SLEEP);
91 private static final List<String> CORE_200S300S_NIGHT_LIGHT_MODES = Arrays.asList(MODE_ON, MODE_DIM, MODE_OFF);
93 private final Logger logger = LoggerFactory.getLogger(VeSyncDeviceAirPurifierHandler.class);
95 public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_AIR_PURIFIER);
97 private final Object pollLock = new Object();
99 public VeSyncDeviceAirPurifierHandler(Thing thing) {
104 public void initialize() {
110 protected @NotNull String[] getChannelsToRemove() {
111 final String deviceFamily = getThing().getProperties().get(DEVICE_PROP_DEVICE_FAMILY);
112 String[] toRemove = new String[] {};
113 if (deviceFamily != null) {
114 switch (deviceFamily) {
115 case DEV_FAMILY_CORE_600S:
116 case DEV_FAMILY_CORE_400S:
117 toRemove = new String[] { DEVICE_CHANNEL_AF_NIGHT_LIGHT };
119 case DEV_FAMILY_PUR_131S:
120 toRemove = new String[] { DEVICE_CHANNEL_AF_NIGHT_LIGHT, DEVICE_CHANNEL_AF_CONFIG_AUTO_ROOM_SIZE,
121 DEVICE_CHANNEL_AF_CONFIG_AUTO_MODE_PREF, DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME,
122 DEVICE_CHANNEL_AIR_FILTER_LIFE_PERCENTAGE_REMAINING, DEVICE_CHANNEL_AIRQUALITY_PM25,
123 DEVICE_CHANNEL_AF_SCHEDULES_COUNT, DEVICE_CHANNEL_AF_CONFIG_DISPLAY_FOREVER };
126 toRemove = new String[] { DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME, DEVICE_CHANNEL_AF_SCHEDULES_COUNT };
133 public void updateBridgeBasedPolls(final VeSyncBridgeConfiguration config) {
134 Integer pollRate = config.airPurifierPollInterval;
135 if (pollRate == null) {
136 pollRate = DEFAULT_AIR_PURIFIER_POLL_RATE;
139 if (ThingStatus.OFFLINE.equals(getThing().getStatus())) {
140 setBackgroundPollInterval(-1);
142 setBackgroundPollInterval(pollRate);
147 public void dispose() {
148 this.setBackgroundPollInterval(-1);
152 public String getDeviceFamilyProtocolPrefix() {
153 return DEV_TYPE_FAMILY_AIR_PURIFIER;
157 public List<VeSyncDeviceMetadata> getSupportedDeviceMetadata() {
158 return SUPPORTED_MODEL_FAMILIES;
162 public void handleCommand(final ChannelUID channelUID, final Command command) {
163 final String deviceFamily = getThing().getProperties().get(DEVICE_PROP_DEVICE_FAMILY);
164 if (deviceFamily == null) {
168 scheduler.submit(() -> {
170 if (command instanceof OnOffType) {
171 switch (channelUID.getId()) {
172 case DEVICE_CHANNEL_ENABLED:
173 sendV2BypassControlCommand(DEVICE_SET_SWITCH,
174 new VeSyncRequestManagedDeviceBypassV2.SetSwitchPayload(command.equals(OnOffType.ON),
177 case DEVICE_CHANNEL_DISPLAY_ENABLED:
178 sendV2BypassControlCommand(DEVICE_SET_DISPLAY,
179 new VeSyncRequestManagedDeviceBypassV2.SetState(command.equals(OnOffType.ON)));
181 case DEVICE_CHANNEL_CHILD_LOCK_ENABLED:
182 sendV2BypassControlCommand(DEVICE_SET_CHILD_LOCK,
183 new VeSyncRequestManagedDeviceBypassV2.SetChildLock(command.equals(OnOffType.ON)));
186 } else if (command instanceof StringType) {
187 switch (channelUID.getId()) {
188 case DEVICE_CHANNEL_FAN_MODE_ENABLED:
189 final String targetFanMode = command.toString().toLowerCase();
190 switch (deviceFamily) {
191 case DEV_FAMILY_CORE_600S:
192 case DEV_FAMILY_CORE_400S:
193 if (!CORE_400S600S_FAN_MODES.contains(targetFanMode)) {
195 "Fan mode command for \"{}\" is not valid in the (Core400S) API possible options {}",
196 command, String.join(",", CORE_400S600S_FAN_MODES));
200 case DEV_FAMILY_CORE_200S:
201 case DEV_FAMILY_CORE_300S:
202 if (!CORE_200S300S_FAN_MODES.contains(targetFanMode)) {
204 "Fan mode command for \"{}\" is not valid in the (Core200S/Core300S) API possible options {}",
205 command, String.join(",", CORE_200S300S_FAN_MODES));
211 sendV2BypassControlCommand(DEVICE_SET_PURIFIER_MODE,
212 new VeSyncRequestManagedDeviceBypassV2.SetMode(targetFanMode));
214 case DEVICE_CHANNEL_AF_NIGHT_LIGHT:
215 final String targetNightLightMode = command.toString().toLowerCase();
216 switch (deviceFamily) {
217 case DEV_FAMILY_CORE_600S:
218 case DEV_FAMILY_CORE_400S:
219 logger.warn("Core400S API does not support night light");
221 case DEV_FAMILY_CORE_200S:
222 case DEV_FAMILY_CORE_300S:
223 if (!CORE_200S300S_NIGHT_LIGHT_MODES.contains(targetNightLightMode)) {
225 "Night light mode command for \"{}\" is not valid in the (Core200S/Core300S) API possible options {}",
226 command, String.join(",", CORE_200S300S_NIGHT_LIGHT_MODES));
230 sendV2BypassControlCommand(DEVICE_SET_NIGHT_LIGHT,
231 new VeSyncRequestManagedDeviceBypassV2.SetNightLight(targetNightLightMode));
237 } else if (command instanceof QuantityType quantityCommand) {
238 switch (channelUID.getId()) {
239 case DEVICE_CHANNEL_FAN_SPEED_ENABLED:
240 // If the fan speed is being set enforce manual mode
241 sendV2BypassControlCommand(DEVICE_SET_PURIFIER_MODE,
242 new VeSyncRequestManagedDeviceBypassV2.SetMode(MODE_MANUAL), false);
244 int requestedLevel = quantityCommand.intValue();
245 if (requestedLevel < 1) {
246 logger.warn("Fan speed command less than 1 - adjusting to 1 as the valid API value");
250 switch (deviceFamily) {
251 case DEV_FAMILY_CORE_600S:
252 case DEV_FAMILY_CORE_400S:
253 if (requestedLevel > 4) {
255 "Fan speed command greater than 4 - adjusting to 4 as the valid (Core400S) API value");
259 case DEV_FAMILY_CORE_200S:
260 case DEV_FAMILY_CORE_300S:
261 if (requestedLevel > 3) {
263 "Fan speed command greater than 3 - adjusting to 3 as the valid (Core200S/Core300S) API value");
269 sendV2BypassControlCommand(DEVICE_SET_LEVEL,
270 new VeSyncRequestManagedDeviceBypassV2.SetLevelPayload(0, DEVICE_LEVEL_TYPE_WIND,
274 } else if (command instanceof RefreshType) {
277 logger.trace("UNKNOWN COMMAND: {} {}", command.getClass().toString(), channelUID);
283 protected void pollForDeviceData(final ExpiringCache<String> cachedResponse) {
284 final String deviceFamily = getThing().getProperties().get(DEVICE_PROP_DEVICE_FAMILY);
285 if (deviceFamily == null) {
289 switch (deviceFamily) {
290 case DEV_FAMILY_CORE_600S:
291 case DEV_FAMILY_CORE_400S:
292 case DEV_FAMILY_CORE_300S:
293 case DEV_FAMILY_CORE_200S:
294 processV2BypassPoll(cachedResponse);
296 case DEV_FAMILY_PUR_131S:
297 processV1AirPurifierPoll(cachedResponse);
302 private void processV1AirPurifierPoll(final ExpiringCache<String> cachedResponse) {
303 final String deviceUuid = getThing().getProperties().get(DEVICE_PROP_DEVICE_UUID);
304 if (deviceUuid == null) {
309 VeSyncV1AirPurifierDeviceDetailsResponse purifierStatus;
310 synchronized (pollLock) {
311 response = cachedResponse.getValue();
312 boolean cachedDataUsed = response != null;
313 if (response == null) {
314 logger.trace("Requesting fresh response");
315 response = sendV1Command("POST", "https://smartapi.vesync.com/131airPurifier/v1/device/deviceDetail",
316 new VeSyncRequestV1ManagedDeviceDetails(deviceUuid));
318 logger.trace("Using cached response {}", response);
321 if (response.equals(EMPTY_STRING)) {
325 purifierStatus = VeSyncConstants.GSON.fromJson(response, VeSyncV1AirPurifierDeviceDetailsResponse.class);
327 if (purifierStatus == null) {
331 if (!cachedDataUsed) {
332 cachedResponse.putValue(response);
336 // Bail and update the status of the thing - it will be updated to online by the next search
337 // that detects it is online.
338 if (purifierStatus.isDeviceOnline()) {
339 updateStatus(ThingStatus.ONLINE);
341 updateStatus(ThingStatus.OFFLINE);
345 if (!"0".equals(purifierStatus.getCode())) {
346 logger.warn("Check Thing type has been set - API gave a unexpected response for an Air Purifier");
350 updateState(DEVICE_CHANNEL_ENABLED, OnOffType.from(MODE_ON.equals(purifierStatus.getDeviceStatus())));
351 updateState(DEVICE_CHANNEL_CHILD_LOCK_ENABLED, OnOffType.from(MODE_ON.equals(purifierStatus.getChildLock())));
352 updateState(DEVICE_CHANNEL_FAN_MODE_ENABLED, new StringType(purifierStatus.getMode()));
353 updateState(DEVICE_CHANNEL_FAN_SPEED_ENABLED, new DecimalType(String.valueOf(purifierStatus.getLevel())));
354 updateState(DEVICE_CHANNEL_DISPLAY_ENABLED, OnOffType.from(MODE_ON.equals(purifierStatus.getScreenStatus())));
355 updateState(DEVICE_CHANNEL_AIRQUALITY_BASIC, new DecimalType(purifierStatus.getAirQuality()));
358 private void processV2BypassPoll(final ExpiringCache<String> cachedResponse) {
360 VeSyncV2BypassPurifierStatus purifierStatus;
361 synchronized (pollLock) {
362 response = cachedResponse.getValue();
363 boolean cachedDataUsed = response != null;
364 if (response == null) {
365 logger.trace("Requesting fresh response");
366 response = sendV2BypassCommand(DEVICE_GET_PURIFIER_STATUS,
367 new VeSyncRequestManagedDeviceBypassV2.EmptyPayload());
369 logger.trace("Using cached response {}", response);
372 if (response.equals(EMPTY_STRING)) {
376 purifierStatus = VeSyncConstants.GSON.fromJson(response, VeSyncV2BypassPurifierStatus.class);
378 if (purifierStatus == null) {
382 if (!cachedDataUsed) {
383 cachedResponse.putValue(response);
387 // Bail and update the status of the thing - it will be updated to online by the next search
388 // that detects it is online.
389 if (purifierStatus.isMsgDeviceOffline()) {
390 updateStatus(ThingStatus.OFFLINE);
392 } else if (purifierStatus.isMsgSuccess()) {
393 updateStatus(ThingStatus.ONLINE);
396 if (!"0".equals(purifierStatus.result.getCode())) {
397 logger.warn("Check Thing type has been set - API gave a unexpected response for an Air Purifier");
401 updateState(DEVICE_CHANNEL_ENABLED, OnOffType.from(purifierStatus.result.result.enabled));
402 updateState(DEVICE_CHANNEL_CHILD_LOCK_ENABLED, OnOffType.from(purifierStatus.result.result.childLock));
403 updateState(DEVICE_CHANNEL_DISPLAY_ENABLED, OnOffType.from(purifierStatus.result.result.display));
404 updateState(DEVICE_CHANNEL_AIR_FILTER_LIFE_PERCENTAGE_REMAINING,
405 new QuantityType<>(purifierStatus.result.result.filterLife, Units.PERCENT));
406 updateState(DEVICE_CHANNEL_FAN_MODE_ENABLED, new StringType(purifierStatus.result.result.mode));
407 updateState(DEVICE_CHANNEL_FAN_SPEED_ENABLED, new DecimalType(purifierStatus.result.result.level));
408 updateState(DEVICE_CHANNEL_ERROR_CODE, new DecimalType(purifierStatus.result.result.deviceErrorCode));
409 updateState(DEVICE_CHANNEL_AIRQUALITY_BASIC, new DecimalType(purifierStatus.result.result.airQuality));
410 updateState(DEVICE_CHANNEL_AIRQUALITY_PM25,
411 new QuantityType<>(purifierStatus.result.result.airQualityValue, Units.MICROGRAM_PER_CUBICMETRE));
413 updateState(DEVICE_CHANNEL_AF_CONFIG_DISPLAY_FOREVER,
414 OnOffType.from(purifierStatus.result.result.configuration.displayForever));
416 updateState(DEVICE_CHANNEL_AF_CONFIG_AUTO_MODE_PREF,
417 new StringType(purifierStatus.result.result.configuration.autoPreference.autoType));
419 updateState(DEVICE_CHANNEL_AF_CONFIG_AUTO_ROOM_SIZE,
420 new DecimalType(purifierStatus.result.result.configuration.autoPreference.roomSize));
422 // Only 400S appears to have this JSON extension object
423 if (purifierStatus.result.result.extension != null) {
424 if (purifierStatus.result.result.extension.timerRemain > 0) {
425 updateState(DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME, new DateTimeType(LocalDateTime.now()
426 .plus(purifierStatus.result.result.extension.timerRemain, ChronoUnit.SECONDS).toString()));
428 updateState(DEVICE_CHANNEL_AF_AUTO_OFF_CALC_TIME, new DateTimeItem("nullEnforcements").getState());
430 updateState(DEVICE_CHANNEL_AF_SCHEDULES_COUNT,
431 new DecimalType(purifierStatus.result.result.extension.scheduleCount));
434 // Not applicable to 400S payload's
435 if (purifierStatus.result.result.nightLight != null) {
436 updateState(DEVICE_CHANNEL_AF_NIGHT_LIGHT, new DecimalType(purifierStatus.result.result.nightLight));