Home Assistant Unofficial Reference 2024.12.1
light.py
Go to the documentation of this file.
1 """Component to integrate ambilight for TVs exposing the Joint Space API."""
2 
3 from __future__ import annotations
4 
5 from dataclasses import dataclass
6 from typing import Any
7 
8 from haphilipsjs import PhilipsTV
9 from haphilipsjs.typing import AmbilightCurrentConfiguration
10 
12  ATTR_BRIGHTNESS,
13  ATTR_EFFECT,
14  ATTR_HS_COLOR,
15  ColorMode,
16  LightEntity,
17  LightEntityFeature,
18 )
19 from homeassistant.core import HomeAssistant, callback
20 from homeassistant.exceptions import HomeAssistantError
21 from homeassistant.helpers.entity_platform import AddEntitiesCallback
22 from homeassistant.util.color import color_hsv_to_RGB, color_RGB_to_hsv
23 
24 from . import PhilipsTVConfigEntry
25 from .coordinator import PhilipsTVDataUpdateCoordinator
26 from .entity import PhilipsJsEntity
27 
28 EFFECT_PARTITION = ": "
29 EFFECT_MODE = "Mode"
30 EFFECT_EXPERT = "Expert"
31 EFFECT_AUTO = "Auto"
32 EFFECT_EXPERT_STYLES = {"FOLLOW_AUDIO", "FOLLOW_COLOR", "Lounge light"}
33 
34 
36  hass: HomeAssistant,
37  config_entry: PhilipsTVConfigEntry,
38  async_add_entities: AddEntitiesCallback,
39 ) -> None:
40  """Set up the configuration entry."""
41  coordinator = config_entry.runtime_data
43 
44 
45 def _get_settings(style: AmbilightCurrentConfiguration):
46  """Extract the color settings data from a style."""
47  if style["styleName"] in ("FOLLOW_COLOR", "Lounge light"):
48  return style["colorSettings"]
49  if style["styleName"] == "FOLLOW_AUDIO":
50  return style["audioSettings"]
51  return None
52 
53 
54 @dataclass
56  """Data class describing the ambilight effect."""
57 
58  mode: str
59  style: str
60  algorithm: str | None = None
61 
62  def is_on(self, powerstate) -> bool:
63  """Check whether the ambilight is considered on."""
64  if self.modemode in (EFFECT_AUTO, EFFECT_EXPERT):
65  if self.stylestyle in ("FOLLOW_VIDEO", "FOLLOW_AUDIO"):
66  return powerstate in ("On", None)
67  if self.stylestyle == "OFF":
68  return False
69  return True
70 
71  if self.modemode == EFFECT_MODE:
72  if self.stylestyle == "internal":
73  return powerstate in ("On", None)
74  return True
75 
76  return False
77 
78  def is_valid(self) -> bool:
79  """Validate the effect configuration."""
80  if self.modemode == EFFECT_EXPERT:
81  return self.stylestyle in EFFECT_EXPERT_STYLES
82  return True
83 
84  @staticmethod
85  def from_str(effect_string: str) -> AmbilightEffect:
86  """Create AmbilightEffect object from string."""
87  style, _, algorithm = effect_string.partition(EFFECT_PARTITION)
88  if style == EFFECT_MODE:
89  return AmbilightEffect(mode=EFFECT_MODE, style=algorithm, algorithm=None)
90  algorithm, _, expert = algorithm.partition(EFFECT_PARTITION)
91  if expert:
92  return AmbilightEffect(mode=EFFECT_EXPERT, style=style, algorithm=algorithm)
93  return AmbilightEffect(mode=EFFECT_AUTO, style=style, algorithm=algorithm)
94 
95  def __str__(self) -> str:
96  """Get a string representation of the effect."""
97  if self.modemode == EFFECT_MODE:
98  return f"{EFFECT_MODE}{EFFECT_PARTITION}{self.style}"
99  if self.modemode == EFFECT_EXPERT:
100  return f"{self.style}{EFFECT_PARTITION}{self.algorithm}{EFFECT_PARTITION}{EFFECT_EXPERT}"
101  return f"{self.style}{EFFECT_PARTITION}{self.algorithm}"
102 
103 
104 def _get_cache_keys(device: PhilipsTV):
105  """Return a cache keys to avoid always updating."""
106  return (
107  device.on,
108  device.powerstate,
109  device.ambilight_current_configuration,
110  device.ambilight_mode,
111  )
112 
113 
114 def _average_pixels(data):
115  """Calculate an average color over all ambilight pixels."""
116  color_c = 0
117  color_r = 0.0
118  color_g = 0.0
119  color_b = 0.0
120  for layer in data.values():
121  for side in layer.values():
122  for pixel in side.values():
123  color_c += 1
124  color_r += pixel["r"]
125  color_g += pixel["g"]
126  color_b += pixel["b"]
127 
128  if color_c:
129  color_r /= color_c
130  color_g /= color_c
131  color_b /= color_c
132  return color_r, color_g, color_b
133  return 0.0, 0.0, 0.0
134 
135 
137  """Representation of a Philips TV exposing the JointSpace API."""
138 
139  _attr_translation_key = "ambilight"
140 
141  def __init__(
142  self,
143  coordinator: PhilipsTVDataUpdateCoordinator,
144  ) -> None:
145  """Initialize light."""
146  self._tv_tv = coordinator.api
147  self._hs_hs = None
148  self._brightness_brightness = None
149  self._cache_keys_cache_keys = None
150  self._last_selected_effect_last_selected_effect: AmbilightEffect | None = None
151  super().__init__(coordinator)
152 
153  self._attr_supported_color_modes_attr_supported_color_modes = {ColorMode.HS, ColorMode.ONOFF}
154  self._attr_supported_features_attr_supported_features = LightEntityFeature.EFFECT
155  self._attr_unique_id_attr_unique_id = coordinator.unique_id
156 
157  self._update_from_coordinator_update_from_coordinator()
158 
160  """Calculate an effect list based on current status."""
161  effects: list[AmbilightEffect] = []
162  effects.extend(
163  AmbilightEffect(mode=EFFECT_AUTO, style=style, algorithm=setting)
164  for style, data in self._tv_tv.ambilight_styles.items()
165  for setting in data.get("menuSettings", [])
166  )
167 
168  effects.extend(
169  AmbilightEffect(mode=EFFECT_EXPERT, style=style, algorithm=algorithm)
170  for style, data in self._tv_tv.ambilight_styles.items()
171  for algorithm in data.get("algorithms", [])
172  )
173 
174  effects.extend(
175  AmbilightEffect(mode=EFFECT_MODE, style=style)
176  for style in self._tv_tv.ambilight_modes
177  )
178 
179  filtered_effects = [
180  str(effect)
181  for effect in effects
182  if effect.is_valid() and effect.is_on(self._tv_tv.powerstate)
183  ]
184 
185  return sorted(filtered_effects)
186 
187  def _calculate_effect(self) -> AmbilightEffect:
188  """Return the current effect."""
189  current = self._tv_tv.ambilight_current_configuration
190  if current and self._tv_tv.ambilight_mode != "manual":
191  if current["isExpert"]:
192  if settings := _get_settings(current):
193  return AmbilightEffect(
194  EFFECT_EXPERT, current["styleName"], settings["algorithm"]
195  )
196  return AmbilightEffect(EFFECT_EXPERT, current["styleName"], None)
197 
198  return AmbilightEffect(
199  EFFECT_AUTO, current["styleName"], current.get("menuSetting", None)
200  )
201 
202  return AmbilightEffect(EFFECT_MODE, self._tv_tv.ambilight_mode, None)
203 
204  @property
205  def color_mode(self) -> ColorMode:
206  """Return the current color mode."""
207  current = self._tv_tv.ambilight_current_configuration
208  if current and current["isExpert"]:
209  return ColorMode.HS
210 
211  if self._tv_tv.ambilight_mode in ["manual", "expert"]:
212  return ColorMode.HS
213 
214  return ColorMode.ONOFF
215 
216  @property
217  def is_on(self):
218  """Return if the light is turned on."""
219  if self._tv_tv.on:
220  effect = AmbilightEffect.from_str(self.effecteffect)
221  return effect.is_on(self._tv_tv.powerstate)
222 
223  return False
224 
226  current = self._tv_tv.ambilight_current_configuration
227  color = None
228 
229  if (cache_keys := _get_cache_keys(self._tv_tv)) != self._cache_keys_cache_keys:
230  self._cache_keys_cache_keys = cache_keys
231  self._attr_effect_list_attr_effect_list = self._calculate_effect_list_calculate_effect_list()
232  self._attr_effect_attr_effect = str(self._calculate_effect_calculate_effect())
233 
234  if current and current["isExpert"]:
235  if settings := _get_settings(current):
236  color = settings["color"]
237 
238  effect = AmbilightEffect.from_str(self._attr_effect_attr_effect)
239  if effect.is_on(self._tv_tv.powerstate):
240  self._last_selected_effect_last_selected_effect = effect
241 
242  if effect.mode == EFFECT_EXPERT and color:
243  self._attr_hs_color_attr_hs_color = (
244  color["hue"] * 360.0 / 255.0,
245  color["saturation"] * 100.0 / 255.0,
246  )
247  self._attr_brightness_attr_brightness = color["brightness"]
248  elif effect.mode == EFFECT_MODE and self._tv_tv.ambilight_cached:
249  hsv_h, hsv_s, hsv_v = color_RGB_to_hsv(
250  *_average_pixels(self._tv_tv.ambilight_cached)
251  )
252  self._attr_hs_color_attr_hs_color = hsv_h, hsv_s
253  self._attr_brightness_attr_brightness = hsv_v * 255.0 / 100.0
254  else:
255  self._attr_hs_color_attr_hs_color = None
256  self._attr_brightness_attr_brightness = None
257 
258  @callback
259  def _handle_coordinator_update(self) -> None:
260  """Handle updated data from the coordinator."""
261  self._update_from_coordinator_update_from_coordinator()
263 
265  self, effect: AmbilightEffect, hs_color: tuple[float, float], brightness: int
266  ):
267  """Set ambilight via the manual or expert mode."""
268  rgb = color_hsv_to_RGB(hs_color[0], hs_color[1], brightness * 100 / 255)
269 
270  data = {
271  "r": rgb[0],
272  "g": rgb[1],
273  "b": rgb[2],
274  }
275 
276  if not await self._tv_tv.setAmbilightCached(data):
277  raise HomeAssistantError("Failed to set ambilight color")
278 
279  if effect.style != self._tv_tv.ambilight_mode:
280  if not await self._tv_tv.setAmbilightMode(effect.style):
281  raise HomeAssistantError("Failed to set ambilight mode")
282 
284  self, effect: AmbilightEffect, hs_color: tuple[float, float], brightness: int
285  ):
286  """Set ambilight via current configuration."""
287  config: AmbilightCurrentConfiguration = {
288  "styleName": effect.style,
289  "isExpert": True,
290  }
291 
292  setting = {
293  "algorithm": effect.algorithm,
294  "color": {
295  "hue": round(hs_color[0] * 255.0 / 360.0),
296  "saturation": round(hs_color[1] * 255.0 / 100.0),
297  "brightness": round(brightness),
298  },
299  "colorDelta": {
300  "hue": 0,
301  "saturation": 0,
302  "brightness": 0,
303  },
304  }
305 
306  if effect.style in ("FOLLOW_COLOR", "Lounge light"):
307  config["colorSettings"] = setting
308  config["speed"] = 2
309 
310  elif effect.style == "FOLLOW_AUDIO":
311  config["audioSettings"] = setting
312  config["tuning"] = 0
313 
314  if not await self._tv_tv.setAmbilightCurrentConfiguration(config):
315  raise HomeAssistantError("Failed to set ambilight mode")
316 
317  async def _set_ambilight_config(self, effect: AmbilightEffect):
318  """Set ambilight via current configuration."""
319  config: AmbilightCurrentConfiguration = {
320  "styleName": effect.style,
321  "isExpert": False,
322  "menuSetting": effect.algorithm,
323  }
324 
325  if await self._tv_tv.setAmbilightCurrentConfiguration(config) is False:
326  raise HomeAssistantError("Failed to set ambilight mode")
327 
328  async def async_turn_on(self, **kwargs: Any) -> None:
329  """Turn the bulb on."""
330  brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightnessbrightness)
331  hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_colorhs_color)
332  attr_effect = kwargs.get(ATTR_EFFECT, self.effecteffect)
333 
334  if not self._tv_tv.on:
335  raise HomeAssistantError("TV is not available")
336 
337  effect = AmbilightEffect.from_str(attr_effect)
338 
339  if effect.style == "OFF":
340  if self._last_selected_effect_last_selected_effect:
341  effect = self._last_selected_effect_last_selected_effect
342  else:
343  effect = AmbilightEffect(EFFECT_AUTO, "FOLLOW_VIDEO", "STANDARD")
344 
345  if not effect.is_on(self._tv_tv.powerstate):
346  effect.mode = EFFECT_MODE
347  effect.algorithm = None
348  if self._tv_tv.powerstate in ("On", None):
349  effect.style = "internal"
350  else:
351  effect.style = "manual"
352 
353  if brightness is None:
354  brightness = 255
355 
356  if hs_color is None:
357  hs_color = (0, 0)
358 
359  if effect.mode == EFFECT_MODE:
360  await self._set_ambilight_cached_set_ambilight_cached(effect, hs_color, brightness)
361  elif effect.mode == EFFECT_AUTO:
362  await self._set_ambilight_config_set_ambilight_config(effect)
363  elif effect.mode == EFFECT_EXPERT:
364  await self._set_ambilight_expert_config_set_ambilight_expert_config(effect, hs_color, brightness)
365 
366  self._update_from_coordinator_update_from_coordinator()
367  self.async_write_ha_stateasync_write_ha_state()
368 
369  async def async_turn_off(self, **kwargs: Any) -> None:
370  """Turn of ambilight."""
371 
372  if not self._tv_tv.on:
373  raise HomeAssistantError("TV is not available")
374 
375  if await self._tv_tv.setAmbilightMode("internal") is False:
376  raise HomeAssistantError("Failed to set ambilight mode")
377 
378  await self._set_ambilight_config_set_ambilight_config(AmbilightEffect(EFFECT_MODE, "OFF", ""))
379 
380  self._update_from_coordinator_update_from_coordinator()
381  self.async_write_ha_stateasync_write_ha_state()
382 
383  @property
384  def available(self) -> bool:
385  """Return true if entity is available."""
386  if not super().available:
387  return False
388  if not self._tv_tv.on:
389  return False
390  return True
tuple[float, float]|None hs_color(self)
Definition: __init__.py:947
AmbilightEffect from_str(str effect_string)
Definition: light.py:85
None __init__(self, PhilipsTVDataUpdateCoordinator coordinator)
Definition: light.py:144
def _set_ambilight_config(self, AmbilightEffect effect)
Definition: light.py:317
def _set_ambilight_expert_config(self, AmbilightEffect effect, tuple[float, float] hs_color, int brightness)
Definition: light.py:285
def _set_ambilight_cached(self, AmbilightEffect effect, tuple[float, float] hs_color, int brightness)
Definition: light.py:266
None async_setup_entry(HomeAssistant hass, PhilipsTVConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: light.py:39
def _get_settings(AmbilightCurrentConfiguration style)
Definition: light.py:45
def _get_cache_keys(PhilipsTV device)
Definition: light.py:104
tuple[int, int, int] color_hsv_to_RGB(float iH, float iS, float iV)
Definition: color.py:372
tuple[float, float, float] color_RGB_to_hsv(float iR, float iG, float iB)
Definition: color.py:356