Skip to content

Commit d0fa451

Browse files
committed
Fix setting random value for custom capability with no range parameter (#613)
1 parent 5e2d280 commit d0fa451

File tree

7 files changed

+261
-83
lines changed

7 files changed

+261
-83
lines changed

custom_components/yandex_smart_home/capability_custom.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -268,10 +268,6 @@ def supported(self) -> bool:
268268
@property
269269
def support_random_access(self) -> bool:
270270
"""Test if the capability accept arbitrary values to be set."""
271-
for key in [CONF_ENTITY_RANGE_MIN, CONF_ENTITY_RANGE_MAX]:
272-
if key not in self._config.get(CONF_ENTITY_RANGE, {}):
273-
return False
274-
275271
return self._set_value_service_config is not None
276272

277273
async def set_instance_state(self, context: Context, state: RangeCapabilityInstanceActionState) -> None:
@@ -329,17 +325,27 @@ def _get_absolute_value(self, relative_value: float) -> float:
329325

330326
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Missing current value for {self}")
331327

328+
if not self._range:
329+
return value + relative_value
330+
332331
return max(min(value + relative_value, self._range.max), self._range.min)
333332

334333
@cached_property
335-
def _range(self) -> RangeCapabilityRange:
334+
def _range(self) -> RangeCapabilityRange | None:
336335
"""Return supporting value range."""
336+
instance_default_range = super()._range
337+
range_config = self._config.get(CONF_ENTITY_RANGE, {})
338+
339+
if not range_config:
340+
if instance_default_range:
341+
return instance_default_range
342+
343+
return None
344+
337345
return RangeCapabilityRange(
338-
min=self._config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MIN, super()._range.min),
339-
max=self._config.get(CONF_ENTITY_RANGE, {}).get(CONF_ENTITY_RANGE_MAX, super()._range.max),
340-
precision=self._config.get(CONF_ENTITY_RANGE, {}).get(
341-
CONF_ENTITY_RANGE_PRECISION, super()._range.precision
342-
),
346+
min=range_config.get(CONF_ENTITY_RANGE_MIN, 0),
347+
max=range_config.get(CONF_ENTITY_RANGE_MAX, 100),
348+
precision=range_config.get(CONF_ENTITY_RANGE_PRECISION, 1),
343349
)
344350

345351
@property

custom_components/yandex_smart_home/capability_range.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,26 +90,23 @@ def retrievable(self) -> bool:
9090
@property
9191
def parameters(self) -> RangeCapabilityParameters:
9292
"""Return parameters for a devices list request."""
93-
if self.support_random_access:
94-
return RangeCapabilityParameters(instance=self.instance, random_access=True, range=self._range)
95-
96-
if self.instance in [
93+
if self.instance in (
9794
RangeCapabilityInstance.BRIGHTNESS,
9895
RangeCapabilityInstance.HUMIDITY,
9996
RangeCapabilityInstance.OPEN,
10097
RangeCapabilityInstance.TEMPERATURE,
101-
]:
98+
) or (self._range and self.support_random_access):
10299
return RangeCapabilityParameters(
103100
instance=self.instance, random_access=self.support_random_access, range=self._range
104101
)
105102

106-
return RangeCapabilityParameters(instance=self.instance, random_access=False)
103+
return RangeCapabilityParameters(instance=self.instance, random_access=self.support_random_access)
107104

108105
def get_value(self) -> float | None:
109106
"""Return the current capability value."""
110107
value = self._get_value()
111108

112-
if self.support_random_access and value is not None:
109+
if self.support_random_access and value is not None and self._range:
113110
if not (self._range.min <= value <= self._range.max):
114111
_LOGGER.debug(
115112
f"Value {value} is not in range {self._range} for instance {self.instance.value} "
@@ -137,9 +134,15 @@ def _get_service_call_value(self, state: RangeCapabilityInstanceActionState) ->
137134
return state.value
138135

139136
@cached_property
140-
def _range(self) -> RangeCapabilityRange:
137+
def _range(self) -> RangeCapabilityRange | None:
141138
"""Return supporting value range."""
142-
return RangeCapabilityRange(min=0, max=100, precision=1)
139+
match self.instance:
140+
case RangeCapabilityInstance.HUMIDITY | RangeCapabilityInstance.OPEN | RangeCapabilityInstance.TEMPERATURE:
141+
return RangeCapabilityRange(min=0, max=100, precision=1)
142+
case RangeCapabilityInstance.BRIGHTNESS:
143+
return RangeCapabilityRange(min=1, max=100, precision=1)
144+
145+
return None
143146

144147
def _convert_to_float(self, value: Any, strict: bool = True) -> float | None:
145148
"""Return float of a value, ignore some states, catch errors."""
@@ -168,6 +171,9 @@ def _get_absolute_value(self, relative_value: float) -> float:
168171

169172
raise APIError(ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE, f"Missing current value for {self}")
170173

174+
if not self._range:
175+
return value + relative_value
176+
171177
return max(min(value + relative_value, self._range.max), self._range.min)
172178

173179

@@ -390,11 +396,6 @@ def _get_value(self) -> float | None:
390396

391397
return None
392398

393-
@cached_property
394-
def _range(self) -> RangeCapabilityRange:
395-
"""Return supporting value range."""
396-
return RangeCapabilityRange(min=1, max=100, precision=1)
397-
398399

399400
class WhiteLightBrightnessCapability(StateRangeCapability, LightState):
400401
"""Capability to control white brightness and cold white brightness of a RGBW/RGBWW light device."""
@@ -692,11 +693,7 @@ def _get_value(self) -> float | None:
692693
@cached_property
693694
def _range(self) -> RangeCapabilityRange:
694695
"""Return supporting value range."""
695-
return RangeCapabilityRange(
696-
min=0,
697-
max=999,
698-
precision=1,
699-
)
696+
return RangeCapabilityRange(min=0, max=999, precision=1)
700697

701698

702699
class ValvePositionCapability(StateRangeCapability):

custom_components/yandex_smart_home/schema/capability_range.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,15 +70,25 @@ def compute_unit(cls, values: dict[str, Any]) -> dict[str, Any]:
7070
def validate_range(cls, values: dict[str, Any]) -> dict[str, Any]:
7171
"""Force range boundaries for a capability instance."""
7272

73-
r: RangeCapabilityRange | None
74-
if r := values.get("range"):
75-
match values.get("instance"):
73+
instance: RangeCapabilityInstance | None = values.get("instance")
74+
r: RangeCapabilityRange | None = values.get("range")
75+
76+
if r:
77+
match instance:
7678
case RangeCapabilityInstance.HUMIDITY | RangeCapabilityInstance.OPEN:
7779
r.min, r.max = max([0.0, r.min]), min([100.0, r.max])
7880
case RangeCapabilityInstance.BRIGHTNESS:
7981
r.min = max(min(r.min, 1.0), 0.0)
8082
r.max = 100.0
8183
r.precision = 1.0
84+
else:
85+
if instance in (
86+
RangeCapabilityInstance.BRIGHTNESS,
87+
RangeCapabilityInstance.HUMIDITY,
88+
RangeCapabilityInstance.OPEN,
89+
RangeCapabilityInstance.TEMPERATURE,
90+
):
91+
raise ValueError(f"range field required for {instance}")
8292

8393
return values
8494

docs/advanced/capabilities/range.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
## Параметры { id=settings }
55

6-
* `set_value`: Действие выполняемое при установке абсолютного значения функции. В переменной `value` абсолютное или относительное значение (в зависимости от настроек `range` и наличия `increase_value` и `decrease_value`).
6+
* `set_value`: Действие, выполняемое при установке абсолютного значения функции ("Алиса, температура Х").
77
Если не задано - установка абсолютного значения поддерживаться не будет.
88

99
!!! example "Пример"
@@ -16,8 +16,8 @@
1616
```
1717

1818
* `increase_value` и `decrease_value`: Действия, вызываемые при относительной регулировке (кнопки `+` и `-` и "Алиса, убавь температуру"). Если не заданы - будет вызываться действие `set_value`.
19-
* `range`: Граничные значения диапазона. Для `humidity`, `open`, `brightness` есть ограничение: минимум `0`, максимум `100`.
20-
Если не задать `min` и `max` регулировка будет только относительная (в переменной `value` - `1` или `-1`).
19+
* `range`: Допустимый диапазон, в котором может лежать значение функции. Для `humidity`, `open`, `brightness` есть ограничение: минимум `0`, максимум `100`.
20+
Параметр является необязательным, его наличие влияет на визуальный стиль элемента управления в приложении и поведение команд убавь/прибавь.
2121

2222
!!! example "Пример"
2323
```yaml

tests/test_capability_custom.py

Lines changed: 116 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,9 @@ async def test_capability_custom_toggle(hass: HomeAssistant, entry_data: MockCon
542542
assert cap.new_with_value(v3).get_value() is False
543543

544544

545-
async def test_capability_custom_range_random_access(hass: HomeAssistant, entry_data: MockConfigEntryData) -> None:
545+
async def test_capability_custom_range_random_access_with_range(
546+
hass: HomeAssistant, entry_data: MockConfigEntryData
547+
) -> None:
546548
state = State("switch.test", "30", {})
547549
hass.states.async_set(state.entity_id, state.state)
548550
cap = cast(
@@ -566,14 +568,19 @@ async def test_capability_custom_range_random_access(hass: HomeAssistant, entry_
566568
),
567569
},
568570
CapabilityType.RANGE,
569-
RangeCapabilityInstance.OPEN,
571+
RangeCapabilityInstance.VOLUME,
570572
"foo",
571573
),
572574
)
573575
assert cap.supported is True
574576
assert cap.retrievable is True
575577
assert cap.reportable is True
576578
assert cap.support_random_access is True
579+
assert cap.parameters.as_dict() == {
580+
"instance": "volume",
581+
"random_access": True,
582+
"range": {"min": 10, "max": 50, "precision": 3},
583+
}
577584
assert cap.get_value() == 30
578585

579586
for v in ["55", "5"]:
@@ -586,7 +593,7 @@ async def test_capability_custom_range_random_access(hass: HomeAssistant, entry_
586593
for value, relative in ((40, False), (100, False), (10, True), (-3, True), (-50, True)):
587594
await cap.set_instance_state(
588595
Context(),
589-
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.OPEN, value=value, relative=relative),
596+
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.VOLUME, value=value, relative=relative),
590597
)
591598

592599
assert len(calls) == 5
@@ -606,7 +613,7 @@ async def test_capability_custom_range_random_access(hass: HomeAssistant, entry_
606613
with pytest.raises(APIError) as e:
607614
await cap.set_instance_state(
608615
Context(),
609-
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.OPEN, value=10, relative=True),
616+
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.VOLUME, value=10, relative=True),
610617
)
611618
assert e.value.code == ResponseCode.DEVICE_OFF
612619
assert e.value.message == "Device switch.test probably turned off"
@@ -615,21 +622,81 @@ async def test_capability_custom_range_random_access(hass: HomeAssistant, entry_
615622
with pytest.raises(APIError) as e:
616623
await cap.set_instance_state(
617624
Context(),
618-
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.OPEN, value=10, relative=True),
625+
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.VOLUME, value=10, relative=True),
619626
)
620627
assert e.value.code == ResponseCode.NOT_SUPPORTED_IN_CURRENT_MODE
621-
assert e.value.message == "Missing current value for instance open of range capability of foo"
628+
assert e.value.message == "Missing current value for instance volume of range capability of foo"
622629

623630
hass.states.async_remove(state.entity_id)
624631
with pytest.raises(APIError) as e:
625632
await cap.set_instance_state(
626633
Context(),
627-
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.OPEN, value=10, relative=True),
634+
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.VOLUME, value=10, relative=True),
628635
)
629636
assert e.value.code == ResponseCode.DEVICE_OFF
630637
assert e.value.message == "Entity switch.test not found"
631638

632639

640+
async def test_capability_custom_range_random_access_no_range(
641+
hass: HomeAssistant, entry_data: MockConfigEntryData
642+
) -> None:
643+
state = State("switch.test", "30", {})
644+
hass.states.async_set(state.entity_id, state.state)
645+
cap = cast(
646+
CustomRangeCapability,
647+
get_custom_capability(
648+
hass,
649+
entry_data,
650+
{
651+
CONF_ENTITY_CUSTOM_CAPABILITY_STATE_ENTITY_ID: state.entity_id,
652+
CONF_ENTITY_CUSTOM_RANGE_SET_VALUE: SERVICE_SCHEMA(
653+
{
654+
CONF_SERVICE: "test.set_value",
655+
ATTR_ENTITY_ID: "input_number.test",
656+
CONF_SERVICE_DATA: {"value": dynamic_template("value: {{ value|int }}")},
657+
}
658+
),
659+
},
660+
CapabilityType.RANGE,
661+
RangeCapabilityInstance.VOLUME,
662+
"foo",
663+
),
664+
)
665+
assert cap.supported is True
666+
assert cap.retrievable is True
667+
assert cap.reportable is True
668+
assert cap.support_random_access is True
669+
assert cap.parameters.as_dict() == {
670+
"instance": "volume",
671+
"random_access": True,
672+
}
673+
assert cap.get_value() == 30
674+
675+
for v in [55, 5]:
676+
hass.states.async_set(state.entity_id, str(v))
677+
assert cap.get_value() == v
678+
679+
hass.states.async_set(state.entity_id, "30")
680+
681+
calls = async_mock_service(hass, "test", "set_value")
682+
for value, relative in ((40, False), (100, False), (10, True), (-3, True), (-50, True), (100, True)):
683+
await cap.set_instance_state(
684+
Context(),
685+
RangeCapabilityInstanceActionState(instance=RangeCapabilityInstance.VOLUME, value=value, relative=relative),
686+
)
687+
688+
assert len(calls) == 6
689+
for i in range(0, len(calls)):
690+
assert calls[i].data[ATTR_ENTITY_ID] == ["input_number.test"]
691+
692+
assert calls[0].data["value"] == "value: 40"
693+
assert calls[1].data["value"] == "value: 100"
694+
assert calls[2].data["value"] == "value: 40"
695+
assert calls[3].data["value"] == "value: 27"
696+
assert calls[4].data["value"] == "value: -20"
697+
assert calls[5].data["value"] == "value: 130"
698+
699+
633700
async def test_capability_custom_range_random_access_no_state(
634701
hass: HomeAssistant, entry_data: MockConfigEntryData
635702
) -> None:
@@ -664,6 +731,12 @@ async def test_capability_custom_range_random_access_no_state(
664731
assert cap.retrievable is False
665732
assert cap.reportable is False
666733
assert cap.support_random_access is True
734+
assert cap.parameters.as_dict() == {
735+
"instance": "open",
736+
"random_access": True,
737+
"range": {"min": 10, "max": 50, "precision": 3},
738+
"unit": "unit.percent",
739+
}
667740
assert cap.get_value() is None
668741

669742
calls = async_mock_service(hass, "test", "set_value")
@@ -865,7 +938,7 @@ async def test_capability_custom_range_no_service(hass: HomeAssistant, entry_dat
865938
[RangeCapabilityInstance.OPEN, (-10, 150), (0, 100)],
866939
],
867940
)
868-
async def test_capability_custom_range_parameters_range(
941+
async def test_capability_custom_range_limits(
869942
hass: HomeAssistant,
870943
entry_data: MockConfigEntryData,
871944
instance: RangeCapabilityInstance,
@@ -892,3 +965,38 @@ async def test_capability_custom_range_parameters_range(
892965
assert cap.supported is True
893966
assert cap.parameters.range
894967
assert (cap.parameters.range.min, cap.parameters.range.max) == expected_range
968+
969+
970+
@pytest.mark.parametrize(
971+
"instance,range_expected",
972+
[
973+
(RangeCapabilityInstance.BRIGHTNESS, True),
974+
(RangeCapabilityInstance.CHANNEL, False),
975+
(RangeCapabilityInstance.HUMIDITY, True),
976+
(RangeCapabilityInstance.OPEN, True),
977+
(RangeCapabilityInstance.TEMPERATURE, True),
978+
(RangeCapabilityInstance.VOLUME, False),
979+
],
980+
)
981+
async def test_capability_custom_range_requirement(
982+
hass: HomeAssistant,
983+
entry_data: MockConfigEntryData,
984+
instance: RangeCapabilityInstance,
985+
range_expected: bool,
986+
) -> None:
987+
cap = cast(
988+
CustomRangeCapability,
989+
get_custom_capability(
990+
hass,
991+
entry_data,
992+
{},
993+
CapabilityType.RANGE,
994+
instance,
995+
"foo",
996+
),
997+
)
998+
assert cap.supported is True
999+
if range_expected:
1000+
assert cap.parameters.range is not None
1001+
else:
1002+
assert cap.parameters.range is None

0 commit comments

Comments
 (0)