From 8c16ac2e47ef73e644ed8ec16e767e880994dee4 Mon Sep 17 00:00:00 2001 From: Ethan Madden Date: Mon, 30 May 2022 13:13:53 -0700 Subject: [PATCH] Vesync air quality (#72658) --- homeassistant/components/vesync/common.py | 25 +-- homeassistant/components/vesync/const.py | 28 +++ homeassistant/components/vesync/fan.py | 50 +---- homeassistant/components/vesync/sensor.py | 218 ++++++++++++---------- homeassistant/components/vesync/switch.py | 12 +- 5 files changed, 169 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 1104a84e6b6..acee8e20961 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -20,6 +20,8 @@ async def async_process_devices(hass, manager): if manager.fans: devices[VS_FANS].extend(manager.fans) + # Expose fan sensors separately + devices[VS_SENSORS].extend(manager.fans) _LOGGER.info("%d VeSync fans found", len(manager.fans)) if manager.bulbs: @@ -49,31 +51,25 @@ class VeSyncBaseEntity(Entity): def __init__(self, device): """Initialize the VeSync device.""" self.device = device + self._attr_unique_id = self.base_unique_id + self._attr_name = self.base_name @property def base_unique_id(self): """Return the ID of this device.""" + # The unique_id property may be overridden in subclasses, such as in + # sensors. Maintaining base_unique_id allows us to group related + # entities under a single device. if isinstance(self.device.sub_device_no, int): return f"{self.device.cid}{str(self.device.sub_device_no)}" return self.device.cid - @property - def unique_id(self): - """Return the ID of this device.""" - # The unique_id property may be overridden in subclasses, such as in sensors. Maintaining base_unique_id allows - # us to group related entities under a single device. - return self.base_unique_id - @property def base_name(self): """Return the name of the device.""" + # Same story here as `base_unique_id` above return self.device.device_name - @property - def name(self): - """Return the name of the entity (may be overridden).""" - return self.base_name - @property def available(self) -> bool: """Return True if device is available.""" @@ -98,6 +94,11 @@ class VeSyncBaseEntity(Entity): class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): """Base class for VeSync Device Representations.""" + @property + def details(self): + """Provide access to the device details dictionary.""" + return self.device.details + @property def is_on(self): """Return True if device is on.""" diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index fceeff81ae4..b20a04b8a1c 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -9,3 +9,31 @@ VS_FANS = "fans" VS_LIGHTS = "lights" VS_SENSORS = "sensors" VS_MANAGER = "manager" + +DEV_TYPE_TO_HA = { + "wifi-switch-1.3": "outlet", + "ESW03-USA": "outlet", + "ESW01-EU": "outlet", + "ESW15-USA": "outlet", + "ESWL01": "switch", + "ESWL03": "switch", + "ESO15-TB": "outlet", +} + +SKU_TO_BASE_DEVICE = { + "LV-PUR131S": "LV-PUR131S", + "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S + "Core200S": "Core200S", + "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S + "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S + "Core300S": "Core300S", + "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S + "Core400S": "Core400S", + "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S + "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S + "LAP-C401S-WAAA": "Core400S", # Alt ID Model Core400S + "Core600S": "Core600S", + "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S + "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S + "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S +} diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index f16a785ee1e..44e74209c30 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -14,26 +14,16 @@ from homeassistant.util.percentage import ( ) from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_FANS +from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) DEV_TYPE_TO_HA = { "LV-PUR131S": "fan", - "LV-RH131S": "fan", # Alt ID Model LV-PUR131S "Core200S": "fan", - "LAP-C201S-AUSR": "fan", # Alt ID Model Core200S - "LAP-C202S-WUSR": "fan", # Alt ID Model Core200S "Core300S": "fan", - "LAP-C301S-WJP": "fan", # Alt ID Model Core300S "Core400S": "fan", - "LAP-C401S-WJP": "fan", # Alt ID Model Core400S - "LAP-C401S-WUSR": "fan", # Alt ID Model Core400S - "LAP-C401S-WAAA": "fan", # Alt ID Model Core400S "Core600S": "fan", - "LAP-C601S-WUS": "fan", # Alt ID Model Core600S - "LAP-C601S-WUSR": "fan", # Alt ID Model Core600S - "LAP-C601S-WEU": "fan", # Alt ID Model Core600S } FAN_MODE_AUTO = "auto" @@ -41,37 +31,17 @@ FAN_MODE_SLEEP = "sleep" PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LV-RH131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model LV-PUR131S "Core200S": [FAN_MODE_SLEEP], - "LAP-C201S-AUSR": [FAN_MODE_SLEEP], # Alt ID Model Core200S - "LAP-C202S-WUSR": [FAN_MODE_SLEEP], # Alt ID Model Core200S "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C301S-WJP": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core300S "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C401S-WJP": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S - "LAP-C401S-WUSR": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S - "LAP-C401S-WAAA": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C601S-WUS": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S - "LAP-C601S-WUSR": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S - "LAP-C601S-WEU": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), - "LV-RH131S": (1, 3), # ALt ID Model LV-PUR131S "Core200S": (1, 3), - "LAP-C201S-AUSR": (1, 3), # ALt ID Model Core200S - "LAP-C202S-WUSR": (1, 3), # ALt ID Model Core200S "Core300S": (1, 3), - "LAP-C301S-WJP": (1, 3), # ALt ID Model Core300S "Core400S": (1, 4), - "LAP-C401S-WJP": (1, 4), # ALt ID Model Core400S - "LAP-C401S-WUSR": (1, 4), # ALt ID Model Core400S - "LAP-C401S-WAAA": (1, 4), # ALt ID Model Core400S "Core600S": (1, 4), - "LAP-C601S-WUS": (1, 4), # ALt ID Model Core600S - "LAP-C601S-WUSR": (1, 4), # ALt ID Model Core600S - "LAP-C601S-WEU": (1, 4), # ALt ID Model Core600S } @@ -99,7 +69,7 @@ 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(dev.device_type) == "fan": + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type)) == "fan": entities.append(VeSyncFanHA(dev)) else: _LOGGER.warning( @@ -128,19 +98,21 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): and (current_level := self.smartfan.fan_level) is not None ): return ranged_value_to_percentage( - SPEED_RANGE[self.device.device_type], current_level + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level ) return None @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE[self.device.device_type]) + return int_states_in_range( + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]] + ) @property def preset_modes(self): """Get the list of available preset modes.""" - return PRESET_MODES[self.device.device_type] + return PRESET_MODES[SKU_TO_BASE_DEVICE.get(self.device.device_type)] @property def preset_mode(self): @@ -171,15 +143,9 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): if hasattr(self.smartfan, "night_light"): attr["night_light"] = self.smartfan.night_light - if self.smartfan.details.get("air_quality_value") is not None: - attr["air_quality"] = self.smartfan.details["air_quality_value"] - if hasattr(self.smartfan, "mode"): attr["mode"] = self.smartfan.mode - if hasattr(self.smartfan, "filter_life"): - attr["filter_life"] = self.smartfan.filter_life - return attr def set_percentage(self, percentage): @@ -195,7 +161,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): self.smartfan.change_fan_speed( math.ceil( percentage_to_ranged_value( - SPEED_RANGE[self.device.device_type], percentage + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage ) ) ) diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index cc69bf36fa6..24ba6f2f0a0 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -1,25 +1,119 @@ """Support for power & energy sensors for VeSync outlets.""" +from collections.abc import Callable +from dataclasses import dataclass import logging from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .common import VeSyncBaseEntity -from .const import DOMAIN, VS_DISCOVERY, VS_SENSORS -from .switch import DEV_TYPE_TO_HA +from .common import VeSyncBaseEntity, VeSyncDevice +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS _LOGGER = logging.getLogger(__name__) +@dataclass +class VeSyncSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[VeSyncDevice], StateType] + + +@dataclass +class VeSyncSensorEntityDescription( + SensorEntityDescription, VeSyncSensorEntityDescriptionMixin +): + """Describe VeSync sensor entity.""" + + exists_fn: Callable[[VeSyncDevice], bool] = lambda _: True + update_fn: Callable[[VeSyncDevice], None] = lambda _: None + + +def update_energy(device): + """Update outlet details and energy usage.""" + device.update() + device.update_energy() + + +def sku_supported(device, supported): + """Get the base device of which a device is an instance.""" + return SKU_TO_BASE_DEVICE.get(device.device_type) in supported + + +def ha_dev_type(device): + """Get the homeassistant device_type for a given device.""" + return DEV_TYPE_TO_HA.get(device.device_type) + + +FILTER_LIFE_SUPPORTED = ["LV-PUR131S", "Core200S", "Core300S", "Core400S", "Core600S"] +AIR_QUALITY_SUPPORTED = ["LV-PUR131S", "Core400S", "Core600S"] +PM25_SUPPORTED = ["Core400S", "Core600S"] + +SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( + VeSyncSensorEntityDescription( + key="filter-life", + name="Filter Life", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.details["filter_life"], + exists_fn=lambda device: sku_supported(device, FILTER_LIFE_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="air-quality", + name="Air Quality", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["air_quality"], + exists_fn=lambda device: sku_supported(device, AIR_QUALITY_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="pm25", + name="PM2.5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["air_quality_value"], + exists_fn=lambda device: sku_supported(device, PM25_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="power", + name="current power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["power"], + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy", + name="energy use today", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.details["energy"], + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -44,107 +138,33 @@ 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(dev.device_type) != "outlet": - # Not an outlet that supports energy/power, so do not create sensor entities - continue - entities.append(VeSyncPowerSensor(dev)) - entities.append(VeSyncEnergySensor(dev)) - + for description in SENSORS: + if description.exists_fn(dev): + entities.append(VeSyncSensorEntity(dev, description)) async_add_entities(entities, update_before_add=True) class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): - """Representation of a sensor describing diagnostics of a VeSync outlet.""" + """Representation of a sensor describing a VeSync device.""" - def __init__(self, plug): + entity_description: VeSyncSensorEntityDescription + + def __init__( + self, + device: VeSyncDevice, + description: VeSyncSensorEntityDescription, + ) -> None: """Initialize the VeSync outlet device.""" - super().__init__(plug) - self.smartplug = plug + super().__init__(device) + self.entity_description = description + self._attr_name = f"{super().name} {description.name}" + self._attr_unique_id = f"{super().unique_id}-{description.key}" @property - def entity_category(self): - """Return the diagnostic entity category.""" - return EntityCategory.DIAGNOSTIC + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) - -class VeSyncPowerSensor(VeSyncSensorEntity): - """Representation of current power use for a VeSync outlet.""" - - @property - def unique_id(self): - """Return unique ID for power sensor on device.""" - return f"{super().unique_id}-power" - - @property - def name(self): - """Return sensor name.""" - return f"{super().name} current power" - - @property - def device_class(self): - """Return the power device class.""" - return SensorDeviceClass.POWER - - @property - def native_value(self): - """Return the current power usage in W.""" - return self.smartplug.power - - @property - def native_unit_of_measurement(self): - """Return the Watt unit of measurement.""" - return POWER_WATT - - @property - def state_class(self): - """Return the measurement state class.""" - return SensorStateClass.MEASUREMENT - - def update(self): - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() - - -class VeSyncEnergySensor(VeSyncSensorEntity): - """Representation of current day's energy use for a VeSync outlet.""" - - def __init__(self, plug): - """Initialize the VeSync outlet device.""" - super().__init__(plug) - self.smartplug = plug - - @property - def unique_id(self): - """Return unique ID for power sensor on device.""" - return f"{super().unique_id}-energy" - - @property - def name(self): - """Return sensor name.""" - return f"{super().name} energy use today" - - @property - def device_class(self): - """Return the energy device class.""" - return SensorDeviceClass.ENERGY - - @property - def native_value(self): - """Return the today total energy usage in kWh.""" - return self.smartplug.energy_today - - @property - def native_unit_of_measurement(self): - """Return the kWh unit of measurement.""" - return ENERGY_KILO_WATT_HOUR - - @property - def state_class(self): - """Return the total_increasing state class.""" - return SensorStateClass.TOTAL_INCREASING - - def update(self): - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() + def update(self) -> None: + """Run the update function defined for the sensor.""" + return self.entity_description.update_fn(self.device) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 282f8d99817..e5fd4c829fe 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -8,20 +8,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_SWITCHES +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_SWITCHES _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "wifi-switch-1.3": "outlet", - "ESW03-USA": "outlet", - "ESW01-EU": "outlet", - "ESW15-USA": "outlet", - "ESWL01": "switch", - "ESWL03": "switch", - "ESO15-TB": "outlet", -} - async def async_setup_entry( hass: HomeAssistant,