Skip to content

Commit

Permalink
Ignore out of range values for range capabilities (close dext0r#428)
Browse files Browse the repository at this point in the history
  • Loading branch information
dext0r committed Mar 30, 2023
1 parent 1699960 commit 3ee03c2
Show file tree
Hide file tree
Showing 4 changed files with 96 additions and 66 deletions.
15 changes: 9 additions & 6 deletions custom_components/yandex_smart_home/capability_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,8 @@ def support_random_access(self) -> bool:

return self.set_value is not None

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
if not self.retrievable:
return None

return self.float_value(super().get_value())
def get_value(self) -> float | str | None:
return RangeCapability.get_value(self)

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
Expand Down Expand Up @@ -201,3 +197,10 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
blocking=True,
context=data.context
)

@property
def _value(self) -> float | None:
if not self.retrievable:
return None

return self._convert_to_float(CustomCapability.get_value(self))
124 changes: 70 additions & 54 deletions custom_components/yandex_smart_home/capability_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,23 +94,21 @@ def parameters(self) -> dict[str, Any]:
'random_access': False,
}

def float_value(self, value: Any, strict: bool = True) -> float | None:
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
return None
def get_value(self) -> float | str | bool | None:
value = self._value

try:
return float(value)
except (ValueError, TypeError):
if strict:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Unsupported value {value!r} for instance {self.instance} of {self.state.entity_id}'
)
if self.support_random_access and value is not None:
range_min, range_max, _ = self.range
if not (range_min <= value <= range_max):
_LOGGER.debug(f'Value {value} is not in range ({range_min}, {range_max}) for instance {self.instance} '
f'of {self.state.entity_id}')
return None

return value

def get_absolute_value(self, relative_value: float) -> float:
"""Return absolute value for relative value."""
value = self.get_value()
if value is None:
if self._value is None:
if self.state.state == STATE_OFF:
raise SmartHomeError(
ERR_DEVICE_OFF,
Expand All @@ -122,7 +120,26 @@ def get_absolute_value(self, relative_value: float) -> float:
f'Unable to get current value or {self.instance} instance of {self.state.entity_id}'
)

return max(min(value + relative_value, self.range[1]), self.range[0])
return max(min(self._value + relative_value, self.range[1]), self.range[0])

@property
@abstractmethod
def _value(self) -> float | None:
"""Return the current capability value (unguarded)."""

def _convert_to_float(self, value: Any, strict: bool = True) -> float | None:
"""Return float of value, ignore some states, catch errors."""
if str(value).lower() in (STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_NONE):
return None

try:
return float(value)
except (ValueError, TypeError):
if strict:
raise SmartHomeError(
ERR_NOT_SUPPORTED_IN_CURRENT_MODE,
f'Unsupported value {value!r} for instance {self.instance} of {self.state.entity_id}'
)


@register_capability
Expand All @@ -142,10 +159,6 @@ def support_random_access(self) -> bool:
"""Test if capability supports random access."""
return True

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(cover.ATTR_CURRENT_POSITION))

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
Expand All @@ -160,6 +173,10 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
context=data.context
)

@property
def _value(self) -> float | None:
return self._convert_to_float(self.state.attributes.get(cover.ATTR_CURRENT_POSITION))


class TemperatureCapability(RangeCapability, ABC):
"""Set temperature functionality."""
Expand Down Expand Up @@ -191,10 +208,6 @@ def supported(self) -> bool:
return self.state.domain == water_heater.DOMAIN and \
features & water_heater.WaterHeaterEntityFeature.TARGET_TEMPERATURE

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(water_heater.ATTR_TEMPERATURE))

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
Expand All @@ -209,6 +222,10 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
context=data.context
)

@property
def _value(self) -> float | None:
return self._convert_to_float(self.state.attributes.get(water_heater.ATTR_TEMPERATURE))


@register_capability
class TemperatureCapabilityClimate(TemperatureCapability):
Expand All @@ -227,10 +244,6 @@ def supported(self) -> bool:

return self.state.domain == climate.DOMAIN and features & climate.ClimateEntityFeature.TARGET_TEMPERATURE

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(climate.ATTR_TEMPERATURE))

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
Expand All @@ -245,6 +258,10 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
context=data.context
)

@property
def _value(self) -> float | None:
return self._convert_to_float(self.state.attributes.get(climate.ATTR_TEMPERATURE))


class HumidityCapability(RangeCapability, ABC):
"""Set humidity functionality."""
Expand Down Expand Up @@ -275,10 +292,6 @@ def supported(self) -> bool:
"""Test if capability is supported."""
return self.state.domain == humidifier.DOMAIN

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(humidifier.ATTR_HUMIDITY))

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
Expand All @@ -293,6 +306,10 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
context=data.context
)

@property
def _value(self) -> float | None:
return self._convert_to_float(self.state.attributes.get(humidifier.ATTR_HUMIDITY))


@register_capability
class HumidityCapabilityHumidiferXiaomi(HumidityCapability):
Expand All @@ -307,10 +324,6 @@ def supported(self) -> bool:

return False

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
return self.float_value(self.state.attributes.get(ATTR_TARGET_HUMIDITY))

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value'] if not state.get('relative') else self.get_absolute_value(state['value'])
Expand All @@ -325,6 +338,10 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
context=data.context
)

@property
def _value(self) -> float | None:
return self._convert_to_float(self.state.attributes.get(ATTR_TARGET_HUMIDITY))


@register_capability
class BrightnessCapability(RangeCapability):
Expand All @@ -351,12 +368,6 @@ def support_random_access(self) -> bool:
"""Test if capability supports random access."""
return True

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS)
if brightness is not None:
return int(100 * (self.float_value(brightness) / 255))

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
if state.get('relative'):
Expand All @@ -374,6 +385,12 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
context=data.context
)

@property
def _value(self) -> float | None:
brightness = self._convert_to_float(self.state.attributes.get(light.ATTR_BRIGHTNESS))
if brightness is not None:
return int(100 * (brightness / 255))


@register_capability
class VolumeCapability(RangeCapability):
Expand Down Expand Up @@ -408,13 +425,6 @@ def support_random_access(self) -> bool:
return not (features & media_player.MediaPlayerEntityFeature.VOLUME_STEP and
not features & media_player.MediaPlayerEntityFeature.VOLUME_SET)

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
level = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL)

if level is not None:
return int(self.float_value(level) * 100)

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
if not self.support_random_access:
Expand Down Expand Up @@ -452,6 +462,12 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
context=data.context
)

@property
def _value(self) -> float | None:
level = self._convert_to_float(self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL))
if level is not None:
return int(level * 100)


@register_capability
class ChannelCapability(RangeCapability):
Expand Down Expand Up @@ -497,13 +513,6 @@ def support_random_access(self) -> bool:

return False

def get_value(self) -> float | None:
"""Return the state value of this capability for this entity."""
media_content_type = self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_TYPE)

if media_content_type == media_player.const.MEDIA_TYPE_CHANNEL:
return self.float_value(self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_ID), strict=False)

async def set_state(self, data: RequestData, state: dict[str, Any]):
"""Set device state."""
value = state['value']
Expand Down Expand Up @@ -553,3 +562,10 @@ async def set_state(self, data: RequestData, state: dict[str, Any]):
f'Please change setting "support_set_channel" to "false" in entity_config '
f'if the device does not support channel selection. Error: {e!r}'
)

@property
def _value(self) -> float | None:
media_content_type = self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_TYPE)

if media_content_type == media_player.const.MEDIA_TYPE_CHANNEL:
return self._convert_to_float(self.state.attributes.get(media_player.ATTR_MEDIA_CONTENT_ID), strict=False)
6 changes: 6 additions & 0 deletions tests/test_capability_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ async def test_capability_custom_range_random_access(hass):
assert cap.support_random_access
assert cap.get_value() == 30

for v in ['55', '5']:
hass.states.async_set(state.entity_id, v)
assert cap.get_value() is None

hass.states.async_set(state.entity_id, '30')

calls = async_mock_service(hass, 'test', 'set_value')
await cap.set_state(BASIC_DATA, {'value': 40})
await cap.set_state(BASIC_DATA, {'value': 100})
Expand Down
17 changes: 11 additions & 6 deletions tests/test_capability_range.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import patch
from unittest.mock import PropertyMock, patch

from homeassistant.components import climate, cover, humidifier, light, media_player, water_heater
from homeassistant.const import (
Expand Down Expand Up @@ -47,7 +47,8 @@ def supported(self) -> bool:
async def set_state(self, *args, **kwargs):
pass

def get_value(self):
@property
def _value(self) -> float | None:
return None

class MockCapabilityRandomAccess(MockCapability):
Expand Down Expand Up @@ -99,21 +100,25 @@ def support_random_access(self) -> bool:
}

for v in [STATE_UNAVAILABLE, STATE_UNKNOWN, 'None']:
assert cap.float_value(v) is None
assert cap._convert_to_float(v) is None

for v in ['4', '5.5']:
assert cap.float_value(v) == float(v)
assert cap._convert_to_float(v) == float(v)

with pytest.raises(SmartHomeError) as e:
assert cap.float_value('foo')
assert cap._convert_to_float('foo')
assert e.value.code == const.ERR_NOT_SUPPORTED_IN_CURRENT_MODE

with patch.object(cap, 'get_value', return_value=20):
with patch.object(MockCapability, '_value', new_callable=PropertyMock, return_value=20):
assert cap.get_absolute_value(10) == 30
assert cap.get_absolute_value(-5) == 15
assert cap.get_absolute_value(99) == 100
assert cap.get_absolute_value(-50) == 0

for v in [-1, 101]:
with patch.object(MockCapability, '_value', new_callable=PropertyMock, return_value=v):
assert cap.get_value() is None

with pytest.raises(SmartHomeError) as e:
cap.get_absolute_value(0)
assert e.value.code == const.ERR_INVALID_VALUE
Expand Down

0 comments on commit 3ee03c2

Please sign in to comment.