From 0720b0f8911671cb0111debb95f8e912c002fc0a Mon Sep 17 00:00:00 2001 From: Jeff Rescignano Date: Tue, 22 Mar 2022 11:09:18 -0400 Subject: [PATCH] Add all option to light group (#68447) Co-authored-by: Franck Nijhof Co-authored-by: Erik Montnemery --- homeassistant/components/group/config_flow.py | 14 +++- homeassistant/components/group/light.py | 36 ++++++++-- homeassistant/components/group/strings.json | 3 + .../components/group/translations/en.json | 43 ++---------- tests/components/group/test_config_flow.py | 5 +- tests/components/group/test_init.py | 4 +- tests/components/group/test_light.py | 67 +++++++++++++++++++ 7 files changed, 124 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 38c08692fe4..dafb43924a7 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -46,10 +46,20 @@ BINARY_SENSOR_OPTIONS_SCHEMA = basic_group_options_schema("binary_sensor").exten } ) +LIGHT_OPTIONS_SCHEMA = basic_group_options_schema("light").extend( + { + vol.Required(CONF_ALL, default=False): selector.selector({"boolean": {}}), + } +) + BINARY_SENSOR_CONFIG_SCHEMA = vol.Schema( {vol.Required("name"): selector.selector({"text": {}})} ).extend(BINARY_SENSOR_OPTIONS_SCHEMA.schema) +LIGHT_CONFIG_SCHEMA = vol.Schema( + {vol.Required("name"): selector.selector({"text": {}})} +).extend(LIGHT_OPTIONS_SCHEMA.schema) + INITIAL_STEP_SCHEMA = vol.Schema( { @@ -81,7 +91,7 @@ CONFIG_FLOW = { "binary_sensor": HelperFlowStep(BINARY_SENSOR_CONFIG_SCHEMA), "cover": HelperFlowStep(basic_group_config_schema("cover")), "fan": HelperFlowStep(basic_group_config_schema("fan")), - "light": HelperFlowStep(basic_group_config_schema("light")), + "light": HelperFlowStep(LIGHT_CONFIG_SCHEMA), "media_player": HelperFlowStep(basic_group_config_schema("media_player")), } @@ -91,7 +101,7 @@ OPTIONS_FLOW = { "binary_sensor": HelperFlowStep(BINARY_SENSOR_OPTIONS_SCHEMA), "cover": HelperFlowStep(basic_group_options_schema("cover")), "fan": HelperFlowStep(basic_group_options_schema("fan")), - "light": HelperFlowStep(basic_group_options_schema("light")), + "light": HelperFlowStep(LIGHT_OPTIONS_SCHEMA), "media_player": HelperFlowStep(basic_group_options_schema("media_player")), } diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 12f316497e6..e5c87e91889 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -47,6 +47,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -58,6 +59,7 @@ from . import GroupEntity from .util import find_state_attributes, mean_tuple, reduce_attribute DEFAULT_NAME = "Light Group" +CONF_ALL = "all" # No limit on parallel updates to enable a group calling another group PARALLEL_UPDATES = 0 @@ -67,6 +69,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN), + vol.Optional(CONF_ALL): cv.boolean, } ) @@ -87,7 +90,10 @@ async def async_setup_platform( async_add_entities( [ LightGroup( - config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES] + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config[CONF_ENTITIES], + config.get(CONF_ALL), ) ] ) @@ -103,9 +109,10 @@ async def async_setup_entry( entities = er.async_validate_entity_ids( registry, config_entry.options[CONF_ENTITIES] ) + mode = config_entry.options[CONF_ALL] async_add_entities( - [LightGroup(config_entry.entry_id, config_entry.title, entities)] + [LightGroup(config_entry.entry_id, config_entry.title, entities, mode)] ) @@ -132,12 +139,13 @@ class LightGroup(GroupEntity, LightEntity): _attr_available = False _attr_icon = "mdi:lightbulb-group" - _attr_is_on = False _attr_max_mireds = 500 _attr_min_mireds = 154 _attr_should_poll = False - def __init__(self, unique_id: str | None, name: str, entity_ids: list[str]) -> None: + def __init__( + self, unique_id: str | None, name: str, entity_ids: list[str], mode: str | None + ) -> None: """Initialize a light group.""" self._entity_ids = entity_ids self._white_value: int | None = None @@ -145,6 +153,9 @@ class LightGroup(GroupEntity, LightEntity): self._attr_name = name self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} self._attr_unique_id = unique_id + self.mode = any + if mode: + self.mode = all async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -207,7 +218,22 @@ class LightGroup(GroupEntity, LightEntity): states: list[State] = list(filter(None, all_states)) on_states = [state for state in states if state.state == STATE_ON] - self._attr_is_on = len(on_states) > 0 + # filtered_states are members currently in the state machine + filtered_states: list[str] = [x.state for x in all_states if x is not None] + + valid_state = self.mode( + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states + ) + + if not valid_state: + # Set as unknown if any / all member is unknown or unavailable + self._attr_is_on = None + else: + # Set as ON if any / all member is ON + self._attr_is_on = self.mode( + list(map(lambda x: x == STATE_ON, filtered_states)) + ) + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index e0b1eb1ab23..fb343f9006a 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -32,7 +32,9 @@ } }, "light": { + "description": "[%key:component::group::config::step::binary_sensor::description%]", "data": { + "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "title": "[%key:component::group::config::step::user::title%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "name": "[%key:component::group::config::step::binary_sensor::data::name%]" @@ -67,6 +69,7 @@ }, "light_options": { "data": { + "all": "[%key:component::group::config::step::binary_sensor::data::all%]", "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]" } }, diff --git a/homeassistant/components/group/translations/en.json b/homeassistant/components/group/translations/en.json index b2b5750795e..a8c9e09dd0b 100644 --- a/homeassistant/components/group/translations/en.json +++ b/homeassistant/components/group/translations/en.json @@ -15,62 +15,30 @@ "entities": "Members", "name": "Name", "title": "New Group" - }, - "description": "Select group options" - }, - "cover_options": { - "data": { - "entities": "Group members" - }, - "description": "Select group options" + } }, "fan": { "data": { "entities": "Members", "name": "Name", "title": "New Group" - }, - "description": "Select group options" - }, - "fan_options": { - "data": { - "entities": "Group members" - }, - "description": "Select group options" - }, - "init": { - "data": { - "group_type": "Group type" - }, - "description": "Select group type" + } }, "light": { "data": { + "all": "All entities", "entities": "Members", "name": "Name", "title": "New Group" }, - "description": "Select group options" - }, - "light_options": { - "data": { - "entities": "Group members" - }, - "description": "Select group options" + "description": "If \"all entities\" is enabled, the group's state is on only if all members are on. If \"all entities\" is disabled, the group's state is on if any member is on." }, "media_player": { "data": { "entities": "Members", "name": "Name", "title": "New Group" - }, - "description": "Select group options" - }, - "media_player_options": { - "data": { - "entities": "Group members" - }, - "description": "Select group options" + } }, "user": { "data": { @@ -100,6 +68,7 @@ }, "light_options": { "data": { + "all": "All entities", "entities": "Members" } }, diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 16fa2ad6933..f5a9fb22222 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -19,7 +19,8 @@ from tests.common import MockConfigEntry ("binary_sensor", "on", "on", {}, {"all": True}, {"all": True}, {}), ("cover", "open", "open", {}, {}, {}, {}), ("fan", "on", "on", {}, {}, {}, {}), - ("light", "on", "on", {}, {}, {}, {}), + ("light", "on", "on", {}, {}, {"all": False}, {}), + ("light", "on", "on", {}, {"all": True}, {"all": True}, {}), ("media_player", "on", "on", {}, {}, {}, {}), ), ) @@ -174,7 +175,7 @@ def get_suggested(schema, key): ("binary_sensor", "on", {"all": False}), ("cover", "open", {}), ("fan", "on", {}), - ("light", "on", {}), + ("light", "on", {"all": False}), ("media_player", "on", {}), ), ) diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index ba91b9dbbbc..56553ff263c 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1370,7 +1370,7 @@ async def test_plant_group(hass): ("binary_sensor", "on", {"all": False}), ("cover", "open", {}), ("fan", "on", {}), - ("light", "on", {}), + ("light", "on", {"all": False}), ("media_player", "on", {}), ), ) @@ -1435,7 +1435,7 @@ async def test_setup_and_remove_config_entry( ("binary_sensor", {"all": False}), ("cover", {}), ("fan", {}), - ("light", {}), + ("light", {"all": False}), ("media_player", {}), ), ) diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index d356b20b40f..0f125dd3e88 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -49,6 +49,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -68,6 +69,7 @@ async def test_default_state(hass): "entities": ["light.kitchen", "light.bedroom"], "name": "Bedroom Group", "unique_id": "unique_identifier", + "all": "false", } }, ) @@ -102,6 +104,7 @@ async def test_state_reporting(hass): LIGHT_DOMAIN: { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", } }, ) @@ -130,6 +133,49 @@ async def test_state_reporting(hass): assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE +async def test_state_reporting_all(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: { + "platform": DOMAIN, + "entities": ["light.test1", "light.test2"], + "all": "true", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + hass.states.async_set("light.test1", STATE_OFF) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + hass.states.async_set("light.test1", STATE_UNAVAILABLE) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + + async def test_brightness(hass, enable_custom_integrations): """Test brightness reporting.""" platform = getattr(hass.components, "test.light") @@ -155,6 +201,7 @@ async def test_brightness(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -225,6 +272,7 @@ async def test_color_hs(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -296,6 +344,7 @@ async def test_color_rgb(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -367,6 +416,7 @@ async def test_color_rgbw(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -438,6 +488,7 @@ async def test_color_rgbww(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -489,6 +540,7 @@ async def test_white_value(hass): LIGHT_DOMAIN: { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", } }, ) @@ -548,6 +600,7 @@ async def test_white(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -603,6 +656,7 @@ async def test_color_temp(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -674,6 +728,7 @@ async def test_emulated_color_temp_group(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2", "light.test3"], + "all": "false", }, ] }, @@ -739,6 +794,7 @@ async def test_min_max_mireds(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", }, ] }, @@ -784,6 +840,7 @@ async def test_effect_list(hass): LIGHT_DOMAIN: { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", } }, ) @@ -843,6 +900,7 @@ async def test_effect(hass): LIGHT_DOMAIN: { "platform": DOMAIN, "entities": ["light.test1", "light.test2", "light.test3"], + "all": "false", } }, ) @@ -909,6 +967,7 @@ async def test_supported_color_modes(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2", "light.test3"], + "all": "false", }, ] }, @@ -957,6 +1016,7 @@ async def test_color_mode(hass, enable_custom_integrations): { "platform": DOMAIN, "entities": ["light.test1", "light.test2", "light.test3"], + "all": "false", }, ] }, @@ -1051,6 +1111,7 @@ async def test_color_mode2(hass, enable_custom_integrations): "light.test5", "light.test6", ], + "all": "false", }, ] }, @@ -1082,6 +1143,7 @@ async def test_supported_features(hass): LIGHT_DOMAIN: { "platform": DOMAIN, "entities": ["light.test1", "light.test2"], + "all": "false", } }, ) @@ -1157,6 +1219,7 @@ async def test_service_calls(hass, enable_custom_integrations, supported_color_m "light.ceiling_lights", "light.kitchen_lights", ], + "all": "false", }, ] }, @@ -1269,6 +1332,7 @@ async def test_service_call_effect(hass): "light.ceiling_lights", "light.kitchen_lights", ], + "all": "false", }, ] }, @@ -1369,6 +1433,7 @@ async def test_reload(hass): "light.ceiling_lights", "light.kitchen_lights", ], + "all": "false", }, ] }, @@ -1481,11 +1546,13 @@ async def test_nested_group(hass): "platform": DOMAIN, "entities": ["light.bedroom_group"], "name": "Nested Group", + "all": "false", }, { "platform": DOMAIN, "entities": ["light.bed_light", "light.kitchen_lights"], "name": "Bedroom Group", + "all": "false", }, ] },