Support for Fritz!DECT 500 lightbulbs (#52830)

This commit is contained in:
Lars 2021-10-22 14:30:03 +02:00 committed by GitHub
parent ab2ff45726
commit 2148c84386
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 365 additions and 3 deletions

View File

@ -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

View File

@ -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"]

View File

@ -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()

View File

@ -8,7 +8,7 @@
"st": "urn:schemas-upnp-org:device:fritzbox:1"
}
],
"codeowners": ["@mib1185"],
"codeowners": ["@mib1185", "@flabbamann"],
"config_flow": true,
"iot_class": "local_polling"
}

View File

@ -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

View File

@ -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