Home Assistant Unofficial Reference 2024.12.1
schema_basic.py
Go to the documentation of this file.
1 """Support for MQTT lights."""
2 
3 from __future__ import annotations
4 
5 from collections.abc import Callable
6 import logging
7 from typing import Any, cast
8 
9 import voluptuous as vol
10 
12  ATTR_BRIGHTNESS,
13  ATTR_COLOR_MODE,
14  ATTR_COLOR_TEMP,
15  ATTR_EFFECT,
16  ATTR_EFFECT_LIST,
17  ATTR_HS_COLOR,
18  ATTR_MAX_MIREDS,
19  ATTR_MIN_MIREDS,
20  ATTR_RGB_COLOR,
21  ATTR_RGBW_COLOR,
22  ATTR_RGBWW_COLOR,
23  ATTR_SUPPORTED_COLOR_MODES,
24  ATTR_WHITE,
25  ATTR_XY_COLOR,
26  ENTITY_ID_FORMAT,
27  ColorMode,
28  LightEntity,
29  LightEntityFeature,
30  valid_supported_color_modes,
31 )
32 from homeassistant.const import (
33  CONF_NAME,
34  CONF_OPTIMISTIC,
35  CONF_PAYLOAD_OFF,
36  CONF_PAYLOAD_ON,
37  STATE_ON,
38 )
39 from homeassistant.core import callback
41 from homeassistant.helpers.restore_state import RestoreEntity
42 from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
43 from homeassistant.helpers.typing import ConfigType, VolSchemaType
44 import homeassistant.util.color as color_util
45 
46 from .. import subscription
47 from ..config import MQTT_RW_SCHEMA
48 from ..const import (
49  CONF_COMMAND_TOPIC,
50  CONF_STATE_TOPIC,
51  CONF_STATE_VALUE_TEMPLATE,
52  PAYLOAD_NONE,
53 )
54 from ..entity import MqttEntity
55 from ..models import (
56  MqttCommandTemplate,
57  MqttValueTemplate,
58  PayloadSentinel,
59  PublishPayloadType,
60  ReceiveMessage,
61  TemplateVarsType,
62 )
63 from ..schemas import MQTT_ENTITY_COMMON_SCHEMA
64 from ..util import valid_publish_topic, valid_subscribe_topic
65 from .schema import MQTT_LIGHT_SCHEMA_SCHEMA
66 
67 _LOGGER = logging.getLogger(__name__)
68 
69 CONF_BRIGHTNESS_COMMAND_TEMPLATE = "brightness_command_template"
70 CONF_BRIGHTNESS_COMMAND_TOPIC = "brightness_command_topic"
71 CONF_BRIGHTNESS_SCALE = "brightness_scale"
72 CONF_BRIGHTNESS_STATE_TOPIC = "brightness_state_topic"
73 CONF_BRIGHTNESS_VALUE_TEMPLATE = "brightness_value_template"
74 CONF_COLOR_MODE_STATE_TOPIC = "color_mode_state_topic"
75 CONF_COLOR_MODE_VALUE_TEMPLATE = "color_mode_value_template"
76 CONF_COLOR_TEMP_COMMAND_TEMPLATE = "color_temp_command_template"
77 CONF_COLOR_TEMP_COMMAND_TOPIC = "color_temp_command_topic"
78 CONF_COLOR_TEMP_STATE_TOPIC = "color_temp_state_topic"
79 CONF_COLOR_TEMP_VALUE_TEMPLATE = "color_temp_value_template"
80 CONF_EFFECT_COMMAND_TEMPLATE = "effect_command_template"
81 CONF_EFFECT_COMMAND_TOPIC = "effect_command_topic"
82 CONF_EFFECT_LIST = "effect_list"
83 CONF_EFFECT_STATE_TOPIC = "effect_state_topic"
84 CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
85 CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
86 CONF_HS_COMMAND_TOPIC = "hs_command_topic"
87 CONF_HS_STATE_TOPIC = "hs_state_topic"
88 CONF_HS_VALUE_TEMPLATE = "hs_value_template"
89 CONF_MAX_MIREDS = "max_mireds"
90 CONF_MIN_MIREDS = "min_mireds"
91 CONF_RGB_COMMAND_TEMPLATE = "rgb_command_template"
92 CONF_RGB_COMMAND_TOPIC = "rgb_command_topic"
93 CONF_RGB_STATE_TOPIC = "rgb_state_topic"
94 CONF_RGB_VALUE_TEMPLATE = "rgb_value_template"
95 CONF_RGBW_COMMAND_TEMPLATE = "rgbw_command_template"
96 CONF_RGBW_COMMAND_TOPIC = "rgbw_command_topic"
97 CONF_RGBW_STATE_TOPIC = "rgbw_state_topic"
98 CONF_RGBW_VALUE_TEMPLATE = "rgbw_value_template"
99 CONF_RGBWW_COMMAND_TEMPLATE = "rgbww_command_template"
100 CONF_RGBWW_COMMAND_TOPIC = "rgbww_command_topic"
101 CONF_RGBWW_STATE_TOPIC = "rgbww_state_topic"
102 CONF_RGBWW_VALUE_TEMPLATE = "rgbww_value_template"
103 CONF_XY_COMMAND_TEMPLATE = "xy_command_template"
104 CONF_XY_COMMAND_TOPIC = "xy_command_topic"
105 CONF_XY_STATE_TOPIC = "xy_state_topic"
106 CONF_XY_VALUE_TEMPLATE = "xy_value_template"
107 CONF_WHITE_COMMAND_TOPIC = "white_command_topic"
108 CONF_WHITE_SCALE = "white_scale"
109 CONF_ON_COMMAND_TYPE = "on_command_type"
110 
111 MQTT_LIGHT_ATTRIBUTES_BLOCKED = frozenset(
112  {
113  ATTR_COLOR_MODE,
114  ATTR_BRIGHTNESS,
115  ATTR_COLOR_TEMP,
116  ATTR_EFFECT,
117  ATTR_EFFECT_LIST,
118  ATTR_HS_COLOR,
119  ATTR_MAX_MIREDS,
120  ATTR_MIN_MIREDS,
121  ATTR_RGB_COLOR,
122  ATTR_RGBW_COLOR,
123  ATTR_RGBWW_COLOR,
124  ATTR_SUPPORTED_COLOR_MODES,
125  ATTR_XY_COLOR,
126  }
127 )
128 
129 DEFAULT_BRIGHTNESS_SCALE = 255
130 DEFAULT_NAME = "MQTT LightEntity"
131 DEFAULT_PAYLOAD_OFF = "OFF"
132 DEFAULT_PAYLOAD_ON = "ON"
133 DEFAULT_WHITE_SCALE = 255
134 DEFAULT_ON_COMMAND_TYPE = "last"
135 
136 VALUES_ON_COMMAND_TYPE = ["first", "last", "brightness"]
137 
138 COMMAND_TEMPLATE_KEYS = [
139  CONF_BRIGHTNESS_COMMAND_TEMPLATE,
140  CONF_COLOR_TEMP_COMMAND_TEMPLATE,
141  CONF_EFFECT_COMMAND_TEMPLATE,
142  CONF_HS_COMMAND_TEMPLATE,
143  CONF_RGB_COMMAND_TEMPLATE,
144  CONF_RGBW_COMMAND_TEMPLATE,
145  CONF_RGBWW_COMMAND_TEMPLATE,
146  CONF_XY_COMMAND_TEMPLATE,
147 ]
148 VALUE_TEMPLATE_KEYS = [
149  CONF_BRIGHTNESS_VALUE_TEMPLATE,
150  CONF_COLOR_MODE_VALUE_TEMPLATE,
151  CONF_COLOR_TEMP_VALUE_TEMPLATE,
152  CONF_EFFECT_VALUE_TEMPLATE,
153  CONF_HS_VALUE_TEMPLATE,
154  CONF_RGB_VALUE_TEMPLATE,
155  CONF_RGBW_VALUE_TEMPLATE,
156  CONF_RGBWW_VALUE_TEMPLATE,
157  CONF_STATE_VALUE_TEMPLATE,
158  CONF_XY_VALUE_TEMPLATE,
159 ]
160 
161 PLATFORM_SCHEMA_MODERN_BASIC = (
162  MQTT_RW_SCHEMA.extend(
163  {
164  vol.Optional(CONF_BRIGHTNESS_COMMAND_TEMPLATE): cv.template,
165  vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): valid_publish_topic,
166  vol.Optional(
167  CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE
168  ): vol.All(vol.Coerce(int), vol.Range(min=1)),
169  vol.Optional(CONF_BRIGHTNESS_STATE_TOPIC): valid_subscribe_topic,
170  vol.Optional(CONF_BRIGHTNESS_VALUE_TEMPLATE): cv.template,
171  vol.Optional(CONF_COLOR_MODE_STATE_TOPIC): valid_subscribe_topic,
172  vol.Optional(CONF_COLOR_MODE_VALUE_TEMPLATE): cv.template,
173  vol.Optional(CONF_COLOR_TEMP_COMMAND_TEMPLATE): cv.template,
174  vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): valid_publish_topic,
175  vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): valid_subscribe_topic,
176  vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template,
177  vol.Optional(CONF_EFFECT_COMMAND_TEMPLATE): cv.template,
178  vol.Optional(CONF_EFFECT_COMMAND_TOPIC): valid_publish_topic,
179  vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
180  vol.Optional(CONF_EFFECT_STATE_TOPIC): valid_subscribe_topic,
181  vol.Optional(CONF_EFFECT_VALUE_TEMPLATE): cv.template,
182  vol.Optional(CONF_HS_COMMAND_TEMPLATE): cv.template,
183  vol.Optional(CONF_HS_COMMAND_TOPIC): valid_publish_topic,
184  vol.Optional(CONF_HS_STATE_TOPIC): valid_subscribe_topic,
185  vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template,
186  vol.Optional(CONF_MAX_MIREDS): cv.positive_int,
187  vol.Optional(CONF_MIN_MIREDS): cv.positive_int,
188  vol.Optional(CONF_NAME): vol.Any(cv.string, None),
189  vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE): vol.In(
190  VALUES_ON_COMMAND_TYPE
191  ),
192  vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
193  vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
194  vol.Optional(CONF_RGB_COMMAND_TEMPLATE): cv.template,
195  vol.Optional(CONF_RGB_COMMAND_TOPIC): valid_publish_topic,
196  vol.Optional(CONF_RGB_STATE_TOPIC): valid_subscribe_topic,
197  vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template,
198  vol.Optional(CONF_RGBW_COMMAND_TEMPLATE): cv.template,
199  vol.Optional(CONF_RGBW_COMMAND_TOPIC): valid_publish_topic,
200  vol.Optional(CONF_RGBW_STATE_TOPIC): valid_subscribe_topic,
201  vol.Optional(CONF_RGBW_VALUE_TEMPLATE): cv.template,
202  vol.Optional(CONF_RGBWW_COMMAND_TEMPLATE): cv.template,
203  vol.Optional(CONF_RGBWW_COMMAND_TOPIC): valid_publish_topic,
204  vol.Optional(CONF_RGBWW_STATE_TOPIC): valid_subscribe_topic,
205  vol.Optional(CONF_RGBWW_VALUE_TEMPLATE): cv.template,
206  vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template,
207  vol.Optional(CONF_WHITE_COMMAND_TOPIC): valid_publish_topic,
208  vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All(
209  vol.Coerce(int), vol.Range(min=1)
210  ),
211  vol.Optional(CONF_XY_COMMAND_TEMPLATE): cv.template,
212  vol.Optional(CONF_XY_COMMAND_TOPIC): valid_publish_topic,
213  vol.Optional(CONF_XY_STATE_TOPIC): valid_subscribe_topic,
214  vol.Optional(CONF_XY_VALUE_TEMPLATE): cv.template,
215  },
216  )
217  .extend(MQTT_ENTITY_COMMON_SCHEMA.schema)
218  .extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema)
219 )
220 
221 DISCOVERY_SCHEMA_BASIC = vol.All(
222  PLATFORM_SCHEMA_MODERN_BASIC.extend({}, extra=vol.REMOVE_EXTRA),
223 )
224 
225 
227  """Representation of a MQTT light."""
228 
229  _default_name = DEFAULT_NAME
230  _entity_id_format = ENTITY_ID_FORMAT
231  _attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
232  _topic: dict[str, str | None]
233  _payload: dict[str, str]
234  _command_templates: dict[
235  str, Callable[[PublishPayloadType, TemplateVarsType], PublishPayloadType]
236  ]
237  _value_templates: dict[
238  str, Callable[[ReceivePayloadType, ReceivePayloadType], ReceivePayloadType]
239  ]
240  _optimistic: bool
241  _optimistic_brightness: bool
242  _optimistic_color_mode: bool
243  _optimistic_color_temp: bool
244  _optimistic_effect: bool
245  _optimistic_hs_color: bool
246  _optimistic_rgb_color: bool
247  _optimistic_rgbw_color: bool
248  _optimistic_rgbww_color: bool
249  _optimistic_xy_color: bool
250 
251  @staticmethod
252  def config_schema() -> VolSchemaType:
253  """Return the config schema."""
254  return DISCOVERY_SCHEMA_BASIC
255 
256  def _setup_from_config(self, config: ConfigType) -> None:
257  """(Re)Setup the entity."""
258  self._attr_min_mireds_attr_min_mireds = config.get(CONF_MIN_MIREDS, super().min_mireds)
259  self._attr_max_mireds_attr_max_mireds = config.get(CONF_MAX_MIREDS, super().max_mireds)
260  self._attr_effect_list_attr_effect_list = config.get(CONF_EFFECT_LIST)
261 
262  topic: dict[str, str | None] = {
263  key: config.get(key)
264  for key in (
265  CONF_BRIGHTNESS_COMMAND_TOPIC,
266  CONF_BRIGHTNESS_STATE_TOPIC,
267  CONF_COLOR_MODE_STATE_TOPIC,
268  CONF_COLOR_TEMP_COMMAND_TOPIC,
269  CONF_COLOR_TEMP_STATE_TOPIC,
270  CONF_COMMAND_TOPIC,
271  CONF_EFFECT_COMMAND_TOPIC,
272  CONF_EFFECT_STATE_TOPIC,
273  CONF_HS_COMMAND_TOPIC,
274  CONF_HS_STATE_TOPIC,
275  CONF_RGB_COMMAND_TOPIC,
276  CONF_RGB_STATE_TOPIC,
277  CONF_RGBW_COMMAND_TOPIC,
278  CONF_RGBW_STATE_TOPIC,
279  CONF_RGBWW_COMMAND_TOPIC,
280  CONF_RGBWW_STATE_TOPIC,
281  CONF_STATE_TOPIC,
282  CONF_WHITE_COMMAND_TOPIC,
283  CONF_XY_COMMAND_TOPIC,
284  CONF_XY_STATE_TOPIC,
285  )
286  }
287  self._topic_topic = topic
288  self._payload_payload = {"on": config[CONF_PAYLOAD_ON], "off": config[CONF_PAYLOAD_OFF]}
289 
290  self._value_templates_value_templates = {
291  key: MqttValueTemplate(
292  config.get(key), entity=self
293  ).async_render_with_possible_json_value
294  for key in VALUE_TEMPLATE_KEYS
295  }
296 
297  self._command_templates_command_templates = {
298  key: MqttCommandTemplate(config.get(key), entity=self).async_render
299  for key in COMMAND_TEMPLATE_KEYS
300  }
301 
302  optimistic: bool = config[CONF_OPTIMISTIC]
303  self._optimistic_color_mode_optimistic_color_mode = (
304  optimistic or topic[CONF_COLOR_MODE_STATE_TOPIC] is None
305  )
306  self._optimistic_optimistic = optimistic or topic[CONF_STATE_TOPIC] is None
307  self._attr_assumed_state_attr_assumed_state = bool(self._optimistic_optimistic)
308  self._optimistic_rgb_color_optimistic_rgb_color = optimistic or topic[CONF_RGB_STATE_TOPIC] is None
309  self._optimistic_rgbw_color_optimistic_rgbw_color = optimistic or topic[CONF_RGBW_STATE_TOPIC] is None
310  self._optimistic_rgbww_color_optimistic_rgbww_color = (
311  optimistic or topic[CONF_RGBWW_STATE_TOPIC] is None
312  )
313  self._optimistic_brightness_optimistic_brightness = (
314  optimistic
315  or (
316  topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None
317  and topic[CONF_BRIGHTNESS_STATE_TOPIC] is None
318  )
319  or (
320  topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is None
321  and topic[CONF_RGB_STATE_TOPIC] is None
322  )
323  )
324  self._optimistic_color_temp_optimistic_color_temp = (
325  optimistic or topic[CONF_COLOR_TEMP_STATE_TOPIC] is None
326  )
327  self._optimistic_effect_optimistic_effect = optimistic or topic[CONF_EFFECT_STATE_TOPIC] is None
328  self._optimistic_hs_color_optimistic_hs_color = optimistic or topic[CONF_HS_STATE_TOPIC] is None
329  self._optimistic_xy_color_optimistic_xy_color = optimistic or topic[CONF_XY_STATE_TOPIC] is None
330  supported_color_modes: set[ColorMode] = set()
331  if topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None:
332  supported_color_modes.add(ColorMode.COLOR_TEMP)
333  self._attr_color_mode_attr_color_mode = ColorMode.COLOR_TEMP
334  if topic[CONF_HS_COMMAND_TOPIC] is not None:
335  supported_color_modes.add(ColorMode.HS)
336  self._attr_color_mode_attr_color_mode = ColorMode.HS
337  if topic[CONF_RGB_COMMAND_TOPIC] is not None:
338  supported_color_modes.add(ColorMode.RGB)
339  self._attr_color_mode_attr_color_mode = ColorMode.RGB
340  if topic[CONF_RGBW_COMMAND_TOPIC] is not None:
341  supported_color_modes.add(ColorMode.RGBW)
342  self._attr_color_mode_attr_color_mode = ColorMode.RGBW
343  if topic[CONF_RGBWW_COMMAND_TOPIC] is not None:
344  supported_color_modes.add(ColorMode.RGBWW)
345  self._attr_color_mode_attr_color_mode = ColorMode.RGBWW
346  if topic[CONF_WHITE_COMMAND_TOPIC] is not None:
347  supported_color_modes.add(ColorMode.WHITE)
348  if topic[CONF_XY_COMMAND_TOPIC] is not None:
349  supported_color_modes.add(ColorMode.XY)
350  self._attr_color_mode_attr_color_mode = ColorMode.XY
351  if len(supported_color_modes) > 1:
352  self._attr_color_mode_attr_color_mode = ColorMode.UNKNOWN
353 
354  if not supported_color_modes:
355  if topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None:
356  self._attr_color_mode_attr_color_mode = ColorMode.BRIGHTNESS
357  supported_color_modes.add(ColorMode.BRIGHTNESS)
358  else:
359  self._attr_color_mode_attr_color_mode = ColorMode.ONOFF
360  supported_color_modes.add(ColorMode.ONOFF)
361 
362  # Validate the color_modes configuration
363  self._attr_supported_color_modes_attr_supported_color_modes = valid_supported_color_modes(
364  supported_color_modes
365  )
366 
367  self._attr_supported_features_attr_supported_features = LightEntityFeature(0)
368  if topic[CONF_EFFECT_COMMAND_TOPIC] is not None:
369  self._attr_supported_features_attr_supported_features |= LightEntityFeature.EFFECT
370 
371  def _is_optimistic(self, attribute: str) -> bool:
372  """Return True if the attribute is optimistically updated."""
373  attr: bool = getattr(self, f"_optimistic_{attribute}")
374  return attr
375 
376  @callback
377  def _state_received(self, msg: ReceiveMessage) -> None:
378  """Handle new MQTT messages."""
379  payload = self._value_templates_value_templates[CONF_STATE_VALUE_TEMPLATE](
380  msg.payload, PayloadSentinel.NONE
381  )
382  if not payload:
383  _LOGGER.debug("Ignoring empty state message from '%s'", msg.topic)
384  return
385 
386  if payload == self._payload_payload["on"]:
387  self._attr_is_on_attr_is_on = True
388  elif payload == self._payload_payload["off"]:
389  self._attr_is_on_attr_is_on = False
390  elif payload == PAYLOAD_NONE:
391  self._attr_is_on_attr_is_on = None
392 
393  @callback
394  def _brightness_received(self, msg: ReceiveMessage) -> None:
395  """Handle new MQTT messages for the brightness."""
396  payload = self._value_templates_value_templates[CONF_BRIGHTNESS_VALUE_TEMPLATE](
397  msg.payload, PayloadSentinel.DEFAULT
398  )
399  if payload is PayloadSentinel.DEFAULT or not payload:
400  _LOGGER.debug("Ignoring empty brightness message from '%s'", msg.topic)
401  return
402 
403  device_value = float(payload)
404  if device_value == 0:
405  _LOGGER.debug("Ignoring zero brightness from '%s'", msg.topic)
406  return
407 
408  percent_bright = device_value / self._config_config[CONF_BRIGHTNESS_SCALE]
409  self._attr_brightness_attr_brightness = min(round(percent_bright * 255), 255)
410 
411  @callback
413  self,
414  msg: ReceiveMessage,
415  template: str,
416  color_mode: ColorMode,
417  convert_color: Callable[..., tuple[int, ...]],
418  ) -> tuple[int, ...] | None:
419  """Process MQTT messages for RGBW and RGBWW."""
420  payload = self._value_templates_value_templates[template](msg.payload, PayloadSentinel.DEFAULT)
421  if payload is PayloadSentinel.DEFAULT or not payload:
422  _LOGGER.debug("Ignoring empty %s message from '%s'", color_mode, msg.topic)
423  return None
424  color = tuple(int(val) for val in str(payload).split(","))
425  if self._optimistic_color_mode_optimistic_color_mode:
426  self._attr_color_mode_attr_color_mode = color_mode
427  if self._topic_topic[CONF_BRIGHTNESS_STATE_TOPIC] is None:
428  rgb = convert_color(*color)
429  brightness = max(rgb)
430  if brightness == 0:
431  _LOGGER.debug(
432  "Ignoring %s message with zero rgb brightness from '%s'",
433  color_mode,
434  msg.topic,
435  )
436  return None
437  self._attr_brightness_attr_brightness = brightness
438  # Normalize the color to 100% brightness
439  color = tuple(
440  min(round(channel / brightness * 255), 255) for channel in color
441  )
442  return color
443 
444  @callback
445  def _rgb_received(self, msg: ReceiveMessage) -> None:
446  """Handle new MQTT messages for RGB."""
447  rgb = self._rgbx_received_rgbx_received(
448  msg, CONF_RGB_VALUE_TEMPLATE, ColorMode.RGB, lambda *x: x
449  )
450  if rgb is None:
451  return
452  self._attr_rgb_color_attr_rgb_color = cast(tuple[int, int, int], rgb)
453 
454  @callback
455  def _rgbw_received(self, msg: ReceiveMessage) -> None:
456  """Handle new MQTT messages for RGBW."""
457  rgbw = self._rgbx_received_rgbx_received(
458  msg,
459  CONF_RGBW_VALUE_TEMPLATE,
460  ColorMode.RGBW,
461  color_util.color_rgbw_to_rgb,
462  )
463  if rgbw is None:
464  return
465  self._attr_rgbw_color_attr_rgbw_color = cast(tuple[int, int, int, int], rgbw)
466 
467  @callback
468  def _rgbww_received(self, msg: ReceiveMessage) -> None:
469  """Handle new MQTT messages for RGBWW."""
470 
471  @callback
472  def _converter(
473  r: int, g: int, b: int, cw: int, ww: int
474  ) -> tuple[int, int, int]:
475  min_kelvin = color_util.color_temperature_mired_to_kelvin(self.max_miredsmax_mireds)
476  max_kelvin = color_util.color_temperature_mired_to_kelvin(self.min_miredsmin_mireds)
477  return color_util.color_rgbww_to_rgb(
478  r, g, b, cw, ww, min_kelvin, max_kelvin
479  )
480 
481  rgbww = self._rgbx_received_rgbx_received(
482  msg,
483  CONF_RGBWW_VALUE_TEMPLATE,
484  ColorMode.RGBWW,
485  _converter,
486  )
487  if rgbww is None:
488  return
489  self._attr_rgbww_color_attr_rgbww_color = cast(tuple[int, int, int, int, int], rgbww)
490 
491  @callback
492  def _color_mode_received(self, msg: ReceiveMessage) -> None:
493  """Handle new MQTT messages for color mode."""
494  payload = self._value_templates_value_templates[CONF_COLOR_MODE_VALUE_TEMPLATE](
495  msg.payload, PayloadSentinel.DEFAULT
496  )
497  if payload is PayloadSentinel.DEFAULT or not payload:
498  _LOGGER.debug("Ignoring empty color mode message from '%s'", msg.topic)
499  return
500 
501  self._attr_color_mode_attr_color_mode = ColorMode(str(payload))
502 
503  @callback
504  def _color_temp_received(self, msg: ReceiveMessage) -> None:
505  """Handle new MQTT messages for color temperature."""
506  payload = self._value_templates_value_templates[CONF_COLOR_TEMP_VALUE_TEMPLATE](
507  msg.payload, PayloadSentinel.DEFAULT
508  )
509  if payload is PayloadSentinel.DEFAULT or not payload:
510  _LOGGER.debug("Ignoring empty color temp message from '%s'", msg.topic)
511  return
512 
513  if self._optimistic_color_mode_optimistic_color_mode:
514  self._attr_color_mode_attr_color_mode = ColorMode.COLOR_TEMP
515  self._attr_color_temp_attr_color_temp = int(payload)
516 
517  @callback
518  def _effect_received(self, msg: ReceiveMessage) -> None:
519  """Handle new MQTT messages for effect."""
520  payload = self._value_templates_value_templates[CONF_EFFECT_VALUE_TEMPLATE](
521  msg.payload, PayloadSentinel.DEFAULT
522  )
523  if payload is PayloadSentinel.DEFAULT or not payload:
524  _LOGGER.debug("Ignoring empty effect message from '%s'", msg.topic)
525  return
526 
527  self._attr_effect_attr_effect = str(payload)
528 
529  @callback
530  def _hs_received(self, msg: ReceiveMessage) -> None:
531  """Handle new MQTT messages for hs color."""
532  payload = self._value_templates_value_templates[CONF_HS_VALUE_TEMPLATE](
533  msg.payload, PayloadSentinel.DEFAULT
534  )
535  if payload is PayloadSentinel.DEFAULT or not payload:
536  _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic)
537  return
538  try:
539  hs_color = tuple(float(val) for val in str(payload).split(",", 2))
540  if self._optimistic_color_mode_optimistic_color_mode:
541  self._attr_color_mode_attr_color_mode = ColorMode.HS
542  self._attr_hs_color_attr_hs_color = cast(tuple[float, float], hs_color)
543  except ValueError:
544  _LOGGER.warning("Failed to parse hs state update: '%s'", payload)
545 
546  @callback
547  def _xy_received(self, msg: ReceiveMessage) -> None:
548  """Handle new MQTT messages for xy color."""
549  payload = self._value_templates_value_templates[CONF_XY_VALUE_TEMPLATE](
550  msg.payload, PayloadSentinel.DEFAULT
551  )
552  if payload is PayloadSentinel.DEFAULT or not payload:
553  _LOGGER.debug("Ignoring empty xy-color message from '%s'", msg.topic)
554  return
555 
556  xy_color = tuple(float(val) for val in str(payload).split(",", 2))
557  if self._optimistic_color_mode_optimistic_color_mode:
558  self._attr_color_mode_attr_color_mode = ColorMode.XY
559  self._attr_xy_color_attr_xy_color = cast(tuple[float, float], xy_color)
560 
561  @callback
562  def _prepare_subscribe_topics(self) -> None: # noqa: C901
563  """(Re)Subscribe to topics."""
564  self.add_subscriptionadd_subscription(CONF_STATE_TOPIC, self._state_received_state_received, {"_attr_is_on"})
565  self.add_subscriptionadd_subscription(
566  CONF_BRIGHTNESS_STATE_TOPIC, self._brightness_received_brightness_received, {"_attr_brightness"}
567  )
568  self.add_subscriptionadd_subscription(
569  CONF_RGB_STATE_TOPIC,
570  self._rgb_received_rgb_received,
571  {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"},
572  )
573  self.add_subscriptionadd_subscription(
574  CONF_RGBW_STATE_TOPIC,
575  self._rgbw_received_rgbw_received,
576  {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"},
577  )
578  self.add_subscriptionadd_subscription(
579  CONF_RGBWW_STATE_TOPIC,
580  self._rgbww_received_rgbww_received,
581  {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"},
582  )
583  self.add_subscriptionadd_subscription(
584  CONF_COLOR_MODE_STATE_TOPIC, self._color_mode_received_color_mode_received, {"_attr_color_mode"}
585  )
586  self.add_subscriptionadd_subscription(
587  CONF_COLOR_TEMP_STATE_TOPIC,
588  self._color_temp_received_color_temp_received,
589  {"_attr_color_mode", "_attr_color_temp"},
590  )
591  self.add_subscriptionadd_subscription(
592  CONF_EFFECT_STATE_TOPIC, self._effect_received_effect_received, {"_attr_effect"}
593  )
594  self.add_subscriptionadd_subscription(
595  CONF_HS_STATE_TOPIC,
596  self._hs_received_hs_received,
597  {"_attr_color_mode", "_attr_hs_color"},
598  )
599  self.add_subscriptionadd_subscription(
600  CONF_XY_STATE_TOPIC,
601  self._xy_received_xy_received,
602  {"_attr_color_mode", "_attr_xy_color"},
603  )
604 
605  async def _subscribe_topics(self) -> None:
606  """(Re)Subscribe to topics."""
607  subscription.async_subscribe_topics_internal(self.hasshasshass, self._sub_state_sub_state)
608  last_state = await self.async_get_last_stateasync_get_last_state()
609 
610  def restore_state(
611  attribute: str, condition_attribute: str | None = None
612  ) -> None:
613  """Restore a state attribute."""
614  if condition_attribute is None:
615  condition_attribute = attribute
616  optimistic = self._is_optimistic_is_optimistic(condition_attribute)
617  if optimistic and last_state and last_state.attributes.get(attribute):
618  setattr(self, f"_attr_{attribute}", last_state.attributes[attribute])
619 
620  if self._topic_topic[CONF_STATE_TOPIC] is None and self._optimistic_optimistic and last_state:
621  self._attr_is_on_attr_is_on = last_state.state == STATE_ON
622  restore_state(ATTR_BRIGHTNESS)
623  restore_state(ATTR_RGB_COLOR)
624  restore_state(ATTR_HS_COLOR, ATTR_RGB_COLOR)
625  restore_state(ATTR_RGBW_COLOR)
626  restore_state(ATTR_RGBWW_COLOR)
627  restore_state(ATTR_COLOR_MODE)
628  restore_state(ATTR_COLOR_TEMP)
629  restore_state(ATTR_EFFECT)
630  restore_state(ATTR_HS_COLOR)
631  restore_state(ATTR_XY_COLOR)
632  restore_state(ATTR_HS_COLOR, ATTR_XY_COLOR)
633 
634  async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901
635  """Turn the device on.
636 
637  This method is a coroutine.
638  """
639  should_update = False
640  on_command_type: str = self._config_config[CONF_ON_COMMAND_TYPE]
641 
642  async def publish(topic: str, payload: PublishPayloadType) -> None:
643  """Publish an MQTT message."""
644  await self.async_publish_with_configasync_publish_with_config(str(self._topic_topic[topic]), payload)
645 
646  def scale_rgbx(
647  color: tuple[int, ...],
648  brightness: int | None = None,
649  ) -> tuple[int, ...]:
650  """Scale RGBx for brightness."""
651  if brightness is None:
652  # If there's a brightness topic set, we don't want to scale the RGBx
653  # values given using the brightness.
654  if self._topic_topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None:
655  brightness = 255
656  else:
657  brightness = kwargs.get(ATTR_BRIGHTNESS) or self.brightnessbrightness or 255
658  return tuple(int(channel * brightness / 255) for channel in color)
659 
660  def render_rgbx(
661  color: tuple[int, ...],
662  template: str,
663  color_mode: ColorMode,
664  ) -> PublishPayloadType:
665  """Render RGBx payload."""
666  rgb_color_str = ",".join(str(channel) for channel in color)
667  keys = ["red", "green", "blue"]
668  if color_mode == ColorMode.RGBW:
669  keys.append("white")
670  elif color_mode == ColorMode.RGBWW:
671  keys.extend(["cold_white", "warm_white"])
672  variables = dict(zip(keys, color, strict=False))
673  return self._command_templates_command_templates[template](rgb_color_str, variables)
674 
675  def set_optimistic(
676  attribute: str,
677  value: Any,
678  color_mode: ColorMode | None = None,
679  condition_attribute: str | None = None,
680  ) -> bool:
681  """Optimistically update a state attribute."""
682  if condition_attribute is None:
683  condition_attribute = attribute
684  if not self._is_optimistic_is_optimistic(condition_attribute):
685  return False
686  if color_mode and self._optimistic_color_mode_optimistic_color_mode:
687  self._attr_color_mode_attr_color_mode = color_mode
688 
689  setattr(self, f"_attr_{attribute}", value)
690  return True
691 
692  if on_command_type == "first":
693  await publish(CONF_COMMAND_TOPIC, self._payload_payload["on"])
694  should_update = True
695 
696  # If brightness is being used instead of an on command, make sure
697  # there is a brightness input. Either set the brightness to our
698  # saved value or the maximum value if this is the first call
699  elif (
700  on_command_type == "brightness"
701  and ATTR_BRIGHTNESS not in kwargs
702  and ATTR_WHITE not in kwargs
703  ):
704  kwargs[ATTR_BRIGHTNESS] = self.brightnessbrightness or 255
705 
706  hs_color: str | None = kwargs.get(ATTR_HS_COLOR)
707 
708  if hs_color and self._topic_topic[CONF_HS_COMMAND_TOPIC] is not None:
709  device_hs_payload = self._command_templates_command_templates[CONF_HS_COMMAND_TEMPLATE](
710  f"{hs_color[0]},{hs_color[1]}",
711  {"hue": hs_color[0], "sat": hs_color[1]},
712  )
713  await publish(CONF_HS_COMMAND_TOPIC, device_hs_payload)
714  should_update |= set_optimistic(ATTR_HS_COLOR, hs_color, ColorMode.HS)
715 
716  rgb: tuple[int, int, int] | None
717  if (rgb := kwargs.get(ATTR_RGB_COLOR)) and self._topic_topic[
718  CONF_RGB_COMMAND_TOPIC
719  ] is not None:
720  scaled = scale_rgbx(rgb)
721  rgb_s = render_rgbx(scaled, CONF_RGB_COMMAND_TEMPLATE, ColorMode.RGB)
722  await publish(CONF_RGB_COMMAND_TOPIC, rgb_s)
723  should_update |= set_optimistic(ATTR_RGB_COLOR, rgb, ColorMode.RGB)
724 
725  rgbw: tuple[int, int, int, int] | None
726  if (rgbw := kwargs.get(ATTR_RGBW_COLOR)) and self._topic_topic[
727  CONF_RGBW_COMMAND_TOPIC
728  ] is not None:
729  scaled = scale_rgbx(rgbw)
730  rgbw_s = render_rgbx(scaled, CONF_RGBW_COMMAND_TEMPLATE, ColorMode.RGBW)
731  await publish(CONF_RGBW_COMMAND_TOPIC, rgbw_s)
732  should_update |= set_optimistic(ATTR_RGBW_COLOR, rgbw, ColorMode.RGBW)
733 
734  rgbww: tuple[int, int, int, int, int] | None
735  if (rgbww := kwargs.get(ATTR_RGBWW_COLOR)) and self._topic_topic[
736  CONF_RGBWW_COMMAND_TOPIC
737  ] is not None:
738  scaled = scale_rgbx(rgbww)
739  rgbww_s = render_rgbx(scaled, CONF_RGBWW_COMMAND_TEMPLATE, ColorMode.RGBWW)
740  await publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s)
741  should_update |= set_optimistic(ATTR_RGBWW_COLOR, rgbww, ColorMode.RGBWW)
742 
743  xy_color: tuple[float, float] | None
744  if (xy_color := kwargs.get(ATTR_XY_COLOR)) and self._topic_topic[
745  CONF_XY_COMMAND_TOPIC
746  ] is not None:
747  device_xy_payload = self._command_templates_command_templates[CONF_XY_COMMAND_TEMPLATE](
748  f"{xy_color[0]},{xy_color[1]}",
749  {"x": xy_color[0], "y": xy_color[1]},
750  )
751  await publish(CONF_XY_COMMAND_TOPIC, device_xy_payload)
752  should_update |= set_optimistic(ATTR_XY_COLOR, xy_color, ColorMode.XY)
753 
754  if (
755  ATTR_BRIGHTNESS in kwargs
756  and self._topic_topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None
757  ):
758  brightness_normalized: float = kwargs[ATTR_BRIGHTNESS] / 255
759  brightness_scale: int = self._config_config[CONF_BRIGHTNESS_SCALE]
760  device_brightness = min(
761  round(brightness_normalized * brightness_scale), brightness_scale
762  )
763  # Make sure the brightness is not rounded down to 0
764  device_brightness = max(device_brightness, 1)
765  command_tpl = self._command_templates_command_templates[CONF_BRIGHTNESS_COMMAND_TEMPLATE]
766  device_brightness_payload = command_tpl(device_brightness, None)
767  await publish(CONF_BRIGHTNESS_COMMAND_TOPIC, device_brightness_payload)
768  should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS])
769  elif (
770  ATTR_BRIGHTNESS in kwargs
771  and ATTR_RGB_COLOR not in kwargs
772  and self._topic_topic[CONF_RGB_COMMAND_TOPIC] is not None
773  ):
774  rgb_color = self.rgb_colorrgb_color or (255,) * 3
775  rgb_scaled = scale_rgbx(rgb_color, kwargs[ATTR_BRIGHTNESS])
776  rgb_s = render_rgbx(rgb_scaled, CONF_RGB_COMMAND_TEMPLATE, ColorMode.RGB)
777  await publish(CONF_RGB_COMMAND_TOPIC, rgb_s)
778  should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS])
779  elif (
780  ATTR_BRIGHTNESS in kwargs
781  and ATTR_RGBW_COLOR not in kwargs
782  and self._topic_topic[CONF_RGBW_COMMAND_TOPIC] is not None
783  ):
784  rgbw_color = self.rgbw_colorrgbw_color or (255,) * 4
785  rgbw_b = scale_rgbx(rgbw_color, kwargs[ATTR_BRIGHTNESS])
786  rgbw_s = render_rgbx(rgbw_b, CONF_RGBW_COMMAND_TEMPLATE, ColorMode.RGBW)
787  await publish(CONF_RGBW_COMMAND_TOPIC, rgbw_s)
788  should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS])
789  elif (
790  ATTR_BRIGHTNESS in kwargs
791  and ATTR_RGBWW_COLOR not in kwargs
792  and self._topic_topic[CONF_RGBWW_COMMAND_TOPIC] is not None
793  ):
794  rgbww_color = self.rgbww_colorrgbww_color or (255,) * 5
795  rgbww_b = scale_rgbx(rgbww_color, kwargs[ATTR_BRIGHTNESS])
796  rgbww_s = render_rgbx(rgbww_b, CONF_RGBWW_COMMAND_TEMPLATE, ColorMode.RGBWW)
797  await publish(CONF_RGBWW_COMMAND_TOPIC, rgbww_s)
798  should_update |= set_optimistic(ATTR_BRIGHTNESS, kwargs[ATTR_BRIGHTNESS])
799  if (
800  ATTR_COLOR_TEMP in kwargs
801  and self._topic_topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None
802  ):
803  ct_command_tpl = self._command_templates_command_templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE]
804  color_temp = ct_command_tpl(int(kwargs[ATTR_COLOR_TEMP]), None)
805  await publish(CONF_COLOR_TEMP_COMMAND_TOPIC, color_temp)
806  should_update |= set_optimistic(
807  ATTR_COLOR_TEMP, kwargs[ATTR_COLOR_TEMP], ColorMode.COLOR_TEMP
808  )
809 
810  if (
811  ATTR_EFFECT in kwargs
812  and self._topic_topic[CONF_EFFECT_COMMAND_TOPIC] is not None
813  and CONF_EFFECT_LIST in self._config_config
814  ):
815  if kwargs[ATTR_EFFECT] in self._config_config[CONF_EFFECT_LIST]:
816  eff_command_tpl = self._command_templates_command_templates[CONF_EFFECT_COMMAND_TEMPLATE]
817  effect = eff_command_tpl(kwargs[ATTR_EFFECT], None)
818  await publish(CONF_EFFECT_COMMAND_TOPIC, effect)
819  should_update |= set_optimistic(ATTR_EFFECT, kwargs[ATTR_EFFECT])
820 
821  if ATTR_WHITE in kwargs and self._topic_topic[CONF_WHITE_COMMAND_TOPIC] is not None:
822  percent_white = float(kwargs[ATTR_WHITE]) / 255
823  white_scale: int = self._config_config[CONF_WHITE_SCALE]
824  device_white_value = min(round(percent_white * white_scale), white_scale)
825  await publish(CONF_WHITE_COMMAND_TOPIC, device_white_value)
826  should_update |= set_optimistic(
827  ATTR_BRIGHTNESS,
828  kwargs[ATTR_WHITE],
829  ColorMode.WHITE,
830  )
831 
832  if on_command_type == "last":
833  await publish(CONF_COMMAND_TOPIC, self._payload_payload["on"])
834  should_update = True
835 
836  if self._optimistic_optimistic:
837  # Optimistically assume that the light has changed state.
838  self._attr_is_on_attr_is_on = True
839  should_update = True
840 
841  if should_update:
842  self.async_write_ha_stateasync_write_ha_state()
843 
844  async def async_turn_off(self, **kwargs: Any) -> None:
845  """Turn the device off.
846 
847  This method is a coroutine.
848  """
849  await self.async_publish_with_configasync_publish_with_config(
850  str(self._topic_topic[CONF_COMMAND_TOPIC]), self._payload_payload["off"]
851  )
852 
853  if self._optimistic_optimistic:
854  # Optimistically assume that the light has changed state.
855  self._attr_is_on_attr_is_on = False
856  self.async_write_ha_stateasync_write_ha_state()
tuple[int, int, int]|None rgb_color(self)
Definition: __init__.py:957
tuple[int, int, int, int]|None rgbw_color(self)
Definition: __init__.py:962
tuple[int, int, int, int, int]|None rgbww_color(self)
Definition: __init__.py:972
None async_publish_with_config(self, str topic, PublishPayloadType payload)
Definition: entity.py:1377
bool add_subscription(self, str state_topic_config_key, Callable[[ReceiveMessage], None] msg_callback, set[str]|None tracked_attributes, bool disable_encoding=False)
Definition: entity.py:1484
tuple[int,...]|None _rgbx_received(self, ReceiveMessage msg, str template, ColorMode color_mode, Callable[..., tuple[int,...]] convert_color)
set[ColorMode|str] valid_supported_color_modes(Iterable[ColorMode|str] color_modes)
Definition: __init__.py:141
None publish(HomeAssistant hass, str topic, PublishPayloadType payload, int|None qos=0, bool|None retain=False, str|None encoding=DEFAULT_ENCODING)
Definition: client.py:132