Skip to content

Commit 631cfc5

Browse files
authored
Merge pull request #84 from graphras-com/fix/75-normalize-range-validation
fix: normalize range validation for light, cover, and valve domains
2 parents 1d94187 + 06ca7dd commit 631cfc5

5 files changed

Lines changed: 173 additions & 11 deletions

File tree

src/haclient/domains/_utils.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Shared validation helpers for domain action methods."""
2+
3+
from __future__ import annotations
4+
5+
6+
def validate_range(value: int, lo: int, hi: int, name: str) -> int:
7+
"""Coerce *value* to ``int`` and raise if it falls outside ``[lo, hi]``.
8+
9+
Parameters
10+
----------
11+
value : int
12+
The raw value supplied by the caller.
13+
lo : int
14+
Inclusive lower bound.
15+
hi : int
16+
Inclusive upper bound.
17+
name : str
18+
Human-readable parameter name used in the error message.
19+
20+
Returns
21+
-------
22+
int
23+
The coerced integer, guaranteed to satisfy ``lo <= result <= hi``.
24+
25+
Raises
26+
------
27+
ValueError
28+
If the coerced value is less than *lo* or greater than *hi*.
29+
30+
Examples
31+
--------
32+
>>> validate_range(128, 0, 255, "brightness")
33+
128
34+
>>> validate_range(300, 0, 255, "brightness")
35+
Traceback (most recent call last):
36+
...
37+
ValueError: brightness must be between 0 and 255, got 300
38+
"""
39+
coerced = int(value)
40+
if not lo <= coerced <= hi:
41+
raise ValueError(f"{name} must be between {lo} and {hi}, got {coerced}")
42+
return coerced

src/haclient/domains/cover.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from haclient.core.plugins import DomainSpec, register_domain
6+
from haclient.domains._utils import validate_range
67
from haclient.entity.base import Entity, ValueChangeHandler
78

89

@@ -103,10 +104,18 @@ async def set_position(self, position: int) -> None:
103104
Parameters
104105
----------
105106
position : int
106-
Target position, clamped/coerced to ``int``. ``0`` is fully
107+
Target position in the range 0--100. ``0`` is fully
107108
closed; ``100`` is fully open.
109+
110+
Raises
111+
------
112+
ValueError
113+
If *position* is outside the 0--100 range.
108114
"""
109-
await self._call_service("set_cover_position", {"position": int(position)})
115+
await self._call_service(
116+
"set_cover_position",
117+
{"position": validate_range(position, 0, 100, "position")},
118+
)
110119

111120
async def toggle(self) -> None:
112121
"""Toggle open/close state."""

src/haclient/domains/light.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import Any
66

77
from haclient.core.plugins import DomainSpec, register_domain
8+
from haclient.domains._utils import validate_range
89
from haclient.entity.base import Entity, ValueChangeHandler
910

1011

@@ -151,12 +152,17 @@ async def set_brightness(self, brightness: int, *, transition: float | None = No
151152
Parameters
152153
----------
153154
brightness : int
154-
New brightness value, coerced to ``int``. ``0`` turns the
155+
New brightness value in the range 0--255. ``0`` turns the
155156
light off; ``255`` is full brightness.
156157
transition : float or None, optional
157158
Seconds for the transition. Forwarded to HA when set.
159+
160+
Raises
161+
------
162+
ValueError
163+
If *brightness* is outside the 0--255 range.
158164
"""
159-
data: dict[str, Any] = {"brightness": int(brightness)}
165+
data: dict[str, Any] = {"brightness": validate_range(brightness, 0, 255, "brightness")}
160166
if transition is not None:
161167
data["transition"] = transition
162168
await self._call_service("turn_on", data)
@@ -189,15 +195,25 @@ async def set_rgb(
189195
Parameters
190196
----------
191197
r : int
192-
Red component (0-255).
198+
Red component (0--255).
193199
g : int
194-
Green component (0-255).
200+
Green component (0--255).
195201
b : int
196-
Blue component (0-255).
202+
Blue component (0--255).
197203
transition : float or None, optional
198204
Seconds for the transition. Forwarded to HA when set.
205+
206+
Raises
207+
------
208+
ValueError
209+
If any component is outside the 0--255 range.
199210
"""
200-
data: dict[str, Any] = {"rgb_color": [r, g, b]}
211+
rgb = [
212+
validate_range(r, 0, 255, "r"),
213+
validate_range(g, 0, 255, "g"),
214+
validate_range(b, 0, 255, "b"),
215+
]
216+
data: dict[str, Any] = {"rgb_color": rgb}
201217
if transition is not None:
202218
data["transition"] = transition
203219
await self._call_service("turn_on", data)

src/haclient/domains/valve.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66

77
from haclient.core.plugins import DomainSpec, register_domain
8+
from haclient.domains._utils import validate_range
89
from haclient.entity.base import Entity, ValueChangeHandler
910

1011
_LOGGER = logging.getLogger(__name__)
@@ -176,23 +177,31 @@ async def set_position(self, position: int) -> None:
176177
Parameters
177178
----------
178179
position : int
179-
Target position (``0`` = fully closed, ``100`` = fully
180-
open), coerced to ``int``.
180+
Target position in the range 0--100 (``0`` = fully closed,
181+
``100`` = fully open).
182+
183+
Raises
184+
------
185+
ValueError
186+
If *position* is outside the 0--100 range.
181187
182188
Notes
183189
-----
184190
Degrades safely: if the valve does not advertise the
185191
``SET_POSITION`` feature (e.g. a binary water shutoff), this
186192
method logs a debug message and returns without raising.
187193
Callers can pre-check with `supports_set_position`.
194+
Range validation always runs, even when the feature is absent,
195+
so callers receive an immediate error for clearly invalid input.
188196
"""
197+
value = validate_range(position, 0, 100, "position")
189198
if not self.supports_set_position:
190199
_LOGGER.debug(
191200
"set_position() unsupported for %s; skipping (no ValveEntityFeature.SET_POSITION)",
192201
self.entity_id,
193202
)
194203
return
195-
await self._call_service("set_valve_position", {"position": int(position)})
204+
await self._call_service("set_valve_position", {"position": value})
196205

197206
async def toggle(self) -> None:
198207
"""Toggle open/close state."""

tests/test_domains.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1581,3 +1581,89 @@ def _dir(old: Any, new: Any) -> None:
15811581
assert turned_off == [("on", "off")]
15821582
assert speed == [(0, 50), (50, 75), (75, 0)]
15831583
assert direction == [("forward", "reverse"), ("reverse", "forward")]
1584+
1585+
1586+
# ---------------------------------------------------------------------------
1587+
# Range validation — issue #75
1588+
# ---------------------------------------------------------------------------
1589+
1590+
1591+
async def test_light_set_brightness_validation(client: HAClient, fake_ha: FakeHA) -> None:
1592+
"""``set_brightness`` enforces the 0-255 range."""
1593+
light = client.light("kitchen")
1594+
1595+
with pytest.raises(ValueError, match="brightness"):
1596+
await light.set_brightness(-1)
1597+
with pytest.raises(ValueError, match="brightness"):
1598+
await light.set_brightness(256)
1599+
1600+
# Boundary values are accepted.
1601+
await light.set_brightness(0)
1602+
await light.set_brightness(255)
1603+
services = [c["service"] for c in fake_ha.ws_service_calls]
1604+
assert services == ["turn_on", "turn_on"]
1605+
assert fake_ha.ws_service_calls[0]["service_data"]["brightness"] == 0
1606+
assert fake_ha.ws_service_calls[1]["service_data"]["brightness"] == 255
1607+
1608+
1609+
async def test_light_set_rgb_validation(client: HAClient, fake_ha: FakeHA) -> None:
1610+
"""``set_rgb`` enforces the 0-255 range on each component."""
1611+
light = client.light("kitchen")
1612+
1613+
with pytest.raises(ValueError, match="r"):
1614+
await light.set_rgb(-1, 0, 0)
1615+
with pytest.raises(ValueError, match="r"):
1616+
await light.set_rgb(256, 0, 0)
1617+
with pytest.raises(ValueError, match="g"):
1618+
await light.set_rgb(0, -1, 0)
1619+
with pytest.raises(ValueError, match="g"):
1620+
await light.set_rgb(0, 256, 0)
1621+
with pytest.raises(ValueError, match="b"):
1622+
await light.set_rgb(0, 0, -1)
1623+
with pytest.raises(ValueError, match="b"):
1624+
await light.set_rgb(0, 0, 256)
1625+
1626+
# Boundary values are accepted.
1627+
await light.set_rgb(0, 0, 0)
1628+
await light.set_rgb(255, 255, 255)
1629+
assert fake_ha.ws_service_calls[0]["service_data"]["rgb_color"] == [0, 0, 0]
1630+
assert fake_ha.ws_service_calls[1]["service_data"]["rgb_color"] == [255, 255, 255]
1631+
1632+
1633+
async def test_cover_set_position_validation(client: HAClient, fake_ha: FakeHA) -> None:
1634+
"""``set_position`` enforces the 0-100 range."""
1635+
cv = client.cover("garage")
1636+
1637+
with pytest.raises(ValueError, match="position"):
1638+
await cv.set_position(-1)
1639+
with pytest.raises(ValueError, match="position"):
1640+
await cv.set_position(101)
1641+
1642+
# Boundary values are accepted.
1643+
await cv.set_position(0)
1644+
await cv.set_position(100)
1645+
services = [c["service"] for c in fake_ha.ws_service_calls]
1646+
assert services == ["set_cover_position", "set_cover_position"]
1647+
assert fake_ha.ws_service_calls[0]["service_data"]["position"] == 0
1648+
assert fake_ha.ws_service_calls[1]["service_data"]["position"] == 100
1649+
1650+
1651+
async def test_valve_set_position_validation(client: HAClient, fake_ha: FakeHA) -> None:
1652+
"""``set_position`` enforces the 0-100 range even when the feature is absent."""
1653+
valve = client.valve("main_water")
1654+
1655+
# Range validation fires regardless of feature support.
1656+
valve._apply_state({"state": "open", "attributes": {"supported_features": 0}})
1657+
with pytest.raises(ValueError, match="position"):
1658+
await valve.set_position(-1)
1659+
with pytest.raises(ValueError, match="position"):
1660+
await valve.set_position(101)
1661+
1662+
# With feature support, boundary values are accepted and dispatched.
1663+
valve._apply_state({"state": "open", "attributes": {"supported_features": 15}})
1664+
await valve.set_position(0)
1665+
await valve.set_position(100)
1666+
services = [c["service"] for c in fake_ha.ws_service_calls]
1667+
assert services == ["set_valve_position", "set_valve_position"]
1668+
assert fake_ha.ws_service_calls[0]["service_data"]["position"] == 0
1669+
assert fake_ha.ws_service_calls[1]["service_data"]["position"] == 100

0 commit comments

Comments
 (0)