From 8e1ee321908bfd50ffadc0b60f934815ea934418 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Sep 2025 11:54:37 +0200 Subject: [PATCH 01/13] Revert "Improve migration to entity registry version 1.18" (#151561) --- homeassistant/helpers/entity_registry.py | 97 ++---- tests/helpers/test_entity_registry.py | 392 +---------------------- 2 files changed, 35 insertions(+), 454 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index f10edf1f57db6..5529d78e13a0f 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -16,7 +16,7 @@ from enum import StrEnum import logging import time -from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict +from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict import attr import voluptuous as vol @@ -85,8 +85,6 @@ CLEANUP_INTERVAL = 3600 * 24 ORPHANED_ENTITY_KEEP_SECONDS = 3600 * 24 * 30 -UNDEFINED_STR: Final = "UNDEFINED" - ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = { val: idx for idx, val in enumerate(EntityCategory) } @@ -166,17 +164,6 @@ def _protect_entity_options( return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) -def _protect_optional_entity_options( - data: EntityOptionsType | UndefinedType | None, -) -> ReadOnlyEntityOptionsType | UndefinedType: - """Protect entity options from being modified.""" - if data is UNDEFINED: - return UNDEFINED - if data is None: - return ReadOnlyDict({}) - return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) - - @attr.s(frozen=True, kw_only=True, slots=True) class RegistryEntry: """Entity Registry Entry.""" @@ -427,17 +414,15 @@ class DeletedRegistryEntry: config_subentry_id: str | None = attr.ib() created_at: datetime = attr.ib() device_class: str | None = attr.ib() - disabled_by: RegistryEntryDisabler | UndefinedType | None = attr.ib() + disabled_by: RegistryEntryDisabler | None = attr.ib() domain: str = attr.ib(init=False, repr=False) - hidden_by: RegistryEntryHider | UndefinedType | None = attr.ib() + hidden_by: RegistryEntryHider | None = attr.ib() icon: str | None = attr.ib() id: str = attr.ib() labels: set[str] = attr.ib() modified_at: datetime = attr.ib() name: str | None = attr.ib() - options: ReadOnlyEntityOptionsType | UndefinedType = attr.ib( - converter=_protect_optional_entity_options - ) + options: ReadOnlyEntityOptionsType = attr.ib(converter=_protect_entity_options) orphaned_timestamp: float | None = attr.ib() _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @@ -460,21 +445,15 @@ def as_storage_fragment(self) -> json_fragment: "config_subentry_id": self.config_subentry_id, "created_at": self.created_at, "device_class": self.device_class, - "disabled_by": self.disabled_by - if self.disabled_by is not UNDEFINED - else UNDEFINED_STR, + "disabled_by": self.disabled_by, "entity_id": self.entity_id, - "hidden_by": self.hidden_by - if self.hidden_by is not UNDEFINED - else UNDEFINED_STR, + "hidden_by": self.hidden_by, "icon": self.icon, "id": self.id, "labels": list(self.labels), "modified_at": self.modified_at, "name": self.name, - "options": self.options - if self.options is not UNDEFINED - else UNDEFINED_STR, + "options": self.options, "orphaned_timestamp": self.orphaned_timestamp, "platform": self.platform, "unique_id": self.unique_id, @@ -605,12 +584,12 @@ async def _async_migrate_func( # noqa: C901 entity["area_id"] = None entity["categories"] = {} entity["device_class"] = None - entity["disabled_by"] = UNDEFINED_STR - entity["hidden_by"] = UNDEFINED_STR + entity["disabled_by"] = None + entity["hidden_by"] = None entity["icon"] = None entity["labels"] = [] entity["name"] = None - entity["options"] = UNDEFINED_STR + entity["options"] = {} if old_major_version > 1: raise NotImplementedError @@ -980,30 +959,25 @@ def async_get_or_create( categories = deleted_entity.categories created_at = deleted_entity.created_at device_class = deleted_entity.device_class - if deleted_entity.disabled_by is not UNDEFINED: - disabled_by = deleted_entity.disabled_by - # Adjust disabled_by based on config entry state - if config_entry and config_entry is not UNDEFINED: - if config_entry.disabled_by: - if disabled_by is None: - disabled_by = RegistryEntryDisabler.CONFIG_ENTRY - elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY: - disabled_by = None + disabled_by = deleted_entity.disabled_by + # Adjust disabled_by based on config entry state + if config_entry and config_entry is not UNDEFINED: + if config_entry.disabled_by: + if disabled_by is None: + disabled_by = RegistryEntryDisabler.CONFIG_ENTRY elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY: disabled_by = None + elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY: + disabled_by = None # Restore entity_id if it's available if self._entity_id_available(deleted_entity.entity_id): entity_id = deleted_entity.entity_id entity_registry_id = deleted_entity.id - if deleted_entity.hidden_by is not UNDEFINED: - hidden_by = deleted_entity.hidden_by + hidden_by = deleted_entity.hidden_by icon = deleted_entity.icon labels = deleted_entity.labels name = deleted_entity.name - if deleted_entity.options is not UNDEFINED: - options = deleted_entity.options - else: - options = get_initial_options() if get_initial_options else None + options = deleted_entity.options else: aliases = set() area_id = None @@ -1556,20 +1530,6 @@ async def async_load(self) -> None: previous_unique_id=entity["previous_unique_id"], unit_of_measurement=entity["unit_of_measurement"], ) - - def get_optional_enum[_EnumT: StrEnum]( - cls: type[_EnumT], value: str | None - ) -> _EnumT | UndefinedType | None: - """Convert string to the passed enum, UNDEFINED or None.""" - if value is None: - return None - if value == UNDEFINED_STR: - return UNDEFINED - try: - return cls(value) - except ValueError: - return None - for entity in data["deleted_entities"]: try: domain = split_entity_id(entity["entity_id"])[0] @@ -1587,7 +1547,6 @@ def get_optional_enum[_EnumT: StrEnum]( entity["platform"], entity["unique_id"], ) - deleted_entities[key] = DeletedRegistryEntry( aliases=set(entity["aliases"]), area_id=entity["area_id"], @@ -1596,21 +1555,23 @@ def get_optional_enum[_EnumT: StrEnum]( config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], - disabled_by=get_optional_enum( - RegistryEntryDisabler, entity["disabled_by"] + disabled_by=( + RegistryEntryDisabler(entity["disabled_by"]) + if entity["disabled_by"] + else None ), entity_id=entity["entity_id"], - hidden_by=get_optional_enum( - RegistryEntryHider, entity["hidden_by"] + hidden_by=( + RegistryEntryHider(entity["hidden_by"]) + if entity["hidden_by"] + else None ), icon=entity["icon"], id=entity["id"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), name=entity["name"], - options=entity["options"] - if entity["options"] is not UNDEFINED_STR - else UNDEFINED, + options=entity["options"], orphaned_timestamp=entity["orphaned_timestamp"], platform=entity["platform"], unique_id=entity["unique_id"], diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index da6cdf806d7a8..acbcb02a5ded8 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -20,7 +20,6 @@ from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.dt import utc_from_timestamp, utcnow from tests.common import ( @@ -963,10 +962,9 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) assert entry.device_class is None assert entry.original_device_class == "best_class" - # Check migrated data + # Check we store migrated data await flush_store(registry._store) - migrated_data = hass_storage[er.STORAGE_KEY] - assert migrated_data == { + assert hass_storage[er.STORAGE_KEY] == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, "key": er.STORAGE_KEY, @@ -1009,11 +1007,6 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) }, } - # Serialize the migrated data again - registry.async_schedule_save() - await flush_store(registry._store) - assert hass_storage[er.STORAGE_KEY] == migrated_data - @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_7(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: @@ -1149,17 +1142,9 @@ async def test_migration_1_11( assert entry.device_class is None assert entry.original_device_class == "best_class" - deleted_entry = registry.deleted_entities[ - ("test", "super_duper_platform", "very_very_unique") - ] - assert deleted_entry.disabled_by is UNDEFINED - assert deleted_entry.hidden_by is UNDEFINED - assert deleted_entry.options is UNDEFINED - # Check migrated data await flush_store(registry._store) - migrated_data = hass_storage[er.STORAGE_KEY] - assert migrated_data == { + assert hass_storage[er.STORAGE_KEY] == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, "key": er.STORAGE_KEY, @@ -1207,15 +1192,15 @@ async def test_migration_1_11( "config_subentry_id": None, "created_at": "1970-01-01T00:00:00+00:00", "device_class": None, - "disabled_by": "UNDEFINED", + "disabled_by": None, "entity_id": "test.deleted_entity", - "hidden_by": "UNDEFINED", + "hidden_by": None, "icon": None, "id": "23456", "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, - "options": "UNDEFINED", + "options": {}, "orphaned_timestamp": None, "platform": "super_duper_platform", "unique_id": "very_very_unique", @@ -1224,11 +1209,6 @@ async def test_migration_1_11( }, } - # Serialize the migrated data again - registry.async_schedule_save() - await flush_store(registry._store) - assert hass_storage[er.STORAGE_KEY] == migrated_data - async def test_update_entity_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry @@ -3170,366 +3150,6 @@ async def test_restore_entity( assert update_events[16].data == {"action": "create", "entity_id": "light.hue_1234"} -@pytest.mark.parametrize( - ("entity_disabled_by"), - [ - None, - er.RegistryEntryDisabler.CONFIG_ENTRY, - er.RegistryEntryDisabler.DEVICE, - er.RegistryEntryDisabler.HASS, - er.RegistryEntryDisabler.INTEGRATION, - er.RegistryEntryDisabler.USER, - ], -) -@pytest.mark.usefixtures("freezer") -async def test_restore_migrated_entity_disabled_by( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - entity_disabled_by: er.RegistryEntryDisabler | None, -) -> None: - """Check how the disabled_by flag is treated when restoring an entity.""" - update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry = entity_registry.async_get_or_create( - "light", - "hue", - "1234", - capabilities={"key1": "value1"}, - config_entry=config_entry, - config_subentry_id=None, - device_id=device_entry.id, - disabled_by=None, - entity_category=EntityCategory.DIAGNOSTIC, - get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, - has_entity_name=True, - hidden_by=er.RegistryEntryHider.INTEGRATION, - original_device_class="device_class_1", - original_icon="original_icon_1", - original_name="original_name_1", - suggested_object_id="hue_5678", - supported_features=1, - translation_key="translation_key_1", - unit_of_measurement="unit_1", - ) - - entity_registry.async_remove(entry.entity_id) - assert len(entity_registry.entities) == 0 - assert len(entity_registry.deleted_entities) == 1 - - deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] - entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( - deleted_entry, disabled_by=UNDEFINED - ) - - # Re-add entity, integration has changed - entry_restored = entity_registry.async_get_or_create( - "light", - "hue", - "1234", - capabilities={"key2": "value2"}, - config_entry=config_entry, - config_subentry_id=None, - device_id=device_entry.id, - disabled_by=entity_disabled_by, - entity_category=EntityCategory.CONFIG, - get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, - has_entity_name=False, - hidden_by=None, - original_device_class="device_class_2", - original_icon="original_icon_2", - original_name="original_name_2", - suggested_object_id="suggested_2", - supported_features=2, - translation_key="translation_key_2", - unit_of_measurement="unit_2", - ) - - assert len(entity_registry.entities) == 1 - assert len(entity_registry.deleted_entities) == 0 - assert entry != entry_restored - # entity_id and user customizations are restored. new integration options are - # respected. - assert entry_restored == er.RegistryEntry( - entity_id="light.hue_5678", - unique_id="1234", - platform="hue", - aliases=set(), - area_id=None, - categories={}, - capabilities={"key2": "value2"}, - config_entry_id=config_entry.entry_id, - config_subentry_id=None, - created_at=utcnow(), - device_class=None, - device_id=device_entry.id, - disabled_by=entity_disabled_by, - entity_category=EntityCategory.CONFIG, - has_entity_name=False, - hidden_by=er.RegistryEntryHider.INTEGRATION, - icon=None, - id=entry.id, - labels=set(), - modified_at=utcnow(), - name=None, - options={"test_domain": {"key1": "value1"}}, - original_device_class="device_class_2", - original_icon="original_icon_2", - original_name="original_name_2", - suggested_object_id="suggested_2", - supported_features=2, - translation_key="translation_key_2", - unit_of_measurement="unit_2", - ) - - # Check the events - await hass.async_block_till_done() - assert len(update_events) == 3 - assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} - assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} - assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} - - -@pytest.mark.parametrize( - ("entity_hidden_by"), - [ - None, - er.RegistryEntryHider.INTEGRATION, - er.RegistryEntryHider.USER, - ], -) -@pytest.mark.usefixtures("freezer") -async def test_restore_migrated_entity_hidden_by( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, - entity_hidden_by: er.RegistryEntryHider | None, -) -> None: - """Check how the hidden_by flag is treated when restoring an entity.""" - update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry = entity_registry.async_get_or_create( - "light", - "hue", - "1234", - capabilities={"key1": "value1"}, - config_entry=config_entry, - config_subentry_id=None, - device_id=device_entry.id, - disabled_by=None, - entity_category=EntityCategory.DIAGNOSTIC, - get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, - has_entity_name=True, - hidden_by=er.RegistryEntryHider.INTEGRATION, - original_device_class="device_class_1", - original_icon="original_icon_1", - original_name="original_name_1", - suggested_object_id="hue_5678", - supported_features=1, - translation_key="translation_key_1", - unit_of_measurement="unit_1", - ) - - entity_registry.async_remove(entry.entity_id) - assert len(entity_registry.entities) == 0 - assert len(entity_registry.deleted_entities) == 1 - - deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] - entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( - deleted_entry, hidden_by=UNDEFINED - ) - - # Re-add entity, integration has changed - entry_restored = entity_registry.async_get_or_create( - "light", - "hue", - "1234", - capabilities={"key2": "value2"}, - config_entry=config_entry, - config_subentry_id=None, - device_id=device_entry.id, - disabled_by=None, - entity_category=EntityCategory.CONFIG, - get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, - has_entity_name=False, - hidden_by=entity_hidden_by, - original_device_class="device_class_2", - original_icon="original_icon_2", - original_name="original_name_2", - suggested_object_id="suggested_2", - supported_features=2, - translation_key="translation_key_2", - unit_of_measurement="unit_2", - ) - - assert len(entity_registry.entities) == 1 - assert len(entity_registry.deleted_entities) == 0 - assert entry != entry_restored - # entity_id and user customizations are restored. new integration options are - # respected. - assert entry_restored == er.RegistryEntry( - entity_id="light.hue_5678", - unique_id="1234", - platform="hue", - aliases=set(), - area_id=None, - categories={}, - capabilities={"key2": "value2"}, - config_entry_id=config_entry.entry_id, - config_subentry_id=None, - created_at=utcnow(), - device_class=None, - device_id=device_entry.id, - disabled_by=None, - entity_category=EntityCategory.CONFIG, - has_entity_name=False, - hidden_by=entity_hidden_by, - icon=None, - id=entry.id, - labels=set(), - modified_at=utcnow(), - name=None, - options={"test_domain": {"key1": "value1"}}, - original_device_class="device_class_2", - original_icon="original_icon_2", - original_name="original_name_2", - suggested_object_id="suggested_2", - supported_features=2, - translation_key="translation_key_2", - unit_of_measurement="unit_2", - ) - - # Check the events - await hass.async_block_till_done() - assert len(update_events) == 3 - assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} - assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} - assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} - - -@pytest.mark.usefixtures("freezer") -async def test_restore_migrated_entity_initial_options( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Check how the initial options is treated when restoring an entity.""" - update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) - config_entry = MockConfigEntry(domain="light") - config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - entry = entity_registry.async_get_or_create( - "light", - "hue", - "1234", - capabilities={"key1": "value1"}, - config_entry=config_entry, - config_subentry_id=None, - device_id=device_entry.id, - disabled_by=None, - entity_category=EntityCategory.DIAGNOSTIC, - get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, - has_entity_name=True, - hidden_by=er.RegistryEntryHider.INTEGRATION, - original_device_class="device_class_1", - original_icon="original_icon_1", - original_name="original_name_1", - suggested_object_id="hue_5678", - supported_features=1, - translation_key="translation_key_1", - unit_of_measurement="unit_1", - ) - - entity_registry.async_remove(entry.entity_id) - assert len(entity_registry.entities) == 0 - assert len(entity_registry.deleted_entities) == 1 - - deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] - entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( - deleted_entry, options=UNDEFINED - ) - - # Re-add entity, integration has changed - entry_restored = entity_registry.async_get_or_create( - "light", - "hue", - "1234", - capabilities={"key2": "value2"}, - config_entry=config_entry, - config_subentry_id=None, - device_id=device_entry.id, - disabled_by=None, - entity_category=EntityCategory.CONFIG, - get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, - has_entity_name=False, - hidden_by=None, - original_device_class="device_class_2", - original_icon="original_icon_2", - original_name="original_name_2", - suggested_object_id="suggested_2", - supported_features=2, - translation_key="translation_key_2", - unit_of_measurement="unit_2", - ) - - assert len(entity_registry.entities) == 1 - assert len(entity_registry.deleted_entities) == 0 - assert entry != entry_restored - # entity_id and user customizations are restored. new integration options are - # respected. - assert entry_restored == er.RegistryEntry( - entity_id="light.hue_5678", - unique_id="1234", - platform="hue", - aliases=set(), - area_id=None, - categories={}, - capabilities={"key2": "value2"}, - config_entry_id=config_entry.entry_id, - config_subentry_id=None, - created_at=utcnow(), - device_class=None, - device_id=device_entry.id, - disabled_by=None, - entity_category=EntityCategory.CONFIG, - has_entity_name=False, - hidden_by=er.RegistryEntryHider.INTEGRATION, - icon=None, - id=entry.id, - labels=set(), - modified_at=utcnow(), - name=None, - options={"test_domain": {"key2": "value2"}}, - original_device_class="device_class_2", - original_icon="original_icon_2", - original_name="original_name_2", - suggested_object_id="suggested_2", - supported_features=2, - translation_key="translation_key_2", - unit_of_measurement="unit_2", - ) - - # Check the events - await hass.async_block_till_done() - assert len(update_events) == 3 - assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} - assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} - assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} - - @pytest.mark.parametrize( ( "config_entry_disabled_by", From 10baae92a0c07d3aab2c6282215785052b20d610 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Sep 2025 11:58:27 +0200 Subject: [PATCH 02/13] Revert "Improve migration to device registry version 1.11" (#151563) --- homeassistant/helpers/device_registry.py | 49 +++------ tests/helpers/test_device_registry.py | 131 +---------------------- 2 files changed, 15 insertions(+), 165 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9e57c7ee788a6..b6f01ff31aef2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -9,7 +9,7 @@ from functools import lru_cache import logging import time -from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict +from typing import TYPE_CHECKING, Any, Literal, TypedDict import attr from yarl import URL @@ -68,8 +68,6 @@ ORPHANED_DEVICE_KEEP_SECONDS = 86400 * 30 -UNDEFINED_STR: Final = "UNDEFINED" - # Can be removed when suggested_area is removed from DeviceEntry RUNTIME_ONLY_ATTRS = {"suggested_area"} @@ -465,7 +463,7 @@ class DeletedDeviceEntry: validator=_normalize_connections_validator ) created_at: datetime = attr.ib() - disabled_by: DeviceEntryDisabler | UndefinedType | None = attr.ib() + disabled_by: DeviceEntryDisabler | None = attr.ib() id: str = attr.ib() identifiers: set[tuple[str, str]] = attr.ib() labels: set[str] = attr.ib() @@ -480,19 +478,15 @@ def to_device_entry( config_subentry_id: str | None, connections: set[tuple[str, str]], identifiers: set[tuple[str, str]], - disabled_by: DeviceEntryDisabler | UndefinedType | None, ) -> DeviceEntry: """Create DeviceEntry from DeletedDeviceEntry.""" # Adjust disabled_by based on config entry state - if self.disabled_by is not UNDEFINED: - disabled_by = self.disabled_by - if config_entry.disabled_by: - if disabled_by is None: - disabled_by = DeviceEntryDisabler.CONFIG_ENTRY - elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: - disabled_by = None - else: - disabled_by = disabled_by if disabled_by is not UNDEFINED else None + disabled_by = self.disabled_by + if config_entry.disabled_by: + if disabled_by is None: + disabled_by = DeviceEntryDisabler.CONFIG_ENTRY + elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None return DeviceEntry( area_id=self.area_id, # type ignores: likely https://github.com/python/mypy/issues/8625 @@ -523,9 +517,7 @@ def as_storage_fragment(self) -> json_fragment: }, "connections": list(self.connections), "created_at": self.created_at, - "disabled_by": self.disabled_by - if self.disabled_by is not UNDEFINED - else UNDEFINED_STR, + "disabled_by": self.disabled_by, "identifiers": list(self.identifiers), "id": self.id, "labels": list(self.labels), @@ -613,7 +605,7 @@ async def _async_migrate_func( # noqa: C901 # Introduced in 2025.6 for device in old_data["deleted_devices"]: device["area_id"] = None - device["disabled_by"] = UNDEFINED_STR + device["disabled_by"] = None device["labels"] = [] device["name_by_user"] = None if old_minor_version < 11: @@ -943,7 +935,6 @@ def async_get_or_create( config_subentry_id if config_subentry_id is not UNDEFINED else None, connections, identifiers, - disabled_by, ) disabled_by = UNDEFINED @@ -1511,21 +1502,7 @@ async def async_load(self) -> None: sw_version=device["sw_version"], via_device_id=device["via_device_id"], ) - # Introduced in 0.111 - def get_optional_enum[_EnumT: StrEnum]( - cls: type[_EnumT], value: str | None - ) -> _EnumT | UndefinedType | None: - """Convert string to the passed enum, UNDEFINED or None.""" - if value is None: - return None - if value == UNDEFINED_STR: - return UNDEFINED - try: - return cls(value) - except ValueError: - return None - for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( area_id=device["area_id"], @@ -1538,8 +1515,10 @@ def get_optional_enum[_EnumT: StrEnum]( }, connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), - disabled_by=get_optional_enum( - DeviceEntryDisabler, device["disabled_by"] + disabled_by=( + DeviceEntryDisabler(device["disabled_by"]) + if device["disabled_by"] + else None ), identifiers={tuple(iden) for iden in device["identifiers"]}, id=device["id"], diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 8cfd3c66ad9be..9690b2a52fae5 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -8,7 +8,6 @@ from typing import Any from unittest.mock import ANY, patch -import attr from freezegun.api import FrozenDateTimeFactory import pytest from yarl import URL @@ -22,7 +21,6 @@ device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_capture_events, flush_store @@ -510,9 +508,6 @@ async def test_migration_from_1_1( ) assert entry.id == "abcdefghijklm" - deleted_entry = registry.deleted_devices["deletedid"] - assert deleted_entry.disabled_by is UNDEFINED - # Update to trigger a store entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, @@ -586,7 +581,7 @@ async def test_migration_from_1_1( "config_entries_subentries": {"123456": [None]}, "connections": [], "created_at": "1970-01-01T00:00:00+00:00", - "disabled_by": "UNDEFINED", + "disabled_by": None, "id": "deletedid", "identifiers": [["serial", "123456ABCDFF"]], "labels": [], @@ -3838,130 +3833,6 @@ async def test_restore_device( } -@pytest.mark.parametrize( - ("device_disabled_by", "expected_disabled_by"), - [ - (None, None), - (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), - (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), - (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), - (UNDEFINED, None), - ], -) -@pytest.mark.usefixtures("freezer") -async def test_restore_migrated_device_disabled_by( - hass: HomeAssistant, - device_registry: dr.DeviceRegistry, - mock_config_entry: MockConfigEntry, - device_disabled_by: dr.DeviceEntryDisabler | UndefinedType | None, - expected_disabled_by: dr.DeviceEntryDisabler | None, -) -> None: - """Check how the disabled_by flag is treated when restoring a device.""" - entry_id = mock_config_entry.entry_id - update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) - entry = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=None, - configuration_url="http://config_url_orig.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=None, - entry_type=dr.DeviceEntryType.SERVICE, - hw_version="hw_version_orig", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_orig", - model="model_orig", - model_id="model_id_orig", - name="name_orig", - serial_number="serial_no_orig", - suggested_area="suggested_area_orig", - sw_version="version_orig", - via_device="via_device_id_orig", - ) - - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - device_registry.async_remove_device(entry.id) - - assert len(device_registry.devices) == 0 - assert len(device_registry.deleted_devices) == 1 - - deleted_entry = device_registry.deleted_devices[entry.id] - device_registry.deleted_devices[entry.id] = attr.evolve( - deleted_entry, disabled_by=UNDEFINED - ) - - # This will restore the original device, user customizations of - # area_id, disabled_by, labels and name_by_user will be restored - entry3 = device_registry.async_get_or_create( - config_entry_id=entry_id, - config_subentry_id=None, - configuration_url="http://config_url_new.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - disabled_by=device_disabled_by, - entry_type=None, - hw_version="hw_version_new", - identifiers={("bridgeid", "0123")}, - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - name="name_new", - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", - via_device="via_device_id_new", - ) - assert entry3 == dr.DeviceEntry( - area_id="suggested_area_orig", - config_entries={entry_id}, - config_entries_subentries={entry_id: {None}}, - configuration_url="http://config_url_new.bla", - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, - created_at=utcnow(), - disabled_by=expected_disabled_by, - entry_type=None, - hw_version="hw_version_new", - id=entry.id, - identifiers={("bridgeid", "0123")}, - labels=set(), - manufacturer="manufacturer_new", - model="model_new", - model_id="model_id_new", - modified_at=utcnow(), - name_by_user=None, - name="name_new", - primary_config_entry=entry_id, - serial_number="serial_no_new", - suggested_area="suggested_area_new", - sw_version="version_new", - ) - - assert entry.id == entry3.id - assert len(device_registry.devices) == 1 - assert len(device_registry.deleted_devices) == 0 - - assert isinstance(entry3.config_entries, set) - assert isinstance(entry3.connections, set) - assert isinstance(entry3.identifiers, set) - - await hass.async_block_till_done() - - assert len(update_events) == 3 - assert update_events[0].data == { - "action": "create", - "device_id": entry.id, - } - assert update_events[1].data == { - "action": "remove", - "device_id": entry.id, - "device": entry.dict_repr, - } - assert update_events[2].data == { - "action": "create", - "device_id": entry3.id, - } - - @pytest.mark.parametrize( ( "config_entry_disabled_by", From 6b609b019ebe3170ab9b1ad50022266f46c1123a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 2 Sep 2025 12:57:39 +0200 Subject: [PATCH 03/13] Exclude non mowers from husqvarna_automower_ble discovery (#151507) --- .../husqvarna_automower_ble/config_flow.py | 35 ++++++++---- .../husqvarna_automower_ble/manifest.json | 2 +- requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../husqvarna_automower_ble/__init__.py | 53 ++++++++++++++----- .../husqvarna_automower_ble/conftest.py | 6 +-- .../test_config_flow.py | 40 ++++++++++---- .../husqvarna_automower_ble/test_init.py | 4 +- 8 files changed, 100 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index c8f1cfaf63023..d6ec59f0ec960 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -10,6 +10,8 @@ from automower_ble.protocol import ResponseResult from bleak import BleakError from bleak_retry_connector import get_device +from gardena_bluetooth.const import ScanService +from gardena_bluetooth.parse import ManufacturerData, ProductType import voluptuous as vol from homeassistant.components import bluetooth @@ -22,20 +24,31 @@ def _is_supported(discovery_info: BluetoothServiceInfo): """Check if device is supported.""" + if ScanService not in discovery_info.service_uuids: + LOGGER.debug( + "Unsupported device, missing service %s: %s", ScanService, discovery_info + ) + return False - LOGGER.debug( - "%s manufacturer data: %s", - discovery_info.address, - discovery_info.manufacturer_data, - ) + if not (data := discovery_info.manufacturer_data.get(ManufacturerData.company)): + LOGGER.debug( + "Unsupported device, missing manufacturer data %s: %s", + ManufacturerData.company, + discovery_info, + ) + return False - manufacturer = any(key == 1062 for key in discovery_info.manufacturer_data) - service_husqvarna = any( - service == "98bd0001-0b0e-421a-84e5-ddbf75dc6de4" - for service in discovery_info.service_uuids - ) + manufacturer_data = ManufacturerData.decode(data) + product_type = ProductType.from_manufacturer_data(manufacturer_data) - return manufacturer and service_husqvarna + # Some mowers only expose the serial number in the manufacturer data + # and not the product type, so we allow None here as well. + if product_type not in (ProductType.MOWER, None): + LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info) + return False + + LOGGER.debug("Supported device: %s", manufacturer_data) + return True def _pin_valid(pin: str) -> bool: diff --git a/homeassistant/components/husqvarna_automower_ble/manifest.json b/homeassistant/components/husqvarna_automower_ble/manifest.json index 50430c2a9fadf..68cfd5e848602 100644 --- a/homeassistant/components/husqvarna_automower_ble/manifest.json +++ b/homeassistant/components/husqvarna_automower_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble", "iot_class": "local_polling", - "requirements": ["automower-ble==0.2.7"] + "requirements": ["automower-ble==0.2.7", "gardena-bluetooth==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f8fca710040ac..4f3498388c264 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -992,6 +992,7 @@ fyta_cli==0.7.2 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth +# homeassistant.components.husqvarna_automower_ble gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6a61201d9c3e2..b906f5a98b93c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -862,6 +862,7 @@ fyta_cli==0.7.2 gTTS==2.5.3 # homeassistant.components.gardena_bluetooth +# homeassistant.components.husqvarna_automower_ble gardena-bluetooth==1.6.0 # homeassistant.components.google_assistant_sdk diff --git a/tests/components/husqvarna_automower_ble/__init__.py b/tests/components/husqvarna_automower_ble/__init__.py index 841b6f65516af..fbb2a67ab9ad3 100644 --- a/tests/components/husqvarna_automower_ble/__init__.py +++ b/tests/components/husqvarna_automower_ble/__init__.py @@ -9,15 +9,23 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -AUTOMOWER_SERVICE_INFO = BluetoothServiceInfo( +AUTOMOWER_SERVICE_INFO_SERIAL = BluetoothServiceInfo( name="305", address="00000000-0000-0000-0000-000000000003", rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +AUTOMOWER_SERVICE_INFO_MOWER = BluetoothServiceInfo( + name="305", + address="00000000-0000-0000-0000-000000000003", + rssi=-63, + service_data={}, + manufacturer_data={1062: bytes.fromhex("02050104060a2301")}, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -27,9 +35,7 @@ rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -39,9 +45,7 @@ rssi=-63, service_data={}, manufacturer_data={}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -51,9 +55,30 @@ rssi=-63, service_data={}, manufacturer_data={1062: b"\x05\x04\xbf\xcf\xbb\r"}, - service_uuids=[ - "98bd0001-0b0e-421a-84e5-ddbf75dc6de4", - ], + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], + source="local", +) + +MISSING_SERVICE_SERVICE_INFO = BluetoothServiceInfo( + name="Blah", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", +) + + +WATER_TIMER_SERVICE_INFO = BluetoothServiceInfo( + name="Timer", + address="00000000-0000-0000-0000-000000000001", + rssi=-63, + service_data={}, + manufacturer_data={ + 1062: b"\x02\x07d\x02\x05\x01\x02\x08\x00\x02\t\x01\x04\x06\x12\x00\x01" + }, + service_uuids=["98bd0001-0b0e-421a-84e5-ddbf75dc6de4"], source="local", ) @@ -63,7 +88,7 @@ async def setup_entry( ) -> None: """Make sure the device is available.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) + inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO_SERIAL) with patch("homeassistant.components.husqvarna_automower_ble.PLATFORMS", platforms): mock_entry.add_to_hass(hass) diff --git a/tests/components/husqvarna_automower_ble/conftest.py b/tests/components/husqvarna_automower_ble/conftest.py index 820edb29059ab..f5aebf54b7aa1 100644 --- a/tests/components/husqvarna_automower_ble/conftest.py +++ b/tests/components/husqvarna_automower_ble/conftest.py @@ -9,7 +9,7 @@ from homeassistant.components.husqvarna_automower_ble.const import DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN -from . import AUTOMOWER_SERVICE_INFO +from . import AUTOMOWER_SERVICE_INFO_SERIAL from tests.common import MockConfigEntry @@ -56,9 +56,9 @@ def mock_config_entry() -> MockConfigEntry: domain=DOMAIN, title="Husqvarna AutoMower", data={ - CONF_ADDRESS: AUTOMOWER_SERVICE_INFO.address, + CONF_ADDRESS: AUTOMOWER_SERVICE_INFO_SERIAL.address, CONF_CLIENT_ID: 1197489078, CONF_PIN: "1234", }, - unique_id=AUTOMOWER_SERVICE_INFO.address, + unique_id=AUTOMOWER_SERVICE_INFO_SERIAL.address, ) diff --git a/tests/components/husqvarna_automower_ble/test_config_flow.py b/tests/components/husqvarna_automower_ble/test_config_flow.py index 7b47063975e14..affa3715ab820 100644 --- a/tests/components/husqvarna_automower_ble/test_config_flow.py +++ b/tests/components/husqvarna_automower_ble/test_config_flow.py @@ -11,11 +11,15 @@ from homeassistant.const import CONF_ADDRESS, CONF_CLIENT_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo from . import ( AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, - AUTOMOWER_SERVICE_INFO, + AUTOMOWER_SERVICE_INFO_MOWER, + AUTOMOWER_SERVICE_INFO_SERIAL, AUTOMOWER_UNNAMED_SERVICE_INFO, + MISSING_SERVICE_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, ) from tests.common import MockConfigEntry @@ -121,10 +125,16 @@ async def test_user_selection_incorrect_pin( } -async def test_bluetooth(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "service_info", + [AUTOMOWER_SERVICE_INFO_MOWER, AUTOMOWER_SERVICE_INFO_SERIAL], +) +async def test_bluetooth( + hass: HomeAssistant, service_info: BluetoothServiceInfo +) -> None: """Test bluetooth device discovery.""" - inject_bluetooth_service_info(hass, AUTOMOWER_SERVICE_INFO) + inject_bluetooth_service_info(hass, service_info) await hass.async_block_till_done(wait_background_tasks=True) result = hass.config_entries.flow.async_progress_by_handler(DOMAIN)[0] @@ -157,7 +167,7 @@ async def test_bluetooth_incorrect_pin( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_SERVICE_INFO, + data=AUTOMOWER_SERVICE_INFO_SERIAL, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -214,7 +224,7 @@ async def test_bluetooth_unknown_error( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_SERVICE_INFO, + data=AUTOMOWER_SERVICE_INFO_SERIAL, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -241,7 +251,7 @@ async def test_bluetooth_not_paired( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_SERVICE_INFO, + data=AUTOMOWER_SERVICE_INFO_SERIAL, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" @@ -274,18 +284,26 @@ async def test_bluetooth_not_paired( } -async def test_bluetooth_invalid(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "service_info", + [ + AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, + MISSING_SERVICE_SERVICE_INFO, + WATER_TIMER_SERVICE_INFO, + ], +) +async def test_bluetooth_invalid( + hass: HomeAssistant, service_info: BluetoothServiceInfo +) -> None: """Test bluetooth device discovery with invalid data.""" - inject_bluetooth_service_info( - hass, AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO - ) + inject_bluetooth_service_info(hass, service_info) await hass.async_block_till_done(wait_background_tasks=True) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_BLUETOOTH}, - data=AUTOMOWER_MISSING_MANUFACTURER_DATA_SERVICE_INFO, + data=service_info, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" diff --git a/tests/components/husqvarna_automower_ble/test_init.py b/tests/components/husqvarna_automower_ble/test_init.py index 341cc3c282fe8..f10ae1fa7430a 100644 --- a/tests/components/husqvarna_automower_ble/test_init.py +++ b/tests/components/husqvarna_automower_ble/test_init.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import AUTOMOWER_SERVICE_INFO +from . import AUTOMOWER_SERVICE_INFO_SERIAL from tests.common import MockConfigEntry @@ -34,7 +34,7 @@ async def test_setup( assert mock_config_entry.state is ConfigEntryState.LOADED device_entry = device_registry.async_get_device( - identifiers={(DOMAIN, f"{AUTOMOWER_SERVICE_INFO.address}_1197489078")} + identifiers={(DOMAIN, f"{AUTOMOWER_SERVICE_INFO_SERIAL.address}_1197489078")} ) assert device_entry == snapshot From b514a14c102e37711dbf0d88dc94b781c1e51dea Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 2 Sep 2025 13:06:07 +0200 Subject: [PATCH 04/13] Remove config entry from device instead of deleting in Uptime robot (#151557) --- homeassistant/components/uptimerobot/coordinator.py | 5 ++++- homeassistant/components/uptimerobot/quality_scale.yaml | 4 +--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py index 7ecb1ee3313d5..78866800effb7 100644 --- a/homeassistant/components/uptimerobot/coordinator.py +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -65,7 +65,10 @@ async def _async_update_data(self) -> list[UptimeRobotMonitor]: if device := device_registry.async_get_device( identifiers={(DOMAIN, monitor_id)} ): - device_registry.async_remove_device(device.id) + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) # If there are new monitors, we should reload the config entry so we can # create new devices and entities. diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 1244d6a4c19ea..2152f57285371 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -74,9 +74,7 @@ rules: repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet - stale-devices: - status: todo - comment: We should remove the config entry from the device rather than remove the device + stale-devices: done # Platinum async-dependency: done From 8e85faf9972919cab6027b52bba60c3fb0a14533 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 2 Sep 2025 13:06:33 +0200 Subject: [PATCH 05/13] Update SamsungTV quality scale (#151552) --- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/samsungtv/quality_scale.yaml | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 1b927757a3964..e9ce8db0b95e8 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -34,7 +34,7 @@ "integration_type": "device", "iot_class": "local_push", "loggers": ["samsungctl", "samsungtvws"], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": [ "getmac==0.9.5", "samsungctl[websocket]==0.7.1", diff --git a/homeassistant/components/samsungtv/quality_scale.yaml b/homeassistant/components/samsungtv/quality_scale.yaml index 845ebfe6e464c..4cea6ea319eb3 100644 --- a/homeassistant/components/samsungtv/quality_scale.yaml +++ b/homeassistant/components/samsungtv/quality_scale.yaml @@ -32,9 +32,7 @@ rules: status: exempt comment: no configuration options so far docs-installation-parameters: done - entity-unavailable: - status: todo - comment: check super().unavailable + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done From 12ab84a5d9c41fdd836958186679b150287b1363 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Tue, 2 Sep 2025 21:07:48 +1000 Subject: [PATCH 06/13] Expose the transition field to the UI config of effect_colorloop (#151124) Signed-off-by: Avi Miller --- homeassistant/components/lifx/services.yaml | 7 +++++++ homeassistant/components/lifx/strings.json | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml index ac4fbfc15af2c..9e93ace3744e7 100644 --- a/homeassistant/components/lifx/services.yaml +++ b/homeassistant/components/lifx/services.yaml @@ -127,6 +127,13 @@ effect_colorloop: min: 1 max: 100 unit_of_measurement: "%" + transition: + required: false + selector: + number: + min: 0 + max: 3600 + unit_of_measurement: seconds period: default: 60 selector: diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index be0485c6dffa7..d6b3a2c540464 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -149,6 +149,10 @@ "name": "[%key:component::lifx::services::effect_pulse::fields::period::name%]", "description": "Duration between color changes." }, + "transition": { + "name": "Transition", + "description": "Duration of the transition between colors." + }, "change": { "name": "Change", "description": "Hue movement per period, in degrees on a color wheel." From ceda62f6ea2d5febfe4b679faf0b6028bcadc206 Mon Sep 17 00:00:00 2001 From: yufeng Date: Tue, 2 Sep 2025 19:10:36 +0800 Subject: [PATCH 07/13] Add support for new energy sensor entities for TDQ (socket/outlet) devices in the Tuya integration (#151553) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/sensor.py | 6 ++ .../tuya/snapshots/test_sensor.ambr | 56 +++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 7a80a51726de7..b167142323fe6 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -98,6 +98,7 @@ class DPCode(StrEnum): https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq """ + ADD_ELE = "add_ele" # energy AIR_QUALITY = "air_quality" AIR_QUALITY_INDEX = "air_quality_index" ALARM_DELAY_TIME = "alarm_delay_time" diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index d464bb1b566a0..a476ee6cd70a3 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1133,6 +1133,12 @@ class TuyaSensorEntityDescription(SensorEntityDescription): suggested_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), + TuyaSensorEntityDescription( + key=DPCode.ADD_ELE, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), TuyaSensorEntityDescription( key=DPCode.VA_TEMPERATURE, translation_key="temperature", diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 2a3a93b1b3e75..f5d1f229c66f4 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -11600,6 +11600,62 @@ 'state': 'unavailable', }) # --- +# name: test_platform_setup_and_discovery[sensor.socket3_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.socket3_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.7zogt3pcwhxhu8upqdtadd_ele', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.socket3_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Socket3 Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.socket3_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sensor.socket3_voltage-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 75d792207ae8043694a7e2e4d217bd2905bc12ec Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:24:00 +0200 Subject: [PATCH 08/13] Add missing pychromecast imports (#151544) --- homeassistant/components/cast/discovery.py | 3 ++- homeassistant/components/cast/helpers.py | 5 ++++- homeassistant/components/cast/media_player.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py index 4d956205990ae..3fc284cda8b52 100644 --- a/homeassistant/components/cast/discovery.py +++ b/homeassistant/components/cast/discovery.py @@ -3,7 +3,8 @@ import logging import threading -import pychromecast +import pychromecast.discovery +import pychromecast.models from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index c45bbb4fbbc83..2948c30fd1a19 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -11,10 +11,13 @@ import aiohttp import attr -import pychromecast from pychromecast import dial from pychromecast.const import CAST_TYPE_GROUP +import pychromecast.controllers.media +import pychromecast.controllers.multizone +import pychromecast.controllers.receiver from pychromecast.models import CastInfo +import pychromecast.socket_client from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index e17360127b944..6d05fa81f3afb 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -10,8 +10,10 @@ import logging from typing import TYPE_CHECKING, Any, Concatenate -import pychromecast +import pychromecast.config +import pychromecast.const from pychromecast.controllers.homeassistant import HomeAssistantController +import pychromecast.controllers.media from pychromecast.controllers.media import ( MEDIA_PLAYER_ERROR_CODES, MEDIA_PLAYER_STATE_BUFFERING, From 0928e9a6ee660d13bcb52824c451a11510aa5448 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 2 Sep 2025 13:24:42 +0200 Subject: [PATCH 09/13] Add sensor for DHW storage temperature in ViCare integration (#151128) --- homeassistant/components/vicare/sensor.py | 9 +++++++++ homeassistant/components/vicare/strings.json | 3 +++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index cddc5ca021a50..fc26c489cd3a4 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -193,6 +193,15 @@ class ViCareSensorEntityDescription(SensorEntityDescription, ViCareRequiredKeysM device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="dhw_storage_middle_temperature", + translation_key="dhw_storage_middle_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterStorageTemperatureMiddle(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), ViCareSensorEntityDescription( key="dhw_storage_bottom_temperature", translation_key="dhw_storage_bottom_temperature", diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index dd8d93e609abd..3135dd7acc39a 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -182,6 +182,9 @@ "dhw_storage_top_temperature": { "name": "DHW storage top temperature" }, + "dhw_storage_middle_temperature": { + "name": "DHW storage middle temperature" + }, "dhw_storage_bottom_temperature": { "name": "DHW storage bottom temperature" }, From 0f530485d1fbfdef4d0aa3e82f873dc0ac9121c1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 2 Sep 2025 15:07:25 +0200 Subject: [PATCH 10/13] Record current IQS for NextDNS (#146895) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/nextdns/manifest.json | 1 + .../components/nextdns/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 2 - 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/nextdns/quality_scale.yaml diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 4fdbcdb71754d..27c663aedc792 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,5 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], + "quality_scale": "bronze", "requirements": ["nextdns==4.1.0"] } diff --git a/homeassistant/components/nextdns/quality_scale.yaml b/homeassistant/components/nextdns/quality_scale.yaml new file mode 100644 index 0000000000000..898a9b3055a4a --- /dev/null +++ b/homeassistant/components/nextdns/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration does not register services. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration does not register services. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: The integration does not register services. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: No options to configure. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: done + test-coverage: + status: todo + comment: Patch NextDns object instead of functions. + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + discovery: + status: exempt + comment: The integration is a cloud service and thus does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: + status: todo + comment: Add info that there are no known limitations. + docs-supported-devices: + status: exempt + comment: This is a service, which doesn't integrate with any devices. + docs-supported-functions: todo + docs-troubleshooting: + status: exempt + comment: No known issues that could be resolved by the user. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration has a fixed single service. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: + status: todo + comment: Allow API key to be changed in the re-configure flow. + repair-issues: + status: exempt + comment: This integration doesn't have any cases where raising an issue is needed. + stale-devices: + status: exempt + comment: This integration has a fixed single service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 598d0f5a99cef..707360dd3a39f 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -690,7 +690,6 @@ class Rule: "nexia", "nextbus", "nextcloud", - "nextdns", "nfandroidtv", "nibe_heatpump", "nice_go", @@ -1730,7 +1729,6 @@ class Rule: "nexia", "nextbus", "nextcloud", - "nextdns", "nyt_games", "nfandroidtv", "nibe_heatpump", From 1b9acdc2334e1ffc564d8702ac9a8b749318788b Mon Sep 17 00:00:00 2001 From: cdnninja Date: Tue, 2 Sep 2025 07:31:38 -0600 Subject: [PATCH 11/13] Convert Vesync to 3.X version of library (#148239) Co-authored-by: SapuSeven Co-authored-by: Joostlek --- CODEOWNERS | 4 +- homeassistant/components/vesync/__init__.py | 70 +-- .../components/vesync/binary_sensor.py | 21 +- homeassistant/components/vesync/common.py | 40 +- .../components/vesync/config_flow.py | 54 +- homeassistant/components/vesync/const.py | 97 +--- .../components/vesync/coordinator.py | 27 +- .../components/vesync/diagnostics.py | 66 ++- homeassistant/components/vesync/entity.py | 4 +- homeassistant/components/vesync/fan.py | 146 ++--- homeassistant/components/vesync/humidifier.py | 66 ++- homeassistant/components/vesync/light.py | 40 +- homeassistant/components/vesync/manifest.json | 7 +- homeassistant/components/vesync/number.py | 40 +- homeassistant/components/vesync/select.py | 67 ++- homeassistant/components/vesync/sensor.py | 119 ++-- homeassistant/components/vesync/switch.py | 38 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vesync/common.py | 212 +++---- tests/components/vesync/conftest.py | 212 ++++--- .../fixtures/air-purifier-131s-detail.json | 46 +- .../air-purifier-400s-detail-updated.json | 39 -- .../fixtures/air-purifier-400s-detail.json | 39 -- .../fixtures/air-purifier-detail-updated.json | 25 + .../fixtures/air-purifier-detail-v2.json | 26 + .../vesync/fixtures/air-purifier-detail.json | 25 + .../vesync/fixtures/device-detail.json | 7 +- .../vesync/fixtures/dimmer-detail.json | 21 +- ...rtTowerFan-detail.json => fan-detail.json} | 5 +- ...ifier-200s.json => humidifier-detail.json} | 2 + .../vesync/fixtures/light-detail.json | 12 + .../vesync/fixtures/outlet-energy-week.json | 7 - .../vesync/fixtures/outlet-energy.json | 12 + .../vesync/fixtures/vesync-auth.json | 9 + .../vesync/fixtures/vesync-devices.json | 78 ++- .../vesync/fixtures/vesync-login.json | 6 +- ..._api_call__device_details__single_fan.json | 15 - ...ll__device_details__single_humidifier.json | 27 - .../vesync_api_call__devices__no_devices.json | 11 - .../vesync_api_call__devices__single_fan.json | 37 -- ..._api_call__devices__single_humidifier.json | 37 -- .../fixtures/vesync_api_call__login.json | 9 - .../vesync/snapshots/test_diagnostics.ambr | 516 +++++++++++------- .../components/vesync/snapshots/test_fan.ambr | 70 +-- .../vesync/snapshots/test_light.ambr | 26 +- .../vesync/snapshots/test_sensor.ambr | 254 +++++---- tests/components/vesync/test_config_flow.py | 18 +- tests/components/vesync/test_diagnostics.py | 47 +- tests/components/vesync/test_fan.py | 29 +- tests/components/vesync/test_humidifier.py | 59 +- tests/components/vesync/test_init.py | 48 +- tests/components/vesync/test_light.py | 6 +- tests/components/vesync/test_number.py | 4 +- tests/components/vesync/test_platform.py | 29 +- tests/components/vesync/test_select.py | 2 +- tests/components/vesync/test_sensor.py | 6 +- tests/components/vesync/test_switch.py | 32 +- 58 files changed, 1552 insertions(+), 1423 deletions(-) delete mode 100644 tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json delete mode 100644 tests/components/vesync/fixtures/air-purifier-400s-detail.json create mode 100644 tests/components/vesync/fixtures/air-purifier-detail-updated.json create mode 100644 tests/components/vesync/fixtures/air-purifier-detail-v2.json create mode 100644 tests/components/vesync/fixtures/air-purifier-detail.json rename tests/components/vesync/fixtures/{SmartTowerFan-detail.json => fan-detail.json} (85%) rename tests/components/vesync/fixtures/{humidifier-200s.json => humidifier-detail.json} (92%) create mode 100644 tests/components/vesync/fixtures/light-detail.json delete mode 100644 tests/components/vesync/fixtures/outlet-energy-week.json create mode 100644 tests/components/vesync/fixtures/outlet-energy.json create mode 100644 tests/components/vesync/fixtures/vesync-auth.json delete mode 100644 tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json delete mode 100644 tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json delete mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json delete mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json delete mode 100644 tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json delete mode 100644 tests/components/vesync/fixtures/vesync_api_call__login.json diff --git a/CODEOWNERS b/CODEOWNERS index 4a48e71a7d358..d467439cae7a7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1699,8 +1699,8 @@ build.json @home-assistant/supervisor /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus -/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak -/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak +/homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven +/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven /homeassistant/components/vicare/ @CFenner /tests/components/vicare/ @CFenner /homeassistant/components/vilfo/ @ManneW diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py index dddf7857545fe..003d93ed60397 100644 --- a/homeassistant/components/vesync/__init__.py +++ b/homeassistant/components/vesync/__init__.py @@ -3,29 +3,17 @@ import logging from pyvesync import VeSync +from pyvesync.utils.errors import VeSyncLoginError from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - EVENT_LOGGING_CHANGED, - Platform, -) -from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send -from .common import async_generate_device_list -from .const import ( - DOMAIN, - SERVICE_UPDATE_DEVS, - VS_COORDINATOR, - VS_DEVICES, - VS_DISCOVERY, - VS_LISTENERS, - VS_MANAGER, -) +from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER from .coordinator import VeSyncDataCoordinator PLATFORMS = [ @@ -53,14 +41,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b username=username, password=password, time_zone=time_zone, - debug=logging.getLogger("pyvesync.vesync").level == logging.DEBUG, - redact=True, + session=async_get_clientsession(hass), ) - - login = await hass.async_add_executor_job(manager.login) - - if not login: - raise ConfigEntryAuthFailed + try: + await manager.login() + except VeSyncLoginError as err: + raise ConfigEntryAuthFailed from err hass.data[DOMAIN] = {} hass.data[DOMAIN][VS_MANAGER] = manager @@ -69,37 +55,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # Store coordinator at domain level since only single integration instance is permitted. hass.data[DOMAIN][VS_COORDINATOR] = coordinator - - hass.data[DOMAIN][VS_DEVICES] = await async_generate_device_list(hass, manager) + await manager.update() + await manager.check_firmware() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - @callback - def _async_handle_logging_changed(_event: Event) -> None: - """Handle when the logging level changes.""" - manager.debug = logging.getLogger("pyvesync.vesync").level == logging.DEBUG - - cleanup = hass.bus.async_listen( - EVENT_LOGGING_CHANGED, _async_handle_logging_changed - ) - - hass.data[DOMAIN][VS_LISTENERS] = cleanup - async def async_new_device_discovery(service: ServiceCall) -> None: - """Discover if new devices should be added.""" + """Discover and add new devices.""" manager = hass.data[DOMAIN][VS_MANAGER] - devices = hass.data[DOMAIN][VS_DEVICES] - - new_devices = await async_generate_device_list(hass, manager) + known_devices = list(manager.devices) + await manager.get_devices() + new_devices = [ + device for device in manager.devices if device not in known_devices + ] - device_set = set(new_devices) - new_devices = list(device_set.difference(devices)) - if new_devices and devices: - devices.extend(new_devices) - async_dispatcher_send(hass, VS_DISCOVERY.format(VS_DEVICES), new_devices) - return - if new_devices and not devices: - devices.extend(new_devices) + if new_devices: + async_dispatcher_send(hass, "vesync_new_devices", new_devices) hass.services.async_register( DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery @@ -110,7 +81,6 @@ async def async_new_device_discovery(service: ServiceCall) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - hass.data[DOMAIN][VS_LISTENERS]() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: hass.data.pop(DOMAIN) diff --git a/homeassistant/components/vesync/binary_sensor.py b/homeassistant/components/vesync/binary_sensor.py index 7b6f14e04dc38..933d2f2599d47 100644 --- a/homeassistant/components/vesync/binary_sensor.py +++ b/homeassistant/components/vesync/binary_sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass import logging -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import rgetattr -from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -31,20 +31,25 @@ class VeSyncBinarySensorEntityDescription(BinarySensorEntityDescription): """A class that describes custom binary sensor entities.""" is_on: Callable[[VeSyncBaseDevice], bool] + exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True SENSOR_DESCRIPTIONS: tuple[VeSyncBinarySensorEntityDescription, ...] = ( VeSyncBinarySensorEntityDescription( key="water_lacks", translation_key="water_lacks", - is_on=lambda device: device.water_lacks, + is_on=lambda device: device.state.water_lacks, device_class=BinarySensorDeviceClass.PROBLEM, + exists_fn=lambda device: rgetattr(device, "state.water_lacks") is not None, ), VeSyncBinarySensorEntityDescription( - key="details.water_tank_lifted", + key="water_tank_lifted", translation_key="water_tank_lifted", - is_on=lambda device: device.details["water_tank_lifted"], + is_on=lambda device: device.state.water_tank_lifted, device_class=BinarySensorDeviceClass.PROBLEM, + exists_fn=( + lambda device: rgetattr(device, "state.water_tank_lifted") is not None + ), ), ) @@ -67,7 +72,9 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator + ) @callback @@ -78,7 +85,7 @@ def _setup_entities(devices, async_add_entities, coordinator): VeSyncBinarySensor(dev, description, coordinator) for dev in devices for description in SENSOR_DESCRIPTIONS - if rgetattr(dev, description.key) is not None + if description.exists_fn(dev) ), ) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 6dda6800c6273..eaad7aded39ea 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -2,14 +2,12 @@ import logging -from pyvesync import VeSync -from pyvesync.vesyncbasedevice import VeSyncBaseDevice -from pyvesync.vesyncoutlet import VeSyncOutlet -from pyvesync.vesyncswitch import VeSyncWallSwitch - -from homeassistant.core import HomeAssistant - -from .const import VeSyncFanDevice, VeSyncHumidifierDevice +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.devices.vesyncswitch import VeSyncWallSwitch _LOGGER = logging.getLogger(__name__) @@ -36,32 +34,16 @@ def rgetattr(obj: object, attr: str): return obj -async def async_generate_device_list( - hass: HomeAssistant, manager: VeSync -) -> list[VeSyncBaseDevice]: - """Assign devices to proper component.""" - devices: list[VeSyncBaseDevice] = [] - - await hass.async_add_executor_job(manager.update) - - devices.extend(manager.fans) - devices.extend(manager.bulbs) - devices.extend(manager.outlets) - devices.extend(manager.switches) - - return devices - - def is_humidifier(device: VeSyncBaseDevice) -> bool: """Check if the device represents a humidifier.""" - return isinstance(device, VeSyncHumidifierDevice) + return isinstance(device, VeSyncHumidifier) def is_fan(device: VeSyncBaseDevice) -> bool: """Check if the device represents a fan.""" - return isinstance(device, VeSyncFanDevice) + return isinstance(device, VeSyncFanBase) def is_outlet(device: VeSyncBaseDevice) -> bool: @@ -74,3 +56,9 @@ def is_wall_switch(device: VeSyncBaseDevice) -> bool: """Check if the device represents a wall switch, note this doessn't include dimming switches.""" return isinstance(device, VeSyncWallSwitch) + + +def is_purifier(device: VeSyncBaseDevice) -> bool: + """Check if the device represents an air purifier.""" + + return isinstance(device, VeSyncPurifier) diff --git a/homeassistant/components/vesync/config_flow.py b/homeassistant/components/vesync/config_flow.py index e5537d8fcc92d..bc1a47be712b9 100644 --- a/homeassistant/components/vesync/config_flow.py +++ b/homeassistant/components/vesync/config_flow.py @@ -1,18 +1,23 @@ """Config flow utilities.""" from collections.abc import Mapping +import logging from typing import Any from pyvesync import VeSync +from pyvesync.utils.errors import VeSyncError import voluptuous as vol from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): cv.string, @@ -49,9 +54,18 @@ async def async_step_user( username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - manager = VeSync(username, password) - login = await self.hass.async_add_executor_job(manager.login) - if not login: + time_zone = str(self.hass.config.time_zone) + + manager = VeSync( + username, + password, + time_zone=time_zone, + session=async_get_clientsession(self.hass), + ) + try: + await manager.login() + except VeSyncError as e: + _LOGGER.error("VeSync login failed: %s", str(e)) return self._show_form(errors={"base": "invalid_auth"}) return self.async_create_entry( @@ -74,17 +88,33 @@ async def async_step_reauth_confirm( username = user_input[CONF_USERNAME] password = user_input[CONF_PASSWORD] - manager = VeSync(username, password) - login = await self.hass.async_add_executor_job(manager.login) - if login: - return self.async_update_reload_and_abort( - self._get_reauth_entry(), - data_updates={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - }, + time_zone = str(self.hass.config.time_zone) + + manager = VeSync( + username, + password, + time_zone=time_zone, + session=async_get_clientsession(self.hass), + ) + try: + await manager.login() + except VeSyncError as e: + _LOGGER.error("VeSync login failed: %s", str(e)) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=DATA_SCHEMA, + description_placeholders={"name": "VeSync"}, + errors={"base": "invalid_auth"}, ) + return self.async_update_reload_and_abort( + self._get_reauth_entry(), + data_updates={ + CONF_USERNAME: username, + CONF_PASSWORD: password, + }, + ) + return self.async_show_form( step_id="reauth_confirm", data_schema=DATA_SCHEMA, diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index 6d818b463d892..df7a45e3034d7 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -1,18 +1,11 @@ """Constants for VeSync Component.""" -from pyvesync.vesyncfan import ( - VeSyncAir131, - VeSyncAirBaseV2, - VeSyncAirBypass, - VeSyncHumid200300S, - VeSyncSuperior6000S, -) - DOMAIN = "vesync" VS_DISCOVERY = "vesync_discovery_{}" SERVICE_UPDATE_DEVS = "update_devices" UPDATE_INTERVAL = 60 +UPDATE_INTERVAL_ENERGY = 60 * 60 * 6 """ Update interval for DataCoordinator. @@ -24,6 +17,9 @@ Using 30 seconds interval gives 8640 for 3 devices which exceeds the quota of 7700. + +Energy history is weekly/monthly/yearly and can be updated a lot more infrequently, +in this case every 6 hours. """ VS_DEVICES = "devices" VS_COORDINATOR = "coordinator" @@ -57,87 +53,14 @@ NIGHT_LIGHT_LEVEL_DIM = "dim" NIGHT_LIGHT_LEVEL_OFF = "off" -FAN_NIGHT_LIGHT_LEVEL_DIM = "dim" -FAN_NIGHT_LIGHT_LEVEL_OFF = "off" -FAN_NIGHT_LIGHT_LEVEL_ON = "on" - HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT = "bright" HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim" HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF = "off" -VeSyncHumidifierDevice = VeSyncHumid200300S | VeSyncSuperior6000S -"""Humidifier device types""" - -VeSyncFanDevice = VeSyncAirBypass | VeSyncAirBypass | VeSyncAirBaseV2 | VeSyncAir131 -"""Fan device types""" - - -DEV_TYPE_TO_HA = { - "wifi-switch-1.3": "outlet", - "ESW03-USA": "outlet", - "ESW01-EU": "outlet", - "ESW15-USA": "outlet", - "ESWL01": "switch", - "ESWL03": "switch", - "ESO15-TB": "outlet", - "LV-PUR131S": "fan", - "Core200S": "fan", - "Core300S": "fan", - "Core400S": "fan", - "Core600S": "fan", - "EverestAir": "fan", - "Vital200S": "fan", - "Vital100S": "fan", - "SmartTowerFan": "fan", - "ESD16": "walldimmer", - "ESWD16": "walldimmer", - "ESL100": "bulb-dimmable", - "ESL100CW": "bulb-tunable-white", -} +OUTLET_NIGHT_LIGHT_LEVEL_AUTO = "auto" +OUTLET_NIGHT_LIGHT_LEVEL_OFF = "off" +OUTLET_NIGHT_LIGHT_LEVEL_ON = "on" -SKU_TO_BASE_DEVICE = { - # Air Purifiers - "LV-PUR131S": "LV-PUR131S", - "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S - "LV-RH131S-WM": "LV-PUR131S", # Alt ID Model LV-PUR131S - "Core200S": "Core200S", - "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S - "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S - "Core300S": "Core300S", - "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S - "LAP-C301S-WAAA": "Core300S", # Alt ID Model Core300S - "LAP-C302S-WUSB": "Core300S", # Alt ID Model Core300S - "Core400S": "Core400S", - "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S - "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S - "LAP-C401S-WAAA": "Core400S", # Alt ID Model Core400S - "Core600S": "Core600S", - "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S - "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S - "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S, - "Vital200S": "Vital200S", - "LAP-V201S-AASR": "Vital200S", # Alt ID Model Vital200S - "LAP-V201S-WJP": "Vital200S", # Alt ID Model Vital200S - "LAP-V201S-WEU": "Vital200S", # Alt ID Model Vital200S - "LAP-V201S-WUS": "Vital200S", # Alt ID Model Vital200S - "LAP-V201-AUSR": "Vital200S", # Alt ID Model Vital200S - "LAP-V201S-AEUR": "Vital200S", # Alt ID Model Vital200S - "LAP-V201S-AUSR": "Vital200S", # Alt ID Model Vital200S - "Vital100S": "Vital100S", - "LAP-V102S-WUS": "Vital100S", # Alt ID Model Vital100S - "LAP-V102S-AASR": "Vital100S", # Alt ID Model Vital100S - "LAP-V102S-WEU": "Vital100S", # Alt ID Model Vital100S - "LAP-V102S-WUK": "Vital100S", # Alt ID Model Vital100S - "LAP-V102S-AUSR": "Vital100S", # Alt ID Model Vital100S - "LAP-V102S-WJP": "Vital100S", # Alt ID Model Vital100S - "EverestAir": "EverestAir", - "LAP-EL551S-AUS": "EverestAir", # Alt ID Model EverestAir - "LAP-EL551S-AEUR": "EverestAir", # Alt ID Model EverestAir - "LAP-EL551S-WEU": "EverestAir", # Alt ID Model EverestAir - "LAP-EL551S-WUS": "EverestAir", # Alt ID Model EverestAir - "SmartTowerFan": "SmartTowerFan", - "LTF-F422S-KEU": "SmartTowerFan", # Alt ID Model SmartTowerFan - "LTF-F422S-WUSR": "SmartTowerFan", # Alt ID Model SmartTowerFan - "LTF-F422_WJP": "SmartTowerFan", # Alt ID Model SmartTowerFan - "LTF-F422S-WUS": "SmartTowerFan", # Alt ID Model SmartTowerFan -} +PURIFIER_NIGHT_LIGHT_LEVEL_DIM = "dim" +PURIFIER_NIGHT_LIGHT_LEVEL_OFF = "off" +PURIFIER_NIGHT_LIGHT_LEVEL_ON = "on" diff --git a/homeassistant/components/vesync/coordinator.py b/homeassistant/components/vesync/coordinator.py index e8c8396bfb449..a857d337c8d71 100644 --- a/homeassistant/components/vesync/coordinator.py +++ b/homeassistant/components/vesync/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from pyvesync import VeSync @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import UPDATE_INTERVAL +from .const import UPDATE_INTERVAL, UPDATE_INTERVAL_ENERGY _LOGGER = logging.getLogger(__name__) @@ -20,6 +20,7 @@ class VeSyncDataCoordinator(DataUpdateCoordinator[None]): """Class representing data coordinator for VeSync devices.""" config_entry: ConfigEntry + update_time: datetime | None = None def __init__( self, hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync @@ -35,15 +36,21 @@ def __init__( update_interval=timedelta(seconds=UPDATE_INTERVAL), ) + def should_update_energy(self) -> bool: + """Test if specified update interval has been exceeded.""" + if self.update_time is None: + return True + + return datetime.now() - self.update_time >= timedelta( + seconds=UPDATE_INTERVAL_ENERGY + ) + async def _async_update_data(self) -> None: """Fetch data from API endpoint.""" - return await self.hass.async_add_executor_job(self.update_data_all) - - def update_data_all(self) -> None: - """Update all the devices.""" + await self._manager.update_all_devices() - # Using `update_all_devices` instead of `update` to avoid fetching device list every time. - self._manager.update_all_devices() - # Vesync updates energy on applicable devices every 6 hours - self._manager.update_energy() + if self.should_update_energy(): + self.update_time = datetime.now() + for outlet in self._manager.devices.outlets: + await outlet.update_energy() diff --git a/homeassistant/components/vesync/diagnostics.py b/homeassistant/components/vesync/diagnostics.py index e1c092b1e32d4..7ca8f7789bd48 100644 --- a/homeassistant/components/vesync/diagnostics.py +++ b/homeassistant/components/vesync/diagnostics.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast from pyvesync import VeSync @@ -13,7 +13,6 @@ from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN, VS_MANAGER -from .entity import VeSyncBaseDevice KEYS_TO_REDACT = {"manager", "uuid", "mac_id"} @@ -26,18 +25,16 @@ async def async_get_config_entry_diagnostics( return { DOMAIN: { - "bulb_count": len(manager.bulbs), - "fan_count": len(manager.fans), - "outlets_count": len(manager.outlets), - "switch_count": len(manager.switches), + "Total Device Count": len(manager.devices), + "bulb_count": len(manager.devices.bulbs), + "fan_count": len(manager.devices.fans), + "humidifers_count": len(manager.devices.humidifiers), + "air_purifiers": len(manager.devices.air_purifiers), + "outlets_count": len(manager.devices.outlets), + "switch_count": len(manager.devices.switches), "timezone": manager.time_zone, }, - "devices": { - "bulbs": [_redact_device_values(device) for device in manager.bulbs], - "fans": [_redact_device_values(device) for device in manager.fans], - "outlets": [_redact_device_values(device) for device in manager.outlets], - "switches": [_redact_device_values(device) for device in manager.switches], - }, + "devices": [_redact_device_values(device) for device in manager.devices], } @@ -46,11 +43,24 @@ async def async_get_device_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a device entry.""" manager: VeSync = hass.data[DOMAIN][VS_MANAGER] - device_dict = _build_device_dict(manager) vesync_device_id = next(iden[1] for iden in device.identifiers if iden[0] == DOMAIN) + def get_vesync_unique_id(dev: Any) -> str: + """Return the unique ID for a VeSync device.""" + cid = getattr(dev, "cid", None) + sub_device_no = getattr(dev, "sub_device_no", None) + if cid is None: + return "" + if isinstance(sub_device_no, int): + return f"{cid}{sub_device_no!s}" + return str(cid) + + vesync_device = next( + dev for dev in manager.devices if get_vesync_unique_id(dev) == vesync_device_id + ) + # Base device information, without sensitive information. - data = _redact_device_values(device_dict[vesync_device_id]) + data = _redact_device_values(vesync_device) data["home_assistant"] = { "name": device.name, @@ -76,7 +86,7 @@ async def async_get_device_diagnostics( # The context doesn't provide useful information in this case. state_dict.pop("context", None) - data["home_assistant"]["entities"].append( + cast(dict[str, Any], data["home_assistant"])["entities"].append( { "domain": entity_entry.domain, "entity_id": entity_entry.entity_id, @@ -97,21 +107,19 @@ async def async_get_device_diagnostics( return data -def _build_device_dict(manager: VeSync) -> dict: - """Build a dictionary of ALL VeSync devices.""" - device_dict = {x.cid: x for x in manager.switches} - device_dict.update({x.cid: x for x in manager.fans}) - device_dict.update({x.cid: x for x in manager.outlets}) - device_dict.update({x.cid: x for x in manager.bulbs}) - return device_dict - - -def _redact_device_values(device: VeSyncBaseDevice) -> dict: +def _redact_device_values(obj: object) -> dict[str, str | dict[str, Any]]: """Rebuild and redact values of a VeSync device.""" - data = {} - for key, item in device.__dict__.items(): - if key not in KEYS_TO_REDACT: - data[key] = item + data: dict[str, str | dict[str, Any]] = {} + for key in dir(obj): + if key.startswith("_"): + # Skip private attributes + continue + if callable(getattr(obj, key)): + data[key] = "Method" + elif key == "state": + data[key] = _redact_device_values(getattr(obj, key)) + elif key not in KEYS_TO_REDACT: + data[key] = getattr(obj, key) else: data[key] = REDACTED diff --git a/homeassistant/components/vesync/entity.py b/homeassistant/components/vesync/entity.py index 3aa7b008cc599..023e1a10c5500 100644 --- a/homeassistant/components/vesync/entity.py +++ b/homeassistant/components/vesync/entity.py @@ -1,6 +1,6 @@ """Common entity for VeSync Component.""" -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -35,7 +35,7 @@ def base_unique_id(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) -> DeviceInfo: diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 5b0197606ae02..834f8c89ed067 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -3,10 +3,9 @@ from __future__ import annotations import logging -import math from typing import Any -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry @@ -15,15 +14,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util.percentage import ( - percentage_to_ranged_value, - ranged_value_to_percentage, + ordered_list_item_to_percentage, + percentage_to_ordered_list_item, ) -from homeassistant.util.scaling import int_states_in_range -from .common import is_fan +from .common import is_fan, is_purifier from .const import ( DOMAIN, - SKU_TO_BASE_DEVICE, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, @@ -35,24 +32,13 @@ VS_FAN_MODE_PRESET_LIST_HA, VS_FAN_MODE_SLEEP, VS_FAN_MODE_TURBO, + VS_MANAGER, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity _LOGGER = logging.getLogger(__name__) -SPEED_RANGE = { # off is not included - "LV-PUR131S": (1, 3), - "Core200S": (1, 3), - "Core300S": (1, 3), - "Core400S": (1, 4), - "Core600S": (1, 4), - "EverestAir": (1, 3), - "Vital200S": (1, 4), - "Vital100S": (1, 4), - "SmartTowerFan": (1, 13), -} - async def async_setup_entry( hass: HomeAssistant, @@ -72,7 +58,9 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator + ) @callback @@ -83,7 +71,11 @@ def _setup_entities( ): """Check if device is fan and add entity.""" - async_add_entities(VeSyncFanHA(dev, coordinator) for dev in devices if is_fan(dev)) + async_add_entities( + VeSyncFanHA(dev, coordinator) + for dev in devices + if is_fan(dev) or is_purifier(dev) + ) class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @@ -101,26 +93,24 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity): @property def is_on(self) -> bool: """Return True if device is on.""" - return self.device.device_status == "on" + return self.device.state.device_status == "on" @property def percentage(self) -> int | None: - """Return the current speed.""" - if ( - self.device.mode == VS_FAN_MODE_MANUAL - and (current_level := self.device.fan_level) is not None - ): - return ranged_value_to_percentage( - SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level + """Return the currently set speed.""" + + current_level = self.device.state.fan_level + + if self.device.state.mode == VS_FAN_MODE_MANUAL and current_level is not None: + return ordered_list_item_to_percentage( + self.device.fan_levels, current_level ) return None @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return int_states_in_range( - SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]] - ) + return len(self.device.fan_levels) @property def preset_modes(self) -> list[str]: @@ -138,8 +128,8 @@ def preset_modes(self) -> list[str]: @property def preset_mode(self) -> str | None: """Get the current preset mode.""" - if self.device.mode in VS_FAN_MODE_PRESET_LIST_HA: - return self.device.mode + if self.device.state.mode in VS_FAN_MODE_PRESET_LIST_HA: + return self.device.state.mode return None @property @@ -147,57 +137,67 @@ def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" attr = {} - if hasattr(self.device, "active_time"): - attr["active_time"] = self.device.active_time + if hasattr(self.device.state, "active_time"): + attr["active_time"] = self.device.state.active_time - if hasattr(self.device, "screen_status"): - attr["screen_status"] = self.device.screen_status + if hasattr(self.device.state, "display_status"): + attr["display_status"] = self.device.state.display_status - if hasattr(self.device, "child_lock"): - attr["child_lock"] = self.device.child_lock + if hasattr(self.device.state, "child_lock"): + attr["child_lock"] = self.device.state.child_lock - if hasattr(self.device, "night_light"): - attr["night_light"] = self.device.night_light + if hasattr(self.device.state, "nightlight_status"): + attr["night_light"] = self.device.state.nightlight_status - if hasattr(self.device, "mode"): - attr["mode"] = self.device.mode + if hasattr(self.device.state, "mode"): + attr["mode"] = self.device.state.mode return attr - def set_percentage(self, percentage: int) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the device. If percentage is 0, turn off the fan. Otherwise, ensure the fan is on, set manual mode if needed, and set the speed. """ - device_type = SKU_TO_BASE_DEVICE[self.device.device_type] - speed_range = SPEED_RANGE[device_type] - if percentage == 0: # Turning off is a special case: do not set speed or mode - if not self.device.turn_off(): - raise HomeAssistantError("An error occurred while turning off.") + if not await self.device.turn_off(): + raise HomeAssistantError( + "An error occurred while turning off: " + + self.device.last_response.message + ) self.schedule_update_ha_state() return # If the fan is off, turn it on first if not self.device.is_on: - if not self.device.turn_on(): - raise HomeAssistantError("An error occurred while turning on.") + if not await self.device.turn_on(): + raise HomeAssistantError( + "An error occurred while turning on: " + + self.device.last_response.message + ) # Switch to manual mode if not already set - if self.device.mode != VS_FAN_MODE_MANUAL: - if not self.device.manual_mode(): - raise HomeAssistantError("An error occurred while setting manual mode.") + if self.device.state.mode != VS_FAN_MODE_MANUAL: + if not await self.device.set_manual_mode(): + raise HomeAssistantError( + "An error occurred while setting manual mode." + + self.device.last_response.message + ) # Calculate the speed level and set it - speed_level = math.ceil(percentage_to_ranged_value(speed_range, percentage)) - if not self.device.change_fan_speed(speed_level): - raise HomeAssistantError("An error occurred while changing fan speed.") + 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 + ) self.schedule_update_ha_state() - def set_preset_mode(self, preset_mode: str) -> None: + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of device.""" if preset_mode not in VS_FAN_MODE_PRESET_LIST_HA: raise ValueError( @@ -206,26 +206,26 @@ def set_preset_mode(self, preset_mode: str) -> None: ) if not self.device.is_on: - self.device.turn_on() + await self.device.turn_on() if preset_mode == VS_FAN_MODE_AUTO: - success = self.device.auto_mode() + success = await self.device.auto_mode() elif preset_mode == VS_FAN_MODE_SLEEP: - success = self.device.sleep_mode() + success = await self.device.sleep_mode() elif preset_mode == VS_FAN_MODE_ADVANCED_SLEEP: - success = self.device.advanced_sleep_mode() + success = await self.device.advanced_sleep_mode() elif preset_mode == VS_FAN_MODE_PET: - success = self.device.pet_mode() + success = await self.device.pet_mode() elif preset_mode == VS_FAN_MODE_TURBO: - success = self.device.turbo_mode() + success = await self.device.turbo_mode() elif preset_mode == VS_FAN_MODE_NORMAL: - success = self.device.normal_mode() + success = await self.device.normal_mode() if not success: - raise HomeAssistantError("An error occurred while setting preset mode.") + raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() - def turn_on( + async def async_turn_on( self, percentage: int | None = None, preset_mode: str | None = None, @@ -233,15 +233,15 @@ def turn_on( ) -> 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) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - success = self.device.turn_off() + success = await self.device.turn_off() if not success: - raise HomeAssistantError("An error occurred while turning off.") + raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() diff --git a/homeassistant/components/vesync/humidifier.py b/homeassistant/components/vesync/humidifier.py index 9a98a39aa8c22..8edb405121a18 100644 --- a/homeassistant/components/vesync/humidifier.py +++ b/homeassistant/components/vesync/humidifier.py @@ -3,7 +3,7 @@ import logging from typing import Any -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.humidifier import ( MODE_AUTO, @@ -18,7 +18,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import is_humidifier from .const import ( DOMAIN, VS_COORDINATOR, @@ -28,7 +27,7 @@ VS_HUMIDIFIER_MODE_HUMIDITY, VS_HUMIDIFIER_MODE_MANUAL, VS_HUMIDIFIER_MODE_SLEEP, - VeSyncHumidifierDevice, + VS_MANAGER, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -36,9 +35,6 @@ _LOGGER = logging.getLogger(__name__) -MIN_HUMIDITY = 30 -MAX_HUMIDITY = 80 - VS_TO_HA_MODE_MAP = { VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO, VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO, @@ -65,7 +61,11 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices.humidifiers, + async_add_entities, + coordinator, + ) @callback @@ -75,9 +75,7 @@ def _setup_entities( coordinator: VeSyncDataCoordinator, ): """Add humidifier entities.""" - async_add_entities( - VeSyncHumidifierHA(dev, coordinator) for dev in devices if is_humidifier(dev) - ) + async_add_entities(VeSyncHumidifierHA(dev, coordinator) for dev in devices) def _get_ha_mode(vs_mode: str) -> str | None: @@ -93,12 +91,8 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity): # The base VeSyncBaseEntity has _attr_has_entity_name and this is to follow the device name _attr_name = None - _attr_max_humidity = MAX_HUMIDITY - _attr_min_humidity = MIN_HUMIDITY _attr_supported_features = HumidifierEntityFeature.MODES - device: VeSyncHumidifierDevice - def __init__( self, device: VeSyncBaseDevice, @@ -113,6 +107,8 @@ def __init__( self._ha_to_vs_mode_map: dict[str, str] = {} self._available_modes: list[str] = [] + self._attr_max_humidity = max(device.target_minmax) + self._attr_min_humidity = min(device.target_minmax) # Populate maps once. for vs_mode in self.device.mist_modes: @@ -134,37 +130,39 @@ def available_modes(self) -> list[str]: @property def current_humidity(self) -> int: """Return the current humidity.""" - return self.device.humidity + return self.device.state.humidity @property def target_humidity(self) -> int: """Return the humidity we try to reach.""" - return self.device.auto_humidity + return self.device.state.auto_humidity @property def mode(self) -> str | None: """Get the current preset mode.""" - return None if self.device.mode is None else _get_ha_mode(self.device.mode) + return ( + None + if self.device.state.mode is None + else _get_ha_mode(self.device.state.mode) + ) - def set_humidity(self, humidity: int) -> None: + async def async_set_humidity(self, humidity: int) -> None: """Set the target humidity of the device.""" - if not self.device.set_humidity(humidity): - raise HomeAssistantError( - f"An error occurred while setting humidity {humidity}." - ) + if not await self.device.set_humidity(humidity): + raise HomeAssistantError(self.device.last_response.message) - 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 HomeAssistantError( - f"{mode} is not one of the valid available modes: {self.available_modes}" + f"Invalid mode {mode}. Available modes: {self.available_modes}" ) - if not self.device.set_humidity_mode(self._get_vs_mode(mode)): - raise HomeAssistantError(f"An error occurred while setting mode {mode}.") + if not await self.device.set_humidity_mode(self._get_vs_mode(mode)): + raise HomeAssistantError(self.device.last_response.message) if mode == MODE_SLEEP: # We successfully changed the mode. Consider it a success even if display operation fails. - self.device.set_display(False) + await self.device.toggle_display(False) # Changing mode while humidifier is off actually turns it on, as per the app. But # the library does not seem to update the device_status. It is also possible that @@ -172,23 +170,23 @@ def set_mode(self, mode: str) -> None: # updated. self.schedule_update_ha_state(force_refresh=True) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - success = self.device.turn_on() + success = await self.device.turn_on() if not success: - raise HomeAssistantError("An error occurred while turning on.") + raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - success = self.device.turn_off() + success = await self.device.turn_off() if not success: - raise HomeAssistantError("An error occurred while turning off.") + raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() @property def is_on(self) -> bool: """Return True if device is on.""" - return self.device.device_status == "on" + return self.device.state.device_status == "on" diff --git a/homeassistant/components/vesync/light.py b/homeassistant/components/vesync/light.py index 887400b2cf060..1e5ce3027cf46 100644 --- a/homeassistant/components/vesync/light.py +++ b/homeassistant/components/vesync/light.py @@ -3,7 +3,9 @@ import logging from typing import Any -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.bulb_base import VeSyncBulb +from pyvesync.base_devices.switch_base import VeSyncSwitch +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -17,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.util import color as color_util -from .const import DEV_TYPE_TO_HA, DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -44,7 +46,9 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator + ) @callback @@ -56,10 +60,13 @@ def _setup_entities( """Check if device is a light and add entity.""" entities: list[VeSyncBaseLightHA] = [] for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) in ("walldimmer", "bulb-dimmable"): + if isinstance(dev, VeSyncBulb): + if dev.supports_color_temp: + entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) + elif dev.supports_brightness: + entities.append(VeSyncDimmableLightHA(dev, coordinator)) + elif isinstance(dev, VeSyncSwitch) and dev.supports_dimmable: entities.append(VeSyncDimmableLightHA(dev, coordinator)) - elif DEV_TYPE_TO_HA.get(dev.device_type) in ("bulb-tunable-white",): - entities.append(VeSyncTunableWhiteLightHA(dev, coordinator)) async_add_entities(entities, update_before_add=True) @@ -72,13 +79,13 @@ class VeSyncBaseLightHA(VeSyncBaseEntity, LightEntity): @property def is_on(self) -> bool: """Return True if device is on.""" - return self.device.device_status == "on" + return self.device.state.device_status == "on" @property def brightness(self) -> int: """Get light brightness.""" # get value from pyvesync library api, - result = self.device.brightness + result = self.device.state.brightness try: # check for validity of brightness value received brightness_value = int(result) @@ -92,7 +99,7 @@ def brightness(self) -> int: # convert percent brightness to ha expected range return round((max(1, brightness_value) / 100) * 255) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" attribute_adjustment_only = False # set white temperature @@ -112,7 +119,7 @@ def turn_on(self, **kwargs: Any) -> None: # 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) + 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 @@ -129,7 +136,7 @@ def turn_on(self, **kwargs: Any) -> None: # ensure value between 1-100 brightness = max(1, min(brightness, 100)) # call pyvesync library api method to set brightness - self.device.set_brightness(brightness) + await self.device.set_brightness(brightness) # flag attribute_adjustment_only, so it doesn't # turn_on the device redundantly attribute_adjustment_only = True @@ -137,11 +144,11 @@ def turn_on(self, **kwargs: Any) -> None: if attribute_adjustment_only: return # send turn_on command to pyvesync api - self.device.turn_on() + await self.device.turn_on() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - self.device.turn_off() + await self.device.turn_off() class VeSyncDimmableLightHA(VeSyncBaseLightHA, LightEntity): @@ -162,8 +169,9 @@ 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, - result = self.device.color_temp_pct + # 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) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index 571c6ee0036fa..ef423796f32a7 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -6,11 +6,12 @@ "@webdjoe", "@thegardenmonkey", "@cdnninja", - "@iprak" + "@iprak", + "@sapuseven" ], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", - "loggers": ["pyvesync.vesync"], - "requirements": ["pyvesync==2.1.18"] + "loggers": ["pyvesync"], + "requirements": ["pyvesync==3.0.0b8"] } diff --git a/homeassistant/components/vesync/number.py b/homeassistant/components/vesync/number.py index 707dd6ab30e03..82444ab124640 100644 --- a/homeassistant/components/vesync/number.py +++ b/homeassistant/components/vesync/number.py @@ -1,10 +1,10 @@ """Support for VeSync numeric entities.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.number import ( NumberEntity, @@ -13,11 +13,12 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import is_humidifier -from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -28,22 +29,24 @@ class VeSyncNumberEntityDescription(NumberEntityDescription): """Class to describe a Vesync number entity.""" - exists_fn: Callable[[VeSyncBaseDevice], bool] + exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True value_fn: Callable[[VeSyncBaseDevice], float] - set_value_fn: Callable[[VeSyncBaseDevice, float], bool] + native_min_value_fn: Callable[[VeSyncBaseDevice], float] + native_max_value_fn: Callable[[VeSyncBaseDevice], float] + set_value_fn: Callable[[VeSyncBaseDevice, float], Awaitable[bool]] NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [ VeSyncNumberEntityDescription( key="mist_level", translation_key="mist_level", - native_min_value=1, - native_max_value=9, + native_min_value_fn=lambda device: min(device.mist_levels), + native_max_value_fn=lambda device: max(device.mist_levels), native_step=1, mode=NumberMode.SLIDER, exists_fn=is_humidifier, set_value_fn=lambda device, value: device.set_mist_level(value), - value_fn=lambda device: device.mist_level, + value_fn=lambda device: device.state.mist_level, ) ] @@ -66,7 +69,9 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator + ) @callback @@ -106,9 +111,18 @@ def native_value(self) -> float: """Return the value reported by the number.""" return self.entity_description.value_fn(self.device) + @property + def native_min_value(self) -> float: + """Return the value reported by the number.""" + return self.entity_description.native_min_value_fn(self.device) + + @property + def native_max_value(self) -> float: + """Return the value reported by the number.""" + return self.entity_description.native_max_value_fn(self.device) + async def async_set_native_value(self, value: float) -> None: """Set new value.""" - if await self.hass.async_add_executor_job( - self.entity_description.set_value_fn, self.device, value - ): - await self.coordinator.async_request_refresh() + if not await self.entity_description.set_value_fn(self.device, value): + raise HomeAssistantError(self.device.last_response.message) + self.schedule_update_ha_state() diff --git a/homeassistant/components/vesync/select.py b/homeassistant/components/vesync/select.py index a9d2e1b533a8b..e34d13babf0eb 100644 --- a/homeassistant/components/vesync/select.py +++ b/homeassistant/components/vesync/select.py @@ -1,29 +1,34 @@ """Support for VeSync numeric entities.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .common import rgetattr +from .common import is_humidifier, is_outlet, is_purifier from .const import ( DOMAIN, - FAN_NIGHT_LIGHT_LEVEL_DIM, - FAN_NIGHT_LIGHT_LEVEL_OFF, - FAN_NIGHT_LIGHT_LEVEL_ON, HUMIDIFIER_NIGHT_LIGHT_LEVEL_BRIGHT, HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM, HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, + OUTLET_NIGHT_LIGHT_LEVEL_AUTO, + OUTLET_NIGHT_LIGHT_LEVEL_OFF, + OUTLET_NIGHT_LIGHT_LEVEL_ON, + PURIFIER_NIGHT_LIGHT_LEVEL_DIM, + PURIFIER_NIGHT_LIGHT_LEVEL_OFF, + PURIFIER_NIGHT_LIGHT_LEVEL_ON, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, + VS_MANAGER, ) from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -47,7 +52,7 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): exists_fn: Callable[[VeSyncBaseDevice], bool] current_option_fn: Callable[[VeSyncBaseDevice], str] - select_option_fn: Callable[[VeSyncBaseDevice, str], bool] + select_option_fn: Callable[[VeSyncBaseDevice, str], Awaitable[bool]] SELECT_DESCRIPTIONS: list[VeSyncSelectEntityDescription] = [ @@ -57,34 +62,45 @@ class VeSyncSelectEntityDescription(SelectEntityDescription): translation_key="night_light_level", options=list(VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.values()), icon="mdi:brightness-6", - exists_fn=lambda device: rgetattr(device, "set_night_light_brightness"), + 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_night_light_brightness( + select_option_fn=lambda device, value: device.set_nightlight_brightness( 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( - device.details.get("night_light_brightness"), + device.state.nightlight_brightness, HUMIDIFIER_NIGHT_LIGHT_LEVEL_OFF, ), ), - # night_light for fan devices based on pyvesync.VeSyncAirBypass + # night_light for air purifiers VeSyncSelectEntityDescription( key="night_light_level", translation_key="night_light_level", options=[ - FAN_NIGHT_LIGHT_LEVEL_OFF, - FAN_NIGHT_LIGHT_LEVEL_DIM, - FAN_NIGHT_LIGHT_LEVEL_ON, + PURIFIER_NIGHT_LIGHT_LEVEL_OFF, + PURIFIER_NIGHT_LIGHT_LEVEL_DIM, + PURIFIER_NIGHT_LIGHT_LEVEL_ON, ], icon="mdi:brightness-6", - exists_fn=lambda device: rgetattr(device, "set_night_light"), - select_option_fn=lambda device, value: device.set_night_light(value), - current_option_fn=lambda device: VS_TO_HA_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP.get( - device.details.get("night_light"), - FAN_NIGHT_LIGHT_LEVEL_OFF, - ), + exists_fn=lambda device: is_purifier(device) and device.supports_nightlight, + select_option_fn=lambda device, value: device.set_nightlight_mode(value), + current_option_fn=lambda device: device.state.nightlight_status, + ), + # night_light for outlets + VeSyncSelectEntityDescription( + key="night_light_level", + translation_key="night_light_level", + options=[ + OUTLET_NIGHT_LIGHT_LEVEL_OFF, + OUTLET_NIGHT_LIGHT_LEVEL_ON, + OUTLET_NIGHT_LIGHT_LEVEL_AUTO, + ], + 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), + current_option_fn=lambda device: device.state.nightlight_status, ), ] @@ -107,7 +123,9 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator + ) @callback @@ -149,7 +167,6 @@ def current_option(self) -> str | None: async def async_select_option(self, option: str) -> None: """Set an option.""" - if await self.hass.async_add_executor_job( - self.entity_description.select_option_fn, self.device, option - ): - await self.coordinator.async_request_refresh() + if not await self.entity_description.select_option_fn(self.device, option): + raise HomeAssistantError(self.device.last_response.message) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 3bc6608989add..0614e522c51c0 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -6,7 +6,7 @@ from dataclasses import dataclass import logging -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.sensor import ( SensorDeviceClass, @@ -28,15 +28,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType -from .common import is_humidifier -from .const import ( - DEV_TYPE_TO_HA, - DOMAIN, - SKU_TO_BASE_DEVICE, - VS_COORDINATOR, - VS_DEVICES, - VS_DISCOVERY, -) +from .common import is_humidifier, is_outlet, rgetattr +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -49,53 +42,9 @@ class VeSyncSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[VeSyncBaseDevice], StateType] - exists_fn: Callable[[VeSyncBaseDevice], bool] = lambda _: True - update_fn: Callable[[VeSyncBaseDevice], None] = lambda _: None - - -def update_energy(device): - """Update outlet details and energy usage.""" - device.update() - device.update_energy() - - -def sku_supported(device, supported): - """Get the base device of which a device is an instance.""" - return SKU_TO_BASE_DEVICE.get(device.device_type) in supported - - -def ha_dev_type(device): - """Get the homeassistant device_type for a given device.""" - return DEV_TYPE_TO_HA.get(device.device_type) + exists_fn: Callable[[VeSyncBaseDevice], bool] -FILTER_LIFE_SUPPORTED = [ - "LV-PUR131S", - "Core200S", - "Core300S", - "Core400S", - "Core600S", - "EverestAir", - "Vital100S", - "Vital200S", -] -AIR_QUALITY_SUPPORTED = [ - "LV-PUR131S", - "Core300S", - "Core400S", - "Core600S", - "Vital100S", - "Vital200S", -] -PM25_SUPPORTED = [ - "Core300S", - "Core400S", - "Core600S", - "EverestAir", - "Vital100S", - "Vital200S", -] - SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( VeSyncSensorEntityDescription( key="filter-life", @@ -103,22 +52,24 @@ def ha_dev_type(device): native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, - value_fn=lambda device: device.filter_life, - exists_fn=lambda device: sku_supported(device, FILTER_LIFE_SUPPORTED), + value_fn=lambda device: device.state.filter_life, + exists_fn=lambda device: rgetattr(device, "state.filter_life") is not None, ), VeSyncSensorEntityDescription( key="air-quality", translation_key="air_quality", - value_fn=lambda device: device.details["air_quality"], - exists_fn=lambda device: sku_supported(device, AIR_QUALITY_SUPPORTED), + value_fn=lambda device: device.state.air_quality_string, + exists_fn=( + lambda device: rgetattr(device, "state.air_quality_string") is not None + ), ), VeSyncSensorEntityDescription( key="pm25", device_class=SensorDeviceClass.PM25, native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda device: device.details["air_quality_value"], - exists_fn=lambda device: sku_supported(device, PM25_SUPPORTED), + value_fn=lambda device: device.state.pm25, + exists_fn=lambda device: rgetattr(device, "state.pm25") is not None, ), VeSyncSensorEntityDescription( key="power", @@ -126,9 +77,8 @@ def ha_dev_type(device): device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda device: device.details["power"], - update_fn=update_energy, - exists_fn=lambda device: ha_dev_type(device) == "outlet", + value_fn=lambda device: device.state.power, + exists_fn=is_outlet, ), VeSyncSensorEntityDescription( key="energy", @@ -136,9 +86,8 @@ def ha_dev_type(device): device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.energy_today, - update_fn=update_energy, - exists_fn=lambda device: ha_dev_type(device) == "outlet", + value_fn=lambda device: device.state.energy, + exists_fn=is_outlet, ), VeSyncSensorEntityDescription( key="energy-weekly", @@ -146,9 +95,10 @@ def ha_dev_type(device): device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.weekly_energy_total, - update_fn=update_energy, - exists_fn=lambda device: ha_dev_type(device) == "outlet", + value_fn=lambda device: getattr( + device.state.weekly_history, "totalEnergy", None + ), + exists_fn=is_outlet, ), VeSyncSensorEntityDescription( key="energy-monthly", @@ -156,9 +106,10 @@ def ha_dev_type(device): device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.monthly_energy_total, - update_fn=update_energy, - exists_fn=lambda device: ha_dev_type(device) == "outlet", + value_fn=lambda device: getattr( + device.state.monthly_history, "totalEnergy", None + ), + exists_fn=is_outlet, ), VeSyncSensorEntityDescription( key="energy-yearly", @@ -166,9 +117,10 @@ def ha_dev_type(device): device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.yearly_energy_total, - update_fn=update_energy, - exists_fn=lambda device: ha_dev_type(device) == "outlet", + value_fn=lambda device: getattr( + device.state.yearly_history, "totalEnergy", None + ), + exists_fn=is_outlet, ), VeSyncSensorEntityDescription( key="voltage", @@ -176,16 +128,15 @@ def ha_dev_type(device): device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda device: device.details["voltage"], - update_fn=update_energy, - exists_fn=lambda device: ha_dev_type(device) == "outlet", + value_fn=lambda device: device.state.voltage, + exists_fn=is_outlet, ), VeSyncSensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, - value_fn=lambda device: device.details["humidity"], + value_fn=lambda device: device.state.humidity, exists_fn=is_humidifier, ), ) @@ -209,7 +160,9 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator + ) @callback @@ -251,7 +204,3 @@ def __init__( def native_value(self) -> StateType: """Return the state of the sensor.""" return self.entity_description.value_fn(self.device) - - def update(self) -> None: - """Run the update function defined for the sensor.""" - return self.entity_description.update_fn(self.device) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 06fbd3606bd1f..8d2feb2740546 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -1,11 +1,11 @@ """Support for VeSync switches.""" -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging from typing import Any, Final -from pyvesync.vesyncbasedevice import VeSyncBaseDevice +from pyvesync.base_devices.vesyncbasedevice import VeSyncBaseDevice from homeassistant.components.switch import ( SwitchDeviceClass, @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .common import is_outlet, is_wall_switch, rgetattr -from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY +from .const import DOMAIN, VS_COORDINATOR, VS_DEVICES, VS_DISCOVERY, VS_MANAGER from .coordinator import VeSyncDataCoordinator from .entity import VeSyncBaseEntity @@ -32,14 +32,14 @@ class VeSyncSwitchEntityDescription(SwitchEntityDescription): is_on: Callable[[VeSyncBaseDevice], bool] exists_fn: Callable[[VeSyncBaseDevice], bool] - on_fn: Callable[[VeSyncBaseDevice], bool] - off_fn: Callable[[VeSyncBaseDevice], bool] + on_fn: Callable[[VeSyncBaseDevice], Awaitable[bool]] + off_fn: Callable[[VeSyncBaseDevice], Awaitable[bool]] SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = ( VeSyncSwitchEntityDescription( key="device_status", - is_on=lambda device: device.device_status == "on", + is_on=lambda device: device.state.device_status == "on", # 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, @@ -48,11 +48,13 @@ class VeSyncSwitchEntityDescription(SwitchEntityDescription): ), VeSyncSwitchEntityDescription( key="display", - is_on=lambda device: device.display_state, - exists_fn=lambda device: rgetattr(device, "display_state") is not None, + is_on=lambda device: device.state.display_set_status == "on", + exists_fn=( + lambda device: rgetattr(device, "state.display_set_status") is not None + ), translation_key="display", - on_fn=lambda device: device.turn_on_display(), - off_fn=lambda device: device.turn_off_display(), + on_fn=lambda device: device.toggle_display(True), + off_fn=lambda device: device.toggle_display(False), ), ) @@ -75,7 +77,9 @@ def discover(devices): async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_DEVICES), discover) ) - _setup_entities(hass.data[DOMAIN][VS_DEVICES], async_add_entities, coordinator) + _setup_entities( + hass.data[DOMAIN][VS_MANAGER].devices, async_add_entities, coordinator + ) @callback @@ -118,16 +122,16 @@ def is_on(self) -> bool | None: """Return the entity value to represent the entity state.""" return self.entity_description.is_on(self.device) - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - if not self.entity_description.off_fn(self.device): - raise HomeAssistantError("An error occurred while turning off.") + if not await self.entity_description.off_fn(self.device): + raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" - if not self.entity_description.on_fn(self.device): - raise HomeAssistantError("An error occurred while turning on.") + if not await self.entity_description.on_fn(self.device): + raise HomeAssistantError(self.device.last_response.message) self.schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index 4f3498388c264..723e9252976c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2581,7 +2581,7 @@ pyvera==0.3.16 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.18 +pyvesync==3.0.0b8 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b906f5a98b93c..45821c0189882 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2139,7 +2139,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.16 # homeassistant.components.vesync -pyvesync==2.1.18 +pyvesync==3.0.0b8 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/common.py b/tests/components/vesync/common.py index cf2f49ff28f68..dd1ef36c783d3 100644 --- a/tests/components/vesync/common.py +++ b/tests/components/vesync/common.py @@ -1,14 +1,12 @@ """Common methods used across tests for VeSync.""" -import json from typing import Any -import requests_mock - from homeassistant.components.vesync.const import DOMAIN from homeassistant.util.json import JsonObjectType -from tests.common import load_fixture, load_json_object_fixture +from tests.common import load_json_object_fixture +from tests.test_util.aiohttp import AiohttpClientMocker ENTITY_HUMIDIFIER = "humidifier.humidifier_200s" ENTITY_HUMIDIFIER_MIST_LEVEL = "number.humidifier_200s_mist_level" @@ -19,55 +17,68 @@ ENTITY_SWITCH_DISPLAY = "switch.humidifier_200s_display" +DEVICE_CATEGORIES = [ + "outlets", + "switches", + "fans", + "bulbs", + "humidifiers", + "air_purifiers", + "air_fryers", + "thermostats", +] + ALL_DEVICES = load_json_object_fixture("vesync-devices.json", DOMAIN) ALL_DEVICE_NAMES: list[str] = [ dev["deviceName"] for dev in ALL_DEVICES["result"]["list"] ] DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = { "Humidifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-200s.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-detail.json") ], "Humidifier 600S": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "humidifier-detail.json") ], "Air Purifier 131s": [ ( "post", - "/131airPurifier/v1/device/deviceDetail", + "/cloud/v1/deviceManaged/deviceDetail", "air-purifier-131s-detail.json", ) ], "Air Purifier 200s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json") ], "Air Purifier 400s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-400s-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json") ], "Air Purifier 600s": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "device-detail.json") + ("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json") ], "Dimmable Light": [ - ("post", "/SmartBulb/v1/device/devicedetail", "device-detail.json") + ("post", "/cloud/v1/deviceManaged/deviceDetail", "device-detail.json") ], "Temperature Light": [ - ("post", "/cloud/v1/deviceManaged/bypass", "device-detail.json") + ("post", "/cloud/v1/deviceManaged/bypass", "light-detail.json") ], "Outlet": [ ("get", "/v1/device/outlet/detail", "outlet-detail.json"), - ("get", "/v1/device/outlet/energy/week", "outlet-energy-week.json"), + ("post", "/cloud/v1/device/getLastWeekEnergy", "outlet-energy.json"), + ("post", "/cloud/v1/device/getLastMonthEnergy", "outlet-energy.json"), + ("post", "/cloud/v1/device/getLastYearEnergy", "outlet-energy.json"), ], "Wall Switch": [ - ("post", "/inwallswitch/v1/device/devicedetail", "device-detail.json") + ("post", "/cloud/v1/deviceManaged/deviceDetail", "device-detail.json") ], - "Dimmer Switch": [("post", "/dimmer/v1/device/devicedetail", "dimmer-detail.json")], - "SmartTowerFan": [ - ("post", "/cloud/v2/deviceManaged/bypassV2", "SmartTowerFan-detail.json") + "Dimmer Switch": [ + ("post", "/cloud/v1/deviceManaged/deviceDetail", "dimmer-detail.json") ], + "SmartTowerFan": [("post", "/cloud/v2/deviceManaged/bypassV2", "fan-detail.json")], } def mock_devices_response( - requests_mock: requests_mock.Mocker, device_name: str + aioclient_mock: AiohttpClientMocker, device_name: str ) -> None: """Build a response for the Helpers.call_api method.""" device_list = [ @@ -76,24 +87,32 @@ def mock_devices_response( if device["deviceName"] == device_name ] - requests_mock.post( + aioclient_mock.post( "https://smartapi.vesync.com/cloud/v1/deviceManaged/devices", - json={"code": 0, "result": {"list": device_list}}, - ) - requests_mock.post( - "https://smartapi.vesync.com/cloud/v1/user/login", - json=load_json_object_fixture("vesync-login.json", DOMAIN), + json={ + "traceId": "1234", + "code": 0, + "msg": None, + "module": None, + "stacktrace": None, + "result": { + "total": len(device_list), + "pageSize": len(device_list), + "pageNo": 1, + "list": device_list, + }, + }, ) + for fixture in DEVICE_FIXTURES[device_name]: - requests_mock.request( - fixture[0], + getattr(aioclient_mock, fixture[0])( f"https://smartapi.vesync.com{fixture[1]}", json=load_json_object_fixture(fixture[2], DOMAIN), ) def mock_multiple_device_responses( - requests_mock: requests_mock.Mocker, device_names: list[str] + aioclient_mock: AiohttpClientMocker, device_names: list[str] ) -> None: """Build a response for the Helpers.call_api method for multiple devices.""" device_list = [ @@ -102,41 +121,50 @@ def mock_multiple_device_responses( if device["deviceName"] in device_names ] - requests_mock.post( + aioclient_mock.post( "https://smartapi.vesync.com/cloud/v1/deviceManaged/devices", - json={"code": 0, "result": {"list": device_list}}, - ) - requests_mock.post( - "https://smartapi.vesync.com/cloud/v1/user/login", - json=load_json_object_fixture("vesync-login.json", DOMAIN), + json={ + "traceId": "1234", + "code": 0, + "msg": None, + "module": None, + "stacktrace": None, + "result": { + "total": len(device_list), + "pageSize": len(device_list), + "pageNo": 1, + "list": device_list, + }, + }, ) + for device_name in device_names: - for fixture in DEVICE_FIXTURES[device_name]: - requests_mock.request( - fixture[0], - f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture(fixture[2], DOMAIN), - ) + fixture = DEVICE_FIXTURES[device_name][0] + getattr(aioclient_mock, fixture[0])( + f"https://smartapi.vesync.com{fixture[1]}", + json=load_json_object_fixture(fixture[2], DOMAIN), + ) -def mock_air_purifier_400s_update_response(requests_mock: requests_mock.Mocker) -> None: + +def mock_air_purifier_400s_update_response(aioclient_mock: AiohttpClientMocker) -> None: """Build a response for the Helpers.call_api method for air_purifier_400s with updated data.""" device_name = "Air Purifier 400s" for fixture in DEVICE_FIXTURES[device_name]: - requests_mock.request( - fixture[0], + getattr(aioclient_mock, fixture[0])( f"https://smartapi.vesync.com{fixture[1]}", - json=load_json_object_fixture( - "air-purifier-400s-detail-updated.json", DOMAIN - ), + json=load_json_object_fixture("air-purifier-detail-updated.json", DOMAIN), ) def mock_device_response( - requests_mock: requests_mock.Mocker, device_name: str, override: Any + aioclient_mock: AiohttpClientMocker, device_name: str, override: Any ) -> None: - """Build a response for the Helpers.call_api method with updated data.""" + """Build a response for the Helpers.call_api method with updated data. + + The provided override only applies to the base device response. + """ def load_and_merge(source: str) -> JsonObjectType: json = load_json_object_fixture(source, DOMAIN) @@ -152,15 +180,14 @@ def load_and_merge(source: str) -> JsonObjectType: if len(fixtures) > 0: item = fixtures[0] - requests_mock.request( - item[0], + getattr(aioclient_mock, item[0])( f"https://smartapi.vesync.com{item[1]}", json=load_and_merge(item[2]), ) def mock_outlet_energy_response( - requests_mock: requests_mock.Mocker, device_name: str, override: Any + aioclient_mock: AiohttpClientMocker, device_name: str, override: Any = None ) -> None: """Build a response for the Helpers.call_api energy request with updated data.""" @@ -168,83 +195,16 @@ def load_and_merge(source: str) -> JsonObjectType: json = load_json_object_fixture(source, DOMAIN) if override: - json.update(override) + if "result" in json: + json["result"].update(override) + else: + json.update(override) return json - fixtures = DEVICE_FIXTURES[device_name] - - # The 2nd item contain energy details - if len(fixtures) > 1: - item = fixtures[1] - - requests_mock.request( - item[0], - f"https://smartapi.vesync.com{item[1]}", - json=load_and_merge(item[2]), - ) - - -def call_api_side_effect__no_devices(*args, **kwargs): - """Build a side_effects method for the Helpers.call_api method.""" - if args[0] == "/cloud/v1/user/login" and args[1] == "post": - return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": - return ( - json.loads( - load_fixture("vesync_api_call__devices__no_devices.json", "vesync") - ), - 200, - ) - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") - - -def call_api_side_effect__single_humidifier(*args, **kwargs): - """Build a side_effects method for the Helpers.call_api method.""" - if args[0] == "/cloud/v1/user/login" and args[1] == "post": - return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": - return ( - json.loads( - load_fixture( - "vesync_api_call__devices__single_humidifier.json", "vesync" - ) - ), - 200, - ) - if args[0] == "/cloud/v2/deviceManaged/bypassV2" and kwargs["method"] == "post": - return ( - json.loads( - load_fixture( - "vesync_api_call__device_details__single_humidifier.json", "vesync" - ) - ), - 200, - ) - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") - - -def call_api_side_effect__single_fan(*args, **kwargs): - """Build a side_effects method for the Helpers.call_api method.""" - if args[0] == "/cloud/v1/user/login" and args[1] == "post": - return json.loads(load_fixture("vesync_api_call__login.json", "vesync")), 200 - if args[0] == "/cloud/v1/deviceManaged/devices" and args[1] == "post": - return ( - json.loads( - load_fixture("vesync_api_call__devices__single_fan.json", "vesync") - ), - 200, - ) - if ( - args[0] == "/131airPurifier/v1/device/deviceDetail" - and kwargs["method"] == "post" - ): - return ( - json.loads( - load_fixture( - "vesync_api_call__device_details__single_fan.json", "vesync" - ) - ), - 200, + # Skip the device details (1st item) + for fixture in DEVICE_FIXTURES[device_name][1:]: + getattr(aioclient_mock, fixture[0])( + f"https://smartapi.vesync.com{fixture[1]}", + json=load_and_merge(fixture[2]), ) - raise ValueError(f"Unhandled API call args={args}, kwargs={kwargs}") diff --git a/tests/components/vesync/conftest.py b/tests/components/vesync/conftest.py index 32f231017552d..faaefb2ed82c2 100644 --- a/tests/components/vesync/conftest.py +++ b/tests/components/vesync/conftest.py @@ -2,15 +2,21 @@ from __future__ import annotations -from unittest.mock import Mock, patch +from collections.abc import Iterator +from contextlib import ExitStack +from itertools import chain +from types import MappingProxyType +from unittest.mock import AsyncMock, MagicMock, Mock, PropertyMock, patch import pytest from pyvesync import VeSync -from pyvesync.vesyncbulb import VeSyncBulb -from pyvesync.vesyncfan import VeSyncAirBypass, VeSyncHumid200300S -from pyvesync.vesyncoutlet import VeSyncOutlet -from pyvesync.vesyncswitch import VeSyncSwitch -import requests_mock +from pyvesync.base_devices.bulb_base import VeSyncBulb +from pyvesync.base_devices.fan_base import VeSyncFanBase +from pyvesync.base_devices.humidifier_base import HumidifierState +from pyvesync.base_devices.outlet_base import VeSyncOutlet +from pyvesync.base_devices.switch_base import VeSyncSwitch +from pyvesync.const import HumidifierFeatures +from pyvesync.devices.vesynchumidifier import VeSyncHumid200S, VeSyncHumid200300S from homeassistant.components.vesync import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -18,9 +24,51 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from .common import mock_multiple_device_responses +from .common import DEVICE_CATEGORIES, mock_multiple_device_responses from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.fixture(autouse=True) +def patch_vesync_firmware(): + """Patch VeSync to disable firmware checks.""" + with patch( + "pyvesync.vesync.VeSync.check_firmware", new=AsyncMock(return_value=True) + ): + yield + + +@pytest.fixture(autouse=True) +def patch_vesync_login(): + """Patch VeSync login method.""" + with patch("pyvesync.vesync.VeSync.login", new=AsyncMock()): + yield + + +@pytest.fixture(autouse=True) +def patch_vesync(): + """Patch VeSync methods and several properties/attributes for all tests.""" + props = { + "enabled": True, + "token": "TEST_TOKEN", + "account_id": "TEST_ACCOUNT_ID", + } + + with ( + patch.multiple( + "pyvesync.vesync.VeSync", + check_firmware=AsyncMock(return_value=True), + login=AsyncMock(return_value=None), + ), + ExitStack() as stack, + ): + for name, value in props.items(): + mock = stack.enter_context( + patch.object(VeSync, name, new_callable=PropertyMock) + ) + mock.return_value = value + yield @pytest.fixture(name="config_entry") @@ -41,103 +89,134 @@ def config_fixture() -> ConfigType: return {DOMAIN: {CONF_USERNAME: "user", CONF_PASSWORD: "pass"}} +class _DevicesContainer: + def __init__(self) -> None: + for category in DEVICE_CATEGORIES: + setattr(self, category, []) + + # wrap all devices in a read-only proxy array + self._devices = MappingProxyType( + {category: getattr(self, category) for category in DEVICE_CATEGORIES} + ) + + def __iter__(self) -> Iterator[_DevicesContainer]: + return chain.from_iterable(getattr(self, c) for c in DEVICE_CATEGORIES) + + def __len__(self) -> int: + return sum(len(getattr(self, c)) for c in DEVICE_CATEGORIES) + + def __bool__(self) -> bool: + return any(getattr(self, c) for c in DEVICE_CATEGORIES) + + @pytest.fixture(name="manager") -def manager_fixture() -> VeSync: +def manager_fixture(): """Create a mock VeSync manager fixture.""" + devices = _DevicesContainer() + + mock_vesync = MagicMock(spec=VeSync) + mock_vesync.update = AsyncMock() + mock_vesync.devices = devices + mock_vesync._dev_list = devices._devices - outlets = [] - switches = [] - fans = [] - bulbs = [] - - mock_vesync = Mock(VeSync) - mock_vesync.login = Mock(return_value=True) - mock_vesync.update = Mock() - mock_vesync.outlets = outlets - mock_vesync.switches = switches - mock_vesync.fans = fans - mock_vesync.bulbs = bulbs - mock_vesync._dev_list = { - "fans": fans, - "outlets": outlets, - "switches": switches, - "bulbs": bulbs, - } mock_vesync.account_id = "account_id" mock_vesync.time_zone = "America/New_York" - mock = Mock(return_value=mock_vesync) - with patch("homeassistant.components.vesync.VeSync", new=mock): + with patch("homeassistant.components.vesync.VeSync", return_value=mock_vesync): yield mock_vesync @pytest.fixture(name="fan") def fan_fixture(): """Create a mock VeSync fan fixture.""" - return Mock(VeSyncAirBypass) + return Mock( + VeSyncFanBase, + cid="fan", + device_type="fan", + device_name="Test Fan", + device_status="on", + modes=[], + connection_status="online", + current_firm_version="1.0.0", + ) @pytest.fixture(name="bulb") def bulb_fixture(): """Create a mock VeSync bulb fixture.""" - return Mock(VeSyncBulb) + return Mock( + VeSyncBulb, + cid="bulb", + device_name="Test Bulb", + ) @pytest.fixture(name="switch") def switch_fixture(): """Create a mock VeSync switch fixture.""" - mock_fixture = Mock(VeSyncSwitch) - mock_fixture.is_dimmable = Mock(return_value=False) - return mock_fixture + return Mock( + VeSyncSwitch, + is_dimmable=Mock(return_value=False), + ) @pytest.fixture(name="dimmable_switch") def dimmable_switch_fixture(): """Create a mock VeSync switch fixture.""" - mock_fixture = Mock(VeSyncSwitch) - mock_fixture.is_dimmable = Mock(return_value=True) - return mock_fixture + return Mock( + VeSyncSwitch, + is_dimmable=Mock(return_value=True), + ) @pytest.fixture(name="outlet") def outlet_fixture(): """Create a mock VeSync outlet fixture.""" - return Mock(VeSyncOutlet) + return Mock( + VeSyncOutlet, + cid="outlet", + device_name="Test Outlet", + ) @pytest.fixture(name="humidifier") def humidifier_fixture(): - """Create a mock VeSync Classic200S humidifier fixture.""" + """Create a mock VeSync Classic 200S humidifier fixture.""" return Mock( - VeSyncHumid200300S, + VeSyncHumid200S, cid="200s-humidifier", config={ "auto_target_humidity": 40, "display": "true", "automatic_stop": "true", }, - details={ - "humidity": 35, - "mode": "manual", - }, + features=[HumidifierFeatures.NIGHTLIGHT], device_type="Classic200S", device_name="Humidifier 200s", device_status="on", - mist_level=6, mist_modes=["auto", "manual"], - mode=None, + mist_levels=[1, 2, 3, 4, 5, 6], sub_device_no=0, - config_module="configModule", + target_minmax=(30, 80), + state=Mock( + HumidifierState, + connection_status="online", + humidity=50, + mist_level=6, + mode=None, + nightlight_status="dim", + nightlight_brightness=50, + water_lacks=False, + water_tank_lifted=False, + ), connection_status="online", current_firm_version="1.0.0", - water_lacks=False, - water_tank_lifted=False, ) @pytest.fixture(name="humidifier_300s") def humidifier_300s_fixture(): - """Create a mock VeSync Classic300S humidifier fixture.""" + """Create a mock VeSync Classic 300S humidifier fixture.""" return Mock( VeSyncHumid200300S, cid="300s-humidifier", @@ -146,26 +225,33 @@ def humidifier_300s_fixture(): "display": "true", "automatic_stop": "true", }, - details={"humidity": 35, "mode": "manual", "night_light_brightness": 50}, + features=[HumidifierFeatures.NIGHTLIGHT], device_type="Classic300S", device_name="Humidifier 300s", device_status="on", - mist_level=6, mist_modes=["auto", "manual"], - mode=None, - night_light=True, + mist_levels=[1, 2, 3, 4, 5, 6], sub_device_no=0, + target_minmax=(30, 80), + state=Mock( + HumidifierState, + connection_status="online", + humidity=50, + mist_level=6, + mode=None, + nightlight_status="dim", + nightlight_brightness=50, + water_lacks=False, + water_tank_lifted=False, + ), config_module="configModule", - connection_status="online", current_firm_version="1.0.0", - water_lacks=False, - water_tank_lifted=False, ) @pytest.fixture(name="humidifier_config_entry") async def humidifier_config_entry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, config + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config ) -> MockConfigEntry: """Create a mock VeSync config entry for `Humidifier 200s`.""" entry = MockConfigEntry( @@ -176,7 +262,7 @@ async def humidifier_config_entry( entry.add_to_hass(hass) device_name = "Humidifier 200s" - mock_multiple_device_responses(requests_mock, [device_name]) + mock_multiple_device_responses(aioclient_mock, [device_name]) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -193,14 +279,14 @@ async def install_humidifier_device( """Create a mock VeSync config entry with the specified humidifier device.""" # Install the defined humidifier - manager._dev_list["fans"].append(request.getfixturevalue(request.param)) + manager._dev_list["humidifiers"].append(request.getfixturevalue(request.param)) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @pytest.fixture(name="fan_config_entry") async def fan_config_entry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, config + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config ) -> MockConfigEntry: """Create a mock VeSync config entry for `SmartTowerFan`.""" entry = MockConfigEntry( @@ -211,7 +297,7 @@ async def fan_config_entry( entry.add_to_hass(hass) device_name = "SmartTowerFan" - mock_multiple_device_responses(requests_mock, [device_name]) + mock_multiple_device_responses(aioclient_mock, [device_name]) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -220,7 +306,7 @@ async def fan_config_entry( @pytest.fixture(name="switch_old_id_config_entry") async def switch_old_id_config_entry( - hass: HomeAssistant, requests_mock: requests_mock.Mocker, config + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, config ) -> MockConfigEntry: """Create a mock VeSync config entry for `switch` with the old unique ID approach.""" entry = MockConfigEntry( @@ -235,6 +321,6 @@ async def switch_old_id_config_entry( wall_switch = "Wall Switch" humidifer = "Humidifier 200s" - mock_multiple_device_responses(requests_mock, [wall_switch, humidifer]) + mock_multiple_device_responses(aioclient_mock, [wall_switch, humidifer]) return entry diff --git a/tests/components/vesync/fixtures/air-purifier-131s-detail.json b/tests/components/vesync/fixtures/air-purifier-131s-detail.json index a7598c621d365..80effb9b4e4f8 100644 --- a/tests/components/vesync/fixtures/air-purifier-131s-detail.json +++ b/tests/components/vesync/fixtures/air-purifier-131s-detail.json @@ -1,25 +1,29 @@ { "code": 0, + "traceId": "1234", "msg": "request success", - "traceId": "1744558015", - "screenStatus": "on", - "filterLife": { - "change": false, - "useHour": 3034, - "percent": 25 - }, - "activeTime": 0, - "timer": null, - "scheduleCount": 0, - "schedule": null, - "levelNew": 0, - "airQuality": "excellent", - "level": null, - "mode": "sleep", - "deviceName": "Levoit 131S Air Purifier", - "currentFirmVersion": "2.0.58", - "childLock": "off", - "deviceStatus": "on", - "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", - "connectionStatus": "online" + "module": null, + "stacktrace": null, + "result": { + "screenStatus": "on", + "filterLife": { + "change": false, + "useHour": 0, + "percent": 25 + }, + "activeTime": 0, + "timer": null, + "scheduleCount": 0, + "schedule": null, + "levelNew": 0, + "airQuality": "excellent", + "level": null, + "mode": "sleep", + "deviceName": "Levoit 131S Air Purifier", + "currentFirmVersion": "2.0.58", + "childLock": "off", + "deviceStatus": "on", + "deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/airpurifier131_240.png", + "connectionStatus": "online" + } } diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json b/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json deleted file mode 100644 index b48eefba4c919..0000000000000 --- a/tests/components/vesync/fixtures/air-purifier-400s-detail-updated.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "code": 0, - "brightNess": "50", - "result": { - "light": { - "brightness": 50, - "colorTempe": 5400 - }, - "result": { - "brightness": 50, - "red": 178.5, - "green": 255, - "blue": 25.5, - "colorMode": "rgb", - "humidity": 35, - "mist_virtual_level": 6, - "mode": "manual", - "water_lacks": true, - "water_tank_lifted": true, - "automatic_stop_reach_target": true, - "night_light_brightness": 10, - "enabled": true, - "filter_life": 99, - "level": 1, - "display": true, - "display_forever": false, - "child_lock": false, - "night_light": "on", - "air_quality": 15, - "air_quality_value": 1, - "configuration": { - "auto_target_humidity": 40, - "display": true, - "automatic_stop": true - } - }, - "code": 0 - } -} diff --git a/tests/components/vesync/fixtures/air-purifier-400s-detail.json b/tests/components/vesync/fixtures/air-purifier-400s-detail.json deleted file mode 100644 index a26d9e2a9758b..0000000000000 --- a/tests/components/vesync/fixtures/air-purifier-400s-detail.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "code": 0, - "brightNess": "50", - "result": { - "light": { - "brightness": 50, - "colorTempe": 5400 - }, - "result": { - "brightness": 50, - "red": 178.5, - "green": 255, - "blue": 25.5, - "colorMode": "rgb", - "humidity": 35, - "mist_virtual_level": 6, - "mode": "manual", - "water_lacks": true, - "water_tank_lifted": true, - "automatic_stop_reach_target": true, - "night_light_brightness": 10, - "enabled": true, - "filter_life": 99, - "level": 1, - "display": true, - "display_forever": false, - "child_lock": false, - "night_light": "off", - "air_quality": 5, - "air_quality_value": 1, - "configuration": { - "auto_target_humidity": 40, - "display": true, - "automatic_stop": true - } - }, - "code": 0 - } -} diff --git a/tests/components/vesync/fixtures/air-purifier-detail-updated.json b/tests/components/vesync/fixtures/air-purifier-detail-updated.json new file mode 100644 index 0000000000000..fdb1ed9454be0 --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-detail-updated.json @@ -0,0 +1,25 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "code": 0, + "result": { + "enabled": true, + "filter_life": 95, + "mode": "manual", + "level": 1, + "device_error_code": 0, + "air_quality": 2, + "air_quality_value": 15, + "display": true, + "child_lock": false, + "configuration": { + "display": true, + "display_forever": true, + "auto_preference": null + }, + "night_light": "on" + } + } +} diff --git a/tests/components/vesync/fixtures/air-purifier-detail-v2.json b/tests/components/vesync/fixtures/air-purifier-detail-v2.json new file mode 100644 index 0000000000000..8d88a7535392a --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-detail-v2.json @@ -0,0 +1,26 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "code": 0, + "result": { + "powerSwitch": 1, + "filterLifePercent": 99, + "workMode": "manual", + "manualSpeedLevel": 1, + "fanSpeedLevel": 0, + "AQLevel": 1, + "PM25": 5, + "screenState": 1, + "childLockSwitch": 0, + "screenSwitch": 1, + "lightDetectionSwitch": 0, + "environmentLightState": 1, + "scheduleCount": 0, + "timerRemain": 0, + "efficientModeTimeRemain": 0, + "errorCode": 0 + } + } +} diff --git a/tests/components/vesync/fixtures/air-purifier-detail.json b/tests/components/vesync/fixtures/air-purifier-detail.json new file mode 100644 index 0000000000000..4340388ad243a --- /dev/null +++ b/tests/components/vesync/fixtures/air-purifier-detail.json @@ -0,0 +1,25 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "code": 0, + "result": { + "enabled": true, + "filter_life": 99, + "mode": "manual", + "level": 1, + "device_error_code": 0, + "air_quality": 1, + "air_quality_value": 5, + "display": true, + "child_lock": false, + "configuration": { + "display": true, + "display_forever": true, + "auto_preference": null + }, + "night_light": "off" + } + } +} diff --git a/tests/components/vesync/fixtures/device-detail.json b/tests/components/vesync/fixtures/device-detail.json index f0cb3033d4c60..db6162a05bcbf 100644 --- a/tests/components/vesync/fixtures/device-detail.json +++ b/tests/components/vesync/fixtures/device-detail.json @@ -1,5 +1,7 @@ { "code": 0, + "msg": "request success", + "traceId": "1234", "brightNess": "50", "result": { "light": { @@ -24,6 +26,7 @@ "enabled": true, "filter_life": 99, "level": 1, + "device_error_code": 0, "display": true, "display_forever": false, "child_lock": false, @@ -37,6 +40,8 @@ "automatic_stop": true } }, - "code": 0 + "code": 0, + "traceId": "1234", + "msg": "" } } diff --git a/tests/components/vesync/fixtures/dimmer-detail.json b/tests/components/vesync/fixtures/dimmer-detail.json index 6da1b1baa573b..4d432fe2b121b 100644 --- a/tests/components/vesync/fixtures/dimmer-detail.json +++ b/tests/components/vesync/fixtures/dimmer-detail.json @@ -1,8 +1,19 @@ { "code": 0, - "deviceStatus": "on", - "activeTime": 100, - "brightness": 50, - "rgbStatus": "on", - "indicatorlightStatus": "on" + "msg": "request success", + "traceId": "1234", + "result": { + "devicename": "Test Dimmer", + "brightness": 50, + "indicatorlightStatus": "on", + "rgbStatus": "on", + "rgbValue": { + "red": 50, + "blue": 100, + "green": 225 + }, + "deviceStatus": "on", + "connectionStatus": "online", + "activeTime": 100 + } } diff --git a/tests/components/vesync/fixtures/SmartTowerFan-detail.json b/tests/components/vesync/fixtures/fan-detail.json similarity index 85% rename from tests/components/vesync/fixtures/SmartTowerFan-detail.json rename to tests/components/vesync/fixtures/fan-detail.json index 061dcb5b0d059..f7f07c1bd587b 100644 --- a/tests/components/vesync/fixtures/SmartTowerFan-detail.json +++ b/tests/components/vesync/fixtures/fan-detail.json @@ -20,13 +20,10 @@ "muteState": 1, "timerRemain": 0, "temperature": 717, - "humidity": 40, - "thermalComfort": 65, "errorCode": 0, "sleepPreference": { - "sleepPreferenceType": "default", + "sleepPreferenceType": 0, "oscillationSwitch": 0, - "initFanSpeedLevel": 0, "fallAsleepRemain": 0, "autoChangeFanLevelSwitch": 0 }, diff --git a/tests/components/vesync/fixtures/humidifier-200s.json b/tests/components/vesync/fixtures/humidifier-detail.json similarity index 92% rename from tests/components/vesync/fixtures/humidifier-200s.json rename to tests/components/vesync/fixtures/humidifier-detail.json index a0a98bde110dd..09cf7a5bad8ae 100644 --- a/tests/components/vesync/fixtures/humidifier-200s.json +++ b/tests/components/vesync/fixtures/humidifier-detail.json @@ -1,5 +1,7 @@ { "code": 0, + "msg": "request success", + "traceId": "1234", "result": { "result": { "humidity": 35, diff --git a/tests/components/vesync/fixtures/light-detail.json b/tests/components/vesync/fixtures/light-detail.json new file mode 100644 index 0000000000000..01baffec98079 --- /dev/null +++ b/tests/components/vesync/fixtures/light-detail.json @@ -0,0 +1,12 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "light": { + "action": "on", + "brightness": 50, + "colorTempe": 50 + } + } +} diff --git a/tests/components/vesync/fixtures/outlet-energy-week.json b/tests/components/vesync/fixtures/outlet-energy-week.json deleted file mode 100644 index 6e23be2e197f2..0000000000000 --- a/tests/components/vesync/fixtures/outlet-energy-week.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "energyConsumptionOfToday": 1, - "costPerKWH": 0.15, - "maxEnergy": 6, - "totalEnergy": 0, - "currency": "$" -} diff --git a/tests/components/vesync/fixtures/outlet-energy.json b/tests/components/vesync/fixtures/outlet-energy.json new file mode 100644 index 0000000000000..336c428364342 --- /dev/null +++ b/tests/components/vesync/fixtures/outlet-energy.json @@ -0,0 +1,12 @@ +{ + "code": 0, + "msg": "request success", + "traceId": "1234", + "result": { + "energyConsumptionOfToday": 1, + "costPerKWH": 0.15, + "maxEnergy": 6, + "totalEnergy": 0, + "energyInfos": [] + } +} diff --git a/tests/components/vesync/fixtures/vesync-auth.json b/tests/components/vesync/fixtures/vesync-auth.json new file mode 100644 index 0000000000000..dd962878e6578 --- /dev/null +++ b/tests/components/vesync/fixtures/vesync-auth.json @@ -0,0 +1,9 @@ +{ + "code": 0, + "traceId": "1234", + "msg": null, + "result": { + "accountID": "1234", + "authorizeCode": "test-code" + } +} diff --git a/tests/components/vesync/fixtures/vesync-devices.json b/tests/components/vesync/fixtures/vesync-devices.json index 3109fd3ea40ad..7fbc9b03e3bd3 100644 --- a/tests/components/vesync/fixtures/vesync-devices.json +++ b/tests/components/vesync/fixtures/vesync-devices.json @@ -1,121 +1,189 @@ { "code": 0, + "traceId": "1234", + "msg": null, "result": { "list": [ { + "deviceRegion": "US", + "isOwner": true, "cid": "200s-humidifier", "deviceType": "Classic200S", "deviceName": "Humidifier 200s", + "deviceImg": "", + "type": "", + "connectionType": "", + "uuid": "00000000-1111-2222-3333-444444444444", + "configModule": "configModule", "subDeviceNo": 4321, "deviceStatus": "on", - "connectionStatus": "online", - "uuid": "00000000-1111-2222-3333-444444444444", - "configModule": "configModule" + "connectionStatus": "online" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "600s-humidifier", "deviceType": "LUH-A602S-WUS", "deviceName": "Humidifier 600S", + "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "off", "connectionStatus": "online", "uuid": "00000000-1111-2222-3333-555555555555", - "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", "configModule": "WFON_AHM_LUH-A602S-WUS_US", "currentFirmVersion": null, "subDeviceType": null }, { + "deviceRegion": "US", + "isOwner": true, "cid": "air-purifier", "deviceType": "LV-PUR131S", "deviceName": "Air Purifier 131s", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55", "deviceType": "Core200S", "deviceName": "Air Purifier 200s", "subDeviceNo": null, "deviceStatus": "on", + "deviceImg": "", "type": "wifi-air", + "connectionType": "", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "400s-purifier", "deviceType": "LAP-C401S-WJP", "deviceName": "Air Purifier 400s", + "deviceImg": "", "subDeviceNo": null, - "deviceStatus": "on", "type": "wifi-air", + "connectionType": "", + "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "600s-purifier", "deviceType": "LAP-C601S-WUS", "deviceName": "Air Purifier 600s", + "deviceImg": "", "subDeviceNo": null, "type": "wifi-air", + "connectionType": "", "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "dimmable-bulb", "deviceType": "ESL100", "deviceName": "Dimmable Light", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "tunable-bulb", "deviceType": "ESL100CW", "deviceName": "Temperature Light", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "outlet", "deviceType": "wifi-switch-1.3", "deviceName": "Outlet", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "switch", "deviceType": "ESWL01", "deviceName": "Wall Switch", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "dimmable-switch", "deviceType": "ESWD16", "deviceName": "Dimmer Switch", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" }, { + "deviceRegion": "US", + "isOwner": true, "cid": "smarttowerfan", "deviceType": "LTF-F422S-KEU", "deviceName": "SmartTowerFan", + "deviceImg": "", + "type": "", + "connectionType": "", "subDeviceNo": null, "deviceStatus": "on", "connectionStatus": "online", + "uuid": "00000000-1111-2222-3333-444444444444", "configModule": "configModule" } ] diff --git a/tests/components/vesync/fixtures/vesync-login.json b/tests/components/vesync/fixtures/vesync-login.json index 08139034738c2..655c752d94b51 100644 --- a/tests/components/vesync/fixtures/vesync-login.json +++ b/tests/components/vesync/fixtures/vesync-login.json @@ -1,7 +1,11 @@ { "code": 0, + "traceId": "1234", + "msg": null, "result": { + "accountID": "1234", "token": "test-token", - "accountID": "1234" + "acceptLanguage": "en", + "countryCode": "US" } } diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json deleted file mode 100644 index 35b5a02fb3db9..0000000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_fan.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "module": null, - "stacktrace": null, - "result": { - "traceId": "0000000000", - "code": 0, - "result": { - "enabled": false, - "mode": "humidity" - } - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json deleted file mode 100644 index f9e4b0e18f1e8..0000000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__device_details__single_humidifier.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "module": null, - "stacktrace": null, - "result": { - "traceId": "0000000000", - "code": 0, - "result": { - "enabled": false, - "mist_virtual_level": 9, - "mist_level": 3, - "mode": "humidity", - "water_lacks": false, - "water_tank_lifted": false, - "humidity": 35, - "humidity_high": false, - "display": false, - "warm_enabled": false, - "warm_level": 0, - "automatic_stop_reach_target": true, - "configuration": { "auto_target_humidity": 60, "display": true }, - "extension": { "schedule_count": 0, "timer_remain": 0 } - } - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json b/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json deleted file mode 100644 index f1eaa523101b7..0000000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__devices__no_devices.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "total": 1, - "pageSize": 100, - "pageNo": 1, - "list": [] - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json deleted file mode 100644 index 2951ab63f0308..0000000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__devices__single_fan.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "total": 1, - "pageSize": 100, - "pageNo": 1, - "list": [ - { - "deviceRegion": "US", - "isOwner": true, - "authKey": null, - "deviceName": "Fan", - "deviceImg": "", - "cid": "abcdefghabcdefghabcdefghabcdefgh", - "deviceStatus": "off", - "connectionStatus": "online", - "connectionType": "WiFi+BTOnboarding+BTNotify", - "deviceType": "LV-PUR131S", - "type": "wifi-air", - "uuid": "00000000-1111-2222-3333-444444444444", - "configModule": "WFON_AHM_LV-PUR131S_US", - "macID": "00:00:00:00:00:00", - "mode": null, - "speed": null, - "currentFirmVersion": null, - "subDeviceNo": null, - "subDeviceType": null, - "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", - "subDeviceList": null, - "extension": null, - "deviceProp": null - } - ] - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json b/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json deleted file mode 100644 index 0f04339440240..0000000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__devices__single_humidifier.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "total": 1, - "pageSize": 100, - "pageNo": 1, - "list": [ - { - "deviceRegion": "US", - "isOwner": true, - "authKey": null, - "deviceName": "Humidifier", - "deviceImg": "https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png", - "cid": "abcdefghabcdefghabcdefghabcdefgh", - "deviceStatus": "off", - "connectionStatus": "online", - "connectionType": "WiFi+BTOnboarding+BTNotify", - "deviceType": "LUH-A602S-WUS", - "type": "wifi-air", - "uuid": "00000000-1111-2222-3333-444444444444", - "configModule": "WFON_AHM_LUH-A602S-WUS_US", - "macID": "00:00:00:00:00:00", - "mode": null, - "speed": null, - "currentFirmVersion": null, - "subDeviceNo": null, - "subDeviceType": null, - "deviceFirstSetupTime": "Jan 24, 2022 12:09:01 AM", - "subDeviceList": null, - "extension": null, - "deviceProp": null - } - ] - } -} diff --git a/tests/components/vesync/fixtures/vesync_api_call__login.json b/tests/components/vesync/fixtures/vesync_api_call__login.json deleted file mode 100644 index 4a956f6734193..0000000000000 --- a/tests/components/vesync/fixtures/vesync_api_call__login.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "traceId": "0000000000", - "code": 0, - "msg": "request success", - "result": { - "accountID": "9999999", - "token": "TOKEN" - } -} diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index aa55a9be3cb7c..7b6c8a2899d84 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -1,223 +1,247 @@ # serializer version: 1 # name: test_async_get_config_entry_diagnostics__no_devices dict({ - 'devices': dict({ - 'bulbs': list([ - ]), - 'fans': list([ - ]), - 'outlets': list([ - ]), - 'switches': list([ - ]), - }), + 'devices': list([ + ]), 'vesync': dict({ + 'Total Device Count': 0, + 'air_purifiers': 0, 'bulb_count': 0, 'fan_count': 0, + 'humidifers_count': 0, 'outlets_count': 0, 'switch_count': 0, - 'timezone': 'US/Pacific', + 'timezone': 'America/New_York', }), }) # --- # name: test_async_get_config_entry_diagnostics__single_humidifier dict({ - 'devices': dict({ - 'bulbs': list([ - ]), - 'fans': list([ - dict({ - '_api_modes': list([ - 'getHumidifierStatus', - 'setAutomaticStop', - 'setSwitch', - 'setNightLightBrightness', - 'setVirtualLevel', - 'setTargetHumidity', - 'setHumidityMode', - 'setDisplay', - 'setLevel', - ]), - '_config_dict': dict({ - 'features': list([ - 'warm_mist', - 'nightlight', - ]), - 'mist_levels': list([ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - ]), - 'mist_modes': list([ - 'humidity', - 'sleep', - 'manual', - ]), - 'models': list([ - 'LUH-A602S-WUSR', - 'LUH-A602S-WUS', - 'LUH-A602S-WEUR', - 'LUH-A602S-WEU', - 'LUH-A602S-WJP', - 'LUH-A602S-WUSC', - ]), - 'module': 'VeSyncHumid200300S', - 'warm_mist_levels': list([ - 0, - 1, - 2, - 3, - ]), - }), - '_features': list([ - 'warm_mist', - 'nightlight', - ]), - 'cid': 'abcdefghabcdefghabcdefghabcdefgh', - 'config': dict({ - 'auto_target_humidity': 60, - 'automatic_stop': True, - 'display': True, - }), - 'config_module': 'WFON_AHM_LUH-A602S-WUS_US', - 'connection_status': 'online', - 'connection_type': 'WiFi+BTOnboarding+BTNotify', - 'current_firm_version': None, - 'details': dict({ - 'automatic_stop_reach_target': True, - 'display': False, - 'humidity': 35, - 'humidity_high': False, - 'mist_level': 3, - 'mist_virtual_level': 9, - 'mode': 'humidity', - 'night_light_brightness': 0, - 'warm_mist_enabled': False, - 'warm_mist_level': 0, - 'water_lacks': False, - 'water_tank_lifted': False, - }), - 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', - 'device_name': 'Humidifier', - 'device_region': 'US', - 'device_status': 'off', - 'device_type': 'LUH-A602S-WUS', - 'enabled': False, - 'extension': None, - 'mac_id': '**REDACTED**', - 'manager': '**REDACTED**', - 'mist_levels': list([ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - ]), - 'mist_modes': list([ - 'humidity', - 'sleep', - 'manual', - ]), - 'mode': 'humidity', - 'night_light': True, - 'pid': None, - 'speed': None, - 'sub_device_no': None, - 'type': 'wifi-air', - 'uuid': '**REDACTED**', - 'warm_mist_feature': True, - 'warm_mist_levels': list([ - 0, - 1, - 2, - 3, - ]), + 'devices': list([ + dict({ + 'assert_any_call': 'Method', + 'assert_called': 'Method', + 'assert_called_once': 'Method', + 'assert_called_once_with': 'Method', + 'assert_called_with': 'Method', + 'assert_has_calls': 'Method', + 'assert_not_called': 'Method', + 'attach_mock': 'Method', + 'automatic_stop_off': 'Method', + 'automatic_stop_on': 'Method', + 'call_args': None, + 'call_args_list': list([ + ]), + 'call_bypassv2_api': 'Method', + 'call_count': 0, + 'called': False, + 'cid': '200s-humidifier', + 'clear_timer': 'Method', + 'config': dict({ + 'auto_target_humidity': 40, + 'automatic_stop': 'true', + 'display': 'true', }), - ]), - 'outlets': list([ - ]), - 'switches': list([ - ]), - }), + 'config_module': 'Method', + 'configure_mock': 'Method', + 'connection_status': 'online', + 'connection_type': 'Method', + 'current_firm_version': '1.0.0', + 'device_image': 'Method', + 'device_name': 'Humidifier 200s', + 'device_region': 'Method', + 'device_status': 'on', + 'device_type': 'Classic200S', + 'display': 'Method', + 'displayJSON': 'Method', + 'enabled': 'Method', + 'features': list([ + 'night_light', + ]), + 'firmware_update': 'Method', + 'get_details': 'Method', + 'get_state': 'Method', + 'get_timer': 'Method', + 'is_on': 'Method', + 'last_response': 'Method', + 'latest_firm_version': 'Method', + 'mac_id': 'Method', + 'manager': 'Method', + 'method_calls': list([ + ]), + 'mist_levels': list([ + 1, + 2, + 3, + 4, + 5, + 6, + ]), + 'mist_modes': list([ + 'auto', + 'manual', + ]), + 'mock_add_spec': 'Method', + 'mock_calls': list([ + ]), + 'pid': 'Method', + 'product_type': 'Method', + 'request_keys': 'Method', + 'reset_mock': 'Method', + 'return_value': 'Method', + 'set_auto_mode': 'Method', + 'set_automatic_stop': 'Method', + 'set_display': 'Method', + 'set_humidity': 'Method', + 'set_humidity_mode': 'Method', + 'set_manual_mode': 'Method', + 'set_mist_level': 'Method', + 'set_mode': 'Method', + 'set_nightlight_brightness': 'Method', + 'set_sleep_mode': 'Method', + 'set_state': 'Method', + 'set_timer': 'Method', + 'set_warm_level': 'Method', + 'side_effect': None, + 'state': 'Method', + 'sub_device_no': 0, + 'supports_drying_mode': 'Method', + 'supports_nightlight': 'Method', + 'supports_nightlight_brightness': 'Method', + 'supports_warm_mist': 'Method', + 'target_minmax': list([ + 30, + 80, + ]), + 'to_dict': 'Method', + 'to_json': 'Method', + 'to_jsonb': 'Method', + 'toggle_automatic_stop': 'Method', + 'toggle_display': 'Method', + 'toggle_drying_mode': 'Method', + 'toggle_switch': 'Method', + 'turn_off': 'Method', + 'turn_off_automatic_stop': 'Method', + 'turn_off_display': 'Method', + 'turn_on': 'Method', + 'turn_on_automatic_stop': 'Method', + 'turn_on_display': 'Method', + 'type': 'Method', + 'update': 'Method', + 'uuid': 'Method', + 'warm_mist_levels': 'Method', + }), + ]), 'vesync': dict({ + 'Total Device Count': 1, + 'air_purifiers': 0, 'bulb_count': 0, - 'fan_count': 1, + 'fan_count': 0, + 'humidifers_count': 1, 'outlets_count': 0, 'switch_count': 0, - 'timezone': 'US/Pacific', + 'timezone': 'America/New_York', }), }) # --- # name: test_async_get_device_diagnostics__single_fan dict({ - '_config_dict': dict({ - 'features': list([ - 'air_quality', - ]), - 'levels': list([ - 1, - 2, - ]), - 'models': list([ - 'LV-PUR131S', - 'LV-RH131S', - 'LV-RH131S-WM', - ]), - 'modes': list([ - 'manual', - 'auto', - 'sleep', - 'off', - ]), - 'module': 'VeSyncAir131', - }), - '_features': list([ - 'air_quality', + 'advanced_sleep_mode': 'Method', + 'assert_any_call': 'Method', + 'assert_called': 'Method', + 'assert_called_once': 'Method', + 'assert_called_once_with': 'Method', + 'assert_called_with': 'Method', + 'assert_has_calls': 'Method', + 'assert_not_called': 'Method', + 'attach_mock': 'Method', + 'call_args': None, + 'call_args_list': list([ ]), - 'air_quality_feature': True, - 'cid': 'abcdefghabcdefghabcdefghabcdefgh', - 'config': dict({ - }), - 'config_module': 'WFON_AHM_LV-PUR131S_US', - 'connection_status': 'unknown', - 'connection_type': 'WiFi+BTOnboarding+BTNotify', - 'current_firm_version': None, - 'details': dict({ - 'active_time': 0, - 'air_quality': 'unknown', - 'filter_life': dict({ - }), - 'level': 0, - 'screen_status': 'unknown', - }), - 'device_image': '', - 'device_name': 'Fan', - 'device_region': 'US', - 'device_status': 'unknown', - 'device_type': 'LV-PUR131S', - 'enabled': True, - 'extension': None, + 'call_count': 0, + 'called': False, + 'cid': 'fan', + 'clear_timer': 'Method', + 'config_module': 'Method', + 'configure_mock': 'Method', + 'connection_status': 'online', + 'connection_type': 'Method', + 'current_firm_version': '1.0.0', + 'device_image': 'Method', + 'device_name': 'Test Fan', + 'device_region': 'Method', + 'device_status': 'on', + 'device_type': 'fan', + 'display': 'Method', + 'displayJSON': 'Method', + 'enabled': 'Method', + 'fan_levels': 'Method', + 'features': 'Method', + 'firmware_update': 'Method', + 'get_details': 'Method', + 'get_state': 'Method', + 'get_timer': 'Method', 'home_assistant': dict({ 'disabled': False, 'disabled_by': None, 'entities': list([ + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fan_low_water', + 'icon': None, + 'name': None, + 'original_device_class': 'problem', + 'original_icon': None, + 'original_name': 'Low water', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'problem', + 'friendly_name': 'Test Fan Low water', + }), + 'entity_id': 'binary_sensor.test_fan_low_water', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_fan_water_tank_lifted', + 'icon': None, + 'name': None, + 'original_device_class': 'problem', + 'original_icon': None, + 'original_name': 'Water tank lifted', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'problem', + 'friendly_name': 'Test Fan Water tank lifted', + }), + 'entity_id': 'binary_sensor.test_fan_water_tank_lifted', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': None, + }), dict({ 'device_class': None, 'disabled': False, 'disabled_by': None, 'domain': 'fan', 'entity_category': None, - 'entity_id': 'fan.fan', + 'entity_id': 'fan.test_fan', 'icon': None, 'name': None, 'original_device_class': None, @@ -225,14 +249,12 @@ 'original_name': None, 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan', + 'friendly_name': 'Test Fan', 'preset_modes': list([ - 'auto', - 'sleep', ]), 'supported_features': 57, }), - 'entity_id': 'fan.fan', + 'entity_id': 'fan.test_fan', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -246,7 +268,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.fan_air_quality', + 'entity_id': 'sensor.test_fan_air_quality', 'icon': None, 'name': None, 'original_device_class': None, @@ -254,9 +276,9 @@ 'original_name': 'Air quality', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Air quality', + 'friendly_name': 'Test Fan Air quality', }), - 'entity_id': 'sensor.fan_air_quality', + 'entity_id': 'sensor.test_fan_air_quality', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -270,7 +292,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': 'diagnostic', - 'entity_id': 'sensor.fan_filter_lifetime', + 'entity_id': 'sensor.test_fan_filter_lifetime', 'icon': None, 'name': None, 'original_device_class': None, @@ -278,11 +300,11 @@ 'original_name': 'Filter lifetime', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Filter lifetime', + 'friendly_name': 'Test Fan Filter lifetime', 'state_class': 'measurement', 'unit_of_measurement': '%', }), - 'entity_id': 'sensor.fan_filter_lifetime', + 'entity_id': 'sensor.test_fan_filter_lifetime', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -290,13 +312,40 @@ }), 'unit_of_measurement': '%', }), + dict({ + 'device_class': None, + 'disabled': False, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_fan_pm2_5', + 'icon': None, + 'name': None, + 'original_device_class': 'pm25', + 'original_icon': None, + 'original_name': 'PM2.5', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'pm25', + 'friendly_name': 'Test Fan PM2.5', + 'state_class': 'measurement', + 'unit_of_measurement': 'μg/m³', + }), + 'entity_id': 'sensor.test_fan_pm2_5', + 'last_changed': str, + 'last_reported': str, + 'last_updated': str, + 'state': 'unavailable', + }), + 'unit_of_measurement': 'μg/m³', + }), dict({ 'device_class': None, 'disabled': False, 'disabled_by': None, 'domain': 'switch', 'entity_category': None, - 'entity_id': 'switch.fan_display', + 'entity_id': 'switch.test_fan_display', 'icon': None, 'name': None, 'original_device_class': None, @@ -304,9 +353,9 @@ 'original_name': 'Display', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'Fan Display', + 'friendly_name': 'Test Fan Display', }), - 'entity_id': 'switch.fan_display', + 'entity_id': 'switch.test_fan_display', 'last_changed': str, 'last_reported': str, 'last_updated': str, @@ -315,22 +364,65 @@ 'unit_of_measurement': None, }), ]), - 'name': 'Fan', + 'name': 'Test Fan', 'name_by_user': None, }), - 'mac_id': '**REDACTED**', - 'manager': '**REDACTED**', - 'mode': None, + 'is_on': 'Method', + 'last_response': 'Method', + 'latest_firm_version': 'Method', + 'mac_id': 'Method', + 'manager': 'Method', + 'manual_mode': 'Method', + 'method_calls': list([ + ]), + 'mock_add_spec': 'Method', + 'mock_calls': list([ + ]), + 'mode_toggle': 'Method', 'modes': list([ - 'manual', - 'auto', - 'sleep', - 'off', ]), - 'pid': None, - 'speed': None, - 'sub_device_no': None, - 'type': 'wifi-air', - 'uuid': '**REDACTED**', + 'normal_mode': 'Method', + 'pid': 'Method', + 'product_type': 'Method', + 'request_keys': 'Method', + 'reset_mock': 'Method', + 'return_value': 'Method', + 'set_advanced_sleep_mode': 'Method', + 'set_auto_mode': 'Method', + 'set_fan_speed': 'Method', + 'set_manual_mode': 'Method', + 'set_mode': 'Method', + 'set_normal_mode': 'Method', + 'set_sleep_mode': 'Method', + 'set_state': 'Method', + 'set_timer': 'Method', + 'set_turbo_mode': 'Method', + 'side_effect': None, + 'sleep_mode': 'Method', + 'sleep_preferences': 'Method', + 'state': 'Method', + 'sub_device_no': 'Method', + 'supports_displaying_type': 'Method', + 'supports_mute': 'Method', + 'supports_oscillation': 'Method', + 'to_dict': 'Method', + 'to_json': 'Method', + 'to_jsonb': 'Method', + 'toggle_display': 'Method', + 'toggle_displaying_type': 'Method', + 'toggle_mute': 'Method', + 'toggle_oscillation': 'Method', + 'toggle_switch': 'Method', + 'turn_off': 'Method', + 'turn_off_display': 'Method', + 'turn_off_mute': 'Method', + 'turn_off_oscillation': 'Method', + 'turn_on': 'Method', + 'turn_on_display': 'Method', + 'turn_on_mute': 'Method', + 'turn_on_oscillation': 'Method', + 'type': 'Method', + 'update': 'Method', + 'uuid': 'Method', }) # --- diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 86cfa8198ba41..7dc838ba6d6d2 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -40,8 +40,8 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - 'auto', - 'sleep', + , + , ]), }), 'config_entry_id': , @@ -78,16 +78,18 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'active_time': 0, + 'child_lock': False, + 'display_status': 'on', 'friendly_name': 'Air Purifier 131s', 'mode': 'sleep', + 'night_light': None, 'percentage': None, 'percentage_step': 33.333333333333336, 'preset_mode': 'sleep', 'preset_modes': list([ - 'auto', - 'sleep', + , + , ]), - 'screen_status': 'on', 'supported_features': , }), 'context': , @@ -139,7 +141,7 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - 'sleep', + , ]), }), 'config_entry_id': , @@ -175,17 +177,18 @@ # name: test_fan_state[Air Purifier 200s][fan.air_purifier_200s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': None, 'child_lock': False, + 'display_status': , 'friendly_name': 'Air Purifier 200s', 'mode': 'manual', - 'night_light': 'off', + 'night_light': , 'percentage': 33, 'percentage_step': 33.333333333333336, 'preset_mode': None, 'preset_modes': list([ - 'sleep', + , ]), - 'screen_status': True, 'supported_features': , }), 'context': , @@ -237,8 +240,8 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - 'auto', - 'sleep', + , + , ]), }), 'config_entry_id': , @@ -274,18 +277,19 @@ # name: test_fan_state[Air Purifier 400s][fan.air_purifier_400s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': None, 'child_lock': False, + 'display_status': , 'friendly_name': 'Air Purifier 400s', 'mode': 'manual', - 'night_light': 'off', + 'night_light': , 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, 'preset_modes': list([ - 'auto', - 'sleep', + , + , ]), - 'screen_status': True, 'supported_features': , }), 'context': , @@ -337,8 +341,8 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - 'auto', - 'sleep', + , + , ]), }), 'config_entry_id': , @@ -374,18 +378,19 @@ # name: test_fan_state[Air Purifier 600s][fan.air_purifier_600s] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'active_time': None, 'child_lock': False, + 'display_status': , 'friendly_name': 'Air Purifier 600s', 'mode': 'manual', - 'night_light': 'off', + 'night_light': , 'percentage': 25, 'percentage_step': 25.0, 'preset_mode': None, 'preset_modes': list([ - 'auto', - 'sleep', + , + , ]), - 'screen_status': True, 'supported_features': , }), 'context': , @@ -622,10 +627,10 @@ 'area_id': None, 'capabilities': dict({ 'preset_modes': list([ - 'advancedSleep', - 'auto', - 'normal', - 'turbo', + , + , + , + , ]), }), 'config_entry_id': , @@ -661,20 +666,19 @@ # name: test_fan_state[SmartTowerFan][fan.smarttowerfan] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'child_lock': False, + 'active_time': None, + 'display_status': , 'friendly_name': 'SmartTowerFan', 'mode': 'normal', - 'night_light': 'off', 'percentage': None, - 'percentage_step': 7.6923076923076925, + 'percentage_step': 8.333333333333334, 'preset_mode': 'normal', 'preset_modes': list([ - 'advancedSleep', - 'auto', - 'normal', - 'turbo', + , + , + , + , ]), - 'screen_status': False, 'supported_features': , }), 'context': , diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index df2dad8825d2e..a55659b613041 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -560,29 +560,39 @@ # name: test_light_state[Temperature Light][light.temperature_light] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'brightness': None, - 'color_mode': None, - 'color_temp': None, - 'color_temp_kelvin': None, + 'brightness': 128, + 'color_mode': , + 'color_temp': 262, + 'color_temp_kelvin': 3816, 'friendly_name': 'Temperature Light', - 'hs_color': None, + 'hs_color': tuple( + 26.914, + 38.308, + ), 'max_color_temp_kelvin': 6500, 'max_mireds': 370, 'min_color_temp_kelvin': 2700, 'min_mireds': 153, - 'rgb_color': None, + 'rgb_color': tuple( + 255, + 201, + 157, + ), 'supported_color_modes': list([ , ]), 'supported_features': , - 'xy_color': None, + 'xy_color': tuple( + 0.432, + 0.368, + ), }), 'context': , 'entity_id': 'light.temperature_light', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'off', + 'state': 'on', }) # --- # name: test_light_state[Wall Switch][devices] diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index e29255cdc7217..23d31d33bcbd6 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -38,17 +38,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.air_purifier_131s_filter_lifetime', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_131s_air_quality', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -60,28 +58,30 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Filter lifetime', + 'original_name': 'Air quality', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'filter_life', - 'unique_id': 'air-purifier-filter-life', - 'unit_of_measurement': '%', + 'translation_key': 'air_quality', + 'unique_id': 'air-purifier-air-quality', + 'unit_of_measurement': None, }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.air_purifier_131s_air_quality', + 'entity_category': , + 'entity_id': 'sensor.air_purifier_131s_filter_lifetime', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -93,14 +93,14 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Air quality', + 'original_name': 'Filter lifetime', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': 'air-purifier-air-quality', - 'unit_of_measurement': None, + 'translation_key': 'filter_life', + 'unique_id': 'air-purifier-filter-life', + 'unit_of_measurement': '%', }), ]) # --- @@ -167,6 +167,39 @@ # --- # name: test_sensor_state[Air Purifier 200s][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': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_200s_air_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Air quality', + 'platform': 'vesync', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_quality', + 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-air-quality', + 'unit_of_measurement': None, + }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), @@ -204,6 +237,19 @@ }), ]) # --- +# name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_air_quality] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Air Purifier 200s Air quality', + }), + 'context': , + 'entity_id': 'sensor.air_purifier_200s_air_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'None', + }) +# --- # name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_filter_lifetime] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -258,17 +304,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.air_purifier_400s_filter_lifetime', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_400s_air_quality', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -280,20 +324,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Filter lifetime', + 'original_name': 'Air quality', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'filter_life', - 'unique_id': '400s-purifier-filter-life', - 'unit_of_measurement': '%', + 'translation_key': 'air_quality', + 'unique_id': '400s-purifier-air-quality', + 'unit_of_measurement': None, }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -301,7 +347,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.air_purifier_400s_air_quality', + 'entity_id': 'sensor.air_purifier_400s_pm2_5', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -311,16 +357,16 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Air quality', + 'original_name': 'PM2.5', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '400s-purifier-air-quality', - 'unit_of_measurement': None, + 'translation_key': None, + 'unique_id': '400s-purifier-pm25', + 'unit_of_measurement': 'μg/m³', }), EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -335,8 +381,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.air_purifier_400s_pm2_5', + 'entity_category': , + 'entity_id': 'sensor.air_purifier_400s_filter_lifetime', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -346,16 +392,16 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'PM2.5', + 'original_name': 'Filter lifetime', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '400s-purifier-pm25', - 'unit_of_measurement': 'μg/m³', + 'translation_key': 'filter_life', + 'unique_id': '400s-purifier-filter-life', + 'unit_of_measurement': '%', }), ]) # --- @@ -369,7 +415,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': 'excellent', }) # --- # name: test_sensor_state[Air Purifier 400s][sensor.air_purifier_400s_filter_lifetime] @@ -400,7 +446,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5', }) # --- # name: test_sensor_state[Air Purifier 600s][devices] @@ -442,17 +488,15 @@ 'aliases': set({ }), 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), + 'capabilities': None, 'config_entry_id': , 'config_subentry_id': , 'device_class': None, 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': , - 'entity_id': 'sensor.air_purifier_600s_filter_lifetime', + 'entity_category': None, + 'entity_id': 'sensor.air_purifier_600s_air_quality', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -464,20 +508,22 @@ }), 'original_device_class': None, 'original_icon': None, - 'original_name': 'Filter lifetime', + 'original_name': 'Air quality', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'filter_life', - 'unique_id': '600s-purifier-filter-life', - 'unit_of_measurement': '%', + 'translation_key': 'air_quality', + 'unique_id': '600s-purifier-air-quality', + 'unit_of_measurement': None, }), EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': , + }), 'config_entry_id': , 'config_subentry_id': , 'device_class': None, @@ -485,7 +531,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.air_purifier_600s_air_quality', + 'entity_id': 'sensor.air_purifier_600s_pm2_5', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -495,16 +541,16 @@ 'name': None, 'options': dict({ }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, - 'original_name': 'Air quality', + 'original_name': 'PM2.5', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'air_quality', - 'unique_id': '600s-purifier-air-quality', - 'unit_of_measurement': None, + 'translation_key': None, + 'unique_id': '600s-purifier-pm25', + 'unit_of_measurement': 'μg/m³', }), EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -519,8 +565,8 @@ 'device_id': , 'disabled_by': None, 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.air_purifier_600s_pm2_5', + 'entity_category': , + 'entity_id': 'sensor.air_purifier_600s_filter_lifetime', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -530,16 +576,16 @@ 'name': None, 'options': dict({ }), - 'original_device_class': , + 'original_device_class': None, 'original_icon': None, - 'original_name': 'PM2.5', + 'original_name': 'Filter lifetime', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': None, - 'unique_id': '600s-purifier-pm25', - 'unit_of_measurement': 'μg/m³', + 'translation_key': 'filter_life', + 'unique_id': '600s-purifier-filter-life', + 'unit_of_measurement': '%', }), ]) # --- @@ -553,7 +599,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '5', + 'state': 'excellent', }) # --- # name: test_sensor_state[Air Purifier 600s][sensor.air_purifier_600s_filter_lifetime] @@ -584,7 +630,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1', + 'state': '5', }) # --- # name: test_sensor_state[Dimmable Light][devices] @@ -877,7 +923,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -886,7 +932,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.outlet_current_power', + 'entity_id': 'sensor.outlet_energy_use_today', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -896,19 +942,19 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Current power', + 'original_name': 'Energy use today', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'current_power', - 'unique_id': 'outlet-power', - 'unit_of_measurement': , + 'translation_key': 'energy_today', + 'unique_id': 'outlet-energy', + 'unit_of_measurement': , }), EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -924,7 +970,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.outlet_energy_use_today', + 'entity_id': 'sensor.outlet_energy_use_weekly', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -939,13 +985,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy use today', + 'original_name': 'Energy use weekly', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_today', - 'unique_id': 'outlet-energy', + 'translation_key': 'energy_week', + 'unique_id': 'outlet-energy-weekly', 'unit_of_measurement': , }), EntityRegistryEntrySnapshot({ @@ -962,7 +1008,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.outlet_energy_use_weekly', + 'entity_id': 'sensor.outlet_energy_use_monthly', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -977,13 +1023,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy use weekly', + 'original_name': 'Energy use monthly', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_week', - 'unique_id': 'outlet-energy-weekly', + 'translation_key': 'energy_month', + 'unique_id': 'outlet-energy-monthly', 'unit_of_measurement': , }), EntityRegistryEntrySnapshot({ @@ -1000,7 +1046,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.outlet_energy_use_monthly', + 'entity_id': 'sensor.outlet_energy_use_yearly', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1015,13 +1061,13 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy use monthly', + 'original_name': 'Energy use yearly', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_month', - 'unique_id': 'outlet-energy-monthly', + 'translation_key': 'energy_year', + 'unique_id': 'outlet-energy-yearly', 'unit_of_measurement': , }), EntityRegistryEntrySnapshot({ @@ -1029,7 +1075,7 @@ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -1038,7 +1084,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.outlet_energy_use_yearly', + 'entity_id': 'sensor.outlet_current_voltage', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1048,19 +1094,19 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Energy use yearly', + 'original_name': 'Current voltage', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'energy_year', - 'unique_id': 'outlet-energy-yearly', - 'unit_of_measurement': , + 'translation_key': 'current_voltage', + 'unique_id': 'outlet-voltage', + 'unit_of_measurement': , }), EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -1076,7 +1122,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.outlet_current_voltage', + 'entity_id': 'sensor.outlet_current_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -1089,16 +1135,16 @@ 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Current voltage', + 'original_name': 'Current power', 'platform': 'vesync', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'current_voltage', - 'unique_id': 'outlet-voltage', - 'unit_of_measurement': , + 'translation_key': 'current_power', + 'unique_id': 'outlet-power', + 'unit_of_measurement': , }), ]) # --- @@ -1147,7 +1193,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensor_state[Outlet][sensor.outlet_energy_use_today] @@ -1163,7 +1209,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '100', + 'state': '100.0', }) # --- # name: test_sensor_state[Outlet][sensor.outlet_energy_use_weekly] @@ -1179,7 +1225,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensor_state[Outlet][sensor.outlet_energy_use_yearly] @@ -1195,7 +1241,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '0', + 'state': '0.0', }) # --- # name: test_sensor_state[SmartTowerFan][devices] diff --git a/tests/components/vesync/test_config_flow.py b/tests/components/vesync/test_config_flow.py index 38f28e73aedbf..4eb41d8f24c55 100644 --- a/tests/components/vesync/test_config_flow.py +++ b/tests/components/vesync/test_config_flow.py @@ -2,6 +2,8 @@ from unittest.mock import patch +from pyvesync.utils.errors import VeSyncLoginError + from homeassistant.components.vesync import DOMAIN, config_flow from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant @@ -28,7 +30,10 @@ async def test_invalid_login_error(hass: HomeAssistant) -> None: test_dict = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} flow = config_flow.VeSyncFlowHandler() flow.hass = hass - with patch("pyvesync.vesync.VeSync.login", return_value=False): + with patch( + "pyvesync.vesync.VeSync.login", + side_effect=VeSyncLoginError("Mock login failed"), + ): result = await flow.async_step_user(user_input=test_dict) assert result["type"] is FlowResultType.FORM @@ -41,7 +46,7 @@ async def test_config_flow_user_input(hass: HomeAssistant) -> None: flow.hass = hass result = await flow.async_step_user() assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=True): + with patch("pyvesync.vesync.VeSync.login"): result = await flow.async_step_user( {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} ) @@ -62,7 +67,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=True): + with patch("pyvesync.vesync.VeSync.login"): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, @@ -89,14 +94,17 @@ async def test_reauth_flow_invalid_auth(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=False): + with patch( + "pyvesync.vesync.VeSync.login", + side_effect=VeSyncLoginError("Mock login failed"), + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, ) assert result["type"] is FlowResultType.FORM - with patch("pyvesync.vesync.VeSync.login", return_value=True): + with patch("pyvesync.vesync.VeSync.login"): result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_USERNAME: "new-username", CONF_PASSWORD: "new-password"}, diff --git a/tests/components/vesync/test_diagnostics.py b/tests/components/vesync/test_diagnostics.py index c2b789a932ec5..31e0e514dd333 100644 --- a/tests/components/vesync/test_diagnostics.py +++ b/tests/components/vesync/test_diagnostics.py @@ -1,8 +1,5 @@ """Tests for the diagnostics data provided by the VeSync integration.""" -from unittest.mock import patch - -from pyvesync.helpers import Helpers from syrupy.assertion import SnapshotAssertion from syrupy.matchers import path_type @@ -13,12 +10,6 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component -from .common import ( - call_api_side_effect__no_devices, - call_api_side_effect__single_fan, - call_api_side_effect__single_humidifier, -) - from tests.components.diagnostics import ( get_diagnostics_for_config_entry, get_diagnostics_for_device, @@ -32,12 +23,11 @@ async def test_async_get_config_entry_diagnostics__no_devices( config_entry: ConfigEntry, config: ConfigType, snapshot: SnapshotAssertion, + manager, ) -> None: """Test diagnostics for config entry.""" - with patch.object(Helpers, "call_api") as call_api: - call_api.side_effect = call_api_side_effect__no_devices - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) @@ -51,12 +41,14 @@ async def test_async_get_config_entry_diagnostics__single_humidifier( config_entry: ConfigEntry, config: ConfigType, snapshot: SnapshotAssertion, + manager, + humidifier, ) -> None: """Test diagnostics for config entry.""" - with patch.object(Helpers, "call_api") as call_api: - call_api.side_effect = call_api_side_effect__single_humidifier - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + manager._dev_list["humidifiers"].append(humidifier) + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() diag = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) @@ -71,15 +63,17 @@ async def test_async_get_device_diagnostics__single_fan( config_entry: ConfigEntry, config: ConfigType, snapshot: SnapshotAssertion, + manager, + fan, ) -> None: """Test diagnostics for config entry.""" - with patch.object(Helpers, "call_api") as call_api: - call_api.side_effect = call_api_side_effect__single_fan - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + manager._dev_list["fans"].append(fan) + + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() device = device_registry.async_get_device( - identifiers={(DOMAIN, "abcdefghabcdefghabcdefghabcdefgh")}, + identifiers={(DOMAIN, "fan")}, ) assert device is not None @@ -104,6 +98,15 @@ async def test_async_get_device_diagnostics__single_fan( "home_assistant.entities.3.state.last_changed": (str,), "home_assistant.entities.3.state.last_reported": (str,), "home_assistant.entities.3.state.last_updated": (str,), + "home_assistant.entities.4.state.last_changed": (str,), + "home_assistant.entities.4.state.last_reported": (str,), + "home_assistant.entities.4.state.last_updated": (str,), + "home_assistant.entities.5.state.last_changed": (str,), + "home_assistant.entities.5.state.last_reported": (str,), + "home_assistant.entities.5.state.last_updated": (str,), + "home_assistant.entities.6.state.last_changed": (str,), + "home_assistant.entities.6.state.last_reported": (str,), + "home_assistant.entities.6.state.last_updated": (str,), } ) ) diff --git a/tests/components/vesync/test_fan.py b/tests/components/vesync/test_fan.py index cf572e5b981c3..e5c59bef30f3f 100644 --- a/tests/components/vesync/test_fan.py +++ b/tests/components/vesync/test_fan.py @@ -1,10 +1,9 @@ """Tests for the fan module.""" from contextlib import nullcontext -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.fan import ATTR_PRESET_MODE, DOMAIN as FAN_DOMAIN @@ -16,6 +15,7 @@ from .common import ALL_DEVICE_NAMES, ENTITY_FAN, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker NoException = nullcontext() @@ -27,13 +27,13 @@ async def test_fan_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) @@ -61,20 +61,23 @@ async def test_fan_state( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + (SERVICE_TURN_ON, "pyvesync.devices.vesyncfan.VeSyncTowerFan.turn_on"), + (SERVICE_TURN_OFF, "pyvesync.devices.vesyncfan.VeSyncTowerFan.turn_off"), ], ) async def test_turn_on_off_success( hass: HomeAssistant, fan_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, action: str, command: str, ) -> None: """Test turn_on and turn_off method.""" + mock_devices_response(aioclient_mock, "SmartTowerFan") + with ( - patch(command, return_value=True) as method_mock, + patch(command, new_callable=AsyncMock, return_value=True) as method_mock, ): with patch( "homeassistant.components.vesync.fan.VeSyncFanHA.schedule_update_ha_state" @@ -94,8 +97,14 @@ async def test_turn_on_off_success( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncTowerFan.turn_on"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncTowerFan.turn_off"), + ( + SERVICE_TURN_ON, + "pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice.turn_on", + ), + ( + SERVICE_TURN_OFF, + "pyvesync.base_devices.vesyncbasedevice.VeSyncBaseToggleDevice.turn_off", + ), ], ) async def test_turn_on_off_raises_error( @@ -141,7 +150,7 @@ async def test_set_preset_mode( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncTowerFan.normal_mode", + "pyvesync.devices.vesyncfan.VeSyncTowerFan.normal_mode", return_value=api_response, ) as method_mock, ): diff --git a/tests/components/vesync/test_humidifier.py b/tests/components/vesync/test_humidifier.py index d5057c449513e..e96efd355ee8d 100644 --- a/tests/components/vesync/test_humidifier.py +++ b/tests/components/vesync/test_humidifier.py @@ -73,7 +73,9 @@ async def test_set_target_humidity_invalid( # Setting value out of range results in ServiceValidationError and # VeSyncHumid200300S.set_humidity does not get called. with ( - patch("pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity") as method_mock, + patch( + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity" + ) as method_mock, pytest.raises(ServiceValidationError), ): await hass.services.async_call( @@ -102,7 +104,7 @@ async def test_set_target_humidity( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity", return_value=api_response, ) as method_mock, ): @@ -133,7 +135,8 @@ async def test_turn_on( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on", return_value=api_response + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.turn_on", + return_value=api_response, ) as method_mock, ): with patch( @@ -168,7 +171,8 @@ async def test_turn_off( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off", return_value=api_response + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.turn_off", + return_value=api_response, ) as method_mock, ): with patch( @@ -193,7 +197,7 @@ async def test_set_mode_invalid( """Test handling of invalid value in set_mode method.""" with patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode" + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity_mode" ) as method_mock: with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -222,7 +226,7 @@ async def test_set_mode( with ( expectation, patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_humidity_mode", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_humidity_mode", return_value=api_response, ) as method_mock, ): @@ -257,17 +261,14 @@ async def test_invalid_mist_modes( """Test unsupported mist mode.""" humidifier.mist_modes = ["invalid_mode"] + manager._dev_list["humidifiers"].append(humidifier) - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - caplog.clear() - caplog.set_level(logging.WARNING) + caplog.clear() + caplog.set_level(logging.WARNING) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert "Unknown mode 'invalid_mode'" in caplog.text + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'invalid_mode'" in caplog.text async def test_valid_mist_modes( @@ -280,18 +281,15 @@ async def test_valid_mist_modes( """Test supported mist mode.""" humidifier.mist_modes = ["auto", "manual"] + manager._dev_list["humidifiers"].append(humidifier) - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - caplog.clear() - caplog.set_level(logging.WARNING) + caplog.clear() + caplog.set_level(logging.WARNING) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert "Unknown mode 'auto'" not in caplog.text - assert "Unknown mode 'manual'" not in caplog.text + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert "Unknown mode 'auto'" not in caplog.text + assert "Unknown mode 'manual'" not in caplog.text async def test_set_mode_sleep_turns_display_off( @@ -308,17 +306,14 @@ async def test_set_mode_sleep_turns_display_off( VS_HUMIDIFIER_MODE_MANUAL, VS_HUMIDIFIER_MODE_SLEEP, ] + manager._dev_list["humidifiers"].append(humidifier) - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() with ( patch.object(humidifier, "set_humidity_mode", return_value=True), - patch.object(humidifier, "set_display") as display_mock, + patch.object(humidifier, "toggle_display") as display_mock, ): await hass.services.async_call( HUMIDIFIER_DOMAIN, diff --git a/tests/components/vesync/test_init.py b/tests/components/vesync/test_init.py index d1e76174ea0e5..de6aa358e76b2 100644 --- a/tests/components/vesync/test_init.py +++ b/tests/components/vesync/test_init.py @@ -1,11 +1,12 @@ """Tests for the init module.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, patch from pyvesync import VeSync +from pyvesync.utils.errors import VeSyncLoginError from homeassistant.components.vesync import SERVICE_UPDATE_DEVS, async_setup_entry -from homeassistant.components.vesync.const import DOMAIN, VS_DEVICES, VS_MANAGER +from homeassistant.components.vesync.const import DOMAIN, VS_MANAGER from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,7 +21,7 @@ async def test_async_setup_entry__not_login( manager: VeSync, ) -> None: """Test setup does not create config entry when not logged in.""" - manager.login = Mock(return_value=False) + manager.login = AsyncMock(side_effect=VeSyncLoginError("Mock login failed")) assert not await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -54,18 +55,14 @@ async def test_async_setup_entry__no_devices( assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert not hass.data[DOMAIN][VS_DEVICES] + assert not hass.data[DOMAIN][VS_MANAGER].devices async def test_async_setup_entry__loads_fans( hass: HomeAssistant, config_entry: ConfigEntry, manager: VeSync, fan ) -> None: """Test setup connects to vesync and loads fan.""" - fans = [fan] - manager.fans = fans - manager._dev_list = { - "fans": fans, - } + manager._dev_list["fans"].append(fan) with patch.object(hass.config_entries, "async_forward_entry_setups") as setups_mock: assert await async_setup_entry(hass, config_entry) @@ -85,7 +82,7 @@ async def test_async_setup_entry__loads_fans( ] assert manager.login.call_count == 1 assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan] + assert list(hass.data[DOMAIN][VS_MANAGER].devices) == [fan] async def test_async_new_device_discovery( @@ -97,30 +94,23 @@ async def test_async_new_device_discovery( # Assert platforms loaded await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED - assert not hass.data[DOMAIN][VS_DEVICES] + assert not hass.data[DOMAIN][VS_MANAGER].devices # Mock discovery of new fan which would get added to VS_DEVICES. - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[fan], - ): - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + manager._dev_list["fans"].append(fan) + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) - assert manager.login.call_count == 1 - assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan] + assert manager.get_devices.call_count == 1 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert list(hass.data[DOMAIN][VS_MANAGER].devices) == [fan] # Mock discovery of new humidifier which would invoke discovery in all platforms. - # The mocked humidifier needs to have all properties populated for correct processing. - with patch( - "homeassistant.components.vesync.async_generate_device_list", - return_value=[humidifier], - ): - await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) - - assert manager.login.call_count == 1 - assert hass.data[DOMAIN][VS_MANAGER] == manager - assert hass.data[DOMAIN][VS_DEVICES] == [fan, humidifier] + manager._dev_list["humidifiers"].append(humidifier) + await hass.services.async_call(DOMAIN, SERVICE_UPDATE_DEVS, {}, blocking=True) + + assert manager.get_devices.call_count == 2 + assert hass.data[DOMAIN][VS_MANAGER] == manager + assert list(hass.data[DOMAIN][VS_MANAGER].devices) == [fan, humidifier] async def test_migrate_config_entry( diff --git a/tests/components/vesync/test_light.py b/tests/components/vesync/test_light.py index 7300e28e40688..ce67efe3ed2d9 100644 --- a/tests/components/vesync/test_light.py +++ b/tests/components/vesync/test_light.py @@ -1,7 +1,6 @@ """Tests for the light module.""" import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN @@ -11,6 +10,7 @@ from .common import ALL_DEVICE_NAMES, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) @@ -20,13 +20,13 @@ async def test_light_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/vesync/test_number.py b/tests/components/vesync/test_number.py index a9230b76db045..debd95cad2bda 100644 --- a/tests/components/vesync/test_number.py +++ b/tests/components/vesync/test_number.py @@ -25,7 +25,7 @@ async def test_set_mist_level_bad_range( with ( pytest.raises(ServiceValidationError), patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_mist_level", return_value=True, ) as method_mock, ): @@ -45,7 +45,7 @@ async def test_set_mist_level( """Test set_mist_level usage.""" with patch( - "pyvesync.vesyncfan.VeSyncHumid200300S.set_mist_level", + "pyvesync.devices.vesynchumidifier.VeSyncHumid200300S.set_mist_level", return_value=True, ) as method_mock: await hass.services.async_call( diff --git a/tests/components/vesync/test_platform.py b/tests/components/vesync/test_platform.py index fa1e24f462826..85ab3395263ae 100644 --- a/tests/components/vesync/test_platform.py +++ b/tests/components/vesync/test_platform.py @@ -3,7 +3,6 @@ from datetime import timedelta from freezegun.api import FrozenDateTimeFactory -import requests_mock from homeassistant.components.vesync.const import DOMAIN, UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntryState @@ -18,12 +17,13 @@ ) from tests.common import MockConfigEntry, async_fire_time_changed +from tests.test_util.aiohttp import AiohttpClientMocker async def test_entity_update( hass: HomeAssistant, freezer: FrozenDateTimeFactory, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, ) -> None: """Test Vesync coordinator data update. @@ -38,7 +38,8 @@ async def test_entity_update( entry_id="1", ) - mock_multiple_device_responses(requests_mock, ["Air Purifier 400s", "Outlet"]) + mock_multiple_device_responses(aioclient_mock, ["Air Purifier 400s", "Outlet"]) + mock_outlet_energy_response(aioclient_mock, "Outlet") expected_entities = [ # From "Air Purifier 400s" @@ -65,28 +66,32 @@ async def test_entity_update( for entity_id in expected_entities: assert hass.states.get(entity_id).state != STATE_UNAVAILABLE - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "5" + assert hass.states.get("sensor.air_purifier_400s_pm2_5").state == "5" + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "excellent" assert hass.states.get("sensor.outlet_current_voltage").state == "120.0" - assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0" + assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0.0" # Update the mock responses - mock_air_purifier_400s_update_response(requests_mock) - mock_outlet_energy_response(requests_mock, "Outlet", {"totalEnergy": 2.2}) - mock_device_response(requests_mock, "Outlet", {"voltage": 129}) + aioclient_mock.clear_requests() + mock_air_purifier_400s_update_response(aioclient_mock) + mock_device_response(aioclient_mock, "Outlet", {"voltage": 129}) + mock_outlet_energy_response(aioclient_mock, "Outlet", {"totalEnergy": 2.2}) freezer.tick(timedelta(seconds=UPDATE_INTERVAL)) async_fire_time_changed(hass) await hass.async_block_till_done(True) - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" + assert hass.states.get("sensor.air_purifier_400s_pm2_5").state == "15" + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "good" assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" + assert hass.states.get("sensor.outlet_energy_use_weekly").state == "0.0" - # Test energy update - # pyvesync only updates energy parameters once every 6 hours. + # energy history only updates once every 6 hours. freezer.tick(timedelta(hours=6)) async_fire_time_changed(hass) await hass.async_block_till_done(True) - assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "15" + assert hass.states.get("sensor.air_purifier_400s_pm2_5").state == "15" + assert hass.states.get("sensor.air_purifier_400s_air_quality").state == "good" assert hass.states.get("sensor.outlet_current_voltage").state == "129.0" assert hass.states.get("sensor.outlet_energy_use_weekly").state == "2.2" diff --git a/tests/components/vesync/test_select.py b/tests/components/vesync/test_select.py index c96d687dfd2fb..a4183d0c0cb0b 100644 --- a/tests/components/vesync/test_select.py +++ b/tests/components/vesync/test_select.py @@ -36,7 +36,7 @@ async def test_humidifier_set_nightlight_level( ) # Assert that setter API was invoked with the expected translated value - humidifier_300s.set_night_light_brightness.assert_called_once_with( + humidifier_300s.set_nightlight_brightness.assert_called_once_with( HA_TO_VS_HUMIDIFIER_NIGHT_LIGHT_LEVEL_MAP[HUMIDIFIER_NIGHT_LIGHT_LEVEL_DIM] ) # Assert that devices were refreshed diff --git a/tests/components/vesync/test_sensor.py b/tests/components/vesync/test_sensor.py index d4e6abcdbab0b..792c21f98a9e4 100644 --- a/tests/components/vesync/test_sensor.py +++ b/tests/components/vesync/test_sensor.py @@ -1,7 +1,6 @@ """Tests for the sensor module.""" import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -11,6 +10,7 @@ from .common import ALL_DEVICE_NAMES, ENTITY_HUMIDIFIER_HUMIDITY, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker @pytest.mark.parametrize("device_name", ALL_DEVICE_NAMES) @@ -20,13 +20,13 @@ async def test_sensor_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/vesync/test_switch.py b/tests/components/vesync/test_switch.py index b0af5afc5d279..d99d4b46136a6 100644 --- a/tests/components/vesync/test_switch.py +++ b/tests/components/vesync/test_switch.py @@ -4,7 +4,6 @@ from unittest.mock import patch import pytest -import requests_mock from syrupy.assertion import SnapshotAssertion from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -16,6 +15,7 @@ from .common import ALL_DEVICE_NAMES, ENTITY_SWITCH_DISPLAY, mock_devices_response from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker NoException = nullcontext() @@ -27,13 +27,13 @@ async def test_switch_state( config_entry: MockConfigEntry, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, - requests_mock: requests_mock.Mocker, + aioclient_mock: AiohttpClientMocker, device_name: str, ) -> None: """Test the resulting setup state is as expected for the platform.""" # Configure the API devices call for device_name - mock_devices_response(requests_mock, device_name) + mock_devices_response(aioclient_mock, device_name) # setup platform - only including the named device await hass.config_entries.async_setup(config_entry.entry_id) @@ -61,18 +61,27 @@ async def test_switch_state( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ( + SERVICE_TURN_ON, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), + ( + SERVICE_TURN_OFF, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), ], ) async def test_turn_on_off_display_success( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, action: str, command: str, ) -> None: """Test switch turn on and off command with success response.""" + mock_devices_response(aioclient_mock, "Humidifier 200s") + with ( patch( command, @@ -97,18 +106,27 @@ async def test_turn_on_off_display_success( @pytest.mark.parametrize( ("action", "command"), [ - (SERVICE_TURN_ON, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_on_display"), - (SERVICE_TURN_OFF, "pyvesync.vesyncfan.VeSyncHumid200300S.turn_off_display"), + ( + SERVICE_TURN_ON, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), + ( + SERVICE_TURN_OFF, + "pyvesync.devices.vesynchumidifier.VeSyncHumid200S.toggle_display", + ), ], ) async def test_turn_on_off_display_raises_error( hass: HomeAssistant, humidifier_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, action: str, command: str, ) -> None: """Test switch turn on and off command raises HomeAssistantError.""" + mock_devices_response(aioclient_mock, "Humidifier 200s") + with ( patch( command, From 72128e97084b55f324f1038f9c168207c036037e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 2 Sep 2025 16:49:24 +0200 Subject: [PATCH 12/13] Add start mowing and dock intents for lawn mower (#140525) --- homeassistant/components/lawn_mower/intent.py | 35 ++++++ tests/components/lawn_mower/test_intent.py | 103 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 homeassistant/components/lawn_mower/intent.py create mode 100644 tests/components/lawn_mower/test_intent.py diff --git a/homeassistant/components/lawn_mower/intent.py b/homeassistant/components/lawn_mower/intent.py new file mode 100644 index 0000000000000..ca06ed2e2389f --- /dev/null +++ b/homeassistant/components/lawn_mower/intent.py @@ -0,0 +1,35 @@ +"""Intents for the lawn mower integration.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from . import DOMAIN, SERVICE_DOCK, SERVICE_START_MOWING + +INTENT_LANW_MOWER_START_MOWING = "HassLawnMowerStartMowing" +INTENT_LANW_MOWER_DOCK = "HassLawnMowerDock" + + +async def async_setup_intents(hass: HomeAssistant) -> None: + """Set up the lawn mower intents.""" + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_LANW_MOWER_START_MOWING, + DOMAIN, + SERVICE_START_MOWING, + description="Starts a lawn mower", + required_domains={DOMAIN}, + platforms={DOMAIN}, + ), + ) + intent.async_register( + hass, + intent.ServiceIntentHandler( + INTENT_LANW_MOWER_DOCK, + DOMAIN, + SERVICE_DOCK, + description="Sends a lawn mower to dock", + required_domains={DOMAIN}, + platforms={DOMAIN}, + ), + ) diff --git a/tests/components/lawn_mower/test_intent.py b/tests/components/lawn_mower/test_intent.py new file mode 100644 index 0000000000000..f673833d75673 --- /dev/null +++ b/tests/components/lawn_mower/test_intent.py @@ -0,0 +1,103 @@ +"""The tests for the lawn mower platform.""" + +from homeassistant.components.lawn_mower import ( + DOMAIN, + SERVICE_DOCK, + SERVICE_START_MOWING, + LawnMowerActivity, + intent as lawn_mower_intent, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import intent + +from tests.common import async_mock_service + + +async def test_start_lawn_mower_intent(hass: HomeAssistant) -> None: + """Test HassLawnMowerStartMowing intent for lawn mowers.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set(entity_id, LawnMowerActivity.DOCKED) + calls = async_mock_service(hass, DOMAIN, SERVICE_START_MOWING) + + response = await intent.async_handle( + hass, + "test", + lawn_mower_intent.INTENT_LANW_MOWER_START_MOWING, + {"name": {"value": "test lawn mower"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_START_MOWING + assert call.data == {"entity_id": entity_id} + + +async def test_start_lawn_mower_without_name(hass: HomeAssistant) -> None: + """Test starting a lawn mower without specifying the name.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set(entity_id, LawnMowerActivity.DOCKED) + calls = async_mock_service(hass, DOMAIN, SERVICE_START_MOWING) + + response = await intent.async_handle( + hass, "test", lawn_mower_intent.INTENT_LANW_MOWER_START_MOWING, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_START_MOWING + assert call.data == {"entity_id": entity_id} + + +async def test_stop_lawn_mower_intent(hass: HomeAssistant) -> None: + """Test HassLawnMowerDock intent for lawn mowers.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set(entity_id, LawnMowerActivity.MOWING) + calls = async_mock_service(hass, DOMAIN, SERVICE_DOCK) + + response = await intent.async_handle( + hass, + "test", + lawn_mower_intent.INTENT_LANW_MOWER_DOCK, + {"name": {"value": "test lawn mower"}}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_DOCK + assert call.data == {"entity_id": entity_id} + + +async def test_stop_lawn_mower_without_name(hass: HomeAssistant) -> None: + """Test stopping a lawn mower without specifying the name.""" + await lawn_mower_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_lawn_mower" + hass.states.async_set(entity_id, LawnMowerActivity.MOWING) + calls = async_mock_service(hass, DOMAIN, SERVICE_DOCK) + + response = await intent.async_handle( + hass, "test", lawn_mower_intent.INTENT_LANW_MOWER_DOCK, {} + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_DOCK + assert call.data == {"entity_id": entity_id} From e0bf7749e6db215abcd6a9bb66eae712ec9a5911 Mon Sep 17 00:00:00 2001 From: Blear <723712241@qq.com> Date: Tue, 2 Sep 2025 23:00:05 +0800 Subject: [PATCH 13/13] Adjust Zhong_Hong climate set_fan_mode to lowercase (#151559) --- .../components/zhong_hong/climate.py | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py index 217636edbd56b..69065d1472bc1 100644 --- a/homeassistant/components/zhong_hong/climate.py +++ b/homeassistant/components/zhong_hong/climate.py @@ -11,6 +11,10 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODE, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_MIDDLE, PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, ClimateEntity, ClimateEntityFeature, @@ -65,6 +69,17 @@ ZHONG_HONG_MODE_FAN_ONLY: HVACMode.FAN_ONLY, } +# HA → zhong_hong +FAN_MODE_MAP = { + FAN_LOW: "LOW", + FAN_MEDIUM: "MID", + FAN_HIGH: "HIGH", + FAN_MIDDLE: "MID", + "medium_high": "MIDHIGH", + "medium_low": "MIDLOW", +} +FAN_MODE_REVERSE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} + def setup_platform( hass: HomeAssistant, @@ -208,12 +223,16 @@ def is_on(self): @property def fan_mode(self): """Return the fan setting.""" - return self._current_fan_mode + if not self._current_fan_mode: + return None + return FAN_MODE_REVERSE_MAP.get(self._current_fan_mode, self._current_fan_mode) @property def fan_modes(self): """Return the list of available fan modes.""" - return self._device.fan_list + if not self._device.fan_list: + return [] + return list({FAN_MODE_REVERSE_MAP.get(x, x) for x in self._device.fan_list}) @property def min_temp(self) -> float: @@ -255,4 +274,7 @@ def set_hvac_mode(self, hvac_mode: HVACMode) -> None: def set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - self._device.set_fan_mode(fan_mode) + mapped_mode = FAN_MODE_MAP.get(fan_mode) + if not mapped_mode: + _LOGGER.error("Unsupported fan mode: %s", fan_mode) + self._device.set_fan_mode(mapped_mode)