Home Assistant Unofficial Reference 2024.12.1
group.py
Go to the documentation of this file.
1 """Support for Hue groups (room/zone)."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from typing import Any
7 
8 from aiohue.v2 import HueBridgeV2
9 from aiohue.v2.controllers.events import EventType
10 from aiohue.v2.controllers.groups import GroupedLight, Room, Zone
11 from aiohue.v2.models.feature import DynamicStatus
12 
14  ATTR_BRIGHTNESS,
15  ATTR_COLOR_TEMP,
16  ATTR_FLASH,
17  ATTR_TRANSITION,
18  ATTR_XY_COLOR,
19  FLASH_SHORT,
20  ColorMode,
21  LightEntity,
22  LightEntityDescription,
23  LightEntityFeature,
24 )
25 from homeassistant.config_entries import ConfigEntry
26 from homeassistant.core import HomeAssistant, callback
27 from homeassistant.helpers.device_registry import DeviceInfo
28 from homeassistant.helpers.entity_platform import AddEntitiesCallback
30 
31 from ..bridge import HueBridge
32 from ..const import DOMAIN
33 from .entity import HueBaseEntity
34 from .helpers import (
35  normalize_hue_brightness,
36  normalize_hue_colortemp,
37  normalize_hue_transition,
38 )
39 
40 
42  hass: HomeAssistant,
43  config_entry: ConfigEntry,
44  async_add_entities: AddEntitiesCallback,
45 ) -> None:
46  """Set up Hue groups on light platform."""
47  bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id]
48  api: HueBridgeV2 = bridge.api
49 
50  async def async_add_light(event_type: EventType, resource: GroupedLight) -> None:
51  """Add Grouped Light for Hue Room/Zone."""
52  # delay group creation a bit due to a race condition where the
53  # grouped_light resource is created before the zone/room
54  retries = 5
55  while (
56  retries
57  and (group := api.groups.grouped_light.get_zone(resource.id)) is None
58  ):
59  retries -= 1
60  await asyncio.sleep(0.5)
61  if group is None:
62  # guard, just in case
63  return
64  light = GroupedHueLight(bridge, resource, group)
65  async_add_entities([light])
66 
67  # add current items
68  for item in api.groups.grouped_light.items:
69  await async_add_light(EventType.RESOURCE_ADDED, item)
70 
71  # register listener for new grouped_light
72  config_entry.async_on_unload(
73  api.groups.grouped_light.subscribe(
74  async_add_light, event_filter=EventType.RESOURCE_ADDED
75  )
76  )
77 
78 
79 # pylint: disable-next=hass-enforce-class-module
81  """Representation of a Grouped Hue light."""
82 
83  entity_description = LightEntityDescription(
84  key="hue_grouped_light",
85  icon="mdi:lightbulb-group",
86  has_entity_name=True,
87  name=None,
88  )
89 
90  def __init__(
91  self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone
92  ) -> None:
93  """Initialize the light."""
94  controller = bridge.api.groups.grouped_light
95  super().__init__(bridge, controller, resource)
96  self.resourceresourceresource = resource
97  self.groupgroup = group
98  self.controllercontrollercontroller = controller
99  self.api: HueBridgeV2 = bridge.api
100  self._attr_supported_features |= LightEntityFeature.FLASH
101  self._attr_supported_features |= LightEntityFeature.TRANSITION
102  self._restore_brightness_restore_brightness: float | None = None
103  self._brightness_pct_brightness_pct: float = 0
104  # we create a virtual service/device for Hue zones/rooms
105  # so we have a parent for grouped lights and scenes
107  identifiers={(DOMAIN, self.groupgroup.id)},
108  )
109  self._dynamic_mode_active_dynamic_mode_active = False
110  self._update_values_update_values()
111 
112  async def async_added_to_hass(self) -> None:
113  """Call when entity is added."""
114  await super().async_added_to_hass()
115 
116  # subscribe to group updates
117  self.async_on_removeasync_on_remove(
118  self.api.groups.subscribe(self._handle_event_handle_event, self.groupgroup.id)
119  )
120  # We need to watch the underlying lights too
121  # if we want feedback about color/brightness changes
122  if self._attr_supported_color_modes_attr_supported_color_modes:
123  light_ids = tuple(
124  x.id for x in self.controllercontrollercontroller.get_lights(self.resourceresourceresource.id)
125  )
126  self.async_on_removeasync_on_remove(
127  self.api.lights.subscribe(self._handle_event_handle_event, light_ids)
128  )
129 
130  @property
131  def is_on(self) -> bool:
132  """Return true if light is on."""
133  return self.resourceresourceresource.on.on
134 
135  @property
136  def extra_state_attributes(self) -> dict[str, Any] | None:
137  """Return the optional state attributes."""
138  scenes = {
139  x.metadata.name for x in self.api.scenes if x.group.rid == self.groupgroup.id
140  }
141  light_resource_ids = tuple(
142  x.id for x in self.controllercontrollercontroller.get_lights(self.resourceresourceresource.id)
143  )
144  light_names, light_entities = self._get_names_and_entity_ids_for_resource_ids_get_names_and_entity_ids_for_resource_ids(
145  light_resource_ids
146  )
147  return {
148  "is_hue_group": True,
149  "hue_scenes": scenes,
150  "hue_type": self.groupgroup.type.value,
151  "lights": light_names,
152  "entity_id": light_entities,
153  "dynamics": self._dynamic_mode_active_dynamic_mode_active,
154  }
155 
156  async def async_turn_on(self, **kwargs: Any) -> None:
157  """Turn the grouped_light on."""
158  transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
159  xy_color = kwargs.get(ATTR_XY_COLOR)
160  color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP))
161  brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS))
162  flash = kwargs.get(ATTR_FLASH)
163 
164  if self._restore_brightness_restore_brightness and brightness is None:
165  # The Hue bridge sets the brightness to 1% when turning on a bulb
166  # when a transition was used to turn off the bulb.
167  # This issue has been reported on the Hue forum several times:
168  # https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692
169  # https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700
170  # https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585
171  # https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323
172  # https://developers.meethue.com/forum/t/fade-in-fade-out/6673
173  brightness = self._restore_brightness_restore_brightness
174  self._restore_brightness_restore_brightness = None
175 
176  if flash is not None:
177  await self.async_set_flashasync_set_flash(flash)
178  return
179 
180  await self.bridgebridge.async_request_call(
181  self.controllercontrollercontroller.set_state,
182  id=self.resourceresourceresource.id,
183  on=True,
184  brightness=brightness,
185  color_xy=xy_color,
186  color_temp=color_temp,
187  transition_time=transition,
188  )
189 
190  async def async_turn_off(self, **kwargs: Any) -> None:
191  """Turn the light off."""
192  transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION))
193  if transition is not None:
194  self._restore_brightness_restore_brightness = self._brightness_pct_brightness_pct
195  flash = kwargs.get(ATTR_FLASH)
196 
197  if flash is not None:
198  await self.async_set_flashasync_set_flash(flash)
199  # flash cannot be sent with other commands at the same time
200  return
201 
202  await self.bridgebridge.async_request_call(
203  self.controllercontrollercontroller.set_state,
204  id=self.resourceresourceresource.id,
205  on=False,
206  transition_time=transition,
207  )
208 
209  async def async_set_flash(self, flash: str) -> None:
210  """Send flash command to light."""
211  await self.bridgebridge.async_request_call(
212  self.controllercontrollercontroller.set_flash,
213  id=self.resourceresourceresource.id,
214  short=flash == FLASH_SHORT,
215  )
216 
217  @callback
218  def on_update(self) -> None:
219  """Call on update event."""
220  self._update_values_update_values()
221 
222  @callback
223  def _update_values(self) -> None:
224  """Set base values from underlying lights of a group."""
225  supported_color_modes: set[ColorMode | str] = set()
226  lights_with_color_support = 0
227  lights_with_color_temp_support = 0
228  lights_with_dimming_support = 0
229  total_brightness = 0
230  all_lights = self.controllercontrollercontroller.get_lights(self.resourceresourceresource.id)
231  lights_in_colortemp_mode = 0
232  lights_in_dynamic_mode = 0
233  # loop through all lights to find capabilities
234  for light in all_lights:
235  if color_temp := light.color_temperature:
236  lights_with_color_temp_support += 1
237  # we assume mired values from the first capable light
238  self._attr_color_temp_attr_color_temp = color_temp.mirek
239  self._attr_max_mireds_attr_max_mireds = color_temp.mirek_schema.mirek_maximum
240  self._attr_min_mireds_attr_min_mireds = color_temp.mirek_schema.mirek_minimum
241  if color_temp.mirek is not None and color_temp.mirek_valid:
242  lights_in_colortemp_mode += 1
243  if color := light.color:
244  lights_with_color_support += 1
245  # we assume xy values from the first capable light
246  self._attr_xy_color_attr_xy_color = (color.xy.x, color.xy.y)
247  if dimming := light.dimming:
248  lights_with_dimming_support += 1
249  total_brightness += dimming.brightness
250  if (
251  light.dynamics
252  and light.dynamics.status == DynamicStatus.DYNAMIC_PALETTE
253  ):
254  lights_in_dynamic_mode += 1
255 
256  # this is a bit hacky because light groups may contain lights
257  # of different capabilities. We set a colormode as supported
258  # if any of the lights support it
259  # this means that the state is derived from only some of the lights
260  # and will never be 100% accurate but it will be close
261  if lights_with_color_support > 0:
262  supported_color_modes.add(ColorMode.XY)
263  if lights_with_color_temp_support > 0:
264  supported_color_modes.add(ColorMode.COLOR_TEMP)
265  if lights_with_dimming_support > 0:
266  if len(supported_color_modes) == 0:
267  # only add color mode brightness if no color variants
268  supported_color_modes.add(ColorMode.BRIGHTNESS)
269  self._brightness_pct_brightness_pct = total_brightness / lights_with_dimming_support
270  self._attr_brightness_attr_brightness = round(
271  ((total_brightness / lights_with_dimming_support) / 100) * 255
272  )
273  else:
274  supported_color_modes.add(ColorMode.ONOFF)
275  self._dynamic_mode_active_dynamic_mode_active = lights_in_dynamic_mode > 0
276  self._attr_supported_color_modes_attr_supported_color_modes = supported_color_modes
277  # pick a winner for the current colormode
278  if lights_with_color_temp_support > 0 and lights_in_colortemp_mode > 0:
279  self._attr_color_mode_attr_color_mode = ColorMode.COLOR_TEMP
280  elif lights_with_color_support > 0:
281  self._attr_color_mode_attr_color_mode = ColorMode.XY
282  elif lights_with_dimming_support > 0:
283  self._attr_color_mode_attr_color_mode = ColorMode.BRIGHTNESS
284  else:
285  self._attr_color_mode_attr_color_mode = ColorMode.ONOFF
286 
287  @callback
289  self, resource_ids: tuple[str]
290  ) -> tuple[set[str], set[str]]:
291  """Return the names and entity ids for the given Hue (light) resource IDs."""
292  ent_reg = er.async_get(self.hasshass)
293  light_names: set[str] = set()
294  light_entities: set[str] = set()
295  for resource_id in resource_ids:
296  light_names.add(self.controllercontrollercontroller.get_device(resource_id).metadata.name)
297  if entity_id := ent_reg.async_get_entity_id(
298  self.platformplatform.domain, DOMAIN, resource_id
299  ):
300  light_entities.add(entity_id)
301  return light_names, light_entities
None _handle_event(self, EventType event_type, HueResource resource)
Definition: entity.py:126
None __init__(self, HueBridge bridge, GroupedLight resource, Room|Zone group)
Definition: group.py:92
dict[str, Any]|None extra_state_attributes(self)
Definition: group.py:136
tuple[set[str], set[str]] _get_names_and_entity_ids_for_resource_ids(self, tuple[str] resource_ids)
Definition: group.py:290
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
DeviceEntry get_device(HomeAssistant hass, str unique_id)
Definition: util.py:12
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: group.py:45
float|None normalize_hue_brightness(float|None brightness)
Definition: helpers.py:6
float|None normalize_hue_transition(float|None transition)
Definition: helpers.py:15
int|None normalize_hue_colortemp(int|None colortemp)
Definition: helpers.py:24