Home Assistant Unofficial Reference 2024.12.1
type_lights.py
Go to the documentation of this file.
1 """Class to hold all light accessories."""
2 
3 from __future__ import annotations
4 
5 from datetime import datetime
6 import logging
7 from typing import Any
8 
9 from pyhap.const import CATEGORY_LIGHTBULB
10 
12  ATTR_BRIGHTNESS,
13  ATTR_BRIGHTNESS_PCT,
14  ATTR_COLOR_MODE,
15  ATTR_COLOR_TEMP_KELVIN,
16  ATTR_HS_COLOR,
17  ATTR_MAX_COLOR_TEMP_KELVIN,
18  ATTR_MIN_COLOR_TEMP_KELVIN,
19  ATTR_RGBW_COLOR,
20  ATTR_RGBWW_COLOR,
21  ATTR_SUPPORTED_COLOR_MODES,
22  ATTR_WHITE,
23  DOMAIN as LIGHT_DOMAIN,
24  ColorMode,
25  brightness_supported,
26  color_supported,
27  color_temp_supported,
28 )
29 from homeassistant.const import (
30  ATTR_ENTITY_ID,
31  SERVICE_TURN_OFF,
32  SERVICE_TURN_ON,
33  STATE_ON,
34 )
35 from homeassistant.core import CALLBACK_TYPE, State, callback
36 from homeassistant.helpers.event import async_call_later
37 from homeassistant.util.color import (
38  color_temperature_kelvin_to_mired,
39  color_temperature_mired_to_kelvin,
40  color_temperature_to_hs,
41  color_temperature_to_rgbww,
42 )
43 
44 from .accessories import TYPES, HomeAccessory
45 from .const import (
46  CHAR_BRIGHTNESS,
47  CHAR_COLOR_TEMPERATURE,
48  CHAR_HUE,
49  CHAR_ON,
50  CHAR_SATURATION,
51  PROP_MAX_VALUE,
52  PROP_MIN_VALUE,
53  SERV_LIGHTBULB,
54 )
55 
56 _LOGGER = logging.getLogger(__name__)
57 
58 
59 CHANGE_COALESCE_TIME_WINDOW = 0.01
60 
61 DEFAULT_MIN_COLOR_TEMP = 2000 # 500 mireds
62 DEFAULT_MAX_COLOR_TEMP = 6500 # 153 mireds
63 
64 COLOR_MODES_WITH_WHITES = {ColorMode.RGBW, ColorMode.RGBWW, ColorMode.WHITE}
65 
66 
67 @TYPES.register("Light")
69  """Generate a Light accessory for a light entity.
70 
71  Currently supports: state, brightness, color temperature, rgb_color.
72  """
73 
74  def __init__(self, *args: Any) -> None:
75  """Initialize a new Light accessory object."""
76  super().__init__(*args, category=CATEGORY_LIGHTBULB)
77  self._reload_on_change_attrs_reload_on_change_attrs.extend(
78  (
79  ATTR_SUPPORTED_COLOR_MODES,
80  ATTR_MAX_COLOR_TEMP_KELVIN,
81  ATTR_MIN_COLOR_TEMP_KELVIN,
82  )
83  )
84  self.charschars = []
85  self._event_timer_event_timer: CALLBACK_TYPE | None = None
86  self._pending_events_pending_events: dict[str, Any] = {}
87 
88  state = self.hasshass.states.get(self.entity_identity_id)
89  assert state
90  attributes = state.attributes
91  self.color_modescolor_modes = color_modes = (
92  attributes.get(ATTR_SUPPORTED_COLOR_MODES) or []
93  )
94  self._previous_color_mode_previous_color_mode = attributes.get(ATTR_COLOR_MODE)
95  self.color_supportedcolor_supported = color_supported(color_modes)
96  self.color_temp_supportedcolor_temp_supported = color_temp_supported(color_modes)
97  self.rgbw_supportedrgbw_supported = ColorMode.RGBW in color_modes
98  self.rgbww_supportedrgbww_supported = ColorMode.RGBWW in color_modes
99  self.white_supportedwhite_supported = ColorMode.WHITE in color_modes
100  self.brightness_supportedbrightness_supported = brightness_supported(color_modes)
101 
102  if self.brightness_supportedbrightness_supported:
103  self.charschars.append(CHAR_BRIGHTNESS)
104 
105  if self.color_supportedcolor_supported:
106  self.charschars.extend([CHAR_HUE, CHAR_SATURATION])
107 
108  if self.color_temp_supportedcolor_temp_supported or COLOR_MODES_WITH_WHITES.intersection(
109  self.color_modescolor_modes
110  ):
111  self.charschars.append(CHAR_COLOR_TEMPERATURE)
112 
113  serv_light = self.add_preload_service(SERV_LIGHTBULB, self.charschars)
114  self.char_onchar_on = serv_light.configure_char(CHAR_ON, value=0)
115 
116  if self.brightness_supportedbrightness_supported:
117  # Initial value is set to 100 because 0 is a special value (off). 100 is
118  # an arbitrary non-zero value. It is updated immediately by async_update_state
119  # to set to the correct initial value.
120  self.char_brightnesschar_brightness = serv_light.configure_char(CHAR_BRIGHTNESS, value=100)
121 
122  if CHAR_COLOR_TEMPERATURE in self.charschars:
124  attributes.get(ATTR_MAX_COLOR_TEMP_KELVIN, DEFAULT_MAX_COLOR_TEMP)
125  )
127  attributes.get(ATTR_MIN_COLOR_TEMP_KELVIN, DEFAULT_MIN_COLOR_TEMP)
128  )
129  if not self.color_temp_supportedcolor_temp_supported and not self.rgbww_supportedrgbww_supported:
130  self.max_miredsmax_mireds = self.min_miredsmin_mireds
131  self.char_color_tempchar_color_temp = serv_light.configure_char(
132  CHAR_COLOR_TEMPERATURE,
133  value=self.min_miredsmin_mireds,
134  properties={
135  PROP_MIN_VALUE: self.min_miredsmin_mireds,
136  PROP_MAX_VALUE: self.max_miredsmax_mireds,
137  },
138  )
139 
140  if self.color_supportedcolor_supported:
141  self.char_huechar_hue = serv_light.configure_char(CHAR_HUE, value=0)
142  self.char_saturationchar_saturation = serv_light.configure_char(CHAR_SATURATION, value=75)
143 
144  self.async_update_stateasync_update_stateasync_update_state(state)
145  serv_light.setter_callback = self._set_chars_set_chars
146 
147  def _set_chars(self, char_values: dict[str, Any]) -> None:
148  _LOGGER.debug("Light _set_chars: %s", char_values)
149  # Newest change always wins
150  if CHAR_COLOR_TEMPERATURE in self._pending_events_pending_events and (
151  CHAR_SATURATION in char_values or CHAR_HUE in char_values
152  ):
153  del self._pending_events_pending_events[CHAR_COLOR_TEMPERATURE]
154  for char in (CHAR_HUE, CHAR_SATURATION):
155  if char in self._pending_events_pending_events and CHAR_COLOR_TEMPERATURE in char_values:
156  del self._pending_events_pending_events[char]
157 
158  self._pending_events_pending_events.update(char_values)
159  if self._event_timer_event_timer:
160  self._event_timer_event_timer()
161  self._event_timer_event_timer = async_call_later(
162  self.hasshass, CHANGE_COALESCE_TIME_WINDOW, self._async_send_events_async_send_events
163  )
164 
165  @callback
166  def _async_send_events(self, _now: datetime) -> None:
167  """Process all changes at once."""
168  _LOGGER.debug("Coalesced _set_chars: %s", self._pending_events_pending_events)
169  char_values = self._pending_events_pending_events
170  self._pending_events_pending_events = {}
171  events = []
172  service = SERVICE_TURN_ON
173  params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_identity_id}
174  has_on = CHAR_ON in char_values
175 
176  if has_on:
177  if not char_values[CHAR_ON]:
178  service = SERVICE_TURN_OFF
179  events.append(f"Set state to {char_values[CHAR_ON]}")
180 
181  brightness_pct = None
182  if CHAR_BRIGHTNESS in char_values:
183  if char_values[CHAR_BRIGHTNESS] == 0:
184  if has_on:
185  events[-1] = "Set state to 0"
186  else:
187  events.append("Set state to 0")
188  service = SERVICE_TURN_OFF
189  else:
190  brightness_pct = char_values[CHAR_BRIGHTNESS]
191  events.append(f"brightness at {char_values[CHAR_BRIGHTNESS]}%")
192 
193  if service == SERVICE_TURN_OFF:
194  self.async_call_serviceasync_call_service(
195  LIGHT_DOMAIN,
196  service,
197  {ATTR_ENTITY_ID: self.entity_identity_id},
198  ", ".join(events),
199  )
200  return
201 
202  # Handle white channels
203  if CHAR_COLOR_TEMPERATURE in char_values:
204  temp = char_values[CHAR_COLOR_TEMPERATURE]
205  events.append(f"color temperature at {temp}")
206  bright_val = round(
207  ((brightness_pct or self.char_brightnesschar_brightness.value) * 255) / 100
208  )
209  if self.color_temp_supportedcolor_temp_supported:
210  params[ATTR_COLOR_TEMP_KELVIN] = color_temperature_mired_to_kelvin(temp)
211  elif self.rgbww_supportedrgbww_supported:
212  params[ATTR_RGBWW_COLOR] = color_temperature_to_rgbww(
214  bright_val,
217  )
218  elif self.rgbw_supportedrgbw_supported:
219  params[ATTR_RGBW_COLOR] = (*(0,) * 3, bright_val)
220  elif self.white_supportedwhite_supported:
221  params[ATTR_WHITE] = bright_val
222 
223  elif CHAR_HUE in char_values or CHAR_SATURATION in char_values:
224  hue_sat = (
225  char_values.get(CHAR_HUE, self.char_huechar_hue.value),
226  char_values.get(CHAR_SATURATION, self.char_saturationchar_saturation.value),
227  )
228  _LOGGER.debug("%s: Set hs_color to %s", self.entity_identity_id, hue_sat)
229  events.append(f"set color at {hue_sat}")
230  params[ATTR_HS_COLOR] = hue_sat
231 
232  if (
233  brightness_pct
234  and ATTR_RGBWW_COLOR not in params
235  and ATTR_RGBW_COLOR not in params
236  ):
237  params[ATTR_BRIGHTNESS_PCT] = brightness_pct
238 
239  _LOGGER.debug(
240  "Calling light service with params: %s -> %s", char_values, params
241  )
242  self.async_call_serviceasync_call_service(LIGHT_DOMAIN, service, params, ", ".join(events))
243 
244  @callback
245  def async_update_state(self, new_state: State) -> None:
246  """Update light after state change."""
247  # Handle State
248  state = new_state.state
249  attributes = new_state.attributes
250  color_mode = attributes.get(ATTR_COLOR_MODE)
251  self.char_onchar_on.set_value(int(state == STATE_ON))
252  color_mode_changed = self._previous_color_mode_previous_color_mode != color_mode
253  self._previous_color_mode_previous_color_mode = color_mode
254 
255  # Handle Brightness
256  if (
257  self.brightness_supportedbrightness_supported
258  and (brightness := attributes.get(ATTR_BRIGHTNESS)) is not None
259  and isinstance(brightness, (int, float))
260  ):
261  brightness = round(brightness / 255 * 100, 0)
262  # The homeassistant component might report its brightness as 0 but is
263  # not off. But 0 is a special value in homekit. When you turn on a
264  # homekit accessory it will try to restore the last brightness state
265  # which will be the last value saved by char_brightness.set_value.
266  # But if it is set to 0, HomeKit will update the brightness to 100 as
267  # it thinks 0 is off.
268  #
269  # Therefore, if the brightness is 0 and the device is still on,
270  # the brightness is mapped to 1 otherwise the update is ignored in
271  # order to avoid this incorrect behavior.
272  if brightness == 0 and state == STATE_ON:
273  brightness = 1
274  self.char_brightnesschar_brightness.set_value(brightness)
275  if color_mode_changed:
276  self.char_brightnesschar_brightness.notify()
277 
278  # Handle Color - color must always be set before color temperature
279  # or the iOS UI will not display it correctly.
280  if self.color_supportedcolor_supported:
281  if color_temp := attributes.get(ATTR_COLOR_TEMP_KELVIN):
282  hue, saturation = color_temperature_to_hs(color_temp)
283  elif color_mode == ColorMode.WHITE:
284  hue, saturation = 0, 0
285  elif hue_sat := attributes.get(ATTR_HS_COLOR):
286  hue, saturation = hue_sat
287  else:
288  hue = None
289  saturation = None
290  if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)):
291  self.char_huechar_hue.set_value(round(hue, 0))
292  self.char_saturationchar_saturation.set_value(round(saturation, 0))
293  if color_mode_changed:
294  # If the color temp changed, be sure to force the color to update
295  self.char_huechar_hue.notify()
296  self.char_saturationchar_saturation.notify()
297 
298  # Handle white channels
299  if CHAR_COLOR_TEMPERATURE in self.charschars:
300  color_temp = None
301  if self.color_temp_supportedcolor_temp_supported:
302  color_temp_kelvin = attributes.get(ATTR_COLOR_TEMP_KELVIN)
303  if color_temp_kelvin is not None:
304  color_temp = color_temperature_kelvin_to_mired(color_temp_kelvin)
305  elif color_mode == ColorMode.WHITE:
306  color_temp = self.min_miredsmin_mireds
307  if isinstance(color_temp, (int, float)):
308  self.char_color_tempchar_color_temp.set_value(round(color_temp, 0))
309  if color_mode_changed:
310  self.char_color_tempchar_color_temp.notify()
None async_call_service(self, str domain, str service, dict[str, Any]|None service_data, Any|None value=None)
Definition: accessories.py:609
None _set_chars(self, dict[str, Any] char_values)
Definition: type_lights.py:147
None async_update_state(self, State new_state)
Definition: type_lights.py:245
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
CALLBACK_TYPE async_call_later(HomeAssistant hass, float|timedelta delay, HassJob[[datetime], Coroutine[Any, Any, None]|None]|Callable[[datetime], Coroutine[Any, Any, None]|None] action)
Definition: event.py:1597
int color_temperature_mired_to_kelvin(float mired_temperature)
Definition: color.py:631
tuple[float, float] color_temperature_to_hs(float color_temperature_kelvin)
Definition: color.py:505
tuple[int, int, int, int, int] color_temperature_to_rgbww(int temperature, int brightness, int min_kelvin, int max_kelvin)
Definition: color.py:537
int color_temperature_kelvin_to_mired(float kelvin_temperature)
Definition: color.py:636