Home Assistant Unofficial Reference 2024.12.1
light.py
Go to the documentation of this file.
1 """The Twinkly light component."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from typing import Any
7 
8 from aiohttp import ClientError
9 from awesomeversion import AwesomeVersion
10 from ttls.client import Twinkly
11 
13  ATTR_BRIGHTNESS,
14  ATTR_EFFECT,
15  ATTR_RGB_COLOR,
16  ATTR_RGBW_COLOR,
17  ColorMode,
18  LightEntity,
19  LightEntityFeature,
20 )
21 from homeassistant.config_entries import ConfigEntry
22 from homeassistant.const import (
23  ATTR_SW_VERSION,
24  CONF_HOST,
25  CONF_ID,
26  CONF_MODEL,
27  CONF_NAME,
28 )
29 from homeassistant.core import HomeAssistant
30 from homeassistant.helpers import device_registry as dr
31 from homeassistant.helpers.device_registry import DeviceInfo
32 from homeassistant.helpers.entity_platform import AddEntitiesCallback
33 
34 from .const import (
35  DATA_CLIENT,
36  DATA_DEVICE_INFO,
37  DEV_LED_PROFILE,
38  DEV_MODEL,
39  DEV_NAME,
40  DEV_PROFILE_RGB,
41  DEV_PROFILE_RGBW,
42  DOMAIN,
43  MIN_EFFECT_VERSION,
44 )
45 
46 _LOGGER = logging.getLogger(__name__)
47 
48 
50  hass: HomeAssistant,
51  config_entry: ConfigEntry,
52  async_add_entities: AddEntitiesCallback,
53 ) -> None:
54  """Setups an entity from a config entry (UI config flow)."""
55 
56  client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT]
57  device_info = hass.data[DOMAIN][config_entry.entry_id][DATA_DEVICE_INFO]
58  software_version = hass.data[DOMAIN][config_entry.entry_id][ATTR_SW_VERSION]
59 
60  entity = TwinklyLight(config_entry, client, device_info, software_version)
61 
62  async_add_entities([entity], update_before_add=True)
63 
64 
66  """Implementation of the light for the Twinkly service."""
67 
68  _attr_has_entity_name = True
69  _attr_name = None
70  _attr_translation_key = "light"
71 
72  def __init__(
73  self,
74  conf: ConfigEntry,
75  client: Twinkly,
76  device_info,
77  software_version: str | None = None,
78  ) -> None:
79  """Initialize a TwinklyLight entity."""
80  self._attr_unique_id: str = conf.data[CONF_ID]
81  self._conf_conf = conf
82 
83  if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW:
84  self._attr_supported_color_modes_attr_supported_color_modes = {ColorMode.RGBW}
85  self._attr_color_mode_attr_color_mode = ColorMode.RGBW
86  self._attr_rgbw_color_attr_rgbw_color = (255, 255, 255, 0)
87  elif device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGB:
88  self._attr_supported_color_modes_attr_supported_color_modes = {ColorMode.RGB}
89  self._attr_color_mode_attr_color_mode = ColorMode.RGB
90  self._attr_rgb_color_attr_rgb_color = (255, 255, 255)
91  else:
92  self._attr_supported_color_modes_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
93  self._attr_color_mode_attr_color_mode = ColorMode.BRIGHTNESS
94 
95  # Those are saved in the config entry in order to have meaningful values even
96  # if the device is currently offline.
97  # They are expected to be updated using the device_info.
98  self._name_name = conf.data[CONF_NAME] or "Twinkly light"
99  self._model_model = conf.data[CONF_MODEL]
100 
101  self._client_client = client
102 
103  # Set default state before any update
104  self._attr_is_on_attr_is_on = False
105  self._attr_available_attr_available = False
106  self._current_movie_current_movie: dict[Any, Any] = {}
107  self._movies_movies: list[Any] = []
108  self._software_version_software_version = software_version
109  # We guess that most devices are "new" and support effects
110  self._attr_supported_features_attr_supported_features = LightEntityFeature.EFFECT
111 
112  @property
113  def device_info(self) -> DeviceInfo | None:
114  """Get device specific attributes."""
115  return DeviceInfo(
116  identifiers={(DOMAIN, self._attr_unique_id)},
117  manufacturer="LEDWORKS",
118  model=self._model_model,
119  name=self._name_name,
120  sw_version=self._software_version_software_version,
121  )
122 
123  @property
124  def effect(self) -> str | None:
125  """Return the current effect."""
126  if "name" in self._current_movie_current_movie:
127  return f"{self._current_movie['id']} {self._current_movie['name']}"
128  return None
129 
130  @property
131  def effect_list(self) -> list[str]:
132  """Return the list of saved effects."""
133  return [f"{movie['id']} {movie['name']}" for movie in self._movies_movies]
134 
135  async def async_added_to_hass(self) -> None:
136  """Device is added to hass."""
137  if self._software_version_software_version:
138  if AwesomeVersion(self._software_version_software_version) < AwesomeVersion(
139  MIN_EFFECT_VERSION
140  ):
141  self._attr_supported_features_attr_supported_features = (
142  self.supported_featuressupported_featuressupported_features & ~LightEntityFeature.EFFECT
143  )
144  device_registry = dr.async_get(self.hasshass)
145  device_entry = device_registry.async_get_device(
146  {(DOMAIN, self._attr_unique_id)}, set()
147  )
148  if device_entry:
149  device_registry.async_update_device(
150  device_entry.id, sw_version=self._software_version_software_version
151  )
152 
153  async def async_turn_on(self, **kwargs: Any) -> None:
154  """Turn device on."""
155  if ATTR_BRIGHTNESS in kwargs:
156  brightness = int(int(kwargs[ATTR_BRIGHTNESS]) / 2.55)
157 
158  # If brightness is 0, the twinkly will only "disable" the brightness,
159  # which means that it will be 100%.
160  if brightness == 0:
161  await self._client_client.turn_off()
162  return
163 
164  await self._client_client.set_brightness(brightness)
165 
166  if (
167  ATTR_RGBW_COLOR in kwargs
168  and kwargs[ATTR_RGBW_COLOR] != self._attr_rgbw_color_attr_rgbw_color
169  ):
170  await self._client_client.interview()
171  if LightEntityFeature.EFFECT & self.supported_featuressupported_featuressupported_features:
172  # Static color only supports rgb
173  await self._client_client.set_static_colour(
174  (
175  kwargs[ATTR_RGBW_COLOR][0],
176  kwargs[ATTR_RGBW_COLOR][1],
177  kwargs[ATTR_RGBW_COLOR][2],
178  )
179  )
180  await self._client_client.set_mode("color")
181  self._client_client.default_mode = "color"
182  else:
183  await self._client_client.set_cycle_colours(
184  (
185  kwargs[ATTR_RGBW_COLOR][3],
186  kwargs[ATTR_RGBW_COLOR][0],
187  kwargs[ATTR_RGBW_COLOR][1],
188  kwargs[ATTR_RGBW_COLOR][2],
189  )
190  )
191  await self._client_client.set_mode("movie")
192  self._client_client.default_mode = "movie"
193  self._attr_rgbw_color_attr_rgbw_color = kwargs[ATTR_RGBW_COLOR]
194 
195  if ATTR_RGB_COLOR in kwargs and kwargs[ATTR_RGB_COLOR] != self._attr_rgb_color_attr_rgb_color:
196  await self._client_client.interview()
197  if LightEntityFeature.EFFECT & self.supported_featuressupported_featuressupported_features:
198  await self._client_client.set_static_colour(kwargs[ATTR_RGB_COLOR])
199  await self._client_client.set_mode("color")
200  self._client_client.default_mode = "color"
201  else:
202  await self._client_client.set_cycle_colours(kwargs[ATTR_RGB_COLOR])
203  await self._client_client.set_mode("movie")
204  self._client_client.default_mode = "movie"
205 
206  self._attr_rgb_color_attr_rgb_color = kwargs[ATTR_RGB_COLOR]
207 
208  if (
209  ATTR_EFFECT in kwargs
210  and LightEntityFeature.EFFECT & self.supported_featuressupported_featuressupported_features
211  ):
212  movie_id = kwargs[ATTR_EFFECT].split(" ")[0]
213  if "id" not in self._current_movie_current_movie or int(movie_id) != int(
214  self._current_movie_current_movie["id"]
215  ):
216  await self._client_client.interview()
217  await self._client_client.set_current_movie(int(movie_id))
218  await self._client_client.set_mode("movie")
219  self._client_client.default_mode = "movie"
220  if not self._attr_is_on_attr_is_on:
221  await self._client_client.turn_on()
222 
223  async def async_turn_off(self, **kwargs: Any) -> None:
224  """Turn device off."""
225  await self._client_client.turn_off()
226 
227  async def async_update(self) -> None:
228  """Asynchronously updates the device properties."""
229  _LOGGER.debug("Updating '%s'", self._client_client.host)
230 
231  try:
232  self._attr_is_on_attr_is_on = await self._client_client.is_on()
233 
234  brightness = await self._client_client.get_brightness()
235  brightness_value = (
236  int(brightness["value"]) if brightness["mode"] == "enabled" else 100
237  )
238 
239  self._attr_brightness_attr_brightness = (
240  int(round(brightness_value * 2.55)) if self._attr_is_on_attr_is_on else 0
241  )
242 
243  device_info = await self._client_client.get_details()
244 
245  if (
246  DEV_NAME in device_info
247  and DEV_MODEL in device_info
248  and (
249  device_info[DEV_NAME] != self._name_name
250  or device_info[DEV_MODEL] != self._model_model
251  )
252  ):
253  self._name_name = device_info[DEV_NAME]
254  self._model_model = device_info[DEV_MODEL]
255 
256  # If the name has changed, persist it in conf entry,
257  # so we will be able to restore this new name if hass
258  # is started while the LED string is offline.
259  self.hasshass.config_entries.async_update_entry(
260  self._conf_conf,
261  data={
262  CONF_HOST: self._client_client.host, # this cannot change
263  CONF_ID: self._attr_unique_id, # this cannot change
264  CONF_NAME: self._name_name,
265  CONF_MODEL: self._model_model,
266  },
267  )
268 
269  device_registry = dr.async_get(self.hasshass)
270  device_entry = device_registry.async_get_device(
271  {(DOMAIN, self._attr_unique_id)}
272  )
273  if device_entry:
274  device_registry.async_update_device(
275  device_entry.id, name=self._name_name, model=self._model_model
276  )
277 
278  if LightEntityFeature.EFFECT & self.supported_featuressupported_featuressupported_features:
279  await self.async_update_moviesasync_update_movies()
280  await self.async_update_current_movieasync_update_current_movie()
281 
282  if not self._attr_available_attr_available:
283  _LOGGER.warning("Twinkly '%s' is now available", self._client_client.host)
284 
285  # We don't use the echo API to track the availability since
286  # we already have to pull the device to get its state.
287  self._attr_available_attr_available = True
288  except (TimeoutError, ClientError):
289  # We log this as "info" as it's pretty common that the Christmas
290  # light are not reachable in July
291  if self._attr_available_attr_available:
292  _LOGGER.warning(
293  "Twinkly '%s' is not reachable (client error)", self._client_client.host
294  )
295  self._attr_available_attr_available = False
296 
297  async def async_update_movies(self) -> None:
298  """Update the list of movies (effects)."""
299  movies = await self._client_client.get_saved_movies()
300  _LOGGER.debug("Movies: %s", movies)
301  if movies and "movies" in movies:
302  self._movies_movies = movies["movies"]
303 
304  async def async_update_current_movie(self) -> None:
305  """Update the current active movie."""
306  current_movie = await self._client_client.get_current_movie()
307  _LOGGER.debug("Current movie: %s", current_movie)
308  if current_movie and "id" in current_movie:
309  self._current_movie_current_movie = current_movie
LightEntityFeature supported_features(self)
Definition: __init__.py:1307
None __init__(self, ConfigEntry conf, Twinkly client, device_info, str|None software_version=None)
Definition: light.py:78
int|None supported_features(self)
Definition: entity.py:861
None turn_off(self, **Any kwargs)
Definition: entity.py:1705
None turn_on(self, **Any kwargs)
Definition: entity.py:1697
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: light.py:53