From 5d1eb6928192298ec4f8f3b5efdf2a70bd5cc71e Mon Sep 17 00:00:00 2001 From: Markus Adrario Date: Thu, 20 Feb 2025 19:31:31 +0100 Subject: [PATCH] Add light platform to Homee (#138776) --- homeassistant/components/homee/__init__.py | 8 +- homeassistant/components/homee/const.py | 1 + homeassistant/components/homee/light.py | 213 +++++++++++ homeassistant/components/homee/strings.json | 5 + .../homee/fixtures/light_single.json | 102 +++++ tests/components/homee/fixtures/lights.json | 333 +++++++++++++++++ .../homee/snapshots/test_light.ambr | 348 ++++++++++++++++++ tests/components/homee/test_light.py | 158 ++++++++ 8 files changed, 1167 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homee/light.py create mode 100644 tests/components/homee/fixtures/light_single.json create mode 100644 tests/components/homee/fixtures/lights.json create mode 100644 tests/components/homee/snapshots/test_light.ambr create mode 100644 tests/components/homee/test_light.py diff --git a/homeassistant/components/homee/__init__.py b/homeassistant/components/homee/__init__.py index 530c7920b27..0e4959c35ac 100644 --- a/homeassistant/components/homee/__init__.py +++ b/homeassistant/components/homee/__init__.py @@ -14,7 +14,13 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.COVER, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] type HomeeConfigEntry = ConfigEntry[Homee] diff --git a/homeassistant/components/homee/const.py b/homeassistant/components/homee/const.py index 54d7773890f..2c614d3f5eb 100644 --- a/homeassistant/components/homee/const.py +++ b/homeassistant/components/homee/const.py @@ -76,6 +76,7 @@ CLIMATE_PROFILES = [ NodeProfile.WIFI_RADIATOR_THERMOSTAT, NodeProfile.WIFI_ROOM_THERMOSTAT, ] + LIGHT_PROFILES = [ NodeProfile.DIMMABLE_COLOR_LIGHT, NodeProfile.DIMMABLE_COLOR_METERING_PLUG, diff --git a/homeassistant/components/homee/light.py b/homeassistant/components/homee/light.py new file mode 100644 index 00000000000..12d127c0945 --- /dev/null +++ b/homeassistant/components/homee/light.py @@ -0,0 +1,213 @@ +"""The Homee light platform.""" + +from typing import Any + +from pyHomee.const import AttributeType +from pyHomee.model import HomeeAttribute, HomeeNode + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.color import ( + brightness_to_value, + color_hs_to_RGB, + color_RGB_to_hs, + value_to_brightness, +) + +from . import HomeeConfigEntry +from .const import LIGHT_PROFILES +from .entity import HomeeNodeEntity + +LIGHT_ATTRIBUTES = [ + AttributeType.COLOR, + AttributeType.COLOR_MODE, + AttributeType.COLOR_TEMPERATURE, + AttributeType.DIMMING_LEVEL, +] + + +def is_light_node(node: HomeeNode) -> bool: + """Determine if a node is controllable as a homee light based on its profile and attributes.""" + assert node.attribute_map is not None + return node.profile in LIGHT_PROFILES and AttributeType.ON_OFF in node.attribute_map + + +def get_color_mode(supported_modes: set[ColorMode]) -> ColorMode: + """Determine the color mode from the supported modes.""" + if ColorMode.HS in supported_modes: + return ColorMode.HS + if ColorMode.COLOR_TEMP in supported_modes: + return ColorMode.COLOR_TEMP + if ColorMode.BRIGHTNESS in supported_modes: + return ColorMode.BRIGHTNESS + + return ColorMode.ONOFF + + +def get_light_attribute_sets( + node: HomeeNode, +) -> list[dict[AttributeType, HomeeAttribute]]: + """Return the lights with their attributes as found in the node.""" + lights: list[dict[AttributeType, HomeeAttribute]] = [] + on_off_attributes = [ + i for i in node.attributes if i.type == AttributeType.ON_OFF and i.editable + ] + for a in on_off_attributes: + attribute_dict: dict[AttributeType, HomeeAttribute] = {a.type: a} + for attribute in node.attributes: + if attribute.instance == a.instance and attribute.type in LIGHT_ATTRIBUTES: + attribute_dict[attribute.type] = attribute + lights.append(attribute_dict) + + return lights + + +def rgb_list_to_decimal(color: tuple[int, int, int]) -> int: + """Convert an rgb color from list to decimal representation.""" + return int(int(color[0]) << 16) + (int(color[1]) << 8) + (int(color[2])) + + +def decimal_to_rgb_list(color: float) -> list[int]: + """Convert an rgb color from decimal to list representation.""" + return [ + (int(color) & 0xFF0000) >> 16, + (int(color) & 0x00FF00) >> 8, + (int(color) & 0x0000FF), + ] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HomeeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Add the Homee platform for the light entity.""" + + async_add_entities( + HomeeLight(node, light, config_entry) + for node in config_entry.runtime_data.nodes + for light in get_light_attribute_sets(node) + if is_light_node(node) + ) + + +class HomeeLight(HomeeNodeEntity, LightEntity): + """Representation of a Homee light.""" + + def __init__( + self, + node: HomeeNode, + light: dict[AttributeType, HomeeAttribute], + entry: HomeeConfigEntry, + ) -> None: + """Initialize a Homee light.""" + super().__init__(node, entry) + + self._on_off_attr: HomeeAttribute = light[AttributeType.ON_OFF] + self._dimmer_attr: HomeeAttribute | None = light.get( + AttributeType.DIMMING_LEVEL + ) + self._col_attr: HomeeAttribute | None = light.get(AttributeType.COLOR) + self._temp_attr: HomeeAttribute | None = light.get( + AttributeType.COLOR_TEMPERATURE + ) + self._mode_attr: HomeeAttribute | None = light.get(AttributeType.COLOR_MODE) + + self._attr_supported_color_modes = self._get_supported_color_modes() + self._attr_color_mode = get_color_mode(self._attr_supported_color_modes) + + if self._temp_attr is not None: + self._attr_min_color_temp_kelvin = int(self._temp_attr.minimum) + self._attr_max_color_temp_kelvin = int(self._temp_attr.maximum) + + if self._on_off_attr.instance > 0: + self._attr_translation_key = "light_instance" + self._attr_translation_placeholders = { + "instance": str(self._on_off_attr.instance) + } + else: + # If a device has only one light, it will get its name. + self._attr_name = None + self._attr_unique_id = ( + f"{entry.runtime_data.settings.uid}-{self._node.id}-{self._on_off_attr.id}" + ) + + @property + def brightness(self) -> int: + """Return the brightness of the light.""" + assert self._dimmer_attr is not None + return value_to_brightness( + (self._dimmer_attr.minimum + 1, self._dimmer_attr.maximum), + self._dimmer_attr.current_value, + ) + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the color of the light.""" + assert self._col_attr is not None + rgb = decimal_to_rgb_list(self._col_attr.current_value) + return color_RGB_to_hs(*rgb) + + @property + def color_temp_kelvin(self) -> int: + """Return the color temperature of the light.""" + assert self._temp_attr is not None + return int(self._temp_attr.current_value) + + @property + def is_on(self) -> bool: + """Return true if light is on.""" + return bool(self._on_off_attr.current_value) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Instruct the light to turn on.""" + if ATTR_BRIGHTNESS in kwargs and self._dimmer_attr is not None: + target_value = round( + brightness_to_value( + (self._dimmer_attr.minimum, self._dimmer_attr.maximum), + kwargs[ATTR_BRIGHTNESS], + ) + ) + await self.async_set_value(self._dimmer_attr, target_value) + else: + # If no brightness value is given, just turn on. + await self.async_set_value(self._on_off_attr, 1) + + if ATTR_COLOR_TEMP_KELVIN in kwargs and self._temp_attr is not None: + await self.async_set_value(self._temp_attr, kwargs[ATTR_COLOR_TEMP_KELVIN]) + if ATTR_HS_COLOR in kwargs: + color = kwargs[ATTR_HS_COLOR] + if self._col_attr is not None: + await self.async_set_value( + self._col_attr, + rgb_list_to_decimal(color_hs_to_RGB(*color)), + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Instruct the light to turn off.""" + await self.async_set_value(self._on_off_attr, 0) + + def _get_supported_color_modes(self) -> set[ColorMode]: + """Determine the supported color modes from the available attributes.""" + color_modes: set[ColorMode] = set() + + if self._temp_attr is not None and self._temp_attr.editable: + color_modes.add(ColorMode.COLOR_TEMP) + if self._col_attr is not None: + color_modes.add(ColorMode.HS) + + # If no other color modes are available, set one of those. + if len(color_modes) == 0: + if self._dimmer_attr is not None: + color_modes.add(ColorMode.BRIGHTNESS) + else: + color_modes.add(ColorMode.ONOFF) + + return color_modes diff --git a/homeassistant/components/homee/strings.json b/homeassistant/components/homee/strings.json index fabe02a0377..f7e24acff99 100644 --- a/homeassistant/components/homee/strings.json +++ b/homeassistant/components/homee/strings.json @@ -61,6 +61,11 @@ "name": "Ventilate" } }, + "light": { + "light_instance": { + "name": "Light {instance}" + } + }, "sensor": { "brightness_instance": { "name": "Illuminance {instance}" diff --git a/tests/components/homee/fixtures/light_single.json b/tests/components/homee/fixtures/light_single.json new file mode 100644 index 00000000000..30932da8679 --- /dev/null +++ b/tests/components/homee/fixtures/light_single.json @@ -0,0 +1,102 @@ +{ + "id": 2, + "name": "Another Test Light", + "profile": 1002, + "image": "default", + "favorite": 0, + "order": 48, + "protocol": 21, + "sub_protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1694024544, + "added": 1679551927, + "history": 1, + "cube_type": 8, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 12, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 13, + "node_id": 2, + "instance": 0, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 14, + "node_id": 2, + "instance": 0, + "minimum": 2000, + "maximum": 7000, + "current_value": 3700.0, + "target_value": 3700.0, + "last_value": 3700.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/fixtures/lights.json b/tests/components/homee/fixtures/lights.json new file mode 100644 index 00000000000..3363b93fd77 --- /dev/null +++ b/tests/components/homee/fixtures/lights.json @@ -0,0 +1,333 @@ +{ + "id": 1, + "name": "Test Light", + "profile": 1002, + "image": "default", + "favorite": 0, + "order": 48, + "protocol": 21, + "sub_protocol": 3, + "routing": 0, + "state": 1, + "state_changed": 1694024544, + "added": 1679551927, + "history": 1, + "cube_type": 8, + "note": "", + "services": 7, + "phonetic_name": "", + "owner": 2, + "security": 0, + "attributes": [ + { + "id": 1, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 2, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 3, + "node_id": 1, + "instance": 1, + "minimum": 0, + "maximum": 1073741824, + "current_value": 16763000, + "target_value": 16763000, + "last_value": 16763000, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 23, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "7001020;16419669;12026363;16525995", + "name": "" + }, + { + "id": 4, + "node_id": 1, + "instance": 1, + "minimum": 153, + "maximum": 500, + "current_value": 366.0, + "target_value": 366.0, + "last_value": 366.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 5, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1, + "current_value": 0.0, + "target_value": 0.0, + "last_value": 0.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 6, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 100, + "current_value": 100.0, + "target_value": 100.0, + "last_value": 100.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 7, + "node_id": 1, + "instance": 2, + "minimum": 0, + "maximum": 1073741824, + "current_value": 16763000, + "target_value": 16763000, + "last_value": 16763000, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 23, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "7001020;16419669;12026363;16525995", + "name": "" + }, + { + "id": 8, + "node_id": 1, + "instance": 2, + "minimum": 2202, + "maximum": 4000, + "current_value": 3000.0, + "target_value": 3000.0, + "last_value": 3000.0, + "unit": "K", + "step_value": 1.0, + "editable": 1, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 9, + "node_id": 1, + "instance": 3, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "n/a", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1736743294, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 10, + "node_id": 1, + "instance": 3, + "minimum": 0, + "maximum": 100, + "current_value": 40.0, + "target_value": 40.0, + "last_value": 40.0, + "unit": "%", + "step_value": 1.0, + "editable": 1, + "type": 2, + "state": 1, + "last_changed": 1736743291, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + }, + { + "id": 11, + "node_id": 1, + "instance": 4, + "minimum": 0, + "maximum": 1, + "current_value": 1.0, + "target_value": 1.0, + "last_value": 1.0, + "unit": "", + "step_value": 1.0, + "editable": 1, + "type": 1, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "can_observe": [300], + "automations": ["toggle"], + "history": { + "day": 35, + "week": 5, + "month": 1, + "stepped": true + } + } + }, + { + "id": 12, + "node_id": 1, + "instance": 4, + "minimum": 2200, + "maximum": 4000, + "current_value": 3000.0, + "target_value": 3000.0, + "last_value": 3000.0, + "unit": "K", + "step_value": 1.0, + "editable": 0, + "type": 42, + "state": 1, + "last_changed": 1694024544, + "changed_by": 1, + "changed_by_id": 0, + "based_on": 1, + "data": "", + "name": "", + "options": { + "automations": ["step"] + } + } + ] +} diff --git a/tests/components/homee/snapshots/test_light.ambr b/tests/components/homee/snapshots/test_light.ambr new file mode 100644 index 00000000000..3c766552467 --- /dev/null +++ b/tests/components/homee/snapshots/test_light.ambr @@ -0,0 +1,348 @@ +# serializer version: 1 +# name: test_light_snapshot[light.another_test_light-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.another_test_light', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00055511EECC-2-12', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.another_test_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': 270, + 'color_temp_kelvin': 3700, + 'friendly_name': 'Another Test Light', + 'hs_color': tuple( + 26.996, + 40.593, + ), + 'max_color_temp_kelvin': 7000, + 'max_mireds': 500, + 'min_color_temp_kelvin': 2000, + 'min_mireds': 142, + 'rgb_color': tuple( + 255, + 198, + 151, + ), + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.44, + 0.371, + ), + }), + 'context': , + 'entity_id': 'light.another_test_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 500, + 'max_mireds': 6535, + 'min_color_temp_kelvin': 153, + 'min_mireds': 2000, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 1', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-1', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 255, + 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Test Light Light 1', + 'hs_color': tuple( + 35.556, + 52.941, + ), + 'max_color_temp_kelvin': 500, + 'max_mireds': 6535, + 'min_color_temp_kelvin': 153, + 'min_mireds': 2000, + 'rgb_color': tuple( + 255, + 200, + 120, + ), + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': tuple( + 0.464, + 0.402, + ), + }), + 'context': , + 'entity_id': 'light.test_light_light_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 4000, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 250, + 'supported_color_modes': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 2', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-5', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'Test Light Light 2', + 'hs_color': None, + 'max_color_temp_kelvin': 4000, + 'max_mireds': 454, + 'min_color_temp_kelvin': 2202, + 'min_mireds': 250, + 'rgb_color': None, + 'supported_color_modes': list([ + , + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.test_light_light_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_light_snapshot[light.test_light_light_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 3', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-9', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 102, + 'color_mode': , + 'friendly_name': 'Test Light Light 3', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_light_light_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_snapshot[light.test_light_light_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test_light_light_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Light 4', + 'platform': 'homee', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'light_instance', + 'unique_id': '00055511EECC-1-11', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_snapshot[light.test_light_light_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'color_mode': , + 'friendly_name': 'Test Light Light 4', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test_light_light_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/homee/test_light.py b/tests/components/homee/test_light.py new file mode 100644 index 00000000000..c8af4f6b23d --- /dev/null +++ b/tests/components/homee/test_light.py @@ -0,0 +1,158 @@ +"""Test homee lights.""" + +from typing import Any +from unittest.mock import MagicMock, call, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TOGGLE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import build_mock_node, setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +def mock_attribute_map(attributes) -> dict: + """Mock the attribute map of a Homee node.""" + attribute_map = {} + for a in attributes: + attribute_map[a.type] = a + + return attribute_map + + +async def setup_mock_light( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + file: str, +) -> None: + """Setups the light node for the tests.""" + mock_homee.nodes = [build_mock_node(file)] + mock_homee.nodes[0].attribute_map = mock_attribute_map( + mock_homee.nodes[0].attributes + ) + await setup_integration(hass, mock_config_entry) + + +@pytest.mark.parametrize( + ("data", "calls"), + [ + ({}, [call(1, 1, 1)]), + ({ATTR_BRIGHTNESS: 255}, [call(1, 2, 100)]), + ( + { + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_TEMP_KELVIN: 4300, + }, + [call(1, 2, 100), call(1, 4, 4300)], + ), + ({ATTR_HS_COLOR: (100, 100)}, [call(1, 1, 1), call(1, 3, 5635840)]), + ], +) +async def test_turn_on( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + data: dict[str, Any], + calls: list[call], +) -> None: + """Test turning on the light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "light.test_light_light_1"} | data, + blocking=True, + ) + assert mock_homee.set_value.call_args_list == calls + + +async def test_turn_off( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test turning off a light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 0) + + +async def test_toggle( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test toggling a light.""" + await setup_mock_light(hass, mock_homee, mock_config_entry, "lights.json") + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 0) + + mock_homee.nodes[0].attributes[0].current_value = 0.0 + mock_homee.nodes[0].add_on_changed_listener.call_args_list[0][0][0]( + mock_homee.nodes[0] + ) + await hass.async_block_till_done() + mock_homee.reset_mock() + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TOGGLE, + { + ATTR_ENTITY_ID: "light.test_light_light_1", + }, + blocking=True, + ) + mock_homee.set_value.assert_called_once_with(1, 1, 1) + + +async def test_light_snapshot( + hass: HomeAssistant, + mock_homee: MagicMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test snapshot of lights.""" + mock_homee.nodes = [ + build_mock_node("lights.json"), + build_mock_node("light_single.json"), + ] + for i in range(2): + mock_homee.nodes[i].attribute_map = mock_attribute_map( + mock_homee.nodes[i].attributes + ) + with patch("homeassistant.components.homee.PLATFORMS", [Platform.LIGHT]): + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)