diff --git a/CODEOWNERS b/CODEOWNERS index 936fa50cb9f..bd99e2eb737 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -181,7 +181,7 @@ homeassistant/components/foscam/* @skgsergio homeassistant/components/freebox/* @hacf-fr @Quentame homeassistant/components/freedompro/* @stefano055415 homeassistant/components/fritz/* @mammuth @AaronDavidSchneider @chemelli74 -homeassistant/components/fritzbox/* @mib1185 +homeassistant/components/fritzbox/* @mib1185 @flabbamann homeassistant/components/fronius/* @nielstron homeassistant/components/frontend/* @home-assistant/frontend homeassistant/components/garages_amsterdam/* @klaasnicolaas diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index 67e7c9dc564..9d537bec617 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -11,6 +11,9 @@ ATTR_STATE_LOCKED: Final = "locked" ATTR_STATE_SUMMER_MODE: Final = "summer_mode" ATTR_STATE_WINDOW_OPEN: Final = "window_open" +COLOR_MODE: Final = "1" +COLOR_TEMP_MODE: Final = "4" + CONF_CONNECTIONS: Final = "connections" CONF_COORDINATOR: Final = "coordinator" @@ -21,4 +24,4 @@ DOMAIN: Final = "fritzbox" LOGGER: Final[logging.Logger] = logging.getLogger(__package__) -PLATFORMS: Final[list[str]] = ["binary_sensor", "climate", "switch", "sensor"] +PLATFORMS: Final[list[str]] = ["binary_sensor", "climate", "light", "switch", "sensor"] diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py new file mode 100644 index 00000000000..3f9e3cabfa2 --- /dev/null +++ b/homeassistant/components/fritzbox/light.py @@ -0,0 +1,155 @@ +"""Support for AVM FRITZ!SmartHome lightbulbs.""" +from __future__ import annotations + +from typing import Any + +from pyfritzhome.fritzhomedevice import FritzhomeDevice + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + LightEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.util import color + +from . import FritzBoxEntity +from .const import ( + COLOR_MODE, + COLOR_TEMP_MODE, + CONF_COORDINATOR, + DOMAIN as FRITZBOX_DOMAIN, +) + +SUPPORTED_COLOR_MODES = {COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the FRITZ!SmartHome light from ConfigEntry.""" + entities: list[FritzboxLight] = [] + coordinator = hass.data[FRITZBOX_DOMAIN][entry.entry_id][CONF_COORDINATOR] + + for ain, device in coordinator.data.items(): + if not device.has_lightbulb: + continue + + supported_color_temps = await hass.async_add_executor_job( + device.get_color_temps + ) + + supported_colors = await hass.async_add_executor_job(device.get_colors) + + entities.append( + FritzboxLight( + coordinator, + ain, + supported_colors, + supported_color_temps, + ) + ) + + async_add_entities(entities) + + +class FritzboxLight(FritzBoxEntity, LightEntity): + """The light class for FRITZ!SmartHome lightbulbs.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[dict[str, FritzhomeDevice]], + ain: str, + supported_colors: dict, + supported_color_temps: list[str], + ) -> None: + """Initialize the FritzboxLight entity.""" + super().__init__(coordinator, ain, None) + + max_kelvin = int(max(supported_color_temps)) + min_kelvin = int(min(supported_color_temps)) + + # max kelvin is min mireds and min kelvin is max mireds + self._attr_min_mireds = color.color_temperature_kelvin_to_mired(max_kelvin) + self._attr_max_mireds = color.color_temperature_kelvin_to_mired(min_kelvin) + + # Fritz!DECT 500 only supports 12 values for hue, with 3 saturations each. + # Map supported colors to dict {hue: [sat1, sat2, sat3]} for easier lookup + self._supported_hs = {} + for values in supported_colors.values(): + hue = int(values[0][0]) + self._supported_hs[hue] = [ + int(values[0][1]), + int(values[1][1]), + int(values[2][1]), + ] + + @property + def is_on(self) -> bool: + """If the light is currently on or off.""" + return self.device.state # type: ignore [no-any-return] + + @property + def brightness(self) -> int: + """Return the current Brightness.""" + return self.device.level # type: ignore [no-any-return] + + @property + def hs_color(self) -> tuple[float, float] | None: + """Return the hs color value.""" + if self.device.color_mode != COLOR_MODE: + return None + + hue = self.device.hue + saturation = self.device.saturation + + return (hue, float(saturation) * 100.0 / 255.0) + + @property + def color_temp(self) -> int | None: + """Return the CT color value.""" + if self.device.color_mode != COLOR_TEMP_MODE: + return None + + kelvin = self.device.color_temp + return color.color_temperature_kelvin_to_mired(kelvin) + + @property + def supported_color_modes(self) -> set: + """Flag supported color modes.""" + return SUPPORTED_COLOR_MODES + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the light on.""" + if kwargs.get(ATTR_BRIGHTNESS) is not None: + level = kwargs[ATTR_BRIGHTNESS] + await self.hass.async_add_executor_job(self.device.set_level, level) + if kwargs.get(ATTR_HS_COLOR) is not None: + hass_hue = int(kwargs[ATTR_HS_COLOR][0]) + hass_saturation = round(kwargs[ATTR_HS_COLOR][1] * 255.0 / 100.0) + # find supported hs values closest to what user selected + hue = min(self._supported_hs.keys(), key=lambda x: abs(x - hass_hue)) + saturation = min( + self._supported_hs[hue], key=lambda x: abs(x - hass_saturation) + ) + await self.hass.async_add_executor_job( + self.device.set_color, (hue, saturation) + ) + + if kwargs.get(ATTR_COLOR_TEMP) is not None: + kelvin = color.color_temperature_kelvin_to_mired(kwargs[ATTR_COLOR_TEMP]) + await self.hass.async_add_executor_job(self.device.set_color_temp, kelvin) + + await self.hass.async_add_executor_job(self.device.set_state_on) + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the light off.""" + await self.hass.async_add_executor_job(self.device.set_state_off) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index c1db226d348..98c02d0166e 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -8,7 +8,7 @@ "st": "urn:schemas-upnp-org:device:fritzbox:1" } ], - "codeowners": ["@mib1185"], + "codeowners": ["@mib1185", "@flabbamann"], "config_flow": true, "iot_class": "local_polling" } diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py index 2b9a1a783f9..27abd38f8cb 100644 --- a/tests/components/fritzbox/__init__.py +++ b/tests/components/fritzbox/__init__.py @@ -57,6 +57,7 @@ class FritzDeviceBinarySensorMock(FritzDeviceBaseMock): has_alarm = True has_powermeter = False has_switch = False + has_lightbulb = False has_temperature_sensor = False has_thermostat = False present = True @@ -75,6 +76,7 @@ class FritzDeviceClimateMock(FritzDeviceBaseMock): fw_version = "1.2.3" has_alarm = False has_powermeter = False + has_lightbulb = False has_switch = False has_temperature_sensor = False has_thermostat = True @@ -94,6 +96,7 @@ class FritzDeviceSensorMock(FritzDeviceBaseMock): fw_version = "1.2.3" has_alarm = False has_powermeter = False + has_lightbulb = False has_switch = False has_temperature_sensor = True has_thermostat = False @@ -113,6 +116,7 @@ class FritzDeviceSwitchMock(FritzDeviceBaseMock): fw_version = "1.2.3" has_alarm = False has_powermeter = True + has_lightbulb = False has_switch = True has_temperature_sensor = True has_thermostat = False @@ -121,3 +125,18 @@ class FritzDeviceSwitchMock(FritzDeviceBaseMock): power = 5678 present = True temperature = 1.23 + + +class FritzDeviceLightMock(FritzDeviceBaseMock): + """Mock of a AVM Fritz!Box light device.""" + + fw_version = "1.2.3" + has_alarm = False + has_powermeter = False + has_lightbulb = True + has_switch = False + has_temperature_sensor = False + has_thermostat = False + level = 100 + present = True + state = True diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py new file mode 100644 index 00000000000..5b17e36abb2 --- /dev/null +++ b/tests/components/fritzbox/test_light.py @@ -0,0 +1,185 @@ +"""Tests for AVM Fritz!Box light component.""" +from datetime import timedelta +from unittest.mock import Mock + +from requests.exceptions import HTTPError + +from homeassistant.components.fritzbox.const import ( + COLOR_MODE, + COLOR_TEMP_MODE, + DOMAIN as FB_DOMAIN, +) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_HS_COLOR, + DOMAIN, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + CONF_DEVICES, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.util import color +import homeassistant.util.dt as dt_util + +from . import FritzDeviceLightMock, setup_config_entry +from .const import CONF_FAKE_NAME, MOCK_CONFIG + +from tests.common import async_fire_time_changed + +ENTITY_ID = f"{DOMAIN}.{CONF_FAKE_NAME}" + + +async def test_setup(hass: HomeAssistant, fritz: Mock): + """Test setup of platform.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + device.color_mode = COLOR_TEMP_MODE + device.color_temp = 2700 + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_COLOR_TEMP] == color.color_temperature_kelvin_to_mired( + 2700 + ) + + +async def test_setup_color(hass: HomeAssistant, fritz: Mock): + """Test setup of platform in color mode.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + device.color_mode = COLOR_MODE + device.hue = 100 + device.saturation = 70 * 255.0 / 100.0 + + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_ON + assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" + assert state.attributes[ATTR_BRIGHTNESS] == 100 + assert state.attributes[ATTR_HS_COLOR] == (100, 70) + + +async def test_turn_on(hass: HomeAssistant, fritz: Mock): + """Test turn device on.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + assert await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_COLOR_TEMP: 300}, + True, + ) + assert device.set_state_on.call_count == 1 + assert device.set_level.call_count == 1 + assert device.set_color_temp.call_count == 1 + + +async def test_turn_on_color(hass: HomeAssistant, fritz: Mock): + """Test turn device on in color mode.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + assert await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_BRIGHTNESS: 100, ATTR_HS_COLOR: (100, 70)}, + True, + ) + assert device.set_state_on.call_count == 1 + assert device.set_level.call_count == 1 + assert device.set_color.call_count == 1 + + +async def test_turn_off(hass: HomeAssistant, fritz: Mock): + """Test turn device off.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + + assert await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + assert device.set_state_off.call_count == 1 + + +async def test_update(hass: HomeAssistant, fritz: Mock): + """Test update without error.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + assert await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert device.update.call_count == 1 + assert fritz().login.call_count == 1 + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + assert device.update.call_count == 2 + assert fritz().login.call_count == 1 + + +async def test_update_error(hass: HomeAssistant, fritz: Mock): + """Test update with error.""" + device = FritzDeviceLightMock() + device.get_color_temps.return_value = [2700, 6500] + device.get_colors.return_value = { + "Red": [("100", "70", "10"), ("100", "50", "10"), ("100", "30", "10")] + } + device.update.side_effect = HTTPError("Boom") + assert not await setup_config_entry( + hass, MOCK_CONFIG[FB_DOMAIN][CONF_DEVICES][0], ENTITY_ID, device, fritz + ) + assert device.update.call_count == 1 + assert fritz().login.call_count == 1 + + next_update = dt_util.utcnow() + timedelta(seconds=200) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + assert device.update.call_count == 2 + assert fritz().login.call_count == 2