-
-
Notifications
You must be signed in to change notification settings - Fork 29.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add light.group platform #12229
Add light.group platform #12229
Changes from 13 commits
3a24f04
32fd3df
73c80b9
21bf83e
f1a8fa6
dd32be2
33dfd92
9753268
1eab314
59d395e
2b20e72
72bd1fc
2777a81
ff0386d
9e83198
2d37f05
c1f71e1
6d4f4f8
9c1553f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,280 @@ | ||
""" | ||
This component allows several lights to be grouped into one light. | ||
|
||
For more details about this platform, please refer to the documentation at | ||
https://home-assistant.io/components/light.group/ | ||
""" | ||
import logging | ||
import itertools | ||
from typing import List, Tuple, Optional, Iterator, Any, Callable | ||
from collections import Counter | ||
from copy import deepcopy | ||
|
||
import voluptuous as vol | ||
|
||
from homeassistant.core import State, callback | ||
from homeassistant.components import light | ||
from homeassistant.const import (STATE_OFF, STATE_ON, SERVICE_TURN_ON, | ||
SERVICE_TURN_OFF, ATTR_ENTITY_ID, CONF_NAME, | ||
CONF_ENTITIES, STATE_UNAVAILABLE, | ||
STATE_UNKNOWN, ATTR_SUPPORTED_FEATURES) | ||
from homeassistant.helpers.event import async_track_state_change | ||
from homeassistant.helpers.typing import HomeAssistantType, ConfigType | ||
from homeassistant.components.light import ( | ||
SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_COLOR_TEMP, | ||
SUPPORT_TRANSITION, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_XY_COLOR, | ||
SUPPORT_WHITE_VALUE, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, ATTR_XY_COLOR, | ||
ATTR_RGB_COLOR, ATTR_WHITE_VALUE, ATTR_COLOR_TEMP, ATTR_MIN_MIREDS, | ||
ATTR_MAX_MIREDS, ATTR_EFFECT_LIST, ATTR_EFFECT) | ||
import homeassistant.helpers.config_validation as cv | ||
|
||
_LOGGER = logging.getLogger(__name__) | ||
|
||
DOMAIN = 'group' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Only components have a domain. This file is a platform the light component. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also doesn't seem to be used? |
||
|
||
DEFAULT_NAME = 'Group Light' | ||
|
||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ | ||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, | ||
vol.Required(CONF_ENTITIES): cv.entities_domain('light') | ||
}) | ||
|
||
SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | ||
| SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION | ||
| SUPPORT_XY_COLOR | SUPPORT_WHITE_VALUE) | ||
|
||
|
||
async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, | ||
async_add_devices, discovery_info=None) -> None: | ||
"""Initialize light.group platform.""" | ||
async_add_devices([GroupLight(config.get(CONF_NAME), | ||
config[CONF_ENTITIES])]) | ||
|
||
|
||
class GroupLight(light.Light): | ||
"""Representation of a group light.""" | ||
|
||
def __init__(self, name: str, entity_ids: List[str]) -> None: | ||
"""Initialize a group light.""" | ||
self._name = name # type: str | ||
self._entity_ids = entity_ids # type: List[str] | ||
self._state = STATE_UNAVAILABLE # type: str | ||
self._brightness = None # type: Optional[int] | ||
self._xy_color = None # type: Optional[Tuple[float, float]] | ||
self._rgb_color = None # type: Optional[Tuple[int, int, int]] | ||
self._color_temp = None # type: Optional[int] | ||
self._min_mireds = 154 # type: Optional[int] | ||
self._max_mireds = 500 # type: Optional[int] | ||
self._white_value = None # type: Optional[int] | ||
self._effect_list = None # type: Optional[List[str]] | ||
self._effect = None # type: Optional[str] | ||
self._supported_features = 0 # type: int | ||
self._async_unsub_state_changed = None | ||
|
||
async def async_added_to_hass(self) -> None: | ||
"""Register callbacks.""" | ||
@callback | ||
def async_state_changed_listener(entity_id: str, old_state: State, | ||
new_state: State): | ||
"""Handle child updates.""" | ||
self.async_schedule_update_ha_state(True) | ||
|
||
self._async_unsub_state_changed = async_track_state_change( | ||
self.hass, self._entity_ids, async_state_changed_listener) | ||
|
||
async def async_will_remove_from_hass(self): | ||
"""Callback when removed from HASS.""" | ||
if self._async_unsub_state_changed: | ||
self._async_unsub_state_changed() | ||
self._async_unsub_state_changed = None | ||
|
||
@property | ||
def name(self) -> str: | ||
"""Return the name of the entity.""" | ||
return self._name | ||
|
||
@property | ||
def state(self) -> str: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just embed it inside There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is kind of because of my own way of using Home Assistant, but: Don't we want to also support the light reporting an
Do you mean writing this? (usage of @property
def is_on(self) -> bool:
"""Return True if entity is on."""
return self._state == STATE_ON There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (sorry if this is outdated: we'd still like to report There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are never allowed to override There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
"""Return the state.""" | ||
return self._state | ||
|
||
@property | ||
def is_on(self) -> bool: | ||
"""Return True if entity is on.""" | ||
return self._state == STATE_ON | ||
|
||
@property | ||
def brightness(self) -> Optional[int]: | ||
"""Return the brightness of this light between 0..255.""" | ||
return self._brightness | ||
|
||
@property | ||
def xy_color(self) -> Optional[Tuple[float, float]]: | ||
"""Return the XY color value [float, float].""" | ||
return self._xy_color | ||
|
||
@property | ||
def rgb_color(self) -> Optional[Tuple[int, int, int]]: | ||
"""Return the RGB color value [int, int, int].""" | ||
return self._rgb_color | ||
|
||
@property | ||
def color_temp(self) -> Optional[int]: | ||
"""Return the CT color value in mireds.""" | ||
return self._color_temp | ||
|
||
@property | ||
def min_mireds(self) -> Optional[int]: | ||
"""Return the coldest color_temp that this light supports.""" | ||
return self._min_mireds | ||
|
||
@property | ||
def max_mireds(self) -> Optional[int]: | ||
"""Return the warmest color_temp that this light supports.""" | ||
return self._max_mireds | ||
|
||
@property | ||
def white_value(self) -> Optional[int]: | ||
"""Return the white value of this light between 0..255.""" | ||
return self._white_value | ||
|
||
@property | ||
def effect_list(self) -> Optional[List[str]]: | ||
"""Return the list of supported effects.""" | ||
return self._effect_list | ||
|
||
@property | ||
def effect(self) -> Optional[str]: | ||
"""Return the current effect.""" | ||
return self._effect | ||
|
||
@property | ||
def supported_features(self) -> int: | ||
"""Flag supported features.""" | ||
return self._supported_features | ||
|
||
@property | ||
def should_poll(self) -> bool: | ||
"""No polling needed for a group light.""" | ||
return False | ||
|
||
async def _async_send_message(self, service, **kwargs): | ||
"""Send a message to all entities in the group.""" | ||
for entity_id in self._entity_ids: | ||
payload = deepcopy(kwargs) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to do a deepcopy if you only mutate the outer layer. Just Also, are all kwargs for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean as far as I saw, they're the same, but you're right in that we shouldn't rely on that. So I now changed the code to use We could also manually specify the arguments in our own There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Problem with |
||
payload[ATTR_ENTITY_ID] = entity_id | ||
await self.hass.services.async_call(light.DOMAIN, service, payload) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be blocking=True There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why should we be blocking? I mean one potential problem would be that maybe some component takes its time sending the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If I would interact with the normal device, it would take the time to finish the command. If I interact with it via proxy, it should take that time to finish the command. Also, Home Assistant expects commands to be done when the function is done calling. That's why for polling devices it knows it can call There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How about storing every service call as a task (list) and the use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ehr, you can do it with 1 service call. Just pass the list of entities as ATTR_ENTITY_ID and the light component will figure it all out. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Oh, that's certainly good to know, will do.
Ok, so 1. we need to be able to do the service call with blocking mode + 2. we shouldn't simply pass the kwargs to the service call, as that could break in the future (bc we want to maximize maintainability). So ideally I think we would be using something like
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I guess it's nicer to just keep this logic inside the group platform. It's the platforms responsibility to keep working. Otherwise we add the constraint that There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
|
||
async def async_turn_on(self, **kwargs): | ||
"""Forward the turn_on command to all lights in the group.""" | ||
await self._async_send_message(SERVICE_TURN_ON, **kwargs) | ||
|
||
async def async_turn_off(self, **kwargs): | ||
"""Forward the turn_off command to all lights in the group.""" | ||
await self._async_send_message(SERVICE_TURN_OFF, **kwargs) | ||
|
||
async def async_update(self): | ||
"""Query all members and determine the group state.""" | ||
states = self._child_states() | ||
on_states = [state for state in states if state.state == STATE_ON] | ||
|
||
self._state = _determine_on_off_state(states) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All these functions will do their own iteration over all items in the list. That is very inefficient. states = []
min_mireds = 154
for entity_id in self._entity_ids:
state = self.hass.states.get(entity_id)
if state is None:
continue
# In case you still need to map over all states for some checks
states.append(state)
# Etc…
min_mireds = min(min_mireds, state.attributes[MIN_MIREDS]) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice thing of doing it like this is that you can also not do a bunch of checks when the state is off. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Technically they only go through the iteration until they find a state that matches - that's also why I'm using the generator syntax and
I mean if I understand correctly this kinda touches on home-assistant/architecture#13; do we want to report the first light state or the union of all supported features/states? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The example with brightnesses = []
for state in states:
if state.state != STATE_ON:
continue
if state.attributes.get('brightness') is not None:
brightnesses.append(state.attributes['brightness'])
if brightnesses:
self._brightness = sum(brightnesses) / len(brightnesses)
else:
self._brightness = None self._brightness = None
num_brightness = 0
for state in states:
if state.state != STATE_ON:
continue
if state.attributes.get('brightness') is not None:
self._brightness = (self.brightness or 0) + state.attributes['brightness']
num_brightness += 1
if num_brightness > 0:
self._brightness = self._brightness / num_brightness I mean one could probably also design helper methods around these statements, but is the speed benefit really worth the messier code? Also: at least some of the slowness of the previous code has now been fixed by pre-calculating a list of states that are There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ok, I went ahead and created a small speed benchmark for both methods. And the current way with using a helper actually seems faster: (Scroll to the bottom; https://gist.github.com/OttoWinter/58ce0fd4c7743d233af8a10ed7ff940a (I might be doing some things here a bit inefficiently, especially concerning object creation.) |
||
|
||
self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS) | ||
|
||
self._xy_color = _reduce_attribute( | ||
on_states, ATTR_XY_COLOR, reduce=_mean_tuple) | ||
|
||
self._rgb_color = _reduce_attribute( | ||
on_states, ATTR_RGB_COLOR, reduce=_mean_tuple) | ||
if self._rgb_color is not None: | ||
self._rgb_color = tuple(map(int, self._rgb_color)) | ||
|
||
self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE) | ||
|
||
self._color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP) | ||
self._min_mireds = _reduce_attribute( | ||
states, ATTR_MIN_MIREDS, default=154, reduce=min) | ||
self._max_mireds = _reduce_attribute( | ||
states, ATTR_MAX_MIREDS, default=500, reduce=max) | ||
|
||
self._effect_list = None | ||
all_effect_lists = list( | ||
_find_state_attributes(states, ATTR_EFFECT_LIST)) | ||
if all_effect_lists: | ||
# Merge all effects from all effect_lists with a union merge. | ||
self._effect_list = list(set().union(*all_effect_lists)) | ||
|
||
self._effect = None | ||
all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT)) | ||
if all_effects: | ||
# Report the most common effect. | ||
effects_count = Counter(itertools.chain(all_effects)) | ||
self._effect = effects_count.most_common(1)[0][0] | ||
|
||
self._supported_features = 0 | ||
for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES): | ||
# Merge supported features by emulating support for every feature | ||
# we find. | ||
self._supported_features |= support | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would expect There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would expect that too. But there may be instances where you have some lights that do color, and some do only color temperature. If you group those you want to at least be able to set the color temperature. (And let the platform of the colored light figure out how to handle that further.) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm creating a architecture post right now, as I think we should first define how users expect these platforms to behave. |
||
# Bitwise-and the supported features with the GroupedLight's features | ||
# so that we don't break in the future when a new feature is added. | ||
self._supported_features &= SUPPORT_GROUP_LIGHT | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I think that there is no need for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I mean the reason was that if some entity decided to add support for some weird supported effect, that wouldn't be reported to the front-end through our There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right. Let's be conservative and only support the features we know to support. |
||
|
||
def _child_states(self) -> List[State]: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not inline this method? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure :) |
||
"""The states that the group is tracking.""" | ||
states = [self.hass.states.get(x) for x in self._entity_ids] | ||
return list(filter(None, states)) | ||
|
||
|
||
def _find_state_attributes(states: List[State], | ||
key: str) -> Iterator[Any]: | ||
"""Find attributes with matching key from states.""" | ||
for state in states: | ||
value = state.attributes.get(key) | ||
if value is not None: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If attributes is equal to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I once saw some platforms using state attributes as There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
yield value | ||
|
||
|
||
def _mean_int(*args): | ||
"""Return the mean of the supplied values.""" | ||
return int(sum(args) / len(args)) | ||
|
||
|
||
def _mean_tuple(*args): | ||
"""Return the mean values along the columns of the supplied values.""" | ||
return tuple(sum(l) / len(l) for l in zip(*args)) | ||
|
||
|
||
# https://github.com/PyCQA/pylint/issues/1831 | ||
# pylint: disable=bad-whitespace | ||
def _reduce_attribute(states: List[State], | ||
key: str, | ||
default: Optional[Any] = None, | ||
reduce: Callable[..., Any] = _mean_int) -> Any: | ||
"""Find the first attribute matching key from states. | ||
|
||
If none are found, return default. | ||
""" | ||
attrs = list(_find_state_attributes(states, key)) | ||
|
||
if not attrs: | ||
return default | ||
|
||
if len(attrs) == 1: | ||
return attrs[0] | ||
|
||
return reduce(*attrs) | ||
|
||
|
||
def _determine_on_off_state(states: List[State]) -> str: | ||
"""Helper method to determine the ON/OFF/... state of a light.""" | ||
s_states = [state.state for state in states] | ||
|
||
if not s_states or all(state == STATE_UNAVAILABLE for state in s_states): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This function is iterating 3 times over all the states. Could be done in 1? |
||
return STATE_UNAVAILABLE | ||
elif any(state == STATE_ON for state in s_states): | ||
return STATE_ON | ||
elif all(state == STATE_UNKNOWN for state in s_states): | ||
return STATE_UNKNOWN | ||
return STATE_OFF |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added
async_toggle
so that there was an appropriate method to be called from within the event loop (for the tests). It's almost identical toasync_turn_on
andasync_turn_off
.