Do optimistic state update for Z-Wave multilevel switch entities (#90490)

* Do optimistic state update for Z-Wave multilevel switch entities

* simplify

* define constant for setting value to previous value

* Rework to only consider value of 255 and only places where we know that the previous state is known by the integration due to the service being called

* missed commit

* better code

* Add tests and use constant from lib

* fix logic

* fix bug

* Add comments with more details
This commit is contained in:
Raman Gupta 2023-05-24 09:04:11 -04:00 committed by GitHub
parent 3e93dd6a01
commit 66f7218b68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 74 additions and 31 deletions

View File

@ -6,6 +6,7 @@ from typing import Any, cast
from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.client import Client as ZwaveClient
from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass
from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE
from zwave_js_server.const.command_class.thermostat import ( from zwave_js_server.const.command_class.thermostat import (
THERMOSTAT_FAN_OFF_PROPERTY, THERMOSTAT_FAN_OFF_PROPERTY,
THERMOSTAT_FAN_STATE_PROPERTY, THERMOSTAT_FAN_STATE_PROPERTY,
@ -88,6 +89,8 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
assert target_value assert target_value
self._target_value = target_value self._target_value = target_value
self._use_optimistic_state: bool = False
async def async_set_percentage(self, percentage: int) -> None: async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed percentage of the fan.""" """Set the speed percentage of the fan."""
if percentage == 0: if percentage == 0:
@ -111,8 +114,19 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
elif preset_mode is not None: elif preset_mode is not None:
await self.async_set_preset_mode(preset_mode) await self.async_set_preset_mode(preset_mode)
else: else:
# Value 255 tells device to return to previous value if self.info.primary_value.command_class != CommandClass.SWITCH_MULTILEVEL:
await self.info.node.async_set_value(self._target_value, 255) raise HomeAssistantError(
"`percentage` or `preset_mode` must be provided"
)
# If this is a Multilevel Switch CC value, we do an optimistic state update
# when setting to a previous value to avoid waiting for the value to be
# updated from the device which is typically delayed and causes a confusing
# UX.
await self.info.node.async_set_value(
self._target_value, SET_TO_PREVIOUS_VALUE
)
self._use_optimistic_state = True
self.async_write_ha_state()
async def async_turn_off(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the device off.""" """Turn the device off."""
@ -121,6 +135,9 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity):
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
"""Return true if device is on (speed above 0).""" """Return true if device is on (speed above 0)."""
if self._use_optimistic_state:
self._use_optimistic_state = False
return True
if self.info.primary_value.value is None: if self.info.primary_value.value is None:
# guard missing value # guard missing value
return None return None

View File

@ -22,6 +22,7 @@ from zwave_js_server.const.command_class.color_switch import (
TARGET_COLOR_PROPERTY, TARGET_COLOR_PROPERTY,
ColorComponent, ColorComponent,
) )
from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE
from zwave_js_server.model.driver import Driver from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value from zwave_js_server.model.value import Value
@ -164,6 +165,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if self.supports_brightness_transition or self.supports_color_transition: if self.supports_brightness_transition or self.supports_color_transition:
self._attr_supported_features |= LightEntityFeature.TRANSITION self._attr_supported_features |= LightEntityFeature.TRANSITION
self._set_optimistic_state: bool = False
@callback @callback
def on_value_update(self) -> None: def on_value_update(self) -> None:
"""Call when a watched value is added or updated.""" """Call when a watched value is added or updated."""
@ -187,10 +190,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
@property @property
def is_on(self) -> bool | None: def is_on(self) -> bool | None:
"""Return true if device is on (brightness above 0).""" """Return true if device is on (brightness above 0)."""
if self._set_optimistic_state:
self._set_optimistic_state = False
return True
brightness = self.brightness brightness = self.brightness
if brightness is None: return brightness > 0 if brightness is not None else None
return None
return brightness > 0
@property @property
def hs_color(self) -> tuple[float, float] | None: def hs_color(self) -> tuple[float, float] | None:
@ -332,8 +336,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
if not self._target_brightness: if not self._target_brightness:
return return
if brightness is None: if brightness is None:
# Level 255 means to set it to previous value. zwave_brightness = SET_TO_PREVIOUS_VALUE
zwave_brightness = 255
else: else:
# Zwave multilevel switches use a range of [0, 99] to control brightness. # Zwave multilevel switches use a range of [0, 99] to control brightness.
zwave_brightness = byte_to_zwave_brightness(brightness) zwave_brightness = byte_to_zwave_brightness(brightness)
@ -350,6 +353,15 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity):
await self.info.node.async_set_value( await self.info.node.async_set_value(
self._target_brightness, zwave_brightness, zwave_transition self._target_brightness, zwave_brightness, zwave_transition
) )
# We do an optimistic state update when setting to a previous value
# to avoid waiting for the value to be updated from the device which is
# typically delayed and causes a confusing UX.
if (
zwave_brightness == SET_TO_PREVIOUS_VALUE
and self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL
):
self._set_optimistic_state = True
self.async_write_ha_state()
@callback @callback
def _calculate_color_values(self) -> None: def _calculate_color_values(self) -> None:

View File

@ -43,7 +43,35 @@ async def test_generic_fan(
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state assert state
assert state.state == "off" assert state.state == STATE_OFF
# Test turn on no speed
await hass.services.async_call(
"fan",
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == 17
assert args["valueId"] == {
"commandClass": 38,
"endpoint": 0,
"property": "targetValue",
}
assert args["value"] == 255
client.async_send_command.reset_mock()
# Due to optimistic updates, the state should be on even though the Z-Wave state
# hasn't been updated yet
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_ON
# Test turn on setting speed # Test turn on setting speed
await hass.services.async_call( await hass.services.async_call(
@ -77,27 +105,6 @@ async def test_generic_fan(
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
# Test turn on no speed
await hass.services.async_call(
"fan",
"turn_on",
{"entity_id": entity_id},
blocking=True,
)
assert len(client.async_send_command.call_args_list) == 1
args = client.async_send_command.call_args[0][0]
assert args["command"] == "node.set_value"
assert args["nodeId"] == 17
assert args["valueId"] == {
"commandClass": 38,
"endpoint": 0,
"property": "targetValue",
}
assert args["value"] == 255
client.async_send_command.reset_mock()
# Test turning off # Test turning off
await hass.services.async_call( await hass.services.async_call(
"fan", "fan",
@ -140,7 +147,7 @@ async def test_generic_fan(
node.receive_event(event) node.receive_event(event)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.state == "on" assert state.state == STATE_ON
assert state.attributes[ATTR_PERCENTAGE] == 100 assert state.attributes[ATTR_PERCENTAGE] == 100
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
@ -165,7 +172,7 @@ async def test_generic_fan(
node.receive_event(event) node.receive_event(event)
state = hass.states.get(entity_id) state = hass.states.get(entity_id)
assert state.state == "off" assert state.state == STATE_OFF
assert state.attributes[ATTR_PERCENTAGE] == 0 assert state.attributes[ATTR_PERCENTAGE] == 0
client.async_send_command.reset_mock() client.async_send_command.reset_mock()

View File

@ -70,6 +70,13 @@ async def test_light(
} }
assert args["value"] == 255 assert args["value"] == 255
# Due to optimistic updates, the state should be on even though the Z-Wave state
# hasn't been updated yet
state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY)
assert state
assert state.state == STATE_ON
client.async_send_command.reset_mock() client.async_send_command.reset_mock()
# Test turning on with transition # Test turning on with transition