Use feature checks in tplink integration (#133795)

Clean up to use new upstream API:

* Use Feature attributes to check for supported

* Use color_temp range and update tests
This commit is contained in:
Steven B. 2024-12-22 15:17:23 +00:00 committed by GitHub
parent 26d5c55d11
commit 3cc75c3cf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 157 additions and 67 deletions

View File

@ -200,14 +200,13 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
# If _attr_name is None the entity name will be the device name
self._attr_name = None if parent is None else device.alias
modes: set[ColorMode] = {ColorMode.ONOFF}
if light_module.is_variable_color_temp:
if color_temp_feat := light_module.get_feature("color_temp"):
modes.add(ColorMode.COLOR_TEMP)
temp_range = light_module.valid_temperature_range
self._attr_min_color_temp_kelvin = temp_range.min
self._attr_max_color_temp_kelvin = temp_range.max
if light_module.is_color:
self._attr_min_color_temp_kelvin = color_temp_feat.minimum_value
self._attr_max_color_temp_kelvin = color_temp_feat.maximum_value
if light_module.has_feature("hsv"):
modes.add(ColorMode.HS)
if light_module.is_dimmable:
if light_module.has_feature("brightness"):
modes.add(ColorMode.BRIGHTNESS)
self._attr_supported_color_modes = filter_supported_color_modes(modes)
if len(self._attr_supported_color_modes) == 1:
@ -270,15 +269,17 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
self, color_temp: float, brightness: int | None, transition: int | None
) -> None:
light_module = self._light_module
valid_temperature_range = light_module.valid_temperature_range
color_temp_feat = light_module.get_feature("color_temp")
assert color_temp_feat
requested_color_temp = round(color_temp)
# Clamp color temp to valid range
# since if the light in a group we will
# get requests for color temps for the range
# of the group and not the light
clamped_color_temp = min(
valid_temperature_range.max,
max(valid_temperature_range.min, requested_color_temp),
color_temp_feat.maximum_value,
max(color_temp_feat.minimum_value, requested_color_temp),
)
await light_module.set_color_temp(
clamped_color_temp,
@ -325,8 +326,11 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
# The light supports only a single color mode, return it
return self._fixed_color_mode
# The light supports both color temp and color, determine which on is active
if self._light_module.is_variable_color_temp and self._light_module.color_temp:
# The light supports both color temp and color, determine which one is active
if (
self._light_module.has_feature("color_temp")
and self._light_module.color_temp
):
return ColorMode.COLOR_TEMP
return ColorMode.HS
@ -335,7 +339,7 @@ class TPLinkLightEntity(CoordinatedTPLinkEntity, LightEntity):
"""Update the entity's attributes."""
light_module = self._light_module
self._attr_is_on = light_module.state.light_on is True
if light_module.is_dimmable:
if light_module.has_feature("brightness"):
self._attr_brightness = round((light_module.brightness * 255.0) / 100.0)
color_mode = self._determine_color_mode()
self._attr_color_mode = color_mode

View File

@ -257,20 +257,27 @@ def _mocked_device(
for module_name in modules
}
device_features = {}
if features:
device.features = {
device_features = {
feature_id: _mocked_feature(feature_id, require_fixture=True)
for feature_id in features
if isinstance(feature_id, str)
}
device.features.update(
device_features.update(
{
feature.id: feature
for feature in features
if isinstance(feature, Feature)
}
)
device.features = device_features
for mod in device.modules.values():
mod.get_feature.side_effect = device_features.get
mod.has_feature.side_effect = lambda id: id in device_features
device.children = []
if children:
for child in children:
@ -289,6 +296,7 @@ def _mocked_device(
device.protocol = _mock_protocol()
device.config = device_config
device.credentials_hash = credentials_hash
return device
@ -303,8 +311,8 @@ def _mocked_feature(
precision_hint=None,
choices=None,
unit=None,
minimum_value=0,
maximum_value=2**16, # Arbitrary max
minimum_value=None,
maximum_value=None,
) -> Feature:
"""Get a mocked feature.
@ -334,11 +342,14 @@ def _mocked_feature(
feature.unit = unit or fixture.get("unit")
# number
feature.minimum_value = minimum_value or fixture.get("minimum_value")
feature.maximum_value = maximum_value or fixture.get("maximum_value")
min_val = minimum_value or fixture.get("minimum_value")
feature.minimum_value = 0 if min_val is None else min_val
max_val = maximum_value or fixture.get("maximum_value")
feature.maximum_value = 2**16 if max_val is None else max_val
# select
feature.choices = choices or fixture.get("choices")
return feature
@ -350,13 +361,7 @@ def _mocked_light_module(device) -> Light:
light.state = LightState(
light_on=True, brightness=light.brightness, color_temp=light.color_temp
)
light.is_color = True
light.is_variable_color_temp = True
light.is_dimmable = True
light.is_brightness = True
light.has_effects = False
light.hsv = (10, 30, 5)
light.valid_temperature_range = ColorTempRange(min=4000, max=9000)
light.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"}
async def _set_state(state, *_, **__):
@ -389,7 +394,6 @@ def _mocked_light_module(device) -> Light:
def _mocked_light_effect_module(device) -> LightEffect:
effect = MagicMock(spec=LightEffect, name="Mocked light effect")
effect.has_effects = True
effect.has_custom_effects = True
effect.effect = "Effect1"
effect.effect_list = ["Off", "Effect1", "Effect2"]

View File

@ -267,7 +267,9 @@
"target_temperature": {
"value": false,
"type": "Number",
"category": "Primary"
"category": "Primary",
"minimum_value": 5,
"maximum_value": 30
},
"fan_speed_level": {
"value": 2,

View File

@ -9,8 +9,8 @@
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 65536,
'min_temp': None,
'max_temp': 30,
'min_temp': 5,
}),
'config_entry_id': <ANY>,
'device_class': None,
@ -49,8 +49,8 @@
<HVACMode.HEAT: 'heat'>,
<HVACMode.OFF: 'off'>,
]),
'max_temp': 65536,
'min_temp': None,
'max_temp': 30,
'min_temp': 5,
'supported_features': <ClimateEntityFeature: 385>,
'temperature': 22.2,
}),

View File

@ -41,7 +41,7 @@
}),
'area_id': None,
'capabilities': dict({
'max': 65536,
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
@ -77,7 +77,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'my_device Smooth off',
'max': 65536,
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
@ -96,7 +96,7 @@
}),
'area_id': None,
'capabilities': dict({
'max': 65536,
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
@ -132,7 +132,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'my_device Smooth on',
'max': 65536,
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
@ -151,7 +151,7 @@
}),
'area_id': None,
'capabilities': dict({
'max': 65536,
'max': 10,
'min': -10,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
@ -187,7 +187,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'my_device Temperature offset',
'max': 65536,
'max': 10,
'min': -10,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
@ -206,7 +206,7 @@
}),
'area_id': None,
'capabilities': dict({
'max': 65536,
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,
@ -242,7 +242,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'my_device Turn off in',
'max': 65536,
'max': 60,
'min': 0,
'mode': <NumberMode.BOX: 'box'>,
'step': 1.0,

View File

@ -54,6 +54,7 @@ from . import (
MAC_ADDRESS,
MODEL,
_mocked_device,
_mocked_feature,
_patch_connect,
_patch_discovery,
_patch_single_discovery,
@ -335,7 +336,14 @@ async def test_update_attrs_fails_in_init(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
config_entry.add_to_hass(hass)
light = _mocked_device(modules=[Module.Light], alias="my_light")
features = [
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
]
light = _mocked_device(modules=[Module.Light], alias="my_light", features=features)
light_module = light.modules[Module.Light]
p = PropertyMock(side_effect=KasaException)
type(light_module).color_temp = p
@ -363,7 +371,14 @@ async def test_update_attrs_fails_on_update(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
config_entry.add_to_hass(hass)
light = _mocked_device(modules=[Module.Light], alias="my_light")
features = [
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
]
light = _mocked_device(modules=[Module.Light], alias="my_light", features=features)
light_module = light.modules[Module.Light]
with _patch_discovery(device=light), _patch_connect(device=light):

View File

@ -54,6 +54,7 @@ from . import (
DEVICE_ID,
MAC_ADDRESS,
_mocked_device,
_mocked_feature,
_patch_connect,
_patch_discovery,
_patch_single_discovery,
@ -118,8 +119,32 @@ async def test_legacy_dimmer_unique_id(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("device", "transition"),
[
(_mocked_device(modules=[Module.Light]), 2.0),
(_mocked_device(modules=[Module.Light, Module.LightEffect]), None),
(
_mocked_device(
modules=[Module.Light],
features=[
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
],
),
2.0,
),
(
_mocked_device(
modules=[Module.Light, Module.LightEffect],
features=[
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
],
),
None,
),
],
)
async def test_color_light(
@ -131,7 +156,10 @@ async def test_color_light(
)
already_migrated_config_entry.add_to_hass(hass)
light = device.modules[Module.Light]
# Setting color_temp to None emulates a device with active effects
light.color_temp = None
with _patch_discovery(device=device), _patch_connect(device=device):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
await hass.async_block_till_done()
@ -220,9 +248,14 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None:
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
device = _mocked_device(modules=[Module.Light], alias="my_light")
features = [
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
]
device = _mocked_device(modules=[Module.Light], alias="my_light", features=features)
light = device.modules[Module.Light]
light.is_variable_color_temp = False
type(light).color_temp = PropertyMock(side_effect=Exception)
with _patch_discovery(device=device), _patch_connect(device=device):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
@ -272,25 +305,47 @@ async def test_color_light_no_temp(hass: HomeAssistant) -> None:
@pytest.mark.parametrize(
("bulb", "is_color"),
("device", "is_color"),
[
(_mocked_device(modules=[Module.Light], alias="my_light"), True),
(_mocked_device(modules=[Module.Light], alias="my_light"), False),
(
_mocked_device(
modules=[Module.Light],
alias="my_light",
features=[
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
],
),
True,
),
(
_mocked_device(
modules=[Module.Light],
alias="my_light",
features=[
_mocked_feature("brightness", value=50),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
],
),
False,
),
],
)
async def test_color_temp_light(
hass: HomeAssistant, bulb: MagicMock, is_color: bool
hass: HomeAssistant, device: MagicMock, is_color: bool
) -> None:
"""Test a light."""
already_migrated_config_entry = MockConfigEntry(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
device = _mocked_device(modules=[Module.Light], alias="my_light")
# device = _mocked_device(modules=[Module.Light], alias="my_light")
light = device.modules[Module.Light]
light.is_color = is_color
light.color_temp = 4000
light.is_variable_color_temp = True
with _patch_discovery(device=device), _patch_connect(device=device):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
@ -303,7 +358,7 @@ async def test_color_temp_light(
attributes = state.attributes
assert attributes[ATTR_BRIGHTNESS] == 128
assert attributes[ATTR_COLOR_MODE] == "color_temp"
if light.is_color:
if is_color:
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp", "hs"]
else:
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == ["color_temp"]
@ -368,10 +423,11 @@ async def test_brightness_only_light(hass: HomeAssistant) -> None:
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
device = _mocked_device(modules=[Module.Light], alias="my_light")
features = [
_mocked_feature("brightness", value=50),
]
device = _mocked_device(modules=[Module.Light], alias="my_light", features=features)
light = device.modules[Module.Light]
light.is_color = False
light.is_variable_color_temp = False
with _patch_discovery(device=device), _patch_connect(device=device):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
@ -414,11 +470,8 @@ async def test_on_off_light(hass: HomeAssistant) -> None:
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
device = _mocked_device(modules=[Module.Light], alias="my_light")
device = _mocked_device(modules=[Module.Light], alias="my_light", features=[])
light = device.modules[Module.Light]
light.is_color = False
light.is_variable_color_temp = False
light.is_dimmable = False
with _patch_discovery(device=device), _patch_connect(device=device):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
@ -450,11 +503,9 @@ async def test_off_at_start_light(hass: HomeAssistant) -> None:
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
device = _mocked_device(modules=[Module.Light], alias="my_light")
device = _mocked_device(modules=[Module.Light], alias="my_light", features=[])
light = device.modules[Module.Light]
light.is_color = False
light.is_variable_color_temp = False
light.is_dimmable = False
light.state = LightState(light_on=False)
with _patch_discovery(device=device), _patch_connect(device=device):
@ -513,8 +564,15 @@ async def test_smart_strip_effects(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
features = [
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
]
device = _mocked_device(
modules=[Module.Light, Module.LightEffect], alias="my_light"
modules=[Module.Light, Module.LightEffect], alias="my_light", features=features
)
light = device.modules[Module.Light]
light_effect = device.modules[Module.LightEffect]
@ -977,8 +1035,15 @@ async def test_scene_effect_light(
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=MAC_ADDRESS
)
already_migrated_config_entry.add_to_hass(hass)
features = [
_mocked_feature("brightness", value=50),
_mocked_feature("hsv", value=(10, 30, 5)),
_mocked_feature(
"color_temp", value=4000, minimum_value=4000, maximum_value=9000
),
]
device = _mocked_device(
modules=[Module.Light, Module.LightEffect], alias="my_light"
modules=[Module.Light, Module.LightEffect], alias="my_light", features=features
)
light_effect = device.modules[Module.LightEffect]
light_effect.effect = LightEffect.LIGHT_EFFECTS_OFF

View File

@ -129,7 +129,7 @@ async def test_color_light_no_emeter(hass: HomeAssistant) -> None:
)
already_migrated_config_entry.add_to_hass(hass)
bulb = _mocked_device(alias="my_bulb", modules=[Module.Light])
bulb.has_emeter = False
with _patch_discovery(device=bulb), _patch_connect(device=bulb):
await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
await hass.async_block_till_done()