Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement external state restoration #58

Merged
merged 37 commits into from
Jul 5, 2024
Merged
Show file tree
Hide file tree
Changes from 36 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
a87531d
Consolidate init-time state reading for Light entity
puddly Jul 1, 2024
22b85c7
Read the color mode from cache as well
puddly Jul 1, 2024
58f9b63
Remove duplicate `supported_color_modes` variable
puddly Jul 1, 2024
da3c155
Switch `zcl_color_mode_to_entity_color_mode` to a static dictionary
puddly Jul 1, 2024
eafe473
Only set the color mode from the supported color modes if it is uncached
puddly Jul 1, 2024
c54bdda
Update the attribute cache color mode after the color has been succes…
puddly Jul 1, 2024
a8bc97a
Do not persist the color mode for groups
puddly Jul 1, 2024
8585328
Test that the color mode changes
puddly Jul 1, 2024
c21fd00
Account for invalid ZCL color modes
puddly Jul 1, 2024
6d8395d
Add a quick test for HS
puddly Jul 1, 2024
da2cf75
Unit test enhanced hue as well
puddly Jul 1, 2024
bd22c2b
Re-introduce erroneously removed `cached_property`
puddly Jul 2, 2024
ba9ed65
Add `restore_extra_state_attributes`
puddly Jul 2, 2024
4e164c0
Persist the door lock state after locking/unlocking
puddly Jul 2, 2024
73c4be7
Remove unused lock `kwargs`
puddly Jul 2, 2024
b951f8b
Add `restore_external_state_attributes`
puddly Jul 3, 2024
c075f46
Implement external state for `cover`
puddly Jul 3, 2024
9eaf159
Implement external state for `select`
puddly Jul 3, 2024
7ef8ba3
Implement external state for `siren`
puddly Jul 3, 2024
890a826
Remove unnecessary `_persist_lock_state`
puddly Jul 3, 2024
8aebd9a
Revert "Implement external state for `siren`"
puddly Jul 3, 2024
1802d95
Implement a stub `restore_external_state_attributes` for non-ZCL selects
puddly Jul 3, 2024
c6c15b2
Migrate coverage to `pyproject.toml` and exclude NotImplementedError
puddly Jul 3, 2024
8aebec5
Update zha/application/platforms/light/__init__.py
puddly Jul 3, 2024
3eb8222
Migrate lighting to use explicit state restoration instead of ZCL cache
puddly Jul 3, 2024
cd36b2f
Reduce diff size
puddly Jul 3, 2024
4680d66
Only restore the state if the attribute isn't `None`
puddly Jul 3, 2024
d0703bd
Migrate lock to use state restoration
puddly Jul 3, 2024
50f6d16
Add some unit tests
puddly Jul 3, 2024
d8c25ef
Offload validation to Core
puddly Jul 3, 2024
3b069b6
Implement an `undefined` type
puddly Jul 3, 2024
d6165c5
Migrate remaining platforms to use `UNDEFINED` as well, where appropr…
puddly Jul 4, 2024
4df1b9a
Finish unit tests
puddly Jul 4, 2024
462a1ff
Remove `UNDEFINED`
puddly Jul 5, 2024
221fe2d
Only restore (most) light state attributes if they are not `None`
puddly Jul 5, 2024
26874c1
Fix `number` entity name
puddly Jul 5, 2024
b8d5c57
Revert `cached_property` -> `property` change
puddly Jul 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .coveragerc

This file was deleted.

9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,11 @@ split-on-trailing-comma = false
"script/*" = ["T20"]

[tool.ruff.lint.mccabe]
max-complexity = 25
max-complexity = 25

[tool.coverage.report]
show_missing = true
exclude_also = [
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
2 changes: 1 addition & 1 deletion requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
codecov
coverage[toml]
colorlog
codespell
mypy
Expand Down
24 changes: 24 additions & 0 deletions tests/test_cover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
120 changes: 116 additions & 4 deletions tests/test_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
18 changes: 18 additions & 0 deletions tests/test_lock.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions tests/test_number.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
21 changes: 21 additions & 0 deletions tests/test_select.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions zha/application/platforms/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
24 changes: 22 additions & 2 deletions zha/application/platforms/cover/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions zha/application/platforms/cover/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading
Loading