From 38d008bb66d061c1dce2a799ac35e186b13f2083 Mon Sep 17 00:00:00 2001 From: Indu Prakash <6459774+iprak@users.noreply.github.com> Date: Tue, 14 Jan 2025 07:33:48 -0600 Subject: [PATCH] Add vesync number platform (#135564) --- homeassistant/components/vesync/__init__.py | 1 + homeassistant/components/vesync/const.py | 1 + homeassistant/components/vesync/number.py | 114 ++++++++++++++++++ homeassistant/components/vesync/strings.json | 5 + tests/components/vesync/common.py | 4 + tests/components/vesync/conftest.py | 2 +- .../vesync/fixtures/humidifier-200s.json | 1 + tests/components/vesync/test_humidifier.py | 42 +++---- tests/components/vesync/test_init.py | 2 + tests/components/vesync/test_number.py | 66 ++++++++++ tests/components/vesync/test_sensor.py | 10 +- 11 files changed, 222 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/vesync/number.py create mode 100644 tests/components/vesync/test_number.py diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index db093d6802d..240a793f518 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -24,6 +24,7 @@ PLATFORMS = [ Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index e3a72a51658..841185e4308 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -22,6 +22,7 @@ exceeds the quota of 7700. VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" VS_MANAGER = "manager" +VS_NUMBERS = "numbers" VS_HUMIDIFIER_MODE_AUTO = "auto" VS_HUMIDIFIER_MODE_HUMIDITY = "humidity" diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py new file mode 100644 index 00000000000..3c43cce28cf --- /dev/null +++ b/homeassistant/components/vesync/number.py @@ -0,0 +1,114 @@ +"""Support for VeSync numeric entities.""" + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyvesync.vesyncbasedevice import VeSyncBaseDevice + +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) +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 is_humidifier +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .coordinator import VeSyncDataCoordinator +from .entity import VeSyncBaseEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class VeSyncNumberEntityDescription(NumberEntityDescription): + """Class to describe a Vesync number entity.""" + + exists_fn: Callable[[VeSyncBaseDevice], bool] + value_fn: Callable[[VeSyncBaseDevice], float] + set_value_fn: Callable[[VeSyncBaseDevice, float], bool] + + +NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [ + VeSyncNumberEntityDescription( + key="mist_level", + translation_key="mist_level", + native_min_value=1, + native_max_value=9, + native_step=1, + mode=NumberMode.SLIDER, + exists_fn=is_humidifier, + set_value_fn=lambda device, value: device.set_mist_level(value), + value_fn=lambda device: device.mist_level, + ) +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up number entities.""" + + coordinator = hass.data[DOMAIN][VS_COORDINATOR] + + @callback + def discover(devices): + """Add new devices to platform.""" + _setup_entities(devices, async_add_entities, coordinator) + + config_entry.async_on_unload( + async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) + ) + + _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + + +@callback +def _setup_entities( + devices: list[VeSyncBaseDevice], + async_add_entities: AddEntitiesCallback, + coordinator: VeSyncDataCoordinator, +): + """Add number entities.""" + + async_add_entities( + VeSyncNumberEntity(dev, description, coordinator) + for dev in devices + for description in NUMBER_DESCRIPTIONS + if description.exists_fn(dev) + ) + + +class VeSyncNumberEntity(VeSyncBaseEntity, NumberEntity): + """A class to set numeric options on Vesync device.""" + + entity_description: VeSyncNumberEntityDescription + + def __init__( + self, + device: VeSyncBaseDevice, + description: VeSyncNumberEntityDescription, + coordinator: VeSyncDataCoordinator, + ) -> None: + """Initialize the VeSync number device.""" + super().__init__(device, coordinator) + self.entity_description = description + self._attr_unique_id = f"{super().unique_id}-{description.key}" + + @property + def native_value(self) -> float: + """Return the value reported by the number.""" + return self.entity_description.value_fn(self.device) + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + if await self.hass.async_add_executor_job( + self.entity_description.set_value_fn, self.device, value + ): + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index b6e4e2fd957..a23fe7936e7 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -43,6 +43,11 @@ "name": "Current voltage" } }, + "number": { + "mist_level": { + "name": "Mist level" + } + }, "fan": { "vesync": { "state_attributes": { diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index 2c2ec9a5d1d..ead3ecdc173 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -10,6 +10,10 @@ from homeassistant.util.json import JsonObjectType from tests.common import load_fixture, load_json_object_fixture +ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" +ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" +ENTITY_HUMIDIFIER_HUMIDITY = "sensor.humidifier_200s_humidity" + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 9bc0888e8f5..8272da8dfad 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -115,7 +115,7 @@ def humidifier_fixture(): async def humidifier_config_entry( hass: HomeAssistant, requests_mock: requests_mock.Mocker, config ) -> MockConfigEntry: - """Create a mock VeSync config entry for Humidifier 200s.""" + """Create a mock VeSync config entry for `Humidifier 200s`.""" entry = MockConfigEntry( title="VeSync", domain=DOMAIN, diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-200s.json index 668072db0ea..a0a98bde110 100644 --- a/tests/components/vesync/fixtures/humidifier-200s.json +++ b/tests/components/vesync/fixtures/humidifier-200s.json @@ -3,6 +3,7 @@ "result": { "result": { "humidity": 35, + "mist_level": 6, "mist_virtual_level": 6, "mode": "manual", "water_lacks": true, diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index 5251e977c75..e3ab42993db 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -1,4 +1,4 @@ -"""Tests for the humidifer module.""" +"""Tests for the humidifier platform.""" from contextlib import nullcontext from unittest.mock import patch @@ -22,6 +22,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from .common import ( + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, +) + from tests.common import MockConfigEntry NoException = nullcontext() @@ -32,10 +38,10 @@ async def test_humidifier_state( ) -> None: """Test the resulting setup state is as expected for the platform.""" - humidifier_id = "humidifier.humidifier_200s" expected_entities = [ - humidifier_id, - "sensor.humidifier_200s_humidity", + ENTITY_HUMIDIFIER, + ENTITY_HUMIDIFIER_HUMIDITY, + ENTITY_HUMIDIFIER_MIST_LEVEL, ] assert humidifier_config_entry.state is ConfigEntryState.LOADED @@ -43,9 +49,7 @@ async def test_humidifier_state( for entity_id in expected_entities: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - assert hass.states.get("sensor.humidifier_200s_humidity").state == "35" - - state = hass.states.get(humidifier_id) + state = hass.states.get(ENTITY_HUMIDIFIER) # ATTR_HUMIDITY represents the target_humidity which comes from configuration.auto_target_humidity node assert state.attributes.get(ATTR_HUMIDITY) == 40 @@ -57,8 +61,6 @@ async def test_set_target_humidity_invalid( ) -> None: """Test handling of invalid value in set_humidify method.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # Setting value out of range results in ServiceValidationError and # VeSyncHumid200300S.set_humidity does not get called. with ( @@ -68,7 +70,7 @@ async def test_set_target_humidity_invalid( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_HUMIDITY: 20}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 20}, blocking=True, ) await hass.async_block_till_done() @@ -79,7 +81,7 @@ async def test_set_target_humidity_invalid( ("api_response", "expectation"), [(True, NoException), (False, pytest.raises(HomeAssistantError))], ) -async def test_set_target_humidity_VeSync( +async def test_set_target_humidity( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, api_response: bool, @@ -87,8 +89,6 @@ async def test_set_target_humidity_VeSync( ) -> None: """Test handling of return value from VeSyncHumid200300S.set_humidity.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # If VeSyncHumid200300S.set_humidity fails (returns False), then HomeAssistantError is raised with ( expectation, @@ -100,7 +100,7 @@ async def test_set_target_humidity_VeSync( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_HUMIDITY, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_HUMIDITY: 54}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_HUMIDITY: 54}, blocking=True, ) await hass.async_block_till_done() @@ -125,8 +125,6 @@ async def test_turn_on_off( ) -> None: """Test turn_on/off methods.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # turn_on/turn_off returns False indicating failure in which case humidifier.turn_on/turn_off # raises HomeAssistantError. with ( @@ -139,7 +137,7 @@ async def test_turn_on_off( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_TURN_ON if turn_on else SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: humidifier_entity_id}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER}, blocking=True, ) @@ -153,8 +151,6 @@ async def test_set_mode_invalid( ) -> None: """Test handling of invalid value in set_mode method.""" - humidifier_entity_id = "humidifier.humidifier_200s" - with patch( "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" ) as method_mock: @@ -162,7 +158,7 @@ async def test_set_mode_invalid( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_MODE: "something_invalid"}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "something_invalid"}, blocking=True, ) await hass.async_block_till_done() @@ -173,7 +169,7 @@ async def test_set_mode_invalid( ("api_response", "expectation"), [(True, NoException), (False, pytest.raises(HomeAssistantError))], ) -async def test_set_mode_VeSync( +async def test_set_mode( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, api_response: bool, @@ -181,8 +177,6 @@ async def test_set_mode_VeSync( ) -> None: """Test handling of value in set_mode method.""" - humidifier_entity_id = "humidifier.humidifier_200s" - # If VeSyncHumid200300S.set_humidity_mode fails (returns False), then HomeAssistantError is raised with ( expectation, @@ -194,7 +188,7 @@ async def test_set_mode_VeSync( await hass.services.async_call( HUMIDIFIER_DOMAIN, SERVICE_SET_MODE, - {ATTR_ENTITY_ID: humidifier_entity_id, ATTR_MODE: "auto"}, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER, ATTR_MODE: "auto"}, blocking=True, ) await hass.async_block_till_done() diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index 7e2603b7401..3b0df128240 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -51,6 +51,7 @@ async def test_async_setup_entry__no_devices( Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] @@ -80,6 +81,7 @@ async def test_async_setup_entry__loads_fans( Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/tests/components/vesync/test_number.py b/tests/components/vesync/test_number.py new file mode 100644 index 00000000000..a9230b76db0 --- /dev/null +++ b/tests/components/vesync/test_number.py @@ -0,0 +1,66 @@ +"""Tests for the number platform.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError + +from .common import ENTITY_HUMIDIFIER_MIST_LEVEL + +from tests.common import MockConfigEntry + + +async def test_set_mist_level_bad_range( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level invalid value.""" + with ( + pytest.raises(ServiceValidationError), + patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock, + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "10"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_not_called() + + +async def test_set_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test set_mist_level usage.""" + + with patch( + "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + return_value=True, + ) as method_mock: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_HUMIDIFIER_MIST_LEVEL, ATTR_VALUE: "3"}, + blocking=True, + ) + await hass.async_block_till_done() + method_mock.assert_called_once() + + +async def test_mist_level( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of mist_level number entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_MIST_LEVEL).state == "6" diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index bd3a8eb8591..04d759de584 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .common import ALL_DEVICE_NAMES, mock_devices_response +from .common import ALL_DEVICE_NAMES, ENTITY_HUMIDIFIER_HUMIDITY, mock_devices_response from tests.common import MockConfigEntry @@ -49,3 +49,11 @@ async def test_sensor_state( # Check states for entity in entities: assert hass.states.get(entity.entity_id) == snapshot(name=entity.entity_id) + + +async def test_humidity( + hass: HomeAssistant, humidifier_config_entry: MockConfigEntry +) -> None: + """Test the state of humidity sensor entity.""" + + assert hass.states.get(ENTITY_HUMIDIFIER_HUMIDITY).state == "35"