diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 0571d364..00000000 --- a/.coveragerc +++ /dev/null @@ -1,4 +0,0 @@ -[report] -show_missing = True -exclude_also = - if TYPE_CHECKING: \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index ed0bb133..706d5d72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -231,4 +231,11 @@ split-on-trailing-comma = false "script/*" = ["T20"] [tool.ruff.lint.mccabe] -max-complexity = 25 \ No newline at end of file +max-complexity = 25 + +[tool.coverage.report] +show_missing = true +exclude_also = [ + "if TYPE_CHECKING:", + "raise NotImplementedError", +] \ No newline at end of file diff --git a/requirements_test.txt b/requirements_test.txt index 8b6f2955..8a804dd8 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,4 +1,4 @@ -codecov +coverage[toml] colorlog codespell mypy diff --git a/tests/test_cover.py b/tests/test_cover.py index bd412d34..8cf6c0bf 100644 --- a/tests/test_cover.py +++ b/tests/test_cover.py @@ -799,3 +799,27 @@ async def test_cover_remote( assert zha_device.emit_zha_event.call_count == 1 assert ATTR_COMMAND in zha_device.emit_zha_event.call_args[0][0] assert zha_device.emit_zha_event.call_args[0][0][ATTR_COMMAND] == "down_close" + + +async def test_cover_state_restoration( + device_joined: Callable[[ZigpyDevice], Awaitable[Device]], + zigpy_cover_device: ZigpyDevice, + zha_gateway: Gateway, +) -> None: + """Test the cover state restoration.""" + zha_device = await device_joined(zigpy_cover_device) + entity = get_entity(zha_device, platform=Platform.COVER) + + assert entity.state["state"] != STATE_CLOSED + assert entity.state["target_lift_position"] != 12 + assert entity.state["target_tilt_position"] != 34 + + entity.restore_external_state_attributes( + state=STATE_CLOSED, + target_lift_position=12, + target_tilt_position=34, + ) + + assert entity.state["state"] == STATE_CLOSED + assert entity.state["target_lift_position"] == 12 + assert entity.state["target_tilt_position"] == 34 diff --git a/tests/test_light.py b/tests/test_light.py index fec5ceaf..0ba250c5 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -209,6 +209,15 @@ async def device_light_3( ieee=IEEE_GROUPABLE_DEVICE3, nwk=0xB89F, ) + color_cluster = zigpy_device.endpoints[1].light_color + color_cluster.PLUGGED_ATTR_READS = { + "color_capabilities": ( + lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + | lighting.Color.ColorCapabilities.Color_loop + ) + } + zha_device = await device_joined(zigpy_device) zha_device.available = True return zha_device @@ -242,8 +251,10 @@ async def eWeLink_light( ) color_cluster = zigpy_device.endpoints[1].light_color color_cluster.PLUGGED_ATTR_READS = { - "color_capabilities": lighting.Color.ColorCapabilities.Color_temperature - | lighting.Color.ColorCapabilities.XY_attributes, + "color_capabilities": ( + lighting.Color.ColorCapabilities.Color_temperature + | lighting.Color.ColorCapabilities.XY_attributes + ), "color_temp_physical_min": 0, "color_temp_physical_max": 0, } @@ -334,8 +345,11 @@ async def test_light( "color_temperature": 100, "color_temp_physical_min": 0, "color_temp_physical_max": 600, - "color_capabilities": lighting.ColorCapabilities.XY_attributes - | lighting.ColorCapabilities.Color_temperature, + "color_capabilities": ( + lighting.ColorCapabilities.XY_attributes + | lighting.ColorCapabilities.Color_temperature + | lighting.ColorCapabilities.Hue_and_saturation + ), } update_attribute_cache(cluster_color) zha_device = await device_joined(zigpy_device) @@ -398,6 +412,7 @@ async def test_light( assert entity.state["color_temp"] != 200 await entity.async_turn_on(brightness=50, transition=10, color_temp=200) await zha_gateway.async_block_till_done() + assert entity.state["color_mode"] == ColorMode.COLOR_TEMP assert entity.state["brightness"] == 50 assert entity.state["color_temp"] == 200 assert bool(entity.state["on"]) is True @@ -419,6 +434,7 @@ async def test_light( assert entity.state["xy_color"] != [13369, 18087] await entity.async_turn_on(brightness=50, xy_color=[13369, 18087]) await zha_gateway.async_block_till_done() + assert entity.state["color_mode"] == ColorMode.XY assert entity.state["brightness"] == 50 assert entity.state["xy_color"] == [13369, 18087] assert cluster_color.request.call_count == 1 @@ -437,6 +453,58 @@ async def test_light( cluster_color.request.reset_mock() + # test color hs from the client + assert entity.state["hs_color"] != [12, 34] + await entity.async_turn_on(brightness=50, hs_color=[12, 34]) + await zha_gateway.async_block_till_done() + assert entity.state["color_mode"] == ColorMode.HS + assert entity.state["brightness"] == 50 + assert entity.state["hs_color"] == [12, 34] + assert cluster_color.request.call_count == 1 + assert cluster_color.request.await_count == 1 + assert cluster_color.request.call_args == call( + False, + 6, + cluster_color.commands_by_name["move_to_hue_and_saturation"].schema, + hue=8, + saturation=86, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + + cluster_color.request.reset_mock() + + # test enhanced hue support + cluster_color.PLUGGED_ATTR_READS["color_capabilities"] |= ( + lighting.ColorCapabilities.Enhanced_hue + ) + update_attribute_cache(cluster_color) + del entity._color_cluster_handler.color_capabilities + + assert entity.state["hs_color"] != [56, 78] + await entity.async_turn_on(brightness=50, hs_color=[56, 78]) + await zha_gateway.async_block_till_done() + assert entity.state["color_mode"] == ColorMode.HS + assert entity.state["brightness"] == 50 + assert entity.state["hs_color"] == [56, 78] + assert cluster_color.request.call_count == 1 + assert cluster_color.request.await_count == 1 + assert cluster_color.request.call_args == call( + False, + 67, + cluster_color.commands_by_name[ + "enhanced_move_to_hue_and_saturation" + ].schema, + enhanced_hue=10194, + saturation=198, + transition_time=0, + expect_reply=True, + manufacturer=None, + tsn=None, + ) + async def async_test_on_off_from_light( zha_gateway: Gateway, @@ -1836,3 +1904,47 @@ async def test_group_member_assume_state( assert bool(device_1_light_entity.state["on"]) is False assert bool(device_2_light_entity.state["on"]) is False assert bool(entity.state["on"]) is False + + +async def test_light_state_restoration( + device_light_3, # pylint: disable=redefined-outer-name +) -> None: + """Test the light state restoration function.""" + entity = get_entity(device_light_3, platform=Platform.LIGHT) + entity.restore_external_state_attributes( + state=True, + off_with_transition=False, + off_brightness=12, + brightness=34, + color_temp=500, + xy_color=(1, 2), + hs_color=(3, 4), + color_mode=ColorMode.XY, + effect="colorloop", + ) + + assert entity.state["on"] is True + assert entity.state["brightness"] == 34 + assert entity.state["color_temp"] == 500 + assert entity.state["xy_color"] == (1, 2) + assert entity.state["color_mode"] == ColorMode.XY + assert entity.state["effect"] == "colorloop" + + entity.restore_external_state_attributes( + state=None, + off_with_transition=None, + off_brightness=None, + brightness=None, + color_temp=None, + xy_color=None, + hs_color=None, + color_mode=None, + effect=None, # Effect is the only `None` value actually restored + ) + + assert entity.state["on"] is True + assert entity.state["brightness"] == 34 + assert entity.state["color_temp"] == 500 + assert entity.state["xy_color"] == (1, 2) + assert entity.state["color_mode"] == ColorMode.XY + assert entity.state["effect"] is None diff --git a/tests/test_lock.py b/tests/test_lock.py index 15c19eb8..7c5953d2 100644 --- a/tests/test_lock.py +++ b/tests/test_lock.py @@ -14,6 +14,7 @@ from zha.application import Platform from zha.application.gateway import Gateway from zha.application.platforms import PlatformEntity +from zha.application.platforms.lock.const import STATE_LOCKED, STATE_UNLOCKED from zha.zigbee.device import Device LOCK_DOOR = 0 @@ -210,3 +211,20 @@ async def async_disable_user_code( assert cluster.request.call_args[0][1] == SET_USER_STATUS assert cluster.request.call_args[0][3] == 2 # user slot 3 => internal slot 2 assert cluster.request.call_args[0][4] == closures.DoorLock.UserStatus.Disabled + + +async def test_lock_state_restoration( + lock: tuple[Device, closures.DoorLock], # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test the lock state restoration.""" + zha_device, _ = lock + entity = get_entity(zha_device, platform=Platform.LOCK) + + assert entity.state["is_locked"] is False + + entity.restore_external_state_attributes(state=STATE_LOCKED) + assert entity.state["is_locked"] is True + + entity.restore_external_state_attributes(state=STATE_UNLOCKED) + assert entity.state["is_locked"] is False diff --git a/tests/test_number.py b/tests/test_number.py index fca8c7da..ff3a5b5f 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -102,6 +102,8 @@ async def test_number( assert cluster.read_attributes.call_count == 3 + assert entity.name == "PWM1" + # test that the state is 15.0 assert entity.state["state"] == 15.0 diff --git a/tests/test_select.py b/tests/test_select.py index 96a11ecc..380d9de8 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -238,3 +238,24 @@ async def test_on_off_select_attribute_report_v2( assert cluster.write_attributes.call_args == call( {"motion_sensitivity": AqaraMotionSensitivities.Medium}, manufacturer=None ) + + +async def test_non_zcl_select_state_restoration( + siren: tuple[Device, security.IasWd], # pylint: disable=redefined-outer-name + zha_gateway: Gateway, +) -> None: + """Test the non-ZCL select state restoration.""" + zha_device, cluster = siren + entity = get_entity(zha_device, platform=Platform.SELECT, qualifier="WarningMode") + + assert entity.state["state"] is None + + entity.restore_external_state_attributes( + state=security.IasWd.Warning.WarningMode.Burglar.name + ) + assert entity.state["state"] == security.IasWd.Warning.WarningMode.Burglar.name + + entity.restore_external_state_attributes( + state=security.IasWd.Warning.WarningMode.Fire.name + ) + assert entity.state["state"] == security.IasWd.Warning.WarningMode.Fire.name diff --git a/zha/application/platforms/__init__.py b/zha/application/platforms/__init__.py index acc6d5ef..b1bf6c69 100644 --- a/zha/application/platforms/__init__.py +++ b/zha/application/platforms/__init__.py @@ -224,6 +224,13 @@ def extra_state_attribute_names(self) -> set[str] | None: return self._attr_extra_state_attribute_names return None + def restore_external_state_attributes(self, **kwargs: Any) -> None: + """Restore entity specific state attributes from an external source. + + Entities implementing this must accept a keyword argument for each attribute. + """ + raise NotImplementedError + async def on_remove(self) -> None: """Cancel tasks and timers this entity owns.""" for handle in self._tracked_handles: diff --git a/zha/application/platforms/cover/__init__.py b/zha/application/platforms/cover/__init__.py index cd9b9f66..7fbc3958 100644 --- a/zha/application/platforms/cover/__init__.py +++ b/zha/application/platforms/cover/__init__.py @@ -5,7 +5,7 @@ import asyncio import functools import logging -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status @@ -57,6 +57,10 @@ class Cover(PlatformEntity): PLATFORM = Platform.COVER _attr_translation_key: str = "cover" + _attr_extra_state_attribute_names: set[str] = { + "target_lift_position", + "target_tilt_position", + } def __init__( self, @@ -84,7 +88,7 @@ def __init__( ) self._target_lift_position: int | None = None self._target_tilt_position: int | None = None - self._state: str + self._state: str = STATE_OPEN self._determine_initial_state() self._cover_cluster_handler.on_event( CLUSTER_HANDLER_ATTRIBUTE_UPDATED, @@ -102,10 +106,26 @@ def state(self) -> dict[str, Any]: "is_opening": self.is_opening, "is_closing": self.is_closing, "is_closed": self.is_closed, + "target_lift_position": self._target_lift_position, + "target_tilt_position": self._target_tilt_position, } ) return response + def restore_external_state_attributes( + self, + *, + state: Literal[ + "open", "opening", "closed", "closing" + ], # FIXME: why must these be expanded? + target_lift_position: int | None, + target_tilt_position: int | None, + ): + """Restore external state attributes.""" + self._state = state + self._target_lift_position = target_lift_position + self._target_tilt_position = target_tilt_position + @property def is_closed(self) -> bool | None: """Return True if the cover is closed. diff --git a/zha/application/platforms/cover/const.py b/zha/application/platforms/cover/const.py index 644c2c19..af6e25fd 100644 --- a/zha/application/platforms/cover/const.py +++ b/zha/application/platforms/cover/const.py @@ -10,10 +10,10 @@ ATTR_POSITION: Final[str] = "position" ATTR_TILT_POSITION: Final[str] = "tilt_position" -STATE_OPEN: Final[str] = "open" -STATE_OPENING: Final[str] = "opening" -STATE_CLOSED: Final[str] = "closed" -STATE_CLOSING: Final[str] = "closing" +STATE_OPEN: Final = "open" +STATE_OPENING: Final = "opening" +STATE_CLOSED: Final = "closed" +STATE_CLOSING: Final = "closing" class CoverDeviceClass(StrEnum): diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index 7b7d37eb..bc9166ec 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -1064,6 +1064,40 @@ def _assume_group_state(self, update_params) -> None: self.maybe_emit_state_changed_event() + def restore_external_state_attributes( + self, + *, + state: bool | None, + off_with_transition: bool | None, + off_brightness: int | None, + brightness: int | None, + color_temp: int | None, + xy_color: tuple[float, float] | None, + hs_color: tuple[float, float] | None, + color_mode: ColorMode | None, + effect: str | None, + ) -> None: + """Restore extra state attributes that are stored outside of the ZCL cache.""" + if state is not None: + self._state = state + if off_with_transition is not None: + self._off_with_transition = off_with_transition + if off_brightness is not None: + self._off_brightness = off_brightness + if brightness is not None: + self._brightness = brightness + if color_temp is not None: + self._color_temp = color_temp + if xy_color is not None: + self._xy_color = xy_color + if hs_color is not None: + self._hs_color = hs_color + if color_mode is not None: + self._color_mode = color_mode + + # Effect is always restored, as `None` indicates that no effect is active + self._effect = effect + @STRICT_MATCH( cluster_handler_names=CLUSTER_HANDLER_ON_OFF, diff --git a/zha/application/platforms/lock/__init__.py b/zha/application/platforms/lock/__init__.py index 761e4f15..7bcff82c 100644 --- a/zha/application/platforms/lock/__init__.py +++ b/zha/application/platforms/lock/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import functools -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from zigpy.zcl.clusters.closures import DoorLock as DoorLockCluster from zigpy.zcl.foundation import Status @@ -72,21 +72,23 @@ def is_locked(self) -> bool: return False return self._state == STATE_LOCKED - async def async_lock(self, **kwargs: Any) -> None: # pylint: disable=unused-argument + async def async_lock(self) -> None: """Lock the lock.""" result = await self._doorlock_cluster_handler.lock_door() if result[0] is not Status.SUCCESS: self.error("Error with lock_door: %s", result) return + self._state = STATE_LOCKED self.maybe_emit_state_changed_event() - async def async_unlock(self, **kwargs: Any) -> None: # pylint: disable=unused-argument + async def async_unlock(self) -> None: """Unlock the lock.""" result = await self._doorlock_cluster_handler.unlock_door() if result[0] is not Status.SUCCESS: self.error("Error with unlock_door: %s", result) return + self._state = STATE_UNLOCKED self.maybe_emit_state_changed_event() @@ -124,3 +126,11 @@ def handle_cluster_handler_attribute_updated( return self._state = VALUE_TO_STATE.get(event.attribute_value, self._state) self.maybe_emit_state_changed_event() + + def restore_external_state_attributes( + self, + *, + state: Literal["locked", "unlocked"] | None, + ) -> None: + """Restore extra state attributes that are stored outside of the ZCL cache.""" + self._state = state diff --git a/zha/application/platforms/number/__init__.py b/zha/application/platforms/number/__init__.py index 2f378e0a..8ef3a1f1 100644 --- a/zha/application/platforms/number/__init__.py +++ b/zha/application/platforms/number/__init__.py @@ -145,10 +145,9 @@ def native_step(self) -> float | None: def name(self) -> str | None: """Return the name of the number entity.""" description = self._analog_output_cluster_handler.description - # TODO what happened here? - if description is not None and len(description) > 0: - return f"{super().name} {description}" - return super().name + if not description: + return None + return description @functools.cached_property def icon(self) -> str | None: diff --git a/zha/application/platforms/select.py b/zha/application/platforms/select.py index 3ff64f90..9988baba 100644 --- a/zha/application/platforms/select.py +++ b/zha/application/platforms/select.py @@ -109,6 +109,15 @@ async def async_select_option(self, option: str) -> None: ] self.maybe_emit_state_changed_event() + def restore_external_state_attributes( + self, + *, + state: str, + ) -> None: + """Restore extra state attributes that are stored outside of the ZCL cache.""" + value = state.replace(" ", "_") + self._cluster_handler.data_cache[self._attribute_name] = self._enum[value] + class NonZCLSelectEntity(EnumSelectEntity): """Representation of a ZHA select entity with no ZCL interaction.""" @@ -254,6 +263,14 @@ def handle_cluster_handler_attribute_updated( if event.attribute_name == self._attribute_name: self.maybe_emit_state_changed_event() + def restore_external_state_attributes( + self, + *, + state: str, + ) -> None: + """Restore extra state attributes.""" + # Select entities backed by the ZCL cache don't need to restore their state! + @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class StartupOnOffSelectEntity(ZCLEnumSelectEntity):