From a41ab5097dfaf048c2fb6566dbcd81254cad47f6 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 9 Jan 2026 01:10:31 +0000 Subject: [PATCH 01/19] bumpPyvesync --- homeassistant/components/vesync/const.py | 2 -- homeassistant/components/vesync/fan.py | 6 +----- homeassistant/components/vesync/manifest.json | 2 +- homeassistant/components/vesync/strings.json | 1 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../vesync/snapshots/test_diagnostics.ambr | 3 +++ tests/components/vesync/snapshots/test_fan.ambr | 12 ++++++------ tests/components/vesync/test_fan.py | 4 ++-- 9 files changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index e7a7266216db9f..b134b442eb5e54 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -32,7 +32,6 @@ VS_FAN_MODE_AUTO = "auto" VS_FAN_MODE_SLEEP = "sleep" -VS_FAN_MODE_ADVANCED_SLEEP = "advancedSleep" VS_FAN_MODE_TURBO = "turbo" VS_FAN_MODE_PET = "pet" VS_FAN_MODE_MANUAL = "manual" @@ -42,7 +41,6 @@ VS_FAN_MODE_PRESET_LIST_HA = [ VS_FAN_MODE_AUTO, VS_FAN_MODE_SLEEP, - VS_FAN_MODE_ADVANCED_SLEEP, VS_FAN_MODE_TURBO, VS_FAN_MODE_PET, VS_FAN_MODE_NORMAL, diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 75bfb203bda1c2..85acf5d11e58ad 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -22,7 +22,6 @@ from .const import ( VS_DEVICES, VS_DISCOVERY, - VS_FAN_MODE_ADVANCED_SLEEP, VS_FAN_MODE_AUTO, VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL, @@ -40,7 +39,6 @@ VS_TO_HA_MODE_MAP = { VS_FAN_MODE_AUTO: VS_FAN_MODE_AUTO, VS_FAN_MODE_SLEEP: VS_FAN_MODE_SLEEP, - VS_FAN_MODE_ADVANCED_SLEEP: "advanced_sleep", VS_FAN_MODE_TURBO: VS_FAN_MODE_TURBO, VS_FAN_MODE_PET: VS_FAN_MODE_PET, VS_FAN_MODE_MANUAL: VS_FAN_MODE_MANUAL, @@ -206,7 +204,7 @@ def extra_state_attributes(self) -> dict[str, Any]: self.device.state.nightlight_status, "value", None ) if hasattr(self.device.state, "mode"): - attr["mode"] = self.device.state.mode + attr["mode"] = getattr(self.device.state.mode, "value", None) return attr @@ -270,8 +268,6 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: success = await self.device.set_auto_mode() elif vs_mode == VS_FAN_MODE_SLEEP: success = await self.device.set_sleep_mode() - elif vs_mode == VS_FAN_MODE_ADVANCED_SLEEP: - success = await self.device.set_advanced_sleep_mode() elif vs_mode == VS_FAN_MODE_PET: success = await self.device.set_pet_mode() elif vs_mode == VS_FAN_MODE_TURBO: diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 82da2fff6001ac..66b07daa7d3fe5 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==3.3.3"] + "requirements": ["pyvesync==3.4.0"] } diff --git a/homeassistant/components/vesync/strings.json b/homeassistant/components/vesync/strings.json index c6d38456e9e9ac..1172f80d353355 100644 --- a/homeassistant/components/vesync/strings.json +++ b/homeassistant/components/vesync/strings.json @@ -50,7 +50,6 @@ "state_attributes": { "preset_mode": { "state": { - "advanced_sleep": "Advanced sleep", "auto": "[%key:common::state::auto%]", "normal": "[%key:common::state::normal%]", "pet": "Pet", diff --git a/requirements_all.txt b/requirements_all.txt index c661a88da6ea1a..320ffb14d8be7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2651,7 +2651,7 @@ pyvera==0.3.16 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==3.3.3 +pyvesync==3.4.0 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12135ee9fa6ef8..95dfd2b6ee2c5d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2226,7 +2226,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.16 # homeassistant.components.vesync -pyvesync==3.3.3 +pyvesync==3.4.0 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index e6eb53ac54ec0e..99dac5ed53f63a 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -115,17 +115,20 @@ 'to_json': 'Method', 'to_jsonb': 'Method', 'toggle_automatic_stop': 'Method', + 'toggle_child_lock': 'Method', 'toggle_display': 'Method', 'toggle_drying_mode': 'Method', 'toggle_nightlight': 'Method', 'toggle_switch': 'Method', 'turn_off': 'Method', 'turn_off_automatic_stop': 'Method', + 'turn_off_child_lock': 'Method', 'turn_off_display': 'Method', 'turn_off_drying_mode': 'Method', 'turn_off_nightlight': 'Method', 'turn_on': 'Method', 'turn_on_automatic_stop': 'Method', + 'turn_on_child_lock': 'Method', 'turn_on_display': 'Method', 'turn_on_drying_mode': 'Method', 'turn_on_nightlight': 'Method', diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 8984725ac7aa96..c57d7d65bf3fe3 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -80,7 +80,7 @@ 'active_time': 0, 'display_status': 'on', 'friendly_name': 'Air Purifier 131s', - 'mode': 'sleep', + 'mode': None, 'percentage': None, 'percentage_step': 33.333333333333336, 'preset_mode': 'sleep', @@ -179,7 +179,7 @@ 'child_lock': False, 'display_status': 'on', 'friendly_name': 'Air Purifier 200s', - 'mode': 'manual', + 'mode': None, 'night_light': 'off', 'percentage': 33, 'percentage_step': 33.333333333333336, @@ -279,7 +279,7 @@ 'child_lock': False, 'display_status': 'on', 'friendly_name': 'Air Purifier 400s', - 'mode': 'manual', + 'mode': None, 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, @@ -380,7 +380,7 @@ 'child_lock': False, 'display_status': 'on', 'friendly_name': 'Air Purifier 600s', - 'mode': 'manual', + 'mode': None, 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, @@ -662,9 +662,9 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - 'advanced_sleep', 'auto', 'normal', + 'sleep', 'turbo', ]), }), @@ -710,9 +710,9 @@ 'percentage_step': 8.333333333333334, 'preset_mode': 'normal', 'preset_modes': list([ - 'advanced_sleep', 'auto', 'normal', + 'sleep', 'turbo', ]), 'supported_features': , diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index 731045ac10abe9..65505af4ee8522 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -143,8 +143,8 @@ async def test_turn_on_off_raises_error( [ ("normal", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_normal_mode"), ( - "advanced_sleep", - "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_advanced_sleep_mode", + "sleep", + "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_sleep_mode", ), ("turbo", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_turbo_mode"), ("auto", "pyvesync.devices.vesyncfan.VeSyncTowerFan.set_auto_mode"), From 8861d83013415a79c7a2e2538d9c47ad8e3bb09e Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 9 Jan 2026 05:48:14 +0000 Subject: [PATCH 02/19] HumidiferType --- homeassistant/components/vesync/humidifier.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 0ecaeb4e58d26f..8121ec5e196f66 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -3,6 +3,7 @@ import logging from typing import Any +from pyvesync.base_devices.humidifier_base import VeSyncHumidifier from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.humidifier import ( @@ -68,7 +69,7 @@ def discover(devices: list[VeSyncBaseDevice]) -> None: @callback def _setup_entities( - devices: list[VeSyncBaseDevice], + devices: list[VeSyncHumidifier], async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ) -> None: @@ -95,7 +96,7 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): def __init__( self, - device: VeSyncBaseDevice, + device: VeSyncHumidifier, coordinator: VeSyncDataCoordinator, ) -> None: """Initialize the VeSyncHumidifierHA device.""" From f78abcf66ce23df1d277effd76b90bde77bbf837 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sat, 10 Jan 2026 01:53:22 +0000 Subject: [PATCH 03/19] mypy fixes --- homeassistant/components/vesync/humidifier.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 8121ec5e196f66..c73b2dec44ca78 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -101,7 +101,7 @@ def __init__( ) -> None: """Initialize the VeSyncHumidifierHA device.""" super().__init__(device, coordinator) - + self.device: VeSyncHumidifier = device # 2 Vesync humidifier modes (humidity and auto) maps to the HA mode auto. # They are on different devices though. We need to map HA mode to the # device specific mode when setting it. @@ -120,8 +120,8 @@ def __init__( self._available_modes.sort() - def _get_vs_mode(self, ha_mode: str) -> str | None: - return self._ha_to_vs_mode_map.get(ha_mode) + def _get_vs_mode(self, ha_mode: str) -> str: + return self._ha_to_vs_mode_map[ha_mode] @property def available_modes(self) -> list[str]: @@ -129,12 +129,12 @@ def available_modes(self) -> list[str]: return self._available_modes @property - def current_humidity(self) -> int: + def current_humidity(self) -> int | None: """Return the current humidity.""" return self.device.state.humidity @property - def target_humidity(self) -> int: + def target_humidity(self) -> int | None: """Return the humidity we try to reach.""" return self.device.state.auto_humidity @@ -150,7 +150,9 @@ def mode(self) -> str | None: async def async_set_humidity(self, humidity: int) -> None: """Set the target humidity of the device.""" if not await self.device.set_humidity(humidity): - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to set humidity.") async def async_set_mode(self, mode: str) -> None: """Set the mode of the device.""" @@ -158,8 +160,13 @@ async def async_set_mode(self, mode: str) -> None: raise HomeAssistantError( f"Invalid mode {mode}. Available modes: {self.available_modes}" ) + set_mode = self._get_vs_mode(mode) + if set_mode is None: + raise HomeAssistantError(f"Could not map mode {mode} to VeSync mode.") if not await self.device.set_mode(self._get_vs_mode(mode)): - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to set mode.") if mode == MODE_SLEEP: # We successfully changed the mode. Consider it a success even if display operation fails. @@ -175,7 +182,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" success = await self.device.turn_on() if not success: - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to turn on humidifier.") self.async_write_ha_state() @@ -183,7 +192,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" success = await self.device.turn_off() if not success: - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to turn off humidifier.") self.async_write_ha_state() From eb0f497a6e63a78a4e384d28d881c7a7adfc8cb9 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sat, 10 Jan 2026 03:07:43 +0000 Subject: [PATCH 04/19] Light mypy --- homeassistant/components/vesync/light.py | 36 ++++++++---------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 9b56274634a91b..afdcadb111235e 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -76,6 +76,7 @@ def _setup_entities( class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity): """Base class for VeSync Light Devices Representations.""" + device: VeSyncBulb | VeSyncSwitch _attr_name = None @property @@ -86,26 +87,21 @@ def is_on(self) -> bool: @property def brightness(self) -> int: """Get light brightness.""" - # get value from pyvesync library api, - result = self.device.state.brightness - try: - # check for validity of brightness value received - brightness_value = int(result) - except ValueError: - # deal if any unexpected/non numeric value + if self.device.state.brightness is None: _LOGGER.debug( - "VeSync - received unexpected 'brightness' value from pyvesync api: %s", - result, + "VeSync - received unexpected 'brightness' value from pyvesync api of None" ) return 0 + # convert percent brightness to ha expected range - return round((max(1, brightness_value) / 100) * 255) + return round((max(1, self.device.state.brightness) / 100) * 255) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" attribute_adjustment_only = False # set white temperature if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN in kwargs: + self.device: VeSyncBulb # get white temperature from HA data color_temp = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] @@ -165,6 +161,7 @@ class VeSyncDimmableLightHA(VeSyncBaseLightHA, LightEntity): class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity): """Representation of a VeSync Tunable White Light device.""" + device: VeSyncBulb _attr_color_mode = ColorMode.COLOR_TEMP _attr_min_color_temp_kelvin = 2700 # 370 Mireds _attr_max_color_temp_kelvin = 6500 # 153 Mireds @@ -173,24 +170,15 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity): @property def color_temp_kelvin(self) -> int | None: """Return the color temperature value in Kelvin.""" - # get value from pyvesync library api # pyvesync v3 provides BulbState.color_temp_kelvin() - possible to use that instead? - result = self.device.state.color_temp - try: - # check for validity of brightness value received - color_temp_value = int(result) - except ValueError: - # deal if any unexpected/non numeric value + if self.device.state.color_temp is None: _LOGGER.debug( - ( - "VeSync - received unexpected 'color_temp_pct' value from pyvesync" - " api: %s" - ), - result, + "VeSync - received unexpected 'color_temp' value from pyvesync api of None" ) - return None + return 0 + # flip cold/warm - color_temp_value = 100 - color_temp_value + color_temp_value = 100 - self.device.state.color_temp # ensure value between 0-100 color_temp_value = max(0, min(color_temp_value, 100)) # convert percent value to Mireds From cb9d24d25328e18ad8612c5cbfcada39226d3a4e Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 10 Jan 2026 05:08:02 +0000 Subject: [PATCH 05/19] fix typing --- homeassistant/components/vesync/entity.py | 10 ++- homeassistant/components/vesync/fan.py | 77 ++++++++++++------- homeassistant/components/vesync/humidifier.py | 5 +- homeassistant/components/vesync/light.py | 16 ++-- 4 files changed, 68 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py index 8ace861eb3fe14..92608a1f388a79 100644 --- a/homeassistant/components/vesync/entity.py +++ b/homeassistant/components/vesync/entity.py @@ -1,5 +1,7 @@ """Common entity for VeSync Component.""" +from typing import Generic, TypeVar + from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.helpers.device_registry import DeviceInfo @@ -8,15 +10,15 @@ from .const import DOMAIN from .coordinator import VeSyncDataCoordinator +T = TypeVar("T", bound=VeSyncBaseDevice) + -class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator]): +class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator], Generic[T]): """Base class for VeSync Entity Representations.""" _attr_has_entity_name = True - def __init__( - self, device: VeSyncBaseDevice, coordinator: VeSyncDataCoordinator - ) -> None: + def __init__(self, device: T, coordinator: VeSyncDataCoordinator) -> None: """Initialize the VeSync device.""" super().__init__(coordinator) self.device = device diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 85acf5d11e58ad..d4de63fd98102c 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -5,8 +5,7 @@ import logging from typing import Any -from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice -from pyvesync.device_container import DeviceContainer +from pyvesync.base_devices import VeSyncFanBase, VeSyncPurifier from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback @@ -58,7 +57,7 @@ async def async_setup_entry( coordinator = config_entry.runtime_data @callback - def discover(devices: list[VeSyncBaseDevice]) -> None: + def discover(devices: list[VeSyncFanBase | VeSyncPurifier]) -> None: """Add new devices to platform.""" _setup_entities(devices, async_add_entities, coordinator) @@ -67,17 +66,20 @@ def discover(devices: list[VeSyncBaseDevice]) -> None: ) _setup_entities( - config_entry.runtime_data.manager.devices, async_add_entities, coordinator + config_entry.runtime_data.manager.devices.air_purifiers + + config_entry.runtime_data.manager.devices.fans, + async_add_entities, + coordinator, ) @callback def _setup_entities( - devices: DeviceContainer | list[VeSyncBaseDevice], + devices: list[VeSyncFanBase | VeSyncPurifier], async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ) -> None: - """Check if device is fan and add entity.""" + """Check if device is fan or purifier and add entity.""" async_add_entities( VeSyncFanHA(dev, coordinator) @@ -93,7 +95,7 @@ def _get_ha_mode(vs_mode: str) -> str | None: return ha_mode -class VeSyncFanHA(VeSyncBaseEntity, FanEntity): +class VeSyncFanHA(VeSyncBaseEntity[VeSyncFanBase | VeSyncPurifier], FanEntity): """Representation of a VeSync fan.""" _attr_supported_features = ( @@ -107,7 +109,7 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): def __init__( self, - device: VeSyncBaseDevice, + device: VeSyncFanBase | VeSyncPurifier, coordinator: VeSyncDataCoordinator, ) -> None: """Initialize the fan.""" @@ -194,7 +196,10 @@ def extra_state_attributes(self) -> dict[str, Any]: hasattr(self.device.state, "child_lock") and self.device.state.child_lock is not None ): - attr["child_lock"] = self.device.state.child_lock + if isinstance(self.device.state.child_lock, bool): + attr["child_lock"] = int(self.device.state.child_lock) + else: + attr["child_lock"] = 0 if self.device.state.child_lock == "off" else 1 if ( hasattr(self.device.state, "nightlight_status") @@ -217,37 +222,47 @@ async def async_set_percentage(self, percentage: int) -> None: if percentage == 0: # Turning off is a special case: do not set speed or mode if not await self.device.turn_off(): - raise HomeAssistantError( - "An error occurred while turning off: " - + self.device.last_response.message - ) + if self.device.last_response: + raise HomeAssistantError( + "An error occurred while turning off: " + + self.device.last_response.message + ) + raise HomeAssistantError("Failed to turn off fan, no response found.") self.async_write_ha_state() return # If the fan is off, turn it on first if not self.device.is_on: if not await self.device.turn_on(): - raise HomeAssistantError( - "An error occurred while turning on: " - + self.device.last_response.message - ) + if self.device.last_response: + raise HomeAssistantError( + "An error occurred while turning on: " + + self.device.last_response.message + ) + raise HomeAssistantError("Failed to turn on fan, no response found.") # Switch to manual mode if not already set if self.device.state.mode not in (VS_FAN_MODE_MANUAL, VS_FAN_MODE_NORMAL): if not await self.device.set_manual_mode(): + if self.device.last_response: + raise HomeAssistantError( + "An error occurred while setting manual mode." + + self.device.last_response.message + ) raise HomeAssistantError( - "An error occurred while setting manual mode." - + self.device.last_response.message + "Failed to set manual mode, no response found." ) # Calculate the speed level and set it if not await self.device.set_fan_speed( percentage_to_ordered_list_item(self.device.fan_levels, percentage) ): - raise HomeAssistantError( - "An error occurred while changing fan speed: " - + self.device.last_response.message - ) + if self.device.last_response: + raise HomeAssistantError( + "An error occurred while changing fan speed: " + + self.device.last_response.message + ) + raise HomeAssistantError("Failed to set fan speed, no response found.") self.async_write_ha_state() @@ -276,7 +291,9 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: success = await self.device.set_normal_mode() if not success: - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to set preset mode, no response found.") self.async_write_ha_state() @@ -293,7 +310,9 @@ async def async_turn_on( if percentage is None: success = await self.device.turn_on() if not success: - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to turn on fan, no response found.") self.async_write_ha_state() else: await self.async_set_percentage(percentage) @@ -302,12 +321,16 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" success = await self.device.turn_off() if not success: - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to turn off fan, no response found.") self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation.""" success = await self.device.toggle_oscillation(oscillating) if not success: - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Failed to set oscillation, no response found.") self.async_write_ha_state() diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index c73b2dec44ca78..7b49e82b157d8f 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -4,7 +4,6 @@ from typing import Any from pyvesync.base_devices.humidifier_base import VeSyncHumidifier -from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.humidifier import ( MODE_AUTO, @@ -52,7 +51,7 @@ async def async_setup_entry( coordinator = config_entry.runtime_data @callback - def discover(devices: list[VeSyncBaseDevice]) -> None: + def discover(devices: list[VeSyncHumidifier]) -> None: """Add new devices to platform.""" _setup_entities(devices, async_add_entities, coordinator) @@ -84,7 +83,7 @@ def _get_ha_mode(vs_mode: str) -> str | None: return ha_mode -class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): +class VeSyncHumidifierHA(VeSyncBaseEntity[VeSyncHumidifier], HumidifierEntity): """Representation of a VeSync humidifier.""" # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index afdcadb111235e..b873da7f4d9c58 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -5,8 +5,6 @@ from pyvesync.base_devices.bulb_base import VeSyncBulb from pyvesync.base_devices.switch_base import VeSyncSwitch -from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice -from pyvesync.device_container import DeviceContainer from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -40,7 +38,7 @@ async def async_setup_entry( coordinator = config_entry.runtime_data @callback - def discover(devices: list[VeSyncBaseDevice]) -> None: + def discover(devices: list[VeSyncBulb | VeSyncSwitch]) -> None: """Add new devices to platform.""" _setup_entities(devices, async_add_entities, coordinator) @@ -49,13 +47,16 @@ def discover(devices: list[VeSyncBaseDevice]) -> None: ) _setup_entities( - config_entry.runtime_data.manager.devices, async_add_entities, coordinator + config_entry.runtime_data.manager.devices.bulbs + + config_entry.runtime_data.manager.devices.switches, + async_add_entities, + coordinator, ) @callback def _setup_entities( - devices: DeviceContainer | list[VeSyncBaseDevice], + devices: list[VeSyncBulb | VeSyncSwitch], async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ) -> None: @@ -73,7 +74,7 @@ def _setup_entities( async_add_entities(entities, update_before_add=True) -class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity): +class VeSyncBaseLightHA(VeSyncBaseEntity[VeSyncSwitch | VeSyncBulb], LightEntity): """Base class for VeSync Light Devices Representations.""" device: VeSyncBulb | VeSyncSwitch @@ -170,6 +171,9 @@ class VeSyncTunableWhiteLightHA(VeSyncBaseLightHA, LightEntity): @property def color_temp_kelvin(self) -> int | None: """Return the color temperature value in Kelvin.""" + if hasattr(self.device.state, "color_temp") is False: + return None + # pyvesync v3 provides BulbState.color_temp_kelvin() - possible to use that instead? if self.device.state.color_temp is None: _LOGGER.debug( From 0e8f74580dc86106b248fad4be537e61c7ddf26a Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 10 Jan 2026 22:41:14 +0000 Subject: [PATCH 06/19] typing and tests --- homeassistant/components/vesync/common.py | 22 +++--- homeassistant/components/vesync/fan.py | 33 ++++++--- homeassistant/components/vesync/humidifier.py | 13 +++- homeassistant/components/vesync/light.py | 7 +- homeassistant/components/vesync/number.py | 20 +++++- homeassistant/components/vesync/select.py | 33 +++++++-- homeassistant/components/vesync/switch.py | 68 +++++++++++++++---- tests/components/vesync/conftest.py | 9 ++- 8 files changed, 156 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index eaad7aded39eaf..bf7794a254c211 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -1,12 +1,14 @@ """Common utilities for VeSync Component.""" import logging +from typing import TypeGuard from pyvesync.base_devices import VeSyncHumidifier from pyvesync.base_devices.fan_base import VeSyncFanBase from pyvesync.base_devices.outlet_base import VeSyncOutlet from pyvesync.base_devices.purifier_base import VeSyncPurifier from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.const import ProductTypes from pyvesync.devices.vesyncswitch import VeSyncWallSwitch _LOGGER = logging.getLogger(__name__) @@ -34,31 +36,31 @@ def rgetattr(obj: object, attr: str): return obj -def is_humidifier(device: VeSyncBaseDevice) -> bool: +def is_humidifier(device: VeSyncBaseDevice) -> TypeGuard[VeSyncHumidifier]: """Check if the device represents a humidifier.""" - return isinstance(device, VeSyncHumidifier) + return device.product_type == ProductTypes.HUMIDIFIER -def is_fan(device: VeSyncBaseDevice) -> bool: +def is_fan(device: VeSyncBaseDevice) -> TypeGuard[VeSyncFanBase]: """Check if the device represents a fan.""" - return isinstance(device, VeSyncFanBase) + return device.product_type == ProductTypes.FAN -def is_outlet(device: VeSyncBaseDevice) -> bool: +def is_outlet(device: VeSyncBaseDevice) -> TypeGuard[VeSyncOutlet]: """Check if the device represents an outlet.""" - return isinstance(device, VeSyncOutlet) + return device.product_type == ProductTypes.OUTLET -def is_wall_switch(device: VeSyncBaseDevice) -> bool: +def is_wall_switch(device: VeSyncBaseDevice) -> TypeGuard[VeSyncWallSwitch]: """Check if the device represents a wall switch, note this doessn't include dimming switches.""" - return isinstance(device, VeSyncWallSwitch) + return device.product_type == ProductTypes.SWITCH -def is_purifier(device: VeSyncBaseDevice) -> bool: +def is_purifier(device: VeSyncBaseDevice) -> TypeGuard[VeSyncPurifier]: """Check if the device represents an air purifier.""" - return isinstance(device, VeSyncPurifier) + return device.product_type == ProductTypes.PURIFIER diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index d4de63fd98102c..f1698867434ce4 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -59,7 +59,11 @@ async def async_setup_entry( @callback def discover(devices: list[VeSyncFanBase | VeSyncPurifier]) -> None: """Add new devices to platform.""" - _setup_entities(devices, async_add_entities, coordinator) + _setup_entities( + [dev for dev in devices if is_fan(dev) or is_purifier(dev)], + async_add_entities, + coordinator, + ) config_entry.async_on_unload( async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) @@ -197,9 +201,9 @@ def extra_state_attributes(self) -> dict[str, Any]: and self.device.state.child_lock is not None ): if isinstance(self.device.state.child_lock, bool): - attr["child_lock"] = int(self.device.state.child_lock) + attr["child_lock"] = self.device.state.child_lock else: - attr["child_lock"] = 0 if self.device.state.child_lock == "off" else 1 + attr["child_lock"] = self.device.state.child_lock != "off" if ( hasattr(self.device.state, "nightlight_status") @@ -284,11 +288,13 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: elif vs_mode == VS_FAN_MODE_SLEEP: success = await self.device.set_sleep_mode() elif vs_mode == VS_FAN_MODE_PET: - success = await self.device.set_pet_mode() + if hasattr(self.device, "set_pet_mode"): + success = await self.device.set_pet_mode() elif vs_mode == VS_FAN_MODE_TURBO: success = await self.device.set_turbo_mode() elif vs_mode == VS_FAN_MODE_NORMAL: - success = await self.device.set_normal_mode() + if hasattr(self.device, "set_normal_mode"): + success = await self.device.set_normal_mode() if not success: if self.device.last_response: @@ -328,9 +334,14 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation.""" - success = await self.device.toggle_oscillation(oscillating) - if not success: - if self.device.last_response: - raise HomeAssistantError(self.device.last_response.message) - raise HomeAssistantError("Failed to set oscillation, no response found.") - self.async_write_ha_state() + if hasattr(self.device, "toggle_oscillation"): + success = await self.device.toggle_oscillation(oscillating) + if not success: + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError( + "Failed to set oscillation, no response found." + ) + self.async_write_ha_state() + else: + raise HomeAssistantError("Oscillation not supported by this device.") diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 7b49e82b157d8f..ea3a887c7dbdd5 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -17,6 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .common import is_humidifier from .const import ( VS_DEVICES, VS_DISCOVERY, @@ -53,14 +54,22 @@ async def async_setup_entry( @callback def discover(devices: list[VeSyncHumidifier]) -> None: """Add new devices to platform.""" - _setup_entities(devices, async_add_entities, coordinator) + _setup_entities( + [dev for dev in devices if is_humidifier(dev)], + async_add_entities, + coordinator, + ) config_entry.async_on_unload( async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) _setup_entities( - config_entry.runtime_data.manager.devices.humidifiers, + [ + dev + for dev in config_entry.runtime_data.manager.devices.humidifiers + if is_humidifier(dev) + ], async_add_entities, coordinator, ) diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index b873da7f4d9c58..e10dde2670964c 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -101,8 +101,11 @@ async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" attribute_adjustment_only = False # set white temperature - if self.color_mode == ColorMode.COLOR_TEMP and ATTR_COLOR_TEMP_KELVIN in kwargs: - self.device: VeSyncBulb + if ( + self.color_mode == ColorMode.COLOR_TEMP + and ATTR_COLOR_TEMP_KELVIN in kwargs + and hasattr(self.device, "set_color_temp") + ): # get white temperature from HA data color_temp = color_util.color_temperature_kelvin_to_mired( kwargs[ATTR_COLOR_TEMP_KELVIN] diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py index 109774bb6727b0..6793322e7b26ed 100644 --- a/homeassistant/components/vesync/number.py +++ b/homeassistant/components/vesync/number.py @@ -27,6 +27,20 @@ PARALLEL_UPDATES = 1 +def _mist_levels(device: VeSyncBaseDevice) -> list[int]: + """Check if the device supports mist level adjustment.""" + if is_humidifier(device): + return device.mist_levels + raise HomeAssistantError("Device does not support mist level adjustment.") + + +def _set_mist_level(device: VeSyncBaseDevice, value: float) -> Awaitable[bool]: + """Set mist level on humidifier.""" + if is_humidifier(device): + return device.set_mist_level(int(value)) + raise HomeAssistantError("Device does not support mist level adjustment.") + + @dataclass(frozen=True, kw_only=True) class VeSyncNumberEntityDescription(NumberEntityDescription): """Class to describe a Vesync number entity.""" @@ -42,12 +56,12 @@ class VeSyncNumberEntityDescription(NumberEntityDescription): VeSyncNumberEntityDescription( key="mist_level", translation_key="mist_level", - native_min_value_fn=lambda device: min(device.mist_levels), - native_max_value_fn=lambda device: max(device.mist_levels), + native_min_value_fn=lambda device: min(_mist_levels(device)), + native_max_value_fn=lambda device: max(_mist_levels(device)), native_step=1, mode=NumberMode.SLIDER, exists_fn=is_humidifier, - set_value_fn=lambda device, value: device.set_mist_level(value), + set_value_fn=_set_mist_level, value_fn=lambda device: device.state.mist_virtual_level, ) ] diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py index 4da5ff98aba201..de6a70d35769c3 100644 --- a/homeassistant/components/vesync/select.py +++ b/homeassistant/components/vesync/select.py @@ -4,7 +4,7 @@ from dataclasses import dataclass import logging -from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices import VeSyncBaseDevice from pyvesync.device_container import DeviceContainer from homeassistant.components.select import SelectEntity, SelectEntityDescription @@ -45,6 +45,27 @@ PARALLEL_UPDATES = 1 +def _set_humidifier_nightlight(device: VeSyncBaseDevice, *args) -> Awaitable[bool]: + """Toggle humidifier nightlight on.""" + if is_humidifier(device): + return device.set_nightlight_brightness(*args) + raise HomeAssistantError("Device does not support toggling nightlight.") + + +def _toggle_purifier_nightlight(device: VeSyncBaseDevice, *args) -> Awaitable[bool]: + """Toggle air purifier nightlight on.""" + if is_purifier(device): + return device.set_nightlight_mode(*args) + raise HomeAssistantError("Device does not support toggling nightlight.") + + +def _toggle_outlet_nightlight(device: VeSyncBaseDevice, *args) -> Awaitable[bool]: + """Toggle outlet nightlight on.""" + if is_outlet(device) and device.supports_nightlight: + return device.set_nightlight_state(*args) + raise HomeAssistantError("Device does not support toggling nightlight.") + + @dataclass(frozen=True, kw_only=True) class VeSyncSelectEntityDescription(SelectEntityDescription): """Class to describe a Vesync select entity.""" @@ -64,8 +85,8 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): exists_fn=lambda device: is_humidifier(device) and device.supports_nightlight, # The select_option service framework ensures that only options specified are # accepted. ServiceValidationError gets raised for invalid value. - select_option_fn=lambda device, value: device.set_nightlight_brightness( - HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) + select_option_fn=lambda device, value: _set_humidifier_nightlight( + device, HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get(value, 0) ), # Reporting "off" as the choice for unhandled level. current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( @@ -84,7 +105,9 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): ], icon="mdi:brightness-6", exists_fn=lambda device: is_purifier(device) and device.supports_nightlight, - select_option_fn=lambda device, value: device.set_nightlight_mode(value), + select_option_fn=lambda device, value: _toggle_purifier_nightlight( + device, value + ), current_option_fn=lambda device: device.state.nightlight_status, ), # night_light for outlets @@ -98,7 +121,7 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): ], icon="mdi:brightness-6", exists_fn=lambda device: is_outlet(device) and device.supports_nightlight, - select_option_fn=lambda device, value: device.set_nightlight_state(value), + select_option_fn=lambda device, value: _toggle_outlet_nightlight(device, value), current_option_fn=lambda device: device.state.nightlight_status, ), ] diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 701b425cd99dbf..043f656b7fff3d 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -5,7 +5,7 @@ import logging from typing import Any, Final -from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices import VeSyncBaseDevice, VeSyncHumidifier from pyvesync.device_container import DeviceContainer from homeassistant.components.switch import ( @@ -28,6 +28,38 @@ PARALLEL_UPDATES = 1 +def _toggle_switch(device: VeSyncBaseDevice, *args) -> Awaitable[bool]: + """Toggle power on.""" + if args and args[0] is True and hasattr(device, "turn_on"): + return device.turn_on() + if args and args[0] is False and hasattr(device, "turn_off"): + return device.turn_off() + raise HomeAssistantError("Device does not support toggling power.") + + +def _toggle_display(device: VeSyncBaseDevice, *args) -> Awaitable[bool]: + """Toggle display on.""" + if hasattr(device, "toggle_display"): + return device.toggle_display(*args) + raise HomeAssistantError("Device does not support toggling display.") + + +def _toggle_child_lock(device: VeSyncBaseDevice, *args) -> Awaitable[bool]: + """Toggle child lock on.""" + if hasattr(device, "toggle_child_lock"): + return device.toggle_child_lock(*args) + raise HomeAssistantError("Device does not support toggling child lock.") + + +def _toggle_auto_stop(device: VeSyncBaseDevice, *args) -> Awaitable[bool]: + """Toggle automatic stop on.""" + match device: + case VeSyncHumidifier() as sw if hasattr(sw, "toggle_automatic_stop"): + return sw.toggle_automatic_stop(*args) + case _: + raise HomeAssistantError("Device does not support toggling automatic stop.") + + @dataclass(frozen=True, kw_only=True) class VeSyncSwitchEntityDescription(SwitchEntityDescription): """A class that describes custom switch entities.""" @@ -45,8 +77,8 @@ class VeSyncSwitchEntityDescription(SwitchEntityDescription): # Other types of wall switches support dimming. Those use light.py platform. exists_fn=lambda device: is_wall_switch(device) or is_outlet(device), name=None, - on_fn=lambda device: device.turn_on(), - off_fn=lambda device: device.turn_off(), + on_fn=lambda device: _toggle_switch(device, True), + off_fn=lambda device: _toggle_switch(device, False), ), VeSyncSwitchEntityDescription( key="display", @@ -55,16 +87,16 @@ class VeSyncSwitchEntityDescription(SwitchEntityDescription): lambda device: rgetattr(device, "state.display_set_status") is not None ), translation_key="display", - on_fn=lambda device: device.toggle_display(True), - off_fn=lambda device: device.toggle_display(False), + on_fn=lambda device: _toggle_display(device, True), + off_fn=lambda device: _toggle_display(device, False), ), VeSyncSwitchEntityDescription( key="child_lock", is_on=lambda device: device.state.child_lock, exists_fn=(lambda device: rgetattr(device, "state.child_lock") is not None), translation_key="child_lock", - on_fn=lambda device: device.toggle_child_lock(True), - off_fn=lambda device: device.toggle_child_lock(False), + on_fn=lambda device: _toggle_child_lock(device, True), + off_fn=lambda device: _toggle_child_lock(device, False), ), VeSyncSwitchEntityDescription( key="auto_off_config", @@ -73,8 +105,8 @@ class VeSyncSwitchEntityDescription(SwitchEntityDescription): lambda device: rgetattr(device, "state.automatic_stop_config") is not None ), translation_key="auto_off_config", - on_fn=lambda device: device.toggle_automatic_stop(True), - off_fn=lambda device: device.toggle_automatic_stop(False), + on_fn=lambda device: _toggle_auto_stop(device, True), + off_fn=lambda device: _toggle_auto_stop(device, False), ), ) @@ -89,7 +121,7 @@ async def async_setup_entry( coordinator = config_entry.runtime_data @callback - def discover(devices: list[VeSyncBaseDevice]) -> None: + def discover(devices: DeviceContainer) -> None: """Add new devices to platform.""" _setup_entities(devices, async_add_entities, coordinator) @@ -98,13 +130,15 @@ def discover(devices: list[VeSyncBaseDevice]) -> None: ) _setup_entities( - config_entry.runtime_data.manager.devices, async_add_entities, coordinator + config_entry.runtime_data.manager.devices, + async_add_entities, + coordinator, ) @callback def _setup_entities( - devices: DeviceContainer | list[VeSyncBaseDevice], + devices: DeviceContainer, async_add_entities: AddConfigEntryEntitiesCallback, coordinator: VeSyncDataCoordinator, ) -> None: @@ -117,7 +151,7 @@ def _setup_entities( ) -class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity): +class VeSyncSwitchEntity(SwitchEntity, VeSyncBaseEntity[VeSyncBaseDevice]): """VeSync switch entity class.""" entity_description: VeSyncSwitchEntityDescription @@ -145,13 +179,17 @@ def is_on(self) -> bool | None: async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" if not await self.entity_description.off_fn(self.device): - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Unknown error turning off device, no response.") self.async_write_ha_state() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if not await self.entity_description.on_fn(self.device): - raise HomeAssistantError(self.device.last_response.message) + if self.device.last_response: + raise HomeAssistantError(self.device.last_response.message) + raise HomeAssistantError("Unknown error turning on device, no response.") self.async_write_ha_state() diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 3ff5275ce4aa04..08edf6609167d3 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Iterator from contextlib import ExitStack -from itertools import chain +from itertools import chain, product from types import MappingProxyType from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch @@ -144,6 +144,7 @@ def fan_fixture(): cid="fan", device_type="fan", device_name="Test Fan", + product_type="fan", device_status="on", modes=[], connection_status="online", @@ -157,6 +158,7 @@ def bulb_fixture(): """Create a mock VeSync bulb fixture.""" return Mock( VeSyncBulb, + product_type="bulb", cid="bulb", device_name="Test Bulb", ) @@ -167,6 +169,7 @@ def switch_fixture(): """Create a mock VeSync switch fixture.""" return Mock( VeSyncSwitch, + product_type="switch", is_dimmable=Mock(return_value=False), ) @@ -176,6 +179,7 @@ def dimmable_switch_fixture(): """Create a mock VeSync switch fixture.""" return Mock( VeSyncSwitch, + product_type="switch", is_dimmable=Mock(return_value=True), ) @@ -186,6 +190,7 @@ def outlet_fixture(): return Mock( VeSyncOutlet, cid="outlet", + product_type="outlet", device_name="Test Outlet", ) @@ -203,6 +208,7 @@ def humidifier_fixture(): }, features=[HumidifierFeatures.NIGHTLIGHT], device_type="Classic200S", + product_type="humidifier", device_name="Humidifier 200s", device_status="on", mist_modes=["auto", "manual"], @@ -239,6 +245,7 @@ def humidifier_300s_fixture(): }, features=[HumidifierFeatures.NIGHTLIGHT], device_type="Classic300S", + product_type="humidifier", device_name="Humidifier 300s", device_status="on", mist_modes=["auto", "manual"], From 238c62abb0fc2f90aaec5c35f2a6a029f18d773e Mon Sep 17 00:00:00 2001 From: cdnninja Date: Wed, 14 Jan 2026 05:32:52 +0000 Subject: [PATCH 07/19] Air Fryer --- homeassistant/components/vesync/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 9a64f7cc9fbe97..644da22e36e28c 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -67,7 +67,7 @@ def is_purifier(device: VeSyncBaseDevice) -> TypeGuard[VeSyncPurifier]: return device.product_type == ProductTypes.PURIFIER -def is_air_fryer(device: VeSyncBaseDevice) -> bool: +def is_air_fryer(device: VeSyncBaseDevice) -> TypeGuard[VeSyncFryer]: """Check if the device represents an air fryer.""" - return isinstance(device, VeSyncFryer) + return device.product_type == ProductTypes.AIR_FRYER From 18529fdf6ad66446155c77b24ca153a7057ea7dd Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 16 Jan 2026 01:21:22 +0000 Subject: [PATCH 08/19] Prek Failure --- tests/components/vesync/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 08edf6609167d3..e8603b371709f7 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -4,7 +4,7 @@ from collections.abc import Iterator from contextlib import ExitStack -from itertools import chain, product +from itertools import chain from types import MappingProxyType from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch From 8319471333af3bd5143cfe9a82d72add4121ec81 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 23 Jan 2026 14:09:47 +0000 Subject: [PATCH 09/19] Bumpto3.4.1 --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 66b07daa7d3fe5..6730477b05f875 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==3.4.0"] + "requirements": ["pyvesync==3.4.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a8bbebfe55c17f..ea12e715f5a0a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2653,7 +2653,7 @@ pyvera==0.3.16 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==3.4.0 +pyvesync==3.4.1 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 056d464ce75cda..ce98f55cf08b53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2228,7 +2228,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.16 # homeassistant.components.vesync -pyvesync==3.4.0 +pyvesync==3.4.1 # homeassistant.components.vizio pyvizio==0.1.61 From 242fc289cc789797fabb1c01222a6e737f64b350 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 23 Jan 2026 14:13:56 +0000 Subject: [PATCH 10/19] Snapshot update --- .../vesync/snapshots/test_diagnostics.ambr | 4 +- .../vesync/snapshots/test_switch.ambr | 47 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 99dac5ed53f63a..28509cf632dcf6 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -84,7 +84,7 @@ 'mock_calls': list([ ]), 'pid': 'Method', - 'product_type': 'Method', + 'product_type': 'humidifier', 'request_keys': 'Method', 'reset_mock': 'Method', 'return_value': 'Method', @@ -517,7 +517,7 @@ ]), 'normal_mode': 'Method', 'pid': 'Method', - 'product_type': 'Method', + 'product_type': 'fan', 'request_keys': 'Method', 'reset_mock': 'Method', 'return_value': 'Method', diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 7dad568e8eda6e..3e911ce6c9b2f6 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -615,8 +615,55 @@ # --- # name: test_switch_state[Dimmer Switch][entities] list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.dimmer_switch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'dimmable-switch-device_status', + 'unit_of_measurement': None, + }), ]) # --- +# name: test_switch_state[Dimmer Switch][switch.dimmer_switch] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'Dimmer Switch', + }), + 'context': , + 'entity_id': 'switch.dimmer_switch', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- # name: test_switch_state[Humidifier 200s][devices] list([ DeviceRegistryEntrySnapshot({ From 321dbe7077c5d9b95d4d24f41b809b01bf731a8d Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 23 Jan 2026 14:47:56 +0000 Subject: [PATCH 11/19] pylint --- homeassistant/components/vesync/select.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py index de6a70d35769c3..00fa367001a2c8 100644 --- a/homeassistant/components/vesync/select.py +++ b/homeassistant/components/vesync/select.py @@ -105,9 +105,7 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): ], icon="mdi:brightness-6", exists_fn=lambda device: is_purifier(device) and device.supports_nightlight, - select_option_fn=lambda device, value: _toggle_purifier_nightlight( - device, value - ), + select_option_fn=_toggle_purifier_nightlight, current_option_fn=lambda device: device.state.nightlight_status, ), # night_light for outlets @@ -121,7 +119,7 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): ], icon="mdi:brightness-6", exists_fn=lambda device: is_outlet(device) and device.supports_nightlight, - select_option_fn=lambda device, value: _toggle_outlet_nightlight(device, value), + select_option_fn=_toggle_outlet_nightlight, current_option_fn=lambda device: device.state.nightlight_status, ), ] From 32705b4b32f7f693ea6562d834e46dcb3e2a15d5 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Fri, 23 Jan 2026 14:50:53 +0000 Subject: [PATCH 12/19] Merge base snapshot update --- tests/components/vesync/snapshots/test_switch.ambr | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index a1bfdeca1d53eb..7aac5270a2bebb 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -642,6 +642,7 @@ 'labels': set({ }), 'name': None, + 'object_id_base': None, 'options': dict({ }), 'original_device_class': , From 6ecfd0e5538066ff25d651d5a960664fa418ddd7 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Sun, 25 Jan 2026 21:21:28 +0000 Subject: [PATCH 13/19] Revise type method to align to existing filters --- homeassistant/components/vesync/common.py | 4 +- .../vesync/snapshots/test_switch.ambr | 48 ------------------- 2 files changed, 3 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 644da22e36e28c..08f55a6561dad4 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -57,8 +57,10 @@ def is_outlet(device: VeSyncBaseDevice) -> TypeGuard[VeSyncOutlet]: def is_wall_switch(device: VeSyncBaseDevice) -> TypeGuard[VeSyncWallSwitch]: """Check if the device represents a wall switch, note this doessn't include dimming switches.""" + if device.product_type != ProductTypes.SWITCH: + return False - return device.product_type == ProductTypes.SWITCH + return getattr(device, "supports_dimmable", False) is False def is_purifier(device: VeSyncBaseDevice) -> TypeGuard[VeSyncPurifier]: diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 7aac5270a2bebb..f5d3d5cef80d33 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -622,56 +622,8 @@ # --- # name: test_switch_state[Dimmer Switch][entities] list([ - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.dimmer_switch', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'object_id_base': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'vesync', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'dimmable-switch-device_status', - 'unit_of_measurement': None, - }), ]) # --- -# name: test_switch_state[Dimmer Switch][switch.dimmer_switch] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'switch', - 'friendly_name': 'Dimmer Switch', - }), - 'context': , - 'entity_id': 'switch.dimmer_switch', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'on', - }) -# --- # name: test_switch_state[Humidifier 200s][devices] list([ DeviceRegistryEntrySnapshot({ From 240b4023ffd6fc356e8e5b7ad62a7125e2b30f6d Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 27 Jan 2026 00:47:24 +0000 Subject: [PATCH 14/19] Type check --- homeassistant/components/vesync/humidifier.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index ebfe3010fe8673..6c227de890e4b4 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -1,7 +1,7 @@ """Support for VeSync humidifiers.""" import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pyvesync.base_devices.humidifier_base import VeSyncHumidifier @@ -139,11 +139,15 @@ def available_modes(self) -> list[str]: @property def current_humidity(self) -> int | None: """Return the current humidity.""" + if TYPE_CHECKING: + assert self.device.state.humidity is not None return self.device.state.humidity @property - def target_humidity(self) -> int | None: + def target_humidity(self) -> int: """Return the humidity we try to reach.""" + if TYPE_CHECKING: + assert self.device.state.auto_humidity is not None return self.device.state.auto_humidity @property From e79f14b9073504091764df7af1d7e8ab2cc9a1a1 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 27 Jan 2026 00:47:47 +0000 Subject: [PATCH 15/19] Assert --- homeassistant/components/vesync/humidifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 6c227de890e4b4..e1952fa31ff363 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -137,7 +137,7 @@ def available_modes(self) -> list[str]: return self._available_modes @property - def current_humidity(self) -> int | None: + def current_humidity(self) -> int: """Return the current humidity.""" if TYPE_CHECKING: assert self.device.state.humidity is not None From ae07817da7fea502fd2a2b8401cdc0eb5c553de4 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 27 Jan 2026 00:49:56 +0000 Subject: [PATCH 16/19] Feedback --- homeassistant/components/vesync/humidifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index e1952fa31ff363..dafaefc2c9ce76 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -109,7 +109,7 @@ def __init__( ) -> None: """Initialize the VeSyncHumidifierHA device.""" super().__init__(device, coordinator) - self.device: VeSyncHumidifier = device + # 2 Vesync humidifier modes (humidity and auto) maps to the HA mode auto. # They are on different devices though. We need to map HA mode to the # device specific mode when setting it. From fa5240e855bedb3c3dc01f84ea7832b717e3ddc0 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 27 Jan 2026 00:51:56 +0000 Subject: [PATCH 17/19] Feedacbk --- homeassistant/components/vesync/entity.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py index 831a3d9b4a189e..136856c7590f39 100644 --- a/homeassistant/components/vesync/entity.py +++ b/homeassistant/components/vesync/entity.py @@ -1,7 +1,5 @@ """Common entity for VeSync Component.""" -from typing import Generic, TypeVar - from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.helpers.device_registry import DeviceInfo @@ -10,10 +8,8 @@ from .const import DOMAIN from .coordinator import VeSyncDataCoordinator -T = TypeVar("T", bound=VeSyncBaseDevice) - -class VeSyncBaseEntity(CoordinatorEntity[VeSyncDataCoordinator], Generic[T]): +class VeSyncBaseEntity[T: VeSyncBaseDevice](CoordinatorEntity[VeSyncDataCoordinator]): """Base class for VeSync Entity Representations.""" _attr_has_entity_name = True From 995ba17093c2cf59c4caa5af8291d12c635e2b76 Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 27 Jan 2026 01:02:39 +0000 Subject: [PATCH 18/19] Missing Modes --- homeassistant/components/vesync/fan.py | 4 ++-- tests/components/vesync/snapshots/test_fan.ambr | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index f1698867434ce4..100ae13daa5c8d 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -183,7 +183,7 @@ def preset_mode(self) -> str | None: @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" - attr = {} + attr: dict[str, Any] = {} if hasattr(self.device.state, "active_time"): attr["active_time"] = self.device.state.active_time @@ -213,7 +213,7 @@ def extra_state_attributes(self) -> dict[str, Any]: self.device.state.nightlight_status, "value", None ) if hasattr(self.device.state, "mode"): - attr["mode"] = getattr(self.device.state.mode, "value", None) + attr["mode"] = self.device.state.mode return attr diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index fddaaa88573eff..30163fb34a1162 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -81,7 +81,7 @@ 'active_time': 0, 'display_status': 'on', 'friendly_name': 'Air Purifier 131s', - 'mode': None, + 'mode': 'sleep', 'percentage': None, 'percentage_step': 33.333333333333336, 'preset_mode': 'sleep', @@ -181,7 +181,7 @@ 'child_lock': False, 'display_status': 'on', 'friendly_name': 'Air Purifier 200s', - 'mode': None, + 'mode': 'manual', 'night_light': 'off', 'percentage': 33, 'percentage_step': 33.333333333333336, @@ -282,7 +282,7 @@ 'child_lock': False, 'display_status': 'on', 'friendly_name': 'Air Purifier 400s', - 'mode': None, + 'mode': 'manual', 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, @@ -384,7 +384,7 @@ 'child_lock': False, 'display_status': 'on', 'friendly_name': 'Air Purifier 600s', - 'mode': None, + 'mode': 'manual', 'night_light': 'off', 'percentage': 25, 'percentage_step': 25.0, @@ -783,7 +783,7 @@ 'active_time': None, 'display_status': 'off', 'friendly_name': 'SmartTowerFan', - 'mode': 'normal', + 'mode': , 'oscillating': True, 'percentage': 0, 'percentage_step': 8.333333333333334, From 142bcf1b19e95404d0fc58ffc3830193e78ce62f Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 27 Jan 2026 01:08:25 +0000 Subject: [PATCH 19/19] Child Lock --- homeassistant/components/vesync/fan.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 100ae13daa5c8d..062ef5a21d8b61 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -200,10 +200,7 @@ def extra_state_attributes(self) -> dict[str, Any]: hasattr(self.device.state, "child_lock") and self.device.state.child_lock is not None ): - if isinstance(self.device.state.child_lock, bool): - attr["child_lock"] = self.device.state.child_lock - else: - attr["child_lock"] = self.device.state.child_lock != "off" + attr["child_lock"] = self.device.state.child_lock if ( hasattr(self.device.state, "nightlight_status")