From b907fc09b0ec6eb445d77091330200c1e2725069 Mon Sep 17 00:00:00 2001 From: Max R Date: Mon, 29 Jun 2026 07:57:40 -0400 Subject: [PATCH 1/5] Migrate to pyvesync 3.4.2 and Python 3.14 - Upgrade pyvesync from 2.1.x to 3.4.2 (sync->async API, new device container model, state object per device) - Rebuild venv with Python 3.14 - Update homeassistant dev dep to 2026.6.4 - Update pre-commit hooks for Python 3.14 compatibility - All pyvesync calls are now awaited (login, update, turn_on/off, etc.) - Device state accessed via device.state.* instead of device.details/config dicts - Device routing uses manager.devices.air_purifiers/fans/humidifiers/air_fryers instead of manager.fans/kitchen (which split purifiers from humidifiers) - Remove VS_FAN_TYPES/VS_HUMIDIFIERS_TYPES/VS_AIRFRYER_TYPES from const - HA entity methods converted to async_turn_on/off, async_set_*, async_press --- .pre-commit-config.yaml | 9 +- custom_components/vesync/__init__.py | 4 +- custom_components/vesync/binary_sensor.py | 13 +- custom_components/vesync/button.py | 8 +- custom_components/vesync/common.py | 144 ++++++++++------------ custom_components/vesync/config_flow.py | 4 +- custom_components/vesync/const.py | 4 - custom_components/vesync/fan.py | 49 ++++---- custom_components/vesync/humidifier.py | 38 +++--- custom_components/vesync/light.py | 94 +++++--------- custom_components/vesync/manifest.json | 2 +- custom_components/vesync/number.py | 52 ++++---- custom_components/vesync/sensor.py | 84 ++++++------- custom_components/vesync/switch.py | 64 +++++----- requirements.txt | 2 +- requirements_dev.txt | 2 +- 16 files changed, 250 insertions(+), 323 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 62a86f3..c895f0c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,14 +1,13 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.19.1 hooks: - id: pyupgrade - args: [--py37-plus] + args: [--py313-plus] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 25.1.0 hooks: - id: black - additional_dependencies: ['click==8.0.4'] args: - --safe - --quiet @@ -31,7 +30,7 @@ repos: - pydocstyle==6.3.0 files: ^(custom_components)/.+\.py$ - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/adrienverge/yamllint.git diff --git a/custom_components/vesync/__init__.py b/custom_components/vesync/__init__.py index 0f6e533..ec1f39b 100644 --- a/custom_components/vesync/__init__.py +++ b/custom_components/vesync/__init__.py @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b manager = VeSync(username, password, time_zone) - login = await hass.async_add_executor_job(manager.login) + login = await manager.login() if not login: _LOGGER.error("Unable to login to the VeSync server") @@ -68,7 +68,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_update_data(): """Fetch data from API endpoint.""" try: - await hass.async_add_executor_job(manager.update) + await manager.update() except Exception as err: raise UpdateFailed(f"Update failed: {err}") from err diff --git a/custom_components/vesync/binary_sensor.py b/custom_components/vesync/binary_sensor.py index d0d373a..01d5a3b 100644 --- a/custom_components/vesync/binary_sensor.py +++ b/custom_components/vesync/binary_sensor.py @@ -45,7 +45,7 @@ def _setup_entities(devices, async_add_entities, coordinator): """Check if device is online and add entity.""" entities = [] for dev in devices: - if hasattr(dev, "fryer_status"): + if hasattr(dev.state, "cook_set_temp"): for stype in BINARY_SENSOR_TYPES_AIRFRYER.values(): entities.append( # noqa: PERF401 VeSyncairfryerSensor( @@ -90,9 +90,8 @@ def name(self): @property def is_on(self) -> bool: - """Return a value indicating whether the Humidifier's water tank is lifted.""" - return getattr(self.airfryer, self.stype[0], None) - # return self.smarthumidifier.details["water_tank_lifted"] + """Return a value indicating whether the fryer state attribute is active.""" + return getattr(self.airfryer.state, self.stype[0], None) @property def icon(self): @@ -130,7 +129,7 @@ def name(self): @property def is_on(self) -> bool: """Return a value indicating whether the Humidifier is out of water.""" - return self.smarthumidifier.details["water_lacks"] + return self.smarthumidifier.state.water_lacks class VeSyncWaterTankLiftedSensor(VeSyncBinarySensorEntity): @@ -149,7 +148,7 @@ def name(self): @property def is_on(self) -> bool: """Return a value indicating whether the Humidifier's water tank is lifted.""" - return self.smarthumidifier.details["water_tank_lifted"] + return self.smarthumidifier.state.water_tank_lifted class VeSyncFilterOpenStateSensor(VeSyncBinarySensorEntity): @@ -168,4 +167,4 @@ def name(self): @property def is_on(self) -> bool: """Return a value indicating whether the Humidifier's filter is open.""" - return self.smarthumidifier.details["filter_open_state"] + return self.smarthumidifier.state.filter_open_state diff --git a/custom_components/vesync/button.py b/custom_components/vesync/button.py index c02cbfc..38a6fad 100644 --- a/custom_components/vesync/button.py +++ b/custom_components/vesync/button.py @@ -54,7 +54,7 @@ def _setup_entities(devices, async_add_entities, coordinator): """Check if device is online and add entity.""" entities = [] for dev in devices: - if hasattr(dev, "cook_set_temp"): + if hasattr(dev.state, "cook_set_temp"): for stype in SENSOR_TYPES_CS158.values(): entities.append( # noqa: PERF401 VeSyncairfryerButton( @@ -91,6 +91,6 @@ def icon(self): """Return the icon to use in the frontend, if any.""" return self.stype[2] - def press(self) -> None: - """Return True if device is on.""" - self.airfryer.end() + async def async_press(self) -> None: + """Press the button.""" + await self.airfryer.end() diff --git a/custom_components/vesync/common.py b/custom_components/vesync/common.py index 2111f2a..e91739d 100644 --- a/custom_components/vesync/common.py +++ b/custom_components/vesync/common.py @@ -5,18 +5,13 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.helpers.entity import Entity, ToggleEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity -from pyvesync.vesyncfan import model_features as fan_model_features -from pyvesync.vesynckitchen import model_features as kitchen_model_features from .const import ( DOMAIN, - VS_AIRFRYER_TYPES, VS_BINARY_SENSORS, VS_BUTTON, - VS_FAN_TYPES, VS_FANS, VS_HUMIDIFIERS, - VS_HUMIDIFIERS_TYPES, VS_LIGHTS, VS_NUMBERS, VS_SENSORS, @@ -28,6 +23,20 @@ def has_feature(device, dictionary, attribute): """Return the detail of the attribute.""" + if dictionary in ("details", "config"): + return getattr(device.state, attribute, None) is not None + elif dictionary == "_config_dict": + if attribute == "levels": + return hasattr(device, "fan_levels") and len(device.fan_levels) > 0 + elif attribute == "mist_levels": + return hasattr(device, "mist_levels") and len(device.mist_levels) > 0 + elif attribute == "warm_mist_levels": + return ( + hasattr(device, "warm_mist_levels") and len(device.warm_mist_levels) > 0 + ) + elif attribute == "modes": + return hasattr(device, "modes") and len(device.modes) > 0 + return False return getattr(device, dictionary, {}).get(attribute, None) is not None @@ -44,79 +53,62 @@ async def async_process_devices(hass, manager): VS_BUTTON: [], } + all_devices = list(manager.devices) redacted = async_redact_data( - {k: [d.__dict__ for d in v] for k, v in manager._dev_list.items()}, + [d.to_dict() for d in all_devices], ["cid", "uuid", "mac_id"], ) - _LOGGER.debug( - "Found the following devices: %s", - redacted, - ) + _LOGGER.debug("Found the following devices: %s", redacted) - if ( - manager.bulbs is None - and manager.fans is None - and manager.kitchen is None - and manager.outlets is None - and manager.switches is None - ): + if len(manager.devices) == 0: _LOGGER.error("Could not find any device to add in %s", redacted) - if manager.fans: - for fan in manager.fans: - # VeSync classifies humidifiers as fans - if fan_model_features(fan.device_type)["module"] in VS_HUMIDIFIERS_TYPES: - devices[VS_HUMIDIFIERS].append(fan) - elif fan_model_features(fan.device_type)["module"] in VS_FAN_TYPES: - devices[VS_FANS].append(fan) - else: - _LOGGER.warning( - "Unknown fan type %s %s (enable debug for more info)", - fan.device_name, - fan.device_type, - ) - continue - devices[VS_NUMBERS].append(fan) - devices[VS_SWITCHES].append(fan) - devices[VS_SENSORS].append(fan) - devices[VS_BINARY_SENSORS].append(fan) - devices[VS_LIGHTS].append(fan) - - if manager.bulbs: - devices[VS_LIGHTS].extend(manager.bulbs) - - if manager.outlets: - devices[VS_SWITCHES].extend(manager.outlets) - # Expose outlets' power & energy usage as separate sensors - devices[VS_SENSORS].extend(manager.outlets) - - if manager.switches: - for switch in manager.switches: - if not switch.is_dimmable(): - devices[VS_SWITCHES].append(switch) - else: - devices[VS_LIGHTS].append(switch) - - if manager.kitchen: - for airfryer in manager.kitchen: - if ( - kitchen_model_features(airfryer.device_type)["module"] - in VS_AIRFRYER_TYPES - ): - _LOGGER.warning( - "Found air fryer %s, support in progress.\n", airfryer.device_name - ) - devices[VS_SENSORS].append(airfryer) - devices[VS_BINARY_SENSORS].append(airfryer) - devices[VS_SWITCHES].append(airfryer) - devices[VS_BUTTON].append(airfryer) - else: - _LOGGER.warning( - "Unknown device type %s %s (enable debug for more info)", - airfryer.device_name, - airfryer.device_type, - ) + for purifier in manager.devices.air_purifiers: + devices[VS_FANS].append(purifier) + devices[VS_NUMBERS].append(purifier) + devices[VS_SWITCHES].append(purifier) + devices[VS_SENSORS].append(purifier) + devices[VS_BINARY_SENSORS].append(purifier) + devices[VS_LIGHTS].append(purifier) + + for fan in manager.devices.fans: + devices[VS_FANS].append(fan) + devices[VS_NUMBERS].append(fan) + devices[VS_SWITCHES].append(fan) + devices[VS_SENSORS].append(fan) + devices[VS_BINARY_SENSORS].append(fan) + devices[VS_LIGHTS].append(fan) + + for humidifier in manager.devices.humidifiers: + devices[VS_HUMIDIFIERS].append(humidifier) + devices[VS_NUMBERS].append(humidifier) + devices[VS_SWITCHES].append(humidifier) + devices[VS_SENSORS].append(humidifier) + devices[VS_BINARY_SENSORS].append(humidifier) + devices[VS_LIGHTS].append(humidifier) + + if manager.devices.bulbs: + devices[VS_LIGHTS].extend(manager.devices.bulbs) + + if manager.devices.outlets: + devices[VS_SWITCHES].extend(manager.devices.outlets) + devices[VS_SENSORS].extend(manager.devices.outlets) + + for switch in manager.devices.switches: + if not switch.is_dimmable(): + devices[VS_SWITCHES].append(switch) + else: + devices[VS_LIGHTS].append(switch) + + for airfryer in manager.devices.air_fryers: + _LOGGER.warning( + "Found air fryer %s, support in progress.\n", airfryer.device_name + ) + devices[VS_SENSORS].append(airfryer) + devices[VS_BINARY_SENSORS].append(airfryer) + devices[VS_SWITCHES].append(airfryer) + devices[VS_BUTTON].append(airfryer) return devices @@ -139,8 +131,6 @@ def base_unique_id(self): @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 @@ -156,7 +146,7 @@ def name(self): @property def available(self) -> bool: """Return True if device is available.""" - return self.device.connection_status == "online" + return self.device.state.connection_status == "online" @property def device_info(self): @@ -186,8 +176,8 @@ def __init__(self, device, coordinator) -> None: @property def is_on(self): """Return True if device is on.""" - return self.device.device_status == "on" + return self.device.is_on - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - self.device.turn_off() + await self.device.turn_off() diff --git a/custom_components/vesync/config_flow.py b/custom_components/vesync/config_flow.py index 4fdd683..7370356 100644 --- a/custom_components/vesync/config_flow.py +++ b/custom_components/vesync/config_flow.py @@ -76,7 +76,7 @@ async def async_step_reauth_confirm( password = user_input[CONF_PASSWORD] polling_interval = user_input[POLLING_INTERVAL] manager = VeSync(username, password) - login = await self.hass.async_add_executor_job(manager.login) + login = await manager.login() if not login: errors["base"] = "invalid_auth" else: @@ -121,7 +121,7 @@ async def async_step_user( password = user_input[CONF_PASSWORD] polling_interval = user_input[POLLING_INTERVAL] manager = VeSync(username, password) - login = await self.hass.async_add_executor_job(manager.login) + login = await manager.login() if not login: errors["base"] = "invalid_auth" else: diff --git a/custom_components/vesync/const.py b/custom_components/vesync/const.py index 61ca729..214eba0 100644 --- a/custom_components/vesync/const.py +++ b/custom_components/vesync/const.py @@ -32,10 +32,6 @@ VS_TO_HA_ATTRIBUTES = {"humidity": "current_humidity"} -VS_FAN_TYPES = ["VeSyncAirBypass", "VeSyncAir131", "VeSyncAirBaseV2"] -VS_HUMIDIFIERS_TYPES = ["VeSyncHumid200300S", "VeSyncHumid200S", "VeSyncHumid1000S"] -VS_AIRFRYER_TYPES = ["VeSyncAirFryer158"] - DEV_TYPE_TO_HA = { "ESL100": "bulb-dimmable", diff --git a/custom_components/vesync/fan.py b/custom_components/vesync/fan.py index 9bdd246..0820911 100644 --- a/custom_components/vesync/fan.py +++ b/custom_components/vesync/fan.py @@ -72,17 +72,17 @@ def __init__(self, fan, coordinator) -> None: self._speed_range = (1, 1) self._attr_preset_modes = [VS_MODE_MANUAL, VS_MODE_AUTO, VS_MODE_SLEEP] if has_feature(self.smartfan, "_config_dict", VS_LEVELS): - self._speed_range = (1, max(self.smartfan._config_dict[VS_LEVELS])) + self._speed_range = (1, max(self.smartfan.fan_levels)) if has_feature(self.smartfan, "_config_dict", VS_MODES): self._attr_preset_modes = [ VS_MODE_MANUAL, *[ mode for mode in [VS_MODE_AUTO, VS_MODE_SLEEP, VS_MODE_TURBO] - if mode in self.smartfan._config_dict[VS_MODES] + if mode in self.smartfan.modes ], ] - if hasattr(self.smartfan, "pet_mode"): + if hasattr(self.smartfan, "modes") and VS_MODE_PET in self.smartfan.modes: self._attr_preset_modes.append(VS_MODE_PET) if self.smartfan.device_type == "LV-PUR131S": @@ -106,8 +106,8 @@ def supported_features(self): def percentage(self): """Return the current speed.""" if ( - self.smartfan.mode == VS_MODE_MANUAL - and (current_level := self.smartfan.fan_level) is not None + self.smartfan.state.mode == VS_MODE_MANUAL + and (current_level := self.smartfan.state.fan_level) is not None ): return ranged_value_to_percentage(self._speed_range, current_level) return None @@ -120,7 +120,7 @@ def speed_count(self) -> int: @property def preset_mode(self): """Get the current preset mode.""" - return self.smartfan.mode + return self.smartfan.state.mode @property def unique_info(self): @@ -131,7 +131,7 @@ def unique_info(self): def extra_state_attributes(self): """Return the state attributes of the fan.""" attr = {} - for k, v in self.smartfan.details.items(): + for k, v in self.smartfan.state.to_dict().items(): if k in VS_TO_HA_ATTRIBUTES: attr[VS_TO_HA_ATTRIBUTES[k]] = v elif k in self.state_attributes: @@ -140,22 +140,21 @@ def extra_state_attributes(self): attr[k] = v return attr - def set_percentage(self, percentage): + async def async_set_percentage(self, percentage): """Set the speed of the device.""" if percentage == 0: - self.smartfan.turn_off() + await self.smartfan.turn_off() return if not self.smartfan.is_on: - self.smartfan.turn_on() + await self.smartfan.turn_on() - self.smartfan.manual_mode() - self.smartfan.change_fan_speed( + await self.smartfan.manual_mode() + await self.smartfan.change_fan_speed( math.ceil(percentage_to_ranged_value(self._speed_range, percentage)) ) - self.schedule_update_ha_state() - def set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode): """Set the preset mode of device.""" if preset_mode not in self.preset_modes: raise ValueError( @@ -163,33 +162,29 @@ def set_preset_mode(self, preset_mode): ) if not self.smartfan.is_on: - self.smartfan.turn_on() + await self.smartfan.turn_on() if preset_mode == VS_MODE_AUTO: - self.smartfan.auto_mode() + await self.smartfan.auto_mode() elif preset_mode == VS_MODE_SLEEP: - self.smartfan.sleep_mode() + await self.smartfan.sleep_mode() elif preset_mode == VS_MODE_MANUAL: - self.smartfan.manual_mode() + await self.smartfan.manual_mode() elif preset_mode == VS_MODE_TURBO: - self.smartfan.turbo_mode() + await self.smartfan.turbo_mode() elif preset_mode == VS_MODE_PET: - if hasattr(self.smartfan, "pet_mode"): - self.smartfan.pet_mode() + await self.smartfan.pet_mode() - self.schedule_update_ha_state() - - def turn_on( + async def async_turn_on( self, - # speed: str | None = None, percentage: int | None = None, preset_mode: str | None = None, **kwargs, ) -> None: """Turn the device on.""" if preset_mode: - self.set_preset_mode(preset_mode) + await self.async_set_preset_mode(preset_mode) return if percentage is None: percentage = 50 - self.set_percentage(percentage) + await self.async_set_percentage(percentage) diff --git a/custom_components/vesync/humidifier.py b/custom_components/vesync/humidifier.py index 4ae322d..4b3d146 100644 --- a/custom_components/vesync/humidifier.py +++ b/custom_components/vesync/humidifier.py @@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from pyvesync.vesyncfan import VeSyncHumid200300S from .common import VeSyncDevice from .const import ( @@ -102,7 +101,7 @@ class VeSyncHumidifierHA(VeSyncDevice, HumidifierEntity): _attr_max_humidity = MAX_HUMIDITY _attr_min_humidity = MIN_HUMIDITY - def __init__(self, humidifier: VeSyncHumid200300S, coordinator) -> None: + def __init__(self, humidifier, coordinator) -> None: """Initialize the VeSync humidifier device.""" super().__init__(humidifier, coordinator) self.smarthumidifier = humidifier @@ -129,17 +128,17 @@ def supported_features(self): @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" - return self.smarthumidifier.config["auto_target_humidity"] + return self.smarthumidifier.state.auto_target_humidity @property def mode(self) -> str | None: """Get the current preset mode.""" - return _get_ha_mode(self.smarthumidifier.details["mode"]) + return _get_ha_mode(self.smarthumidifier.state.mode) @property def is_on(self) -> bool: """Return True if humidifier is on.""" - return self.smarthumidifier.enabled # device_status is always on + return self.smarthumidifier.is_on @property def unique_info(self) -> str: @@ -149,9 +148,8 @@ def unique_info(self) -> str: @property def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes of the humidifier.""" - attr = {} - for k, v in self.smarthumidifier.details.items(): + for k, v in self.smarthumidifier.state.to_dict().items(): if k in VS_TO_HA_ATTRIBUTES: attr[VS_TO_HA_ATTRIBUTES[k]] = v elif k in self.state_attributes: @@ -160,39 +158,31 @@ def extra_state_attributes(self) -> Mapping[str, Any]: attr[k] = v return attr - def set_humidity(self, humidity: int) -> None: + async def async_set_humidity(self, humidity: int) -> None: """Set the target humidity of the device.""" if humidity not in range(self.min_humidity, self.max_humidity + 1): raise ValueError( "{humidity} is not between {self.min_humidity} and {self.max_humidity} (inclusive)" ) - if self.smarthumidifier.set_humidity(humidity): - self.schedule_update_ha_state() - else: + if not await self.smarthumidifier.set_humidity(humidity): raise ValueError("An error occurred while setting humidity.") - def set_mode(self, mode: str) -> None: + async def async_set_mode(self, mode: str) -> None: """Set the mode of the device.""" if mode not in self.available_modes: raise ValueError( "{mode} is not one of the valid available modes: {self.available_modes}" ) - if self.smarthumidifier.set_humidity_mode(_get_vs_mode(mode)): - self.schedule_update_ha_state() - else: + vs_mode = _get_vs_mode(mode) + if not await self.smarthumidifier.set_mode(vs_mode): raise ValueError("An error occurred while setting mode.") - def turn_on( - self, - **kwargs, - ) -> None: + async def async_turn_on(self, **kwargs) -> None: """Turn the device on.""" - success = self.smarthumidifier.turn_on() - if not success: + if not await self.smarthumidifier.turn_on(): raise ValueError("An error occurred while turning on.") - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn the device off.""" - success = self.smarthumidifier.turn_off() - if not success: + if not await self.smarthumidifier.turn_off(): raise ValueError("An error occurred while turning off.") diff --git a/custom_components/vesync/light.py b/custom_components/vesync/light.py index c2d68bd..76336a1 100644 --- a/custom_components/vesync/light.py +++ b/custom_components/vesync/light.py @@ -13,9 +13,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pyvesync.const import ProductTypes from .common import VeSyncDevice, has_feature -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_FAN_TYPES, VS_LIGHTS +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_LIGHTS _LOGGER = logging.getLogger(__name__) @@ -54,7 +55,7 @@ def _setup_entities(devices, async_add_entities, coordinator): entities.append(VeSyncDimmableLightHA(dev, coordinator)) if DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) - if hasattr(dev, "night_light") and dev.night_light: + if getattr(dev, "supports_nightlight", False): entities.append(VeSyncNightLightHA(dev, coordinator)) async_add_entities(entities, update_before_add=True) @@ -62,25 +63,19 @@ def _setup_entities(devices, async_add_entities, coordinator): def _vesync_brightness_to_ha(vesync_brightness): try: - # check for validity of brightness value received brightness_value = int(vesync_brightness) - except ValueError: - # deal if any unexpected/non numeric value + except (ValueError, TypeError): _LOGGER.debug( "VeSync - received unexpected 'brightness' value from pyvesync api: %s", vesync_brightness, ) return None - # convert percent brightness to ha expected range return round((max(1, brightness_value) / 100) * 255) def _ha_brightness_to_vesync(ha_brightness): - # get brightness from HA data brightness = int(ha_brightness) - # ensure value between 1-255 brightness = max(1, min(brightness, 255)) - # convert to percent that vesync api expects brightness = round((brightness / 255) * 100) return max(1, min(brightness, 100)) @@ -88,56 +83,42 @@ def _ha_brightness_to_vesync(ha_brightness): class VeSyncBaseLight(VeSyncDevice, LightEntity): """Base class for VeSync Light Devices Representations.""" - def __init_(self, light, coordinator): + def __init__(self, light, coordinator): """Initialize the VeSync light device.""" super().__init__(light, coordinator) @property def brightness(self): """Get light brightness.""" - # get value from pyvesync library api, - return _vesync_brightness_to_ha(self.device.brightness) + return _vesync_brightness_to_ha(self.device.state.brightness) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" attribute_adjustment_only = False - # set white temperature if ( self.color_mode in (ColorMode.COLOR_TEMP,) and ATTR_COLOR_TEMP_KELVIN in kwargs ): - # get white temperature from HA data color_temp = int(kwargs[ATTR_COLOR_TEMP_KELVIN]) - # ensure value between min-max supported Mireds color_temp = max(self.min_mireds, min(color_temp, self.max_mireds)) - # convert Mireds to Percent value that api expects color_temp = round( ((color_temp - self.min_mireds) / (self.max_mireds - self.min_mireds)) * 100 ) - # flip cold/warm to what pyvesync api expects color_temp = 100 - color_temp - # ensure value between 0-100 color_temp = max(0, min(color_temp, 100)) - # call pyvesync library api method to set color_temp - self.device.set_color_temp(color_temp) - # flag attribute_adjustment_only, so it doesn't turn_on the device redundantly + await self.device.set_color_temp(color_temp) attribute_adjustment_only = True - # set brightness level if ( self.color_mode in (ColorMode.BRIGHTNESS, ColorMode.COLOR_TEMP) and ATTR_BRIGHTNESS in kwargs ): - # get brightness from HA data brightness = _ha_brightness_to_vesync(kwargs[ATTR_BRIGHTNESS]) - self.device.set_brightness(brightness) - # flag attribute_adjustment_only, so it doesn't turn_on the device redundantly + await self.device.set_brightness(brightness) attribute_adjustment_only = True - # check flag if should skip sending the turn_on command if attribute_adjustment_only: return - # send turn_on command to pyvesync api - self.device.turn_on() + await self.device.turn_on() class VeSyncDimmableLightHA(VeSyncBaseLight, LightEntity): @@ -168,39 +149,32 @@ def __init__(self, device, coordinator) -> None: @property def color_temp(self): """Get device white temperature.""" - # get value from pyvesync library api, - result = self.device.color_temp_pct + 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 + except (ValueError, TypeError): _LOGGER.debug( - "VeSync - received unexpected 'color_temp_pct' value from pyvesync api: %s", + "VeSync - received unexpected 'color_temp' value from pyvesync api: %s", result, ) return 0 - # flip cold/warm color_temp_value = 100 - color_temp_value - # ensure value between 0-100 color_temp_value = max(0, min(color_temp_value, 100)) - # convert percent value to Mireds color_temp_value = round( self.min_mireds + ((self.max_mireds - self.min_mireds) / 100 * color_temp_value) ) - # ensure value between minimum and maximum Mireds return max(self.min_mireds, min(color_temp_value, self.max_mireds)) @property def min_mireds(self): """Set device coldest white temperature.""" - return 154 # 154 Mireds ( 1,000,000 divided by 6500 Kelvin = 154 Mireds) + return 154 @property def max_mireds(self): """Set device warmest white temperature.""" - return 370 # 370 Mireds ( 1,000,000 divided by 2700 Kelvin = 370 Mireds) + return 370 @property def color_mode(self): @@ -221,7 +195,7 @@ def __init__(self, device, coordinator) -> None: super().__init__(device, coordinator) self.device = device self.has_brightness = has_feature( - self.device, "details", "night_light_brightness" + self.device, "details", "nightlight_brightness" ) @property @@ -237,42 +211,42 @@ def name(self): @property def brightness(self): """Get night light brightness.""" - return ( - _vesync_brightness_to_ha(self.device.details["night_light_brightness"]) - if self.has_brightness - else {"on": 255, "dim": 125, "off": 0}[self.device.details["night_light"]] - ) + if self.has_brightness: + return _vesync_brightness_to_ha(self.device.state.nightlight_brightness) + status = self.device.state.nightlight_status + return {"on": 255, "dim": 125, "off": 0}.get(status, 0) @property def is_on(self): """Return True if night light is on.""" - if has_feature(self.device, "details", "night_light"): - return self.device.details["night_light"] in ["on", "dim"] if self.has_brightness: - return self.device.details["night_light_brightness"] > 0 + brightness = self.device.state.nightlight_brightness + return brightness is not None and brightness > 0 + status = self.device.state.nightlight_status + return status in ["on", "dim"] @property def entity_category(self): """Return the configuration entity category.""" return EntityCategory.CONFIG - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the night light on.""" - if self.device._config_dict["module"] in VS_FAN_TYPES: + if self.device.product_type in (ProductTypes.PURIFIER, ProductTypes.FAN): if ATTR_BRIGHTNESS in kwargs and kwargs[ATTR_BRIGHTNESS] < 255: - self.device.set_night_light("dim") + await self.device.set_nightlight_mode("dim") else: - self.device.set_night_light("on") + await self.device.set_nightlight_mode("on") elif ATTR_BRIGHTNESS in kwargs: - self.device.set_night_light_brightness( + await self.device.set_nightlight_brightness( _ha_brightness_to_vesync(kwargs[ATTR_BRIGHTNESS]) ) else: - self.device.set_night_light_brightness(100) + await self.device.set_nightlight_brightness(100) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the night light off.""" - if self.device._config_dict["module"] in VS_FAN_TYPES: - self.device.set_night_light("off") + if self.device.product_type in (ProductTypes.PURIFIER, ProductTypes.FAN): + await self.device.set_nightlight_mode("off") else: - self.device.set_night_light_brightness(0) + await self.device.set_nightlight_brightness(0) diff --git a/custom_components/vesync/manifest.json b/custom_components/vesync/manifest.json index 34db743..2a1d3a1 100644 --- a/custom_components/vesync/manifest.json +++ b/custom_components/vesync/manifest.json @@ -23,7 +23,7 @@ "pyvesync" ], "requirements": [ - "pyvesync==2.1.15" + "pyvesync==3.4.2" ], "version": "1.4.0" } diff --git a/custom_components/vesync/number.py b/custom_components/vesync/number.py index 5074932..77600a6 100644 --- a/custom_components/vesync/number.py +++ b/custom_components/vesync/number.py @@ -48,7 +48,7 @@ def _setup_entities(devices, async_add_entities, coordinator): for dev in devices: if has_feature(dev, "details", "mist_virtual_level"): entities.append(VeSyncHumidifierMistLevelHA(dev, coordinator)) - if has_feature(dev, "config", "auto_target_humidity"): + if has_feature(dev, "details", "auto_target_humidity"): entities.append(VeSyncHumidifierTargetLevelHA(dev, coordinator)) if has_feature(dev, "details", "warm_mist_level"): entities.append(VeSyncHumidifierWarmthLevelHA(dev, coordinator)) @@ -77,8 +77,8 @@ class VeSyncFanSpeedLevelHA(VeSyncNumberEntity): def __init__(self, device, coordinator) -> None: """Initialize the number entity.""" super().__init__(device, coordinator) - self._attr_native_min_value = device._config_dict["levels"][0] - self._attr_native_max_value = device._config_dict["levels"][-1] + self._attr_native_min_value = device.fan_levels[0] + self._attr_native_max_value = device.fan_levels[-1] self._attr_native_step = 1 @property @@ -94,16 +94,16 @@ def name(self): @property def native_value(self): """Return the fan speed level.""" - return self.device.speed + return self.device.state.fan_level @property def extra_state_attributes(self): """Return the state attributes of the humidifier.""" - return {"fan speed levels": self.device._config_dict["levels"]} + return {"fan speed levels": self.device.fan_levels} - def set_native_value(self, value): + async def async_set_native_value(self, value): """Set the fan speed level.""" - self.device.change_fan_speed(int(value)) + await self.device.change_fan_speed(int(value)) class VeSyncHumidifierMistLevelHA(VeSyncNumberEntity): @@ -112,8 +112,8 @@ class VeSyncHumidifierMistLevelHA(VeSyncNumberEntity): def __init__(self, device, coordinator) -> None: """Initialize the number entity.""" super().__init__(device, coordinator) - self._attr_native_min_value = device._config_dict["mist_levels"][0] - self._attr_native_max_value = device._config_dict["mist_levels"][-1] + self._attr_native_min_value = device.mist_levels[0] + self._attr_native_max_value = device.mist_levels[-1] self._attr_native_step = 1 @property @@ -129,16 +129,16 @@ def name(self): @property def native_value(self): """Return the mist level.""" - return self.device.details["mist_virtual_level"] + return self.device.state.mist_virtual_level @property def extra_state_attributes(self): """Return the state attributes of the humidifier.""" - return {"mist levels": self.device._config_dict["mist_levels"]} + return {"mist levels": self.device.mist_levels} - def set_native_value(self, value): + async def async_set_native_value(self, value): """Set the mist level.""" - self.device.set_mist_level(int(value)) + await self.device.set_mist_level(int(value)) class VeSyncHumidifierWarmthLevelHA(VeSyncNumberEntity): @@ -147,8 +147,8 @@ class VeSyncHumidifierWarmthLevelHA(VeSyncNumberEntity): def __init__(self, device, coordinator) -> None: """Initialize the number entity.""" super().__init__(device, coordinator) - self._attr_native_min_value = device._config_dict["warm_mist_levels"][0] - self._attr_native_max_value = device._config_dict["warm_mist_levels"][-1] + self._attr_native_min_value = device.warm_mist_levels[0] + self._attr_native_max_value = device.warm_mist_levels[-1] self._attr_native_step = 1 @property @@ -164,16 +164,16 @@ def name(self): @property def native_value(self): """Return the warmth level.""" - return self.device.details["warm_mist_level"] + return self.device.state.warm_mist_level @property def extra_state_attributes(self): """Return the state attributes of the humidifier.""" - return {"warm mist levels": self.device._config_dict["warm_mist_levels"]} + return {"warm mist levels": self.device.warm_mist_levels} - def set_native_value(self, value): + async def async_set_native_value(self, value): """Set the mist level.""" - self.device.set_warm_level(int(value)) + await self.device.set_warm_level(int(value)) class VeSyncHumidifierTargetLevelHA(VeSyncNumberEntity): @@ -199,7 +199,7 @@ def name(self): @property def native_value(self): """Return the current target humidity level.""" - return self.device.config["auto_target_humidity"] + return self.device.state.auto_target_humidity @property def native_unit_of_measurement(self): @@ -208,15 +208,9 @@ def native_unit_of_measurement(self): @property def device_class(self): - """Return the device class of the target humidity level. - - Eventually this should become NumberDeviceClass but that was introduced in 2022.12. - For maximum compatibility, using SensorDeviceClass as recommended by deprecation notice. - Or hard code this to "humidity" - """ - + """Return the device class of the target humidity level.""" return SensorDeviceClass.HUMIDITY - def set_native_value(self, value): + async def async_set_native_value(self, value): """Set the target humidity level.""" - self.device.set_humidity(int(value)) + await self.device.set_humidity(int(value)) diff --git a/custom_components/vesync/sensor.py b/custom_components/vesync/sensor.py index dc0a8e7..f233f37 100644 --- a/custom_components/vesync/sensor.py +++ b/custom_components/vesync/sensor.py @@ -64,7 +64,7 @@ def _setup_entities(devices, async_add_entities, coordinator): """Check if device is online and add entity.""" entities = [] for dev in devices: - if hasattr(dev, "fryer_status"): + if hasattr(dev.state, "cook_set_temp"): for stype in SENSOR_TYPES_AIRFRYER.values(): entities.append( # noqa: PERF401 VeSyncairfryerSensor( @@ -83,14 +83,14 @@ def _setup_entities(devices, async_add_entities, coordinator): ) if has_feature(dev, "details", "humidity"): entities.append(VeSyncHumiditySensor(dev, coordinator)) - if has_feature(dev, "details", "air_quality"): + if has_feature(dev, "details", "air_quality_level"): if dev.device_type == "LV-PUR131S": entities.append(VeSyncAirQualitySensorPUR131S(dev, coordinator)) else: entities.append(VeSyncAirQualitySensor(dev, coordinator)) if has_feature(dev, "details", "aq_percent"): entities.append(VeSyncAirQualityPercSensor(dev, coordinator)) - if has_feature(dev, "details", "air_quality_value"): + if has_feature(dev, "details", "pm25"): entities.append(VeSyncAirQualityValueSensor(dev, coordinator)) if has_feature(dev, "details", "pm1"): entities.append(VeSyncPM1Sensor(dev, coordinator)) @@ -131,12 +131,11 @@ def device_class(self): @property def native_value(self): """Return the value.""" - return getattr(self.airfryer, self.stype[5], None) + return getattr(self.airfryer.state, self.stype[5], None) @property def native_unit_of_measurement(self): """Return the unit of measurement.""" - # return self.airfryer.temp_unit return self.stype[2] @property @@ -196,10 +195,11 @@ def state_class(self): """Return the measurement state class.""" return SensorStateClass.MEASUREMENT - def update(self): + async def async_update(self): """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() + await self.smartplug.update() + if hasattr(self.smartplug, "update_energy"): + await self.smartplug.update_energy() class VeSyncEnergySensor(VeSyncOutletSensorEntity): @@ -240,10 +240,11 @@ def state_class(self): """Return the total_increasing state class.""" return SensorStateClass.TOTAL_INCREASING - def update(self): + async def async_update(self): """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() + await self.smartplug.update() + if hasattr(self.smartplug, "update_energy"): + await self.smartplug.update_energy() class VeSyncHumidifierSensorEntity(VeSyncBaseEntity, SensorEntity): @@ -290,12 +291,12 @@ def name(self): @property def native_value(self): """Return the air quality index.""" - if has_feature(self.smarthumidifier, "details", "air_quality"): - quality = self.smarthumidifier.details["air_quality"] + quality = getattr(self.smarthumidifier.state, "air_quality_level", None) + if quality is not None: if isinstance(quality, (int, float)): return quality _LOGGER.warning( - "Got non numeric value for AQI sensor from 'air_quality' for %s: %s", + "Got non numeric value for AQI sensor from 'air_quality_level' for %s: %s", self.name, quality, ) @@ -309,14 +310,16 @@ class VeSyncAirQualitySensorPUR131S(VeSyncAirQualitySensor): @property def native_value(self): """Return the air quality index.""" - if has_feature(self.smarthumidifier, "details", "air_quality"): - quality = self.smarthumidifier.details["air_quality"] + quality = getattr(self.smarthumidifier.state, "air_quality_level", None) + if quality is not None: try: - index = pur131s_quality_strings.index(quality) - return index + 1 + if isinstance(quality, str): + index = pur131s_quality_strings.index(quality) + return index + 1 + return quality except ValueError: _LOGGER.warning( - "Got unrecognized value for PUR131S AQI sensor from 'air_quality' for %s: %s", + "Got unrecognized value for PUR131S AQI sensor from 'air_quality_level' for %s: %s", self.name, quality, ) @@ -354,8 +357,8 @@ def native_unit_of_measurement(self): @property def native_value(self): """Return the air quality percentage.""" - if has_feature(self.smarthumidifier, "details", "aq_percent"): - quality = self.smarthumidifier.details["aq_percent"] + quality = getattr(self.smarthumidifier.state, "aq_percent", None) + if quality is not None: if isinstance(quality, (int, float)): return quality _LOGGER.warning( @@ -391,12 +394,12 @@ def name(self): @property def native_value(self): """Return the air quality index.""" - if has_feature(self.smarthumidifier, "details", "air_quality_value"): - quality_value = self.smarthumidifier.details["air_quality_value"] + quality_value = getattr(self.smarthumidifier.state, "pm25", None) + if quality_value is not None: if isinstance(quality_value, (int, float)): return quality_value _LOGGER.warning( - "Got non numeric value for AQI sensor from 'air_quality_value' for %s: %s", + "Got non numeric value for AQI sensor from 'pm25' for %s: %s", self.name, quality_value, ) @@ -428,8 +431,8 @@ def name(self): @property def native_value(self): """Return the PM1.""" - if has_feature(self.smarthumidifier, "details", "pm1"): - quality_value = self.smarthumidifier.details["pm1"] + quality_value = getattr(self.smarthumidifier.state, "pm1", None) + if quality_value is not None: if isinstance(quality_value, (int, float)): return quality_value _LOGGER.warning( @@ -465,8 +468,8 @@ def name(self): @property def native_value(self): """Return the PM10.""" - if has_feature(self.smarthumidifier, "details", "pm10"): - quality_value = self.smarthumidifier.details["pm10"] + quality_value = getattr(self.smarthumidifier.state, "pm10", None) + if quality_value is not None: if isinstance(quality_value, (int, float)): return quality_value _LOGGER.warning( @@ -508,11 +511,7 @@ def device_class(self): @property def native_value(self): """Return the filter life index.""" - return ( - self.smarthumidifier.filter_life - if hasattr(self.smarthumidifier, "filter_life") - else self.smarthumidifier.details["filter_life"] - ) + return getattr(self.smarthumidifier.state, "filter_life", None) @property def native_unit_of_measurement(self): @@ -524,15 +523,6 @@ def state_class(self): """Return the measurement state class.""" return SensorStateClass.MEASUREMENT - @property - def state_attributes(self): - """Return the state attributes.""" - return ( - self.smarthumidifier.details["filter_life"] - if isinstance(self.smarthumidifier.details["filter_life"], dict) - else {} - ) - @property def icon(self): """Return the icon to use in the frontend, if any.""" @@ -569,15 +559,11 @@ def device_class(self): @property def native_value(self): """Return the fan rotate angle index.""" - return ( - self.smarthumidifier.fan_rotate_angle - if hasattr(self.smarthumidifier, "fan_rotate_angle") - else self.smarthumidifier.details["fan_rotate_angle"] - ) + return getattr(self.smarthumidifier.state, "fan_rotate_angle", None) @property def native_unit_of_measurement(self): - """Return the % unit of measurement.""" + """Return the degree unit of measurement.""" return DEGREE @property @@ -616,7 +602,7 @@ def device_class(self): @property def native_value(self): """Return the current humidity in percent.""" - return self.smarthumidifier.details["humidity"] + return self.smarthumidifier.state.humidity @property def native_unit_of_measurement(self): diff --git a/custom_components/vesync/switch.py b/custom_components/vesync/switch.py index 63fe908..8c0ded8 100644 --- a/custom_components/vesync/switch.py +++ b/custom_components/vesync/switch.py @@ -68,9 +68,9 @@ def __init__(self, plug, coordinator) -> None: """Initialize the VeSync outlet device.""" super().__init__(plug, coordinator) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" - self.device.turn_on() + await self.device.turn_on() class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): @@ -95,10 +95,11 @@ def extra_state_attributes(self): else {} ) - def update(self): + async def async_update(self): """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() + await self.smartplug.update() + if hasattr(self.smartplug, "update_energy"): + await self.smartplug.update_energy() class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity): @@ -124,12 +125,15 @@ def entity_category(self): return EntityCategory.CONFIG def is_on_safe(self, keys): - """Return True if the given property is on.""" + """Return True if the given state attribute is on.""" for key in keys: - if key in self.device.details.keys(): - return self.device.details[key] + val = getattr(self.device.state, key, None) + if val is not None: + return val _LOGGER.error( - f'Keys "{keys}" keys are not present in details "{self.device.details}" for "{super().name}"!' + 'Keys "%s" are not present in state for "%s"!', + keys, + super().name, ) return "unavailable" @@ -154,15 +158,15 @@ def name(self): @property def is_on(self): """Return True if it is locked.""" - return super().is_on_safe(["child_lock"]) + return self.device.state.child_lock - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the lock on.""" - self.device.child_lock_on() + await self.device.child_lock_on() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the lock off.""" - self.device.child_lock_off() + await self.device.toggle_child_lock(False) class VeSyncHumidifierDisplayHA(VeSyncSwitchEntity): @@ -185,15 +189,15 @@ def name(self): @property def is_on(self): """Return True if the display is on.""" - return super().is_on_safe(["display", "screen_status"]) + return super().is_on_safe(["display_status", "screen_status"]) - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the display on.""" - self.device.turn_on_display() + await self.device.turn_on_display() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the display off.""" - self.device.turn_off_display() + await self.device.turn_off_display() class VeSyncHumidifierAutomaticStopHA(VeSyncSwitchEntity): @@ -216,15 +220,15 @@ def name(self): @property def is_on(self): """Return True if automatic stop is on.""" - return self.device.config["automatic_stop"] + return self.device.state.automatic_stop - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the automatic stop on.""" - self.device.automatic_stop_on() + await self.device.automatic_stop_on() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the automatic stop off.""" - self.device.automatic_stop_off() + await self.device.automatic_stop_off() class VeSyncHumidifierAutoOnHA(VeSyncSwitchEntity): @@ -247,13 +251,13 @@ def name(self): @property def is_on(self): """Return True if in auto mode.""" - return super().is_on_safe(["mode"]) == "auto" + return self.device.state.mode == "auto" - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn auto mode on.""" - self.device.set_auto_mode() + await self.device.set_auto_mode() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn auto off by setting manual and mist level 1.""" - self.device.set_manual_mode() - self.device.set_mist_level(1) + await self.device.set_manual_mode() + await self.device.set_mist_level(1) diff --git a/requirements.txt b/requirements.txt index 1f0f6a7..428d42d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -pyvesync==2.1.12 +pyvesync==3.4.2 diff --git a/requirements_dev.txt b/requirements_dev.txt index 2c9a326..ae755ad 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -homeassistant==2022.7.7 +homeassistant==2026.6.4 black isort flake8 From 7d79d11f2096e7c6418e16129643dbf4b8b0019c Mon Sep 17 00:00:00 2001 From: Max R Date: Mon, 29 Jun 2026 08:01:27 -0400 Subject: [PATCH 2/5] Update pre-commit, HA dep, and hacs.json for Python 3.14 - pyupgrade: v3.19.1 -> v3.21.2, args --py313-plus -> --py314-plus - requirements_dev.txt, hacs.json: homeassistant -> 2025.11.0 (first HA release to declare Python 3.14 support) - Reorder config_flow.py: VeSyncOptionsFlowHandler before VeSyncFlowHandler to fix F821 forward reference now that pyupgrade removes annotation quotes --- .pre-commit-config.yaml | 4 +- custom_components/vesync/config_flow.py | 54 +++++++++++------------ custom_components/vesync/device_action.py | 2 - custom_components/vesync/diagnostics.py | 2 - custom_components/vesync/humidifier.py | 2 - hacs.json | 2 +- requirements_dev.txt | 2 +- 7 files changed, 30 insertions(+), 38 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c895f0c..996ce70 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,9 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v3.19.1 + rev: v3.21.2 hooks: - id: pyupgrade - args: [--py313-plus] + args: [--py314-plus] - repo: https://github.com/psf/black rev: 25.1.0 hooks: diff --git a/custom_components/vesync/config_flow.py b/custom_components/vesync/config_flow.py index 7370356..b370572 100644 --- a/custom_components/vesync/config_flow.py +++ b/custom_components/vesync/config_flow.py @@ -1,7 +1,5 @@ """Config flow utilities.""" -from __future__ import annotations - import logging from collections.abc import Mapping from typing import Any @@ -43,6 +41,32 @@ def reauth_schema( } +class VeSyncOptionsFlowHandler(config_entries.OptionsFlow): + """Handle VeSync integration options.""" + + async def async_step_init(self, user_input=None): + """Manage options.""" + + return await self.async_step_vesync_options() + + async def async_step_vesync_options(self, user_input=None): + """Manage the VeSync options.""" + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = { + vol.Required( + POLLING_INTERVAL, + default=self.config_entry.options.get(POLLING_INTERVAL, 60), + ): int, + } + + return self.async_show_form( + step_id="vesync_options", data_schema=vol.Schema(options) + ) + + class VeSyncFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -148,29 +172,3 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes _LOGGER.debug("DHCP discovery detected device %s", hostname) self.context["title_placeholders"] = {"gateway_id": hostname} return await self.async_step_user() - - -class VeSyncOptionsFlowHandler(config_entries.OptionsFlow): - """Handle VeSync integration options.""" - - async def async_step_init(self, user_input=None): - """Manage options.""" - - return await self.async_step_vesync_options() - - async def async_step_vesync_options(self, user_input=None): - """Manage the VeSync options.""" - - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - options = { - vol.Required( - POLLING_INTERVAL, - default=self.config_entry.options.get(POLLING_INTERVAL, 60), - ): int, - } - - return self.async_show_form( - step_id="vesync_options", data_schema=vol.Schema(options) - ) diff --git a/custom_components/vesync/device_action.py b/custom_components/vesync/device_action.py index 5a794a8..ae46c3c 100644 --- a/custom_components/vesync/device_action.py +++ b/custom_components/vesync/device_action.py @@ -1,7 +1,5 @@ """Provides device actions for Humidifier.""" -from __future__ import annotations - import logging import homeassistant.helpers.config_validation as cv diff --git a/custom_components/vesync/diagnostics.py b/custom_components/vesync/diagnostics.py index cf2d4f5..140a65d 100644 --- a/custom_components/vesync/diagnostics.py +++ b/custom_components/vesync/diagnostics.py @@ -1,7 +1,5 @@ """Provides diagnostics for VeSync.""" -from __future__ import annotations - from typing import Any from homeassistant.components.diagnostics import async_redact_data diff --git a/custom_components/vesync/humidifier.py b/custom_components/vesync/humidifier.py index 4b3d146..5192803 100644 --- a/custom_components/vesync/humidifier.py +++ b/custom_components/vesync/humidifier.py @@ -1,7 +1,5 @@ """Support for VeSync humidifiers.""" -from __future__ import annotations - import logging from collections.abc import Mapping from typing import Any diff --git a/hacs.json b/hacs.json index f497a6e..d06b9be 100644 --- a/hacs.json +++ b/hacs.json @@ -1,6 +1,6 @@ { "name": "Custom VeSync", - "homeassistant": "2024.12.0b0", + "homeassistant": "2025.11.0", "zip_release": true, "filename": "vesync.zip" } diff --git a/requirements_dev.txt b/requirements_dev.txt index ae755ad..bff4118 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,5 +1,5 @@ -r requirements.txt -homeassistant==2026.6.4 +homeassistant==2025.11.0 black isort flake8 From 51b9521a6819c3f35f5138992c64ba7597fc6869 Mon Sep 17 00:00:00 2001 From: Max R Date: Mon, 29 Jun 2026 08:04:53 -0400 Subject: [PATCH 3/5] Simplify has_feature capability checks with getattr default --- custom_components/vesync/common.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/custom_components/vesync/common.py b/custom_components/vesync/common.py index e91739d..3017b57 100644 --- a/custom_components/vesync/common.py +++ b/custom_components/vesync/common.py @@ -27,15 +27,13 @@ def has_feature(device, dictionary, attribute): return getattr(device.state, attribute, None) is not None elif dictionary == "_config_dict": if attribute == "levels": - return hasattr(device, "fan_levels") and len(device.fan_levels) > 0 + return len(getattr(device, "fan_levels", ())) > 0 elif attribute == "mist_levels": - return hasattr(device, "mist_levels") and len(device.mist_levels) > 0 + return len(getattr(device, "mist_levels", ())) > 0 elif attribute == "warm_mist_levels": - return ( - hasattr(device, "warm_mist_levels") and len(device.warm_mist_levels) > 0 - ) + return len(getattr(device, "warm_mist_levels", ())) > 0 elif attribute == "modes": - return hasattr(device, "modes") and len(device.modes) > 0 + return len(getattr(device, "modes", ())) > 0 return False return getattr(device, dictionary, {}).get(attribute, None) is not None From b6ef5a4f40698842a23ddece1b4072d41a6a2fbe Mon Sep 17 00:00:00 2001 From: Max R Date: Mon, 29 Jun 2026 08:10:15 -0400 Subject: [PATCH 4/5] Make has_feature _config_dict lookup O(1) with module-level dict --- custom_components/vesync/common.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/custom_components/vesync/common.py b/custom_components/vesync/common.py index 3017b57..20f2b51 100644 --- a/custom_components/vesync/common.py +++ b/custom_components/vesync/common.py @@ -21,20 +21,21 @@ _LOGGER = logging.getLogger(__name__) +_CONFIG_DICT_ATTR_MAP = { + "levels": "fan_levels", + "mist_levels": "mist_levels", + "warm_mist_levels": "warm_mist_levels", + "modes": "modes", +} + + def has_feature(device, dictionary, attribute): """Return the detail of the attribute.""" if dictionary in ("details", "config"): return getattr(device.state, attribute, None) is not None elif dictionary == "_config_dict": - if attribute == "levels": - return len(getattr(device, "fan_levels", ())) > 0 - elif attribute == "mist_levels": - return len(getattr(device, "mist_levels", ())) > 0 - elif attribute == "warm_mist_levels": - return len(getattr(device, "warm_mist_levels", ())) > 0 - elif attribute == "modes": - return len(getattr(device, "modes", ())) > 0 - return False + device_attr = _CONFIG_DICT_ATTR_MAP.get(attribute) + return device_attr is not None and len(getattr(device, device_attr, ())) > 0 return getattr(device, dictionary, {}).get(attribute, None) is not None From a732be6e98a00eb2fae5f262dcf8b207bde69537 Mon Sep 17 00:00:00 2001 From: Max R Date: Mon, 29 Jun 2026 08:15:21 -0400 Subject: [PATCH 5/5] Restore comments and type hints dropped during migration --- custom_components/vesync/common.py | 7 +++--- custom_components/vesync/humidifier.py | 5 +++-- custom_components/vesync/light.py | 31 ++++++++++++++++++++++++-- custom_components/vesync/number.py | 8 ++++++- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/custom_components/vesync/common.py b/custom_components/vesync/common.py index 20f2b51..b0a9fe2 100644 --- a/custom_components/vesync/common.py +++ b/custom_components/vesync/common.py @@ -95,10 +95,7 @@ async def async_process_devices(hass, manager): devices[VS_SENSORS].extend(manager.devices.outlets) for switch in manager.devices.switches: - if not switch.is_dimmable(): - devices[VS_SWITCHES].append(switch) - else: - devices[VS_LIGHTS].append(switch) + devices[VS_LIGHTS if switch.is_dimmable() else VS_SWITCHES].append(switch) for airfryer in manager.devices.air_fryers: _LOGGER.warning( @@ -130,6 +127,8 @@ def base_unique_id(self): @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 diff --git a/custom_components/vesync/humidifier.py b/custom_components/vesync/humidifier.py index 5192803..486a783 100644 --- a/custom_components/vesync/humidifier.py +++ b/custom_components/vesync/humidifier.py @@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from pyvesync.base_devices.humidifier_base import VeSyncHumidifier from .common import VeSyncDevice from .const import ( @@ -99,7 +100,7 @@ class VeSyncHumidifierHA(VeSyncDevice, HumidifierEntity): _attr_max_humidity = MAX_HUMIDITY _attr_min_humidity = MIN_HUMIDITY - def __init__(self, humidifier, coordinator) -> None: + def __init__(self, humidifier: VeSyncHumidifier, coordinator) -> None: """Initialize the VeSync humidifier device.""" super().__init__(humidifier, coordinator) self.smarthumidifier = humidifier @@ -136,7 +137,7 @@ def mode(self) -> str | None: @property def is_on(self) -> bool: """Return True if humidifier is on.""" - return self.smarthumidifier.is_on + return self.smarthumidifier.is_on # device_status is always on @property def unique_info(self) -> str: diff --git a/custom_components/vesync/light.py b/custom_components/vesync/light.py index 76336a1..fc22bee 100644 --- a/custom_components/vesync/light.py +++ b/custom_components/vesync/light.py @@ -63,19 +63,25 @@ def _setup_entities(devices, async_add_entities, coordinator): def _vesync_brightness_to_ha(vesync_brightness): try: + # check for validity of brightness value received brightness_value = int(vesync_brightness) except (ValueError, TypeError): + # deal if any unexpected/non numeric value _LOGGER.debug( "VeSync - received unexpected 'brightness' value from pyvesync api: %s", vesync_brightness, ) return None + # convert percent brightness to ha expected range return round((max(1, brightness_value) / 100) * 255) def _ha_brightness_to_vesync(ha_brightness): + # get brightness from HA data brightness = int(ha_brightness) + # ensure value between 1-255 brightness = max(1, min(brightness, 255)) + # convert to percent that vesync api expects brightness = round((brightness / 255) * 100) return max(1, min(brightness, 100)) @@ -90,34 +96,48 @@ def __init__(self, light, coordinator): @property def brightness(self): """Get light brightness.""" + # get value from pyvesync library api return _vesync_brightness_to_ha(self.device.state.brightness) async def async_turn_on(self, **kwargs): """Turn the device on.""" attribute_adjustment_only = False + # set white temperature if ( self.color_mode in (ColorMode.COLOR_TEMP,) and ATTR_COLOR_TEMP_KELVIN in kwargs ): + # get white temperature from HA data color_temp = int(kwargs[ATTR_COLOR_TEMP_KELVIN]) + # ensure value between min-max supported Mireds color_temp = max(self.min_mireds, min(color_temp, self.max_mireds)) + # convert Mireds to Percent value that api expects color_temp = round( ((color_temp - self.min_mireds) / (self.max_mireds - self.min_mireds)) * 100 ) + # flip cold/warm to what pyvesync api expects color_temp = 100 - color_temp + # ensure value between 0-100 color_temp = max(0, min(color_temp, 100)) + # call pyvesync library api method to set color_temp await self.device.set_color_temp(color_temp) + # flag attribute_adjustment_only, so it doesn't turn_on the device redundantly attribute_adjustment_only = True + # set brightness level if ( self.color_mode in (ColorMode.BRIGHTNESS, ColorMode.COLOR_TEMP) and ATTR_BRIGHTNESS in kwargs ): + # get brightness from HA data brightness = _ha_brightness_to_vesync(kwargs[ATTR_BRIGHTNESS]) await self.device.set_brightness(brightness) + # flag attribute_adjustment_only, so it doesn't turn_on the device redundantly attribute_adjustment_only = True + # check flag if should skip sending the turn_on command if attribute_adjustment_only: return + # send turn_on command to pyvesync api await self.device.turn_on() @@ -149,32 +169,39 @@ def __init__(self, device, coordinator) -> None: @property def color_temp(self): """Get device white temperature.""" + # get value from pyvesync library api result = self.device.state.color_temp try: + # check for validity of color_temp value received color_temp_value = int(result) except (ValueError, TypeError): + # deal if any unexpected/non numeric value _LOGGER.debug( "VeSync - received unexpected 'color_temp' value from pyvesync api: %s", result, ) return 0 + # flip cold/warm color_temp_value = 100 - color_temp_value + # ensure value between 0-100 color_temp_value = max(0, min(color_temp_value, 100)) + # convert percent value to Mireds color_temp_value = round( self.min_mireds + ((self.max_mireds - self.min_mireds) / 100 * color_temp_value) ) + # ensure value between minimum and maximum Mireds return max(self.min_mireds, min(color_temp_value, self.max_mireds)) @property def min_mireds(self): """Set device coldest white temperature.""" - return 154 + return 154 # 154 Mireds ( 1,000,000 divided by 6500 Kelvin = 154 Mireds) @property def max_mireds(self): """Set device warmest white temperature.""" - return 370 + return 370 # 370 Mireds ( 1,000,000 divided by 2700 Kelvin = 370 Mireds) @property def color_mode(self): diff --git a/custom_components/vesync/number.py b/custom_components/vesync/number.py index 77600a6..8f0309c 100644 --- a/custom_components/vesync/number.py +++ b/custom_components/vesync/number.py @@ -208,7 +208,13 @@ def native_unit_of_measurement(self): @property def device_class(self): - """Return the device class of the target humidity level.""" + """Return the device class of the target humidity level. + + Eventually this should become NumberDeviceClass but that was introduced in 2022.12. + For maximum compatibility, using SensorDeviceClass as recommended by deprecation notice. + Or hard code this to "humidity" + """ + return SensorDeviceClass.HUMIDITY async def async_set_native_value(self, value):