Add support for setting RGB and RGBW values for Twinkly lights (#62337)

* Change library to ttls

* Add rgbw support

* Add client session to config flow

* Fix config flow

* Adjust tests 1

* Fix more tests

* Fix last tests

* Add new tests

* Update test for coverage

* Update test for coverage 2

* Update test for coverage 3

* Change brightness to attribute

* Set RGBW mode only when available

* Add RGB support
This commit is contained in:
Rob Bierbooms 2022-01-13 18:44:27 +01:00 committed by GitHub
parent 5f9a351889
commit 49a32c398c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 247 additions and 113 deletions

View File

@ -1,28 +1,41 @@
"""The twinkly component."""
import twinkly_client
import asyncio
from aiohttp import ClientError
from ttls.client import Twinkly
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import CONF_ENTRY_HOST, CONF_ENTRY_ID, DOMAIN
from .const import CONF_ENTRY_HOST, CONF_ENTRY_ID, DATA_CLIENT, DATA_DEVICE_INFO, DOMAIN
PLATFORMS = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up entries from config flow."""
hass.data.setdefault(DOMAIN, {})
# We setup the client here so if at some point we add any other entity for this device,
# we will be able to properly share the connection.
uuid = entry.data[CONF_ENTRY_ID]
host = entry.data[CONF_ENTRY_HOST]
hass.data.setdefault(DOMAIN, {})[uuid] = twinkly_client.TwinklyClient(
host, async_get_clientsession(hass)
)
hass.data[DOMAIN].setdefault(uuid, {})
client = Twinkly(host, async_get_clientsession(hass))
try:
device_info = await client.get_details()
except (asyncio.TimeoutError, ClientError) as exception:
raise ConfigEntryNotReady from exception
hass.data[DOMAIN][uuid][DATA_CLIENT] = client
hass.data[DOMAIN][uuid][DATA_DEVICE_INFO] = device_info
hass.config_entries.async_setup_platforms(entry, PLATFORMS)

View File

@ -6,12 +6,13 @@ import logging
from typing import Any
from aiohttp import ClientError
import twinkly_client
from ttls.client import Twinkly
from voluptuous import Required, Schema
from homeassistant import config_entries, data_entry_flow
from homeassistant.components import dhcp
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_ENTRY_HOST,
@ -45,7 +46,9 @@ class TwinklyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if host is not None:
try:
device_info = await twinkly_client.TwinklyClient(host).get_device_info()
device_info = await Twinkly(
host, async_get_clientsession(self.hass)
).get_details()
await self.async_set_unique_id(device_info[DEV_ID])
self._abort_if_unique_id_configured()
@ -65,9 +68,9 @@ class TwinklyConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> data_entry_flow.FlowResult:
"""Handle dhcp discovery for twinkly."""
self._async_abort_entries_match({CONF_ENTRY_HOST: discovery_info.ip})
device_info = await twinkly_client.TwinklyClient(
discovery_info.ip
).get_device_info()
device_info = await Twinkly(
discovery_info.ip, async_get_clientsession(self.hass)
).get_details()
await self.async_set_unique_id(device_info[DEV_ID])
self._abort_if_unique_id_configured(
updates={CONF_ENTRY_HOST: discovery_info.ip}

View File

@ -15,6 +15,13 @@ ATTR_HOST = "host"
DEV_ID = "uuid"
DEV_NAME = "device_name"
DEV_MODEL = "product_code"
DEV_LED_PROFILE = "led_profile"
DEV_PROFILE_RGB = "RGB"
DEV_PROFILE_RGBW = "RGBW"
DATA_CLIENT = "client"
DATA_DEVICE_INFO = "device_info"
HIDDEN_DEV_VALUES = (
"code", # This is the internal status code of the API response

View File

@ -5,10 +5,15 @@ import asyncio
import logging
from aiohttp import ClientError
from ttls.client import Twinkly
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
SUPPORT_BRIGHTNESS,
ATTR_RGB_COLOR,
ATTR_RGBW_COLOR,
COLOR_MODE_BRIGHTNESS,
COLOR_MODE_RGB,
COLOR_MODE_RGBW,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
@ -22,8 +27,13 @@ from .const import (
CONF_ENTRY_ID,
CONF_ENTRY_MODEL,
CONF_ENTRY_NAME,
DATA_CLIENT,
DATA_DEVICE_INFO,
DEV_LED_PROFILE,
DEV_MODEL,
DEV_NAME,
DEV_PROFILE_RGB,
DEV_PROFILE_RGBW,
DOMAIN,
HIDDEN_DEV_VALUES,
)
@ -38,7 +48,10 @@ async def async_setup_entry(
) -> None:
"""Setups an entity from a config entry (UI config flow)."""
entity = TwinklyLight(config_entry, hass)
client = hass.data[DOMAIN][config_entry.data[CONF_ENTRY_ID]][DATA_CLIENT]
device_info = hass.data[DOMAIN][config_entry.data[CONF_ENTRY_ID]][DATA_DEVICE_INFO]
entity = TwinklyLight(config_entry, client, device_info)
async_add_entities([entity], update_before_add=True)
@ -49,34 +62,38 @@ class TwinklyLight(LightEntity):
def __init__(
self,
conf: ConfigEntry,
hass: HomeAssistant,
client: Twinkly,
device_info,
) -> None:
"""Initialize a TwinklyLight entity."""
self._id = conf.data[CONF_ENTRY_ID]
self._hass = hass
self._conf = conf
if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW:
self._attr_supported_color_modes = {COLOR_MODE_RGBW}
self._attr_color_mode = COLOR_MODE_RGBW
self._attr_rgbw_color = (255, 255, 255, 0)
elif device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGB:
self._attr_supported_color_modes = {COLOR_MODE_RGB}
self._attr_color_mode = COLOR_MODE_RGB
self._attr_rgb_color = (255, 255, 255)
else:
self._attr_supported_color_modes = {COLOR_MODE_BRIGHTNESS}
self._attr_color_mode = COLOR_MODE_BRIGHTNESS
# Those are saved in the config entry in order to have meaningful values even
# if the device is currently offline.
# They are expected to be updated using the device_info.
self.__name = conf.data[CONF_ENTRY_NAME]
self.__model = conf.data[CONF_ENTRY_MODEL]
self._client = hass.data.get(DOMAIN, {}).get(self._id)
if self._client is None:
raise ValueError(f"Client for {self._id} has not been configured.")
self._client = client
# Set default state before any update
self._is_on = False
self._brightness = 0
self._is_available = False
self._attributes = {ATTR_HOST: self._client.host}
@property
def supported_features(self):
"""Get the features supported by this entity."""
return SUPPORT_BRIGHTNESS
@property
def should_poll(self) -> bool:
"""Get a boolean which indicates if this entity should be polled."""
@ -126,11 +143,6 @@ class TwinklyLight(LightEntity):
"""Return true if light is on."""
return self._is_on
@property
def brightness(self) -> int | None:
"""Return the brightness of the light."""
return self._brightness
@property
def extra_state_attributes(self) -> dict:
"""Return device specific state attributes."""
@ -139,7 +151,7 @@ class TwinklyLight(LightEntity):
# Make sure to update any normalized property
attributes[ATTR_HOST] = self._client.host
attributes[ATTR_BRIGHTNESS] = self._brightness
attributes[ATTR_BRIGHTNESS] = self._attr_brightness
return attributes
@ -151,31 +163,62 @@ class TwinklyLight(LightEntity):
# If brightness is 0, the twinkly will only "disable" the brightness,
# which means that it will be 100%.
if brightness == 0:
await self._client.set_is_on(False)
await self._client.turn_off()
return
await self._client.set_brightness(brightness)
await self._client.set_is_on(True)
if ATTR_RGBW_COLOR in kwargs:
if kwargs[ATTR_RGBW_COLOR] != self._attr_rgbw_color:
self._attr_rgbw_color = kwargs[ATTR_RGBW_COLOR]
if isinstance(self._attr_rgbw_color, tuple):
await self._client.interview()
# Reagarrange from rgbw to wrgb
await self._client.set_static_colour(
(
self._attr_rgbw_color[3],
self._attr_rgbw_color[0],
self._attr_rgbw_color[1],
self._attr_rgbw_color[2],
)
)
if ATTR_RGB_COLOR in kwargs:
if kwargs[ATTR_RGB_COLOR] != self._attr_rgb_color:
self._attr_rgb_color = kwargs[ATTR_RGB_COLOR]
if isinstance(self._attr_rgb_color, tuple):
await self._client.interview()
# Reagarrange from rgbw to wrgb
await self._client.set_static_colour(self._attr_rgb_color)
if not self._is_on:
await self._client.turn_on()
async def async_turn_off(self, **kwargs) -> None:
"""Turn device off."""
await self._client.set_is_on(False)
await self._client.turn_off()
async def async_update(self) -> None:
"""Asynchronously updates the device properties."""
_LOGGER.info("Updating '%s'", self._client.host)
try:
self._is_on = await self._client.get_is_on()
self._is_on = await self._client.is_on()
self._brightness = (
int(round((await self._client.get_brightness()) * 2.55))
if self._is_on
else 0
brightness = await self._client.get_brightness()
brightness_value = (
int(brightness["value"]) if brightness["mode"] == "enabled" else 100
)
device_info = await self._client.get_device_info()
self._attr_brightness = (
int(round(brightness_value * 2.55)) if self._is_on else 0
)
device_info = await self._client.get_details()
if (
DEV_NAME in device_info
@ -191,7 +234,7 @@ class TwinklyLight(LightEntity):
if self._conf is not None:
# If the name has changed, persist it in conf entry,
# so we will be able to restore this new name if hass is started while the LED string is offline.
self._hass.config_entries.async_update_entry(
self.hass.config_entries.async_update_entry(
self._conf,
data={
CONF_ENTRY_HOST: self._client.host, # this cannot change

View File

@ -2,7 +2,7 @@
"domain": "twinkly",
"name": "Twinkly",
"documentation": "https://www.home-assistant.io/integrations/twinkly",
"requirements": ["twinkly-client==0.0.2"],
"requirements": ["ttls==1.4.2"],
"dependencies": [],
"codeowners": ["@dr1rrb"],
"config_flow": true,

View File

@ -2375,6 +2375,9 @@ tp-connected==0.0.4
# homeassistant.components.transmission
transmissionrpc==0.11
# homeassistant.components.twinkly
ttls==1.4.2
# homeassistant.components.tuya
tuya-iot-py-sdk==0.6.6
@ -2384,9 +2387,6 @@ twentemilieu==0.5.0
# homeassistant.components.twilio
twilio==6.32.0
# homeassistant.components.twinkly
twinkly-client==0.0.2
# homeassistant.components.rainforest_eagle
uEagle==0.0.2

View File

@ -1433,6 +1433,9 @@ total_connect_client==2021.12
# homeassistant.components.transmission
transmissionrpc==0.11
# homeassistant.components.twinkly
ttls==1.4.2
# homeassistant.components.tuya
tuya-iot-py-sdk==0.6.6
@ -1442,9 +1445,6 @@ twentemilieu==0.5.0
# homeassistant.components.twilio
twilio==6.32.0
# homeassistant.components.twinkly
twinkly-client==0.0.2
# homeassistant.components.rainforest_eagle
uEagle==0.0.2

View File

@ -14,13 +14,14 @@ TEST_MODEL = "twinkly_test_device_model"
class ClientMock:
"""A mock of the twinkly_client.TwinklyClient."""
"""A mock of the ttls.client.Twinkly."""
def __init__(self) -> None:
"""Create a mocked client."""
self.is_offline = False
self.is_on = True
self.brightness = 10
self.state = True
self.brightness = {"mode": "enabled", "value": 10}
self.color = None
self.id = str(uuid4())
self.device_info = {
@ -34,23 +35,29 @@ class ClientMock:
"""Get the mocked host."""
return TEST_HOST
async def get_device_info(self):
async def get_details(self):
"""Get the mocked device info."""
if self.is_offline:
raise ClientConnectionError()
return self.device_info
async def get_is_on(self) -> bool:
async def is_on(self) -> bool:
"""Get the mocked on/off state."""
if self.is_offline:
raise ClientConnectionError()
return self.is_on
return self.state
async def set_is_on(self, is_on: bool) -> None:
"""Set the mocked on/off state."""
async def turn_on(self) -> None:
"""Set the mocked on state."""
if self.is_offline:
raise ClientConnectionError()
self.is_on = is_on
self.state = True
async def turn_off(self) -> None:
"""Set the mocked off state."""
if self.is_offline:
raise ClientConnectionError()
self.state = False
async def get_brightness(self) -> int:
"""Get the mocked brightness."""
@ -62,8 +69,15 @@ class ClientMock:
"""Set the mocked brightness."""
if self.is_offline:
raise ClientConnectionError()
self.brightness = brightness
self.brightness = {"mode": "enabled", "value": brightness}
def change_name(self, new_name: str) -> None:
"""Change the name of this virtual device."""
self.device_info[DEV_NAME] = new_name
async def set_static_colour(self, colour) -> None:
"""Set static color."""
self.color = colour
async def interview(self) -> None:
"""Interview."""

View File

@ -20,7 +20,9 @@ async def test_invalid_host(hass):
"""Test the failure when invalid host provided."""
client = ClientMock()
client.is_offline = True
with patch("twinkly_client.TwinklyClient", return_value=client):
with patch(
"homeassistant.components.twinkly.config_flow.Twinkly", return_value=client
):
result = await hass.config_entries.flow.async_init(
TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -40,7 +42,9 @@ async def test_invalid_host(hass):
async def test_success_flow(hass):
"""Test that an entity is created when the flow completes."""
client = ClientMock()
with patch("twinkly_client.TwinklyClient", return_value=client):
with patch(
"homeassistant.components.twinkly.config_flow.Twinkly", return_value=client
), patch("homeassistant.components.twinkly.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_init(
TWINKLY_DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -67,7 +71,9 @@ async def test_success_flow(hass):
async def test_dhcp_can_confirm(hass):
"""Test DHCP discovery flow can confirm right away."""
client = ClientMock()
with patch("twinkly_client.TwinklyClient", return_value=client):
with patch(
"homeassistant.components.twinkly.config_flow.Twinkly", return_value=client
):
result = await hass.config_entries.flow.async_init(
TWINKLY_DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
@ -86,7 +92,9 @@ async def test_dhcp_can_confirm(hass):
async def test_dhcp_success(hass):
"""Test DHCP discovery flow success."""
client = ClientMock()
with patch("twinkly_client.TwinklyClient", return_value=client):
with patch(
"homeassistant.components.twinkly.config_flow.Twinkly", return_value=client
), patch("homeassistant.components.twinkly.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_init(
TWINKLY_DOMAIN,
context={"source": config_entries.SOURCE_DHCP},
@ -129,7 +137,9 @@ async def test_dhcp_already_exists(hass):
)
entry.add_to_hass(hass)
with patch("twinkly_client.TwinklyClient", return_value=client):
with patch(
"homeassistant.components.twinkly.config_flow.Twinkly", return_value=client
):
result = await hass.config_entries.flow.async_init(
TWINKLY_DOMAIN,
context={"source": config_entries.SOURCE_DHCP},

View File

@ -11,14 +11,21 @@ from homeassistant.components.twinkly.const import (
CONF_ENTRY_NAME,
DOMAIN as TWINKLY_DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.twinkly import TEST_HOST, TEST_MODEL, TEST_NAME_ORIGINAL
from tests.components.twinkly import (
TEST_HOST,
TEST_MODEL,
TEST_NAME_ORIGINAL,
ClientMock,
)
async def test_setup_entry(hass: HomeAssistant):
"""Validate that setup entry also configure the client."""
client = ClientMock()
id = str(uuid4())
config_entry = MockConfigEntry(
@ -38,7 +45,7 @@ async def test_setup_entry(hass: HomeAssistant):
with patch(
"homeassistant.config_entries.ConfigEntries.async_forward_entry_setup",
side_effect=setup_mock,
):
), patch("homeassistant.components.twinkly.Twinkly", return_value=client):
await async_setup_entry(hass, config_entry)
assert hass.data[TWINKLY_DOMAIN][id] is not None
@ -65,3 +72,26 @@ async def test_unload_entry(hass: HomeAssistant):
await async_unload_entry(hass, config_entry)
assert hass.data[TWINKLY_DOMAIN].get(id) is None
async def test_config_entry_not_ready(hass: HomeAssistant):
"""Validate that config entry is retried."""
client = ClientMock()
client.is_offline = True
config_entry = MockConfigEntry(
domain=TWINKLY_DOMAIN,
data={
CONF_ENTRY_HOST: TEST_HOST,
CONF_ENTRY_ID: id,
CONF_ENTRY_NAME: TEST_NAME_ORIGINAL,
CONF_ENTRY_MODEL: TEST_MODEL,
},
)
config_entry.add_to_hass(hass)
with patch("homeassistant.components.twinkly.Twinkly", return_value=client):
await hass.config_entries.async_setup(config_entry.entry_id)
assert config_entry.state is ConfigEntryState.SETUP_RETRY

View File

@ -10,7 +10,6 @@ from homeassistant.components.twinkly.const import (
CONF_ENTRY_NAME,
DOMAIN as TWINKLY_DOMAIN,
)
from homeassistant.components.twinkly.light import TwinklyLight
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
@ -19,31 +18,12 @@ from homeassistant.helpers.entity_registry import RegistryEntry
from tests.common import MockConfigEntry
from tests.components.twinkly import (
TEST_HOST,
TEST_ID,
TEST_MODEL,
TEST_NAME_ORIGINAL,
ClientMock,
)
async def test_missing_client(hass: HomeAssistant):
"""Validate that if client has not been setup, it fails immediately in setup."""
try:
config_entry = MockConfigEntry(
data={
CONF_ENTRY_HOST: TEST_HOST,
CONF_ENTRY_ID: TEST_ID,
CONF_ENTRY_NAME: TEST_NAME_ORIGINAL,
CONF_ENTRY_MODEL: TEST_MODEL,
}
)
TwinklyLight(config_entry, hass)
except ValueError:
return
assert False
async def test_initial_state(hass: HomeAssistant):
"""Validate that entity and device states are updated on startup."""
entity, device, _ = await _create_entries(hass)
@ -69,32 +49,11 @@ async def test_initial_state(hass: HomeAssistant):
assert device.manufacturer == "LEDWORKS"
async def test_initial_state_offline(hass: HomeAssistant):
"""Validate that entity and device are restored from config is offline on startup."""
client = ClientMock()
client.is_offline = True
entity, device, _ = await _create_entries(hass, client)
state = hass.states.get(entity.entity_id)
assert state.name == TEST_NAME_ORIGINAL
assert state.state == "unavailable"
assert state.attributes["friendly_name"] == TEST_NAME_ORIGINAL
assert state.attributes["icon"] == "mdi:string-lights"
assert entity.original_name == TEST_NAME_ORIGINAL
assert entity.original_icon == "mdi:string-lights"
assert device.name == TEST_NAME_ORIGINAL
assert device.model == TEST_MODEL
assert device.manufacturer == "LEDWORKS"
async def test_turn_on(hass: HomeAssistant):
async def test_turn_on_off(hass: HomeAssistant):
"""Test support of the light.turn_on service."""
client = ClientMock()
client.is_on = False
client.brightness = 20
client.state = False
client.brightness = {"mode": "enabled", "value": 20}
entity, _, _ = await _create_entries(hass, client)
assert hass.states.get(entity.entity_id).state == "off"
@ -113,8 +72,8 @@ async def test_turn_on(hass: HomeAssistant):
async def test_turn_on_with_brightness(hass: HomeAssistant):
"""Test support of the light.turn_on service with a brightness parameter."""
client = ClientMock()
client.is_on = False
client.brightness = 20
client.state = False
client.brightness = {"mode": "enabled", "value": 20}
entity, _, _ = await _create_entries(hass, client)
assert hass.states.get(entity.entity_id).state == "off"
@ -131,6 +90,64 @@ async def test_turn_on_with_brightness(hass: HomeAssistant):
assert state.state == "on"
assert state.attributes["brightness"] == 255
await hass.services.async_call(
"light",
"turn_on",
service_data={"entity_id": entity.entity_id, "brightness": 1},
)
await hass.async_block_till_done()
state = hass.states.get(entity.entity_id)
assert state.state == "off"
assert state.attributes["brightness"] == 0
async def test_turn_on_with_color_rgbw(hass: HomeAssistant):
"""Test support of the light.turn_on service with a brightness parameter."""
client = ClientMock()
client.state = False
client.device_info["led_profile"] = "RGBW"
client.brightness = {"mode": "enabled", "value": 255}
entity, _, _ = await _create_entries(hass, client)
assert hass.states.get(entity.entity_id).state == "off"
await hass.services.async_call(
"light",
"turn_on",
service_data={"entity_id": entity.entity_id, "rgbw_color": (128, 64, 32, 0)},
)
await hass.async_block_till_done()
state = hass.states.get(entity.entity_id)
assert state.state == "on"
assert client.color == (0, 128, 64, 32)
async def test_turn_on_with_color_rgb(hass: HomeAssistant):
"""Test support of the light.turn_on service with a brightness parameter."""
client = ClientMock()
client.state = False
client.device_info["led_profile"] = "RGB"
client.brightness = {"mode": "enabled", "value": 255}
entity, _, _ = await _create_entries(hass, client)
assert hass.states.get(entity.entity_id).state == "off"
await hass.services.async_call(
"light",
"turn_on",
service_data={"entity_id": entity.entity_id, "rgb_color": (128, 64, 32)},
)
await hass.async_block_till_done()
state = hass.states.get(entity.entity_id)
assert state.state == "on"
assert client.color == (128, 64, 32)
async def test_turn_off(hass: HomeAssistant):
"""Test support of the light.turn_off service."""
@ -194,10 +211,7 @@ async def _create_entries(
) -> tuple[RegistryEntry, DeviceEntry, ClientMock]:
client = ClientMock() if client is None else client
def get_client_mock(client, _):
return client
with patch("twinkly_client.TwinklyClient", side_effect=get_client_mock):
with patch("homeassistant.components.twinkly.Twinkly", return_value=client):
config_entry = MockConfigEntry(
domain=TWINKLY_DOMAIN,
data={