diff --git a/mypy.ini b/mypy.ini index b3223739..b07280d8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -212,3 +212,25 @@ disallow_untyped_defs = true no_implicit_optional = true warn_return_any = true warn_unreachable = true + +[mypy-pytradfri.device.light] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + +[mypy-pytradfri.device.light_control] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true diff --git a/pyproject.toml b/pyproject.toml index e7db09bb..e4b1e217 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ +[tool.black] +target-version = ["py38", "py39", "py310"] + [tool.isort] # https://github.com/PyCQA/isort/wiki/isort-Settings profile = "black" diff --git a/pytradfri/color.py b/pytradfri/color.py index 689b71f2..0bbf068c 100644 --- a/pytradfri/color.py +++ b/pytradfri/color.py @@ -1,14 +1,12 @@ """Test Color.""" from __future__ import annotations +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from .device.light import LightResponse + from .const import ( - ATTR_LIGHT_COLOR_HEX, - ATTR_LIGHT_COLOR_HUE, - ATTR_LIGHT_COLOR_SATURATION, - ATTR_LIGHT_COLOR_X as X, - ATTR_LIGHT_COLOR_Y as Y, - ATTR_LIGHT_DIMMER, - ATTR_LIGHT_MIREDS, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_HEX_COLOR, @@ -44,28 +42,28 @@ COLORS = {name.lower().replace(" ", "_"): hex for hex, name in COLOR_NAMES.items()} -def supported_features(data: dict[str, str | int]) -> int: +def supported_features(data: LightResponse) -> int: """Return supported features.""" supported_color_features = 0 - if ATTR_LIGHT_DIMMER in data: + if data.dimmer: supported_color_features = supported_color_features + SUPPORT_BRIGHTNESS - if ATTR_LIGHT_COLOR_HEX in data: + if data.color_hex: supported_color_features = supported_color_features + SUPPORT_HEX_COLOR - if ATTR_LIGHT_MIREDS in data: + if data.color_mireds: supported_color_features = supported_color_features + SUPPORT_COLOR_TEMP - if X in data and Y in data: + if data.color_xy_x and data.color_xy_y: supported_color_features = supported_color_features + SUPPORT_XY_COLOR if ( - ATTR_LIGHT_MIREDS not in data - and X in data - and Y in data - and ATTR_LIGHT_COLOR_SATURATION in data - and ATTR_LIGHT_COLOR_HUE in data + data.color_mireds is None + and data.color_xy_x is not None + and data.color_xy_y is not None + and data.color_saturation is not None + and data.color_hue is not None ): supported_color_features = supported_color_features + SUPPORT_RGB_COLOR diff --git a/pytradfri/device/__init__.py b/pytradfri/device/__init__.py index 04862e8f..28e1bab3 100644 --- a/pytradfri/device/__init__.py +++ b/pytradfri/device/__init__.py @@ -24,6 +24,7 @@ ROOT_DEVICES, ROOT_SIGNAL_REPEATER, ) +from ..device.light import LightResponse from ..resource import ApiResource, ApiResourceResponse from .air_purifier_control import AirPurifierControl, AirPurifierResponse from .blind_control import BlindControl, BlindResponse @@ -56,7 +57,7 @@ class DeviceResponse(ApiResourceResponse): blind_control: Optional[List[BlindResponse]] = Field(alias=ATTR_START_BLINDS) device_info: DeviceInfoResponse = Field(alias=ATTR_DEVICE_INFO) last_seen: Optional[int] = Field(alias=ATTR_LAST_SEEN) - light_control: Optional[List[Dict[str, Any]]] = Field(alias=ATTR_LIGHT_CONTROL) + light_control: Optional[List[LightResponse]] = Field(alias=ATTR_LIGHT_CONTROL) reachable: int = Field(alias=ATTR_REACHABLE_STATE) signal_repeater_control: Optional[List[Dict[str, Any]]] = Field( alias=ROOT_SIGNAL_REPEATER diff --git a/pytradfri/device/light.py b/pytradfri/device/light.py index beaea8b0..7a0b536d 100644 --- a/pytradfri/device/light.py +++ b/pytradfri/device/light.py @@ -1,11 +1,14 @@ """Represent a light.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Optional + +from pydantic import BaseModel, Field from ..color import supported_features from ..const import ( ATTR_DEVICE_STATE, + ATTR_ID, ATTR_LIGHT_COLOR_HEX, ATTR_LIGHT_COLOR_HUE, ATTR_LIGHT_COLOR_SATURATION, @@ -19,6 +22,24 @@ SUPPORT_XY_COLOR, ) +if TYPE_CHECKING: + # avoid cyclic import at runtime. + from . import Device + + +class LightResponse(BaseModel): + """Represent API response for a blind.""" + + color_mireds: Optional[int] = Field(alias=ATTR_LIGHT_MIREDS) + color_hex: Optional[str] = Field(alias=ATTR_LIGHT_COLOR_HEX) + color_xy_x: Optional[int] = Field(alias=ATTR_LIGHT_COLOR_X) + color_xy_y: Optional[int] = Field(alias=ATTR_LIGHT_COLOR_Y) + color_hue: Optional[int] = Field(alias=ATTR_LIGHT_COLOR_HUE) + color_saturation: Optional[int] = Field(alias=ATTR_LIGHT_COLOR_SATURATION) + dimmer: int = Field(alias=ATTR_LIGHT_DIMMER) + id: int = Field(alias=ATTR_ID) + state: int = Field(alias=ATTR_DEVICE_STATE) + class Light: """Represent a light. @@ -27,69 +48,83 @@ class Light: pdf """ - def __init__(self, device, index): + def __init__(self, device: Device, index: int): """Create object of class.""" self.device = device self.index = index @property - def supported_features(self): + def supported_features(self) -> int: """Return supported features.""" return supported_features(self.raw) @property - def state(self): + def state(self) -> bool: """Return device state.""" - return self.raw.get(ATTR_DEVICE_STATE) == 1 + return self.raw.state == 1 @property - def dimmer(self): + def dimmer(self) -> int | None: """Return dimmer if present.""" if self.supported_features & SUPPORT_BRIGHTNESS: - return self.raw.get(ATTR_LIGHT_DIMMER) + return self.raw.dimmer return None @property - def color_temp(self): + def color_temp(self) -> int | None: """Return color temperature.""" - if self.supported_features & SUPPORT_COLOR_TEMP: - if self.raw.get(ATTR_LIGHT_MIREDS) != 0: - return self.raw.get(ATTR_LIGHT_MIREDS) + if self.supported_features & SUPPORT_COLOR_TEMP and self.raw.color_mireds: + return self.raw.color_mireds return None @property - def hex_color(self): + def hex_color(self) -> str | None: """Return hex color.""" if self.supported_features & SUPPORT_HEX_COLOR: - return self.raw.get(ATTR_LIGHT_COLOR_HEX) + return self.raw.color_hex return None @property - def xy_color(self): + def xy_color(self) -> tuple[int, int] | None: """Return xy color.""" - if self.supported_features & SUPPORT_XY_COLOR: - return (self.raw.get(ATTR_LIGHT_COLOR_X), self.raw.get(ATTR_LIGHT_COLOR_Y)) + if ( + self.supported_features & SUPPORT_XY_COLOR + and self.raw.color_xy_x is not None + and self.raw.color_xy_y is not None + ): + return (self.raw.color_xy_x, self.raw.color_xy_y) return None @property - def hsb_xy_color(self): + def hsb_xy_color( + self, + ) -> tuple[int, int, int, int, int] | None: """Return hsb xy color.""" - return ( - self.raw.get(ATTR_LIGHT_COLOR_HUE), - self.raw.get(ATTR_LIGHT_COLOR_SATURATION), - self.raw.get(ATTR_LIGHT_DIMMER), - self.raw.get(ATTR_LIGHT_COLOR_X), - self.raw.get(ATTR_LIGHT_COLOR_Y), - ) + if ( + self.raw.color_hue is not None + and self.raw.color_saturation is not None + and self.raw.dimmer is not None + and self.raw.color_xy_x is not None + and self.raw.color_xy_y is not None + ): + return ( + self.raw.color_hue, + self.raw.color_saturation, + self.raw.dimmer, + self.raw.color_xy_x, + self.raw.color_xy_y, + ) + + return None @property - def raw(self) -> dict[str, Any]: + def raw(self) -> LightResponse: """Return raw data that it represents.""" light_control_response = self.device.raw.light_control assert light_control_response is not None return light_control_response[self.index] - def __repr__(self): + def __repr__(self) -> str: """Return representation of class object.""" state = "on" if self.state else "off" return ( diff --git a/pytradfri/device/light_control.py b/pytradfri/device/light_control.py index bbaa0c3d..8707e554 100644 --- a/pytradfri/device/light_control.py +++ b/pytradfri/device/light_control.py @@ -1,7 +1,8 @@ """Class to control the lights.""" from __future__ import annotations -from typing import Any +from collections.abc import Mapping +from typing import TYPE_CHECKING from ..color import COLORS from ..command import Command @@ -25,32 +26,36 @@ ) from ..error import ColorError from .base_controller import BaseController -from .light import Light +from .light import Light, LightResponse + +if TYPE_CHECKING: + # avoid cyclic import at runtime. + from . import Device class LightControl(BaseController): """Class to control the lights.""" - def __init__(self, device): + def __init__(self, device: Device) -> None: """Create object of class.""" super().__init__(device) - self.can_set_dimmer = None - self.can_set_temp = None - self.can_set_xy = None - self.can_set_color = None - self.can_combine_commands = None + self.can_set_dimmer: bool = False + self.can_set_temp: bool = False + self.can_set_xy: bool = False + self.can_set_color: bool = False + self.can_combine_commands: bool = False - if ATTR_LIGHT_DIMMER in self.raw[0]: + if ATTR_LIGHT_DIMMER in self.raw[0].dict(): self.can_set_dimmer = True - if ATTR_LIGHT_MIREDS in self.raw[0]: + if ATTR_LIGHT_MIREDS in self.raw[0].dict(): self.can_set_temp = True - if ATTR_LIGHT_COLOR_X in self.raw[0]: + if ATTR_LIGHT_COLOR_X in self.raw[0].dict(): self.can_set_xy = True - if ATTR_LIGHT_COLOR_HUE in self.raw[0]: + if ATTR_LIGHT_COLOR_HUE in self.raw[0].dict(): self.can_set_color = True # Currently uncertain which bulbs are capable of setting @@ -70,22 +75,24 @@ def __init__(self, device): self.max_saturation = RANGE_SATURATION[1] @property - def raw(self) -> list[dict[str, Any]]: + def raw(self) -> list[LightResponse]: """Return raw data that it represents.""" light_control_response = self._device.raw.light_control assert light_control_response is not None return light_control_response @property - def lights(self): + def lights(self) -> list[Light]: """Return light objects of the light control.""" return [Light(self._device, i) for i in range(len(self.raw))] - def set_state(self, state, *, index=0): + def set_state(self, state: int, *, index: int = 0) -> Command: """Set state of a light.""" - return self.set_values({ATTR_DEVICE_STATE: int(state)}, index=index) + return self.set_values({ATTR_DEVICE_STATE: state}, index=index) - def set_dimmer(self, dimmer, *, index=0, transition_time=None): + def set_dimmer( + self, dimmer: int, *, index: int = 0, transition_time: int | None = None + ) -> Command: """Set dimmer value of a light. transition_time: Integer representing tenth of a second (default None) @@ -99,7 +106,9 @@ def set_dimmer(self, dimmer, *, index=0, transition_time=None): return self.set_values(values, index=index) - def set_color_temp(self, color_temp, *, index=0, transition_time=None): + def set_color_temp( + self, color_temp: int, *, index: int = 0, transition_time: int | None = None + ) -> Command: """Set color temp a light.""" self._value_validate(color_temp, RANGE_MIREDS, "Color temperature") @@ -110,9 +119,11 @@ def set_color_temp(self, color_temp, *, index=0, transition_time=None): return self.set_values(values, index=index) - def set_hex_color(self, color, *, index=0, transition_time=None): + def set_hex_color( + self, color: str, *, index: int = 0, transition_time: int | None = None + ) -> Command: """Set hex color of the light.""" - values = { + values: dict[str, str | int] = { ATTR_LIGHT_COLOR_HEX: color, } @@ -121,12 +132,22 @@ def set_hex_color(self, color, *, index=0, transition_time=None): return self.set_values(values, index=index) - def set_xy_color(self, color_x, color_y, *, index=0, transition_time=None): + def set_xy_color( + self, + color_x: int, + color_y: int, + *, + index: int = 0, + transition_time: int | None = None, + ) -> Command: """Set xy color of the light.""" self._value_validate(color_x, RANGE_X, "X color") self._value_validate(color_y, RANGE_Y, "Y color") - values = {ATTR_LIGHT_COLOR_X: color_x, ATTR_LIGHT_COLOR_Y: color_y} + values = { + ATTR_LIGHT_COLOR_X: color_x, + ATTR_LIGHT_COLOR_Y: color_y, + } if transition_time is not None: values[ATTR_TRANSITION_TIME] = transition_time @@ -134,13 +155,22 @@ def set_xy_color(self, color_x, color_y, *, index=0, transition_time=None): return self.set_values(values, index=index) def set_hsb( - self, hue, saturation, brightness=None, *, index=0, transition_time=None - ): + self, + hue: int, + saturation: int, + brightness: int | None = None, + *, + index: int = 0, + transition_time: int | None = None, + ) -> Command: """Set HSB color settings of the light.""" self._value_validate(hue, RANGE_HUE, "Hue") self._value_validate(saturation, RANGE_SATURATION, "Saturation") - values = {ATTR_LIGHT_COLOR_SATURATION: saturation, ATTR_LIGHT_COLOR_HUE: hue} + values = { + ATTR_LIGHT_COLOR_SATURATION: saturation, + ATTR_LIGHT_COLOR_HUE: hue, + } if brightness is not None: values[ATTR_LIGHT_DIMMER] = brightness @@ -151,7 +181,9 @@ def set_hsb( return self.set_values(values, index=index) - def set_predefined_color(self, colorname, *, index=0, transition_time=None): + def set_predefined_color( + self, colorname: str, *, index: int = 0, transition_time: int | None = None + ) -> Command: """Set predefined color.""" try: color = COLORS[colorname.lower().replace(" ", "_")] @@ -161,7 +193,7 @@ def set_predefined_color(self, colorname, *, index=0, transition_time=None): except KeyError: raise ColorError(f"Invalid color specified: {colorname}") from KeyError - def set_values(self, values, *, index=0): + def set_values(self, values: Mapping[str, str | int], *, index: int = 0) -> Command: """Set values on light control. Returns a Command. diff --git a/tests/test_color.py b/tests/test_color.py index 185fd762..6b15ba9f 100644 --- a/tests/test_color.py +++ b/tests/test_color.py @@ -8,6 +8,7 @@ SUPPORT_RGB_COLOR, SUPPORT_XY_COLOR, ) +from pytradfri.device.light import LightResponse from .devices import ( LIGHT_CWS, @@ -29,10 +30,13 @@ def test_supported_colors(): """Test supported colors.""" - assert supported_features(LIGHT_W[ATTR_LIGHT_CONTROL][0]) == SUPPORT_BRIGHTNESS + assert ( + supported_features(LightResponse(**LIGHT_W[ATTR_LIGHT_CONTROL][0])) + == SUPPORT_BRIGHTNESS + ) assert ( - supported_features(LIGHT_WS["3311"][0]) + supported_features(LightResponse(**LIGHT_WS["3311"][0])) == SUPPORT_BRIGHTNESS + SUPPORT_COLOR_TEMP + SUPPORT_HEX_COLOR @@ -40,7 +44,7 @@ def test_supported_colors(): ) assert ( - supported_features(LIGHT_WS_CUSTOM_COLOR["3311"][0]) + supported_features(LightResponse(**LIGHT_WS_CUSTOM_COLOR["3311"][0])) == SUPPORT_BRIGHTNESS + SUPPORT_COLOR_TEMP + SUPPORT_HEX_COLOR @@ -48,11 +52,11 @@ def test_supported_colors(): ) assert ( - supported_features(LIGHT_CWS["3311"][0]) + supported_features(LightResponse(**LIGHT_CWS["3311"][0])) == SUPPORT_BRIGHTNESS + SUPPORT_RGB_COLOR + SUPPORT_HEX_COLOR + SUPPORT_XY_COLOR ) assert ( - supported_features(LIGHT_CWS_CUSTOM_COLOR["3311"][0]) + supported_features(LightResponse(**LIGHT_CWS_CUSTOM_COLOR["3311"][0])) == SUPPORT_BRIGHTNESS + SUPPORT_RGB_COLOR + SUPPORT_HEX_COLOR + SUPPORT_XY_COLOR ) diff --git a/tests/test_device.py b/tests/test_device.py index 5693994c..aa860cfd 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -352,12 +352,6 @@ def test_socket_state_on(): assert socket.state is True -def test_set_state_none(device): - """Set none state.""" - with pytest.raises(TypeError): - device.light_control.set_state(None) - - def test_set_predefined_color_invalid(device): """Test set invalid color.""" with pytest.raises(error.ColorError): @@ -409,7 +403,6 @@ def test_binary_division(): def test_has_light_control_true(): """Test light has control.""" response = deepcopy(LIGHT_WS) - response[ATTR_LIGHT_CONTROL] = [{1: 2}] dev = Device(response) assert dev.has_light_control is True @@ -425,30 +418,39 @@ def test_has_light_control_false(): # Test light state function -def test_light_state_on(device): +def test_light_state_on(): """Test light on.""" + device_data = deepcopy(LIGHT_WS) + device_data[ATTR_LIGHT_CONTROL][0][ATTR_DEVICE_STATE] = 1 + device = Device(device_data) light = device.light_control.lights[0] - light.raw[ATTR_DEVICE_STATE] = 1 assert light.state is True -def test_light_state_off(device): +def test_light_state_off(): """Test light off.""" + device_data = deepcopy(LIGHT_WS) + device_data[ATTR_LIGHT_CONTROL][0][ATTR_DEVICE_STATE] = 0 + device = Device(device_data) light = device.light_control.lights[0] - light.raw[ATTR_DEVICE_STATE] = 0 assert light.state is False -def test_light_state_mangled(device): +def test_light_state_mangled(): """Test mangled light state.""" - light = device.light_control.lights[0] - light.raw[ATTR_DEVICE_STATE] = "RandomString" - assert light.state is False + device_data = deepcopy(LIGHT_WS) + device_data[ATTR_LIGHT_CONTROL][0][ATTR_DEVICE_STATE] = "RandomString" + with pytest.raises(ValueError): + device = Device(device_data) + light = device.light_control.lights[0] + assert light.state is False # Test light hsb_xy_color function -def test_light_hsb_xy_color(device): +def test_light_hsb_xy_color(): """Very basic test, just to touch it.""" + device_data = deepcopy(LIGHT_CWS) + device = Device(device_data) light = device.light_control.lights[0] assert len(light.hsb_xy_color) == 5