Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for VeSync Humidifiers #84025

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions homeassistant/components/humidifier/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
MODE_AUTO,
MODE_AWAY,
MODE_NORMAL,
MODE_SLEEP,
SERVICE_SET_HUMIDITY,
SERVICE_SET_MODE,
SUPPORT_MODES,
Expand Down
20 changes: 20 additions & 0 deletions homeassistant/components/vesync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SERVICE_UPDATE_DEVS,
VS_DISCOVERY,
VS_FANS,
VS_HUMIDIFIERS,
VS_LIGHTS,
VS_MANAGER,
VS_SENSORS,
Expand Down Expand Up @@ -52,6 +53,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b

switches = hass.data[DOMAIN][VS_SWITCHES] = []
fans = hass.data[DOMAIN][VS_FANS] = []
humidifiers = hass.data[DOMAIN][VS_HUMIDIFIERS] = []
lights = hass.data[DOMAIN][VS_LIGHTS] = []
sensors = hass.data[DOMAIN][VS_SENSORS] = []
platforms = []
Expand All @@ -64,6 +66,10 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
fans.extend(device_dict[VS_FANS])
platforms.append(Platform.FAN)

if device_dict[VS_HUMIDIFIERS]:
humidifiers.extend(device_dict[VS_HUMIDIFIERS])
platforms.append(Platform.HUMIDIFIER)

if device_dict[VS_LIGHTS]:
lights.extend(device_dict[VS_LIGHTS])
platforms.append(Platform.LIGHT)
Expand All @@ -79,12 +85,14 @@ async def async_new_device_discovery(service: ServiceCall) -> None:
manager = hass.data[DOMAIN][VS_MANAGER]
switches = hass.data[DOMAIN][VS_SWITCHES]
fans = hass.data[DOMAIN][VS_FANS]
humidifiers = hass.data[DOMAIN][VS_HUMIDIFIERS]
lights = hass.data[DOMAIN][VS_LIGHTS]
sensors = hass.data[DOMAIN][VS_SENSORS]

dev_dict = await async_process_devices(hass, manager)
switch_devs = dev_dict.get(VS_SWITCHES, [])
fan_devs = dev_dict.get(VS_FANS, [])
humidifier_devs = dev_dict.get(VS_HUMIDIFIERS, [])
light_devs = dev_dict.get(VS_LIGHTS, [])
sensor_devs = dev_dict.get(VS_SENSORS, [])

Expand All @@ -108,6 +116,18 @@ async def async_new_device_discovery(service: ServiceCall) -> None:
fans.extend(new_fans)
hass.async_create_task(forward_setup(config_entry, Platform.FAN))

humidifier_set = set(humidifier_devs)
new_humidifiers = list(humidifier_set.difference(humidifiers))
if new_humidifiers and humidifiers:
humidifiers.extend(new_humidifiers)
async_dispatcher_send(
hass, VS_DISCOVERY.format(VS_HUMIDIFIERS), new_humidifiers
)
return
if new_humidifiers and not humidifiers:
humidifiers.extend(new_humidifiers)
hass.async_create_task(forward_setup(config_entry, Platform.HUMIDIFIER))

light_set = set(light_devs)
new_lights = list(light_set.difference(lights))
if new_lights and lights:
Expand Down
11 changes: 8 additions & 3 deletions homeassistant/components/vesync/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity

from .const import DOMAIN, VS_FANS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES
from .const import DOMAIN, VS_FANS, VS_HUMIDIFIERS, VS_LIGHTS, VS_SENSORS, VS_SWITCHES

_LOGGER = logging.getLogger(__name__)

Expand All @@ -16,14 +16,19 @@ async def async_process_devices(hass, manager):
devices = {}
devices[VS_SWITCHES] = []
devices[VS_FANS] = []
devices[VS_HUMIDIFIERS] = []
devices[VS_LIGHTS] = []
devices[VS_SENSORS] = []

await hass.async_add_executor_job(manager.update)

if manager.fans:
devices[VS_FANS].extend(manager.fans)
# Expose fan sensors separately
for fan in manager.fans:
if hasattr(fan, "set_humidity"):
devices[VS_HUMIDIFIERS].append(fan)
else:
devices[VS_FANS].append(fan)
# Expose fan and humidifier sensors separately
devices[VS_SENSORS].extend(manager.fans)
_LOGGER.info("%d VeSync fans found", len(manager.fans))

Expand Down
18 changes: 18 additions & 0 deletions homeassistant/components/vesync/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

VS_SWITCHES = "switches"
VS_FANS = "fans"
VS_HUMIDIFIERS = "humidifiers"
VS_LIGHTS = "lights"
VS_SENSORS = "sensors"
VS_MANAGER = "manager"
Expand All @@ -21,6 +22,7 @@
}

SKU_TO_BASE_DEVICE = {
# Air Purifiers
"LV-PUR131S": "LV-PUR131S",
"LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S
"Core200S": "Core200S",
Expand All @@ -36,4 +38,20 @@
"LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S
"LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S
"LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S
# Humidifiers
"Classic200S": "Classic200S",
"Classic300S": "Classic300S",
"LUH-A601S-WUSB": "Classic300S", # Alt ID Model Classic300S
"Dual200S": "Dual200S",
"LUH-D301S-WUSR": "Dual200S", # Alt ID Model Dual200S
"LUH-D301S-WJP": "Dual200S", # Alt ID Model Dual200S
"LUH-D301S-WEU": "Dual200S", # Alt ID Model Dual200S
"LV600S": "LV600S",
"LUH-A602S-WUSR": "LV600S", # Alt ID Model LV600S
"LUH-A602S-WUS": "LV600S", # Alt ID Model LV600S
"LUH-A602S-WEUR": "LV600S", # Alt ID Model LV600S
"LUH-A602S-WEU": "LV600S", # Alt ID Model LV600S
"LUH-A602S-WJP": "LV600S", # Alt ID Model LV600S
"OASISMIST": "OASISMIST",
"LUH-O451S-WUS": "OASISMIST", # Alt ID Model OASISMIST
}
135 changes: 135 additions & 0 deletions homeassistant/components/vesync/humidifier.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Support for VeSync humidifiers."""
from __future__ import annotations

import logging
from typing import Any

from homeassistant.components.humidifier import (
MODE_AUTO,
MODE_NORMAL,
MODE_SLEEP,
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .common import VeSyncDevice
from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_HUMIDIFIERS

_LOGGER = logging.getLogger(__name__)

DEV_TYPE_TO_HA = {
"Classic200S": "humidifier",
"Classic300S": "humidifier",
"Dual200S": "humidifier",
"LV600S": "humidifier",
"OASISMIST": "humidifier",
}

PRESET_MODES = {
"Classic200S": [MODE_NORMAL, MODE_AUTO],
"Classic300S": [MODE_NORMAL, MODE_AUTO, MODE_SLEEP],
"Dual200S": [MODE_NORMAL, MODE_AUTO],
"LV600S": [MODE_NORMAL, MODE_AUTO, MODE_SLEEP],
"OASISMIST": [MODE_NORMAL, MODE_AUTO, MODE_SLEEP],
}

MODE_MAP = {
"manual": MODE_NORMAL,
"humidity": MODE_AUTO,
"auto": MODE_AUTO,
"sleep": MODE_SLEEP,
}


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the VeSync fan platform."""

@callback
def discover(devices):
"""Add new devices to platform."""
_setup_entities(devices, async_add_entities)

config_entry.async_on_unload(
async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_HUMIDIFIERS), discover)
)

_setup_entities(hass.data[DOMAIN][VS_HUMIDIFIERS], async_add_entities)


@callback
def _setup_entities(devices, async_add_entities):
"""Check if device is online and add entity."""
entities = []
for dev in devices:
if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type)) == "humidifier":
entities.append(VeSyncHumidifierHA(dev))
else:
_LOGGER.warning(
"%s - Unknown device type - %s", dev.device_name, dev.device_type
)
continue

async_add_entities(entities, update_before_add=True)


class VeSyncHumidifierHA(VeSyncDevice, HumidifierEntity):
"""Representation of a VeSync humidifier."""

_attr_device_class = HumidifierDeviceClass.HUMIDIFIER
_attr_min_humidity = 30
_attr_max_humidity = 80
_attr_supported_features = HumidifierEntityFeature.MODES

@property
def available_modes(self) -> list[str]:
"""Get the list of available preset modes."""
return PRESET_MODES[SKU_TO_BASE_DEVICE[self.device.device_type]]

@property
def mode(self) -> str:
"""Get the current preset mode."""
return MODE_MAP[self.device.details["mode"]]

@property
def target_humidity(self) -> int:
"""Return the current target humidity."""
return self.device.auto_humidity

def set_humidity(self, humidity: int) -> None:
"""Set the humidity of device."""
if not self.device.is_on:
self.device.turn_on()

self.device.set_humidity(humidity)

def set_mode(self, mode: str) -> None:
"""Set the preset mode of device."""
if mode not in self.available_modes:
raise ValueError(
f"{mode} is not one of the valid modes: " f"{self.available_modes}"
)

if not self.device.is_on:
self.device.turn_on()

if mode == MODE_NORMAL:
self.device.set_manual_mode()
elif mode == MODE_SLEEP:
self.device.set_humidity_mode("sleep")
elif mode == MODE_AUTO:
self.device.set_auto_mode()

self.schedule_update_ha_state()

def turn_on(self, **kwargs: Any) -> None:
"""Turn the device on."""
self.device.turn_on()
9 changes: 9 additions & 0 deletions homeassistant/components/vesync/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ def ha_dev_type(device):
value_fn=lambda device: device.details["air_quality_value"],
exists_fn=lambda device: sku_supported(device, PM25_SUPPORTED),
),
VeSyncSensorEntityDescription(
key="humidity",
name="Humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda device: device.details["humidity"],
exists_fn=lambda device: "humidity" in device.details,
),
VeSyncSensorEntityDescription(
key="power",
name="current power",
Expand Down
62 changes: 62 additions & 0 deletions tests/components/vesync/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""pytest helpers for VeSync component tests."""
import pytest

from homeassistant.components.vesync.const import DOMAIN
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.setup import async_setup_component

from tests.common import MockConfigEntry, load_fixture


@pytest.fixture()
async def setup_platform(hass):
"""Set up the ecobee platform."""
mock_entry = MockConfigEntry(
domain=DOMAIN,
data={
CONF_USERNAME: "ABC123",
CONF_PASSWORD: "EFG456",
},
)
mock_entry.add_to_hass(hass)

assert await async_setup_component(hass, DOMAIN, {})

await hass.async_block_till_done()


@pytest.fixture(autouse=True)
def requests_mock_fixture(requests_mock):
"""Fixture to provide a requests mocker."""
requests_mock.post(
"https://smartapi.vesync.com/cloud/v1/user/login",
text=load_fixture("vesync/vesync-login.json"),
)
requests_mock.post(
"https://smartapi.vesync.com/cloud/v1/deviceManaged/devices",
text=load_fixture("vesync/vesync-devices.json"),
)
requests_mock.get(
"https://smartapi.vesync.com/v1/device/outlet/detail",
text=load_fixture("vesync/outlet-detail.json"),
)
requests_mock.post(
"https://smartapi.vesync.com/dimmer/v1/device/devicedetail",
text=load_fixture("vesync/dimmer-detail.json"),
)
requests_mock.post(
"https://smartapi.vesync.com/SmartBulb/v1/device/devicedetail",
text=load_fixture("vesync/device-detail.json"),
)
requests_mock.post(
"https://smartapi.vesync.com/cloud/v1/deviceManaged/bypass",
text=load_fixture("vesync/device-detail.json"),
)
requests_mock.post(
"https://smartapi.vesync.com/cloud/v2/deviceManaged/bypassV2",
text=load_fixture("vesync/device-detail.json"),
)
requests_mock.post(
"https://smartapi.vesync.com/131airPurifier/v1/device/deviceDetail",
text=load_fixture("vesync/purifier-detail.json"),
)
42 changes: 42 additions & 0 deletions tests/components/vesync/fixtures/device-detail.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"code": 0,
"brightNess": "50",
"result": {
"light": {
"brightness": 50,
"colorTempe": 5400
},
"result": {
"brightness": 50,
"red": 178.5,
"green": 255,
"blue": 25.5,
"colorMode": "rgb",

"humidity": 35,
"mist_virtual_level": 6,
"mode": "manual",
"water_lacks": true,
"water_tank_lifted": true,
"automatic_stop_reach_target": true,
"night_light_brightness": 10,

"enabled": true,
"filter_life": 99,
"level": 1,
"display": true,
"display_forever": false,
"child_lock": false,
"night_light": "off",
"air_quality": 5,
"air_quality_value": 1,

"configuration": {
"auto_target_humidity": 40,
"display": true,
"automatic_stop": true
}
},
"code": 0
}
}
Loading