From 3e02fb1f077d66d59988ed66a795aa9446ad5c75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 25 Aug 2023 08:59:33 +0200 Subject: [PATCH] Add preview support to all groups (#98951) --- homeassistant/components/group/config_flow.py | 55 ++++++++++++++++--- homeassistant/components/group/cover.py | 12 ++++ homeassistant/components/group/event.py | 13 +++++ homeassistant/components/group/fan.py | 10 ++++ homeassistant/components/group/light.py | 13 +++++ homeassistant/components/group/lock.py | 10 ++++ .../components/group/media_player.py | 45 +++++++++++++-- homeassistant/components/group/switch.py | 13 +++++ tests/components/group/test_config_flow.py | 49 +++++++++++++---- 9 files changed, 194 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index a5bf9e0b972..9eb973b9609 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -24,7 +24,14 @@ from homeassistant.helpers.schema_config_entry_flow import ( from . import DOMAIN, GroupEntity from .binary_sensor import CONF_ALL, async_create_preview_binary_sensor from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC +from .cover import async_create_preview_cover +from .event import async_create_preview_event +from .fan import async_create_preview_fan +from .light import async_create_preview_light +from .lock import async_create_preview_lock +from .media_player import MediaPlayerGroup, async_create_preview_media_player from .sensor import async_create_preview_sensor +from .switch import async_create_preview_switch _STATISTIC_MEASURES = [ "min", @@ -122,7 +129,7 @@ SENSOR_CONFIG_SCHEMA = basic_group_config_schema( async def light_switch_options_schema( - domain: str, handler: SchemaCommonFlowHandler + domain: str, handler: SchemaCommonFlowHandler | None ) -> vol.Schema: """Generate options schema.""" return (await basic_group_options_schema(domain, handler)).extend( @@ -177,26 +184,32 @@ CONFIG_FLOW = { ), "cover": SchemaFlowFormStep( basic_group_config_schema("cover"), + preview="group", validate_user_input=set_group_type("cover"), ), "event": SchemaFlowFormStep( basic_group_config_schema("event"), + preview="group", validate_user_input=set_group_type("event"), ), "fan": SchemaFlowFormStep( basic_group_config_schema("fan"), + preview="group", validate_user_input=set_group_type("fan"), ), "light": SchemaFlowFormStep( basic_group_config_schema("light"), + preview="group", validate_user_input=set_group_type("light"), ), "lock": SchemaFlowFormStep( basic_group_config_schema("lock"), + preview="group", validate_user_input=set_group_type("lock"), ), "media_player": SchemaFlowFormStep( basic_group_config_schema("media_player"), + preview="group", validate_user_input=set_group_type("media_player"), ), "sensor": SchemaFlowFormStep( @@ -206,6 +219,7 @@ CONFIG_FLOW = { ), "switch": SchemaFlowFormStep( basic_group_config_schema("switch"), + preview="group", validate_user_input=set_group_type("switch"), ), } @@ -217,11 +231,26 @@ OPTIONS_FLOW = { binary_sensor_options_schema, preview="group", ), - "cover": SchemaFlowFormStep(partial(basic_group_options_schema, "cover")), - "event": SchemaFlowFormStep(partial(basic_group_options_schema, "event")), - "fan": SchemaFlowFormStep(partial(basic_group_options_schema, "fan")), - "light": SchemaFlowFormStep(partial(light_switch_options_schema, "light")), - "lock": SchemaFlowFormStep(partial(basic_group_options_schema, "lock")), + "cover": SchemaFlowFormStep( + partial(basic_group_options_schema, "cover"), + preview="group", + ), + "event": SchemaFlowFormStep( + partial(basic_group_options_schema, "event"), + preview="group", + ), + "fan": SchemaFlowFormStep( + partial(basic_group_options_schema, "fan"), + preview="group", + ), + "light": SchemaFlowFormStep( + partial(light_switch_options_schema, "light"), + preview="group", + ), + "lock": SchemaFlowFormStep( + partial(basic_group_options_schema, "lock"), + preview="group", + ), "media_player": SchemaFlowFormStep( partial(basic_group_options_schema, "media_player"), preview="group", @@ -230,17 +259,27 @@ OPTIONS_FLOW = { partial(sensor_options_schema, "sensor"), preview="group", ), - "switch": SchemaFlowFormStep(partial(light_switch_options_schema, "switch")), + "switch": SchemaFlowFormStep( + partial(light_switch_options_schema, "switch"), + preview="group", + ), } PREVIEW_OPTIONS_SCHEMA: dict[str, vol.Schema] = {} CREATE_PREVIEW_ENTITY: dict[ str, - Callable[[str, dict[str, Any]], GroupEntity], + Callable[[str, dict[str, Any]], GroupEntity | MediaPlayerGroup], ] = { "binary_sensor": async_create_preview_binary_sensor, + "cover": async_create_preview_cover, + "event": async_create_preview_event, + "fan": async_create_preview_fan, + "light": async_create_preview_light, + "lock": async_create_preview_lock, + "media_player": async_create_preview_media_player, "sensor": async_create_preview_sensor, + "switch": async_create_preview_switch, } diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 0fe67a9bccd..dbb49222bb0 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -96,6 +96,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_cover( + name: str, validated_config: dict[str, Any] +) -> CoverGroup: + """Create a preview sensor.""" + return CoverGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" diff --git a/homeassistant/components/group/event.py b/homeassistant/components/group/event.py index 81705c7f6f0..ca0c88867fe 100644 --- a/homeassistant/components/group/event.py +++ b/homeassistant/components/group/event.py @@ -2,6 +2,7 @@ from __future__ import annotations import itertools +from typing import Any import voluptuous as vol @@ -87,6 +88,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_event( + name: str, validated_config: dict[str, Any] +) -> EventGroup: + """Create a preview sensor.""" + return EventGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class EventGroup(GroupEntity, EventEntity): """Representation of an event group.""" diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 79ce6fe0d87..4ee788c8402 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -96,6 +96,16 @@ async def async_setup_entry( async_add_entities([FanGroup(config_entry.entry_id, config_entry.title, entities)]) +@callback +def async_create_preview_fan(name: str, validated_config: dict[str, Any]) -> FanGroup: + """Create a preview sensor.""" + return FanGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index c6369d876a4..38da7088c2e 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -110,6 +110,19 @@ async def async_setup_entry( ) +@callback +def async_create_preview_light( + name: str, validated_config: dict[str, Any] +) -> LightGroup: + """Create a preview sensor.""" + return LightGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_ALL, False), + ) + + FORWARDED_ATTRIBUTES = frozenset( { ATTR_BRIGHTNESS, diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index ec0ff13ee15..5558eab5475 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -90,6 +90,16 @@ async def async_setup_entry( ) +@callback +def async_create_preview_lock(name: str, validated_config: dict[str, Any]) -> LockGroup: + """Create a preview sensor.""" + return LockGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class LockGroup(GroupEntity, LockEntity): """Representation of a lock group.""" diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index f0d076ec130..3960f400614 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -1,7 +1,7 @@ """Platform allowing several media players to be grouped into one media player.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from contextlib import suppress from typing import Any @@ -44,7 +44,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import ( @@ -107,6 +107,18 @@ async def async_setup_entry( ) +@callback +def async_create_preview_media_player( + name: str, validated_config: dict[str, Any] +) -> MediaPlayerGroup: + """Create a preview sensor.""" + return MediaPlayerGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" @@ -139,7 +151,8 @@ class MediaPlayerGroup(MediaPlayerEntity): self.async_update_supported_features( event.data["entity_id"], event.data["new_state"] ) - self.async_update_state() + self.async_update_group_state() + self.async_write_ha_state() @callback def async_update_supported_features( @@ -208,6 +221,26 @@ class MediaPlayerGroup(MediaPlayerEntity): else: self._features[KEY_ENQUEUE].discard(entity_id) + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def async_state_changed_listener( + event: EventType[EventStateChangedData] | None, + ) -> None: + """Handle child updates.""" + self.async_update_group_state() + preview_callback(*self._async_generate_attributes()) + + async_state_changed_listener(None) + return async_track_state_change_event( + self.hass, self._entities, async_state_changed_listener + ) + async def async_added_to_hass(self) -> None: """Register listeners.""" for entity_id in self._entities: @@ -216,7 +249,8 @@ class MediaPlayerGroup(MediaPlayerEntity): async_track_state_change_event( self.hass, self._entities, self.async_on_state_change ) - self.async_update_state() + self.async_update_group_state() + self.async_write_ha_state() @property def name(self) -> str: @@ -391,7 +425,7 @@ class MediaPlayerGroup(MediaPlayerEntity): await self.async_set_volume_level(max(0, volume_level - 0.1)) @callback - def async_update_state(self) -> None: + def async_update_group_state(self) -> None: """Query all members and determine the media group state.""" states = [ state.state @@ -455,4 +489,3 @@ class MediaPlayerGroup(MediaPlayerEntity): supported_features |= MediaPlayerEntityFeature.MEDIA_ENQUEUE self._attr_supported_features = supported_features - self.async_write_ha_state() diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index bef42824d86..64bc9a99636 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -85,6 +85,19 @@ async def async_setup_entry( ) +@callback +def async_create_preview_switch( + name: str, validated_config: dict[str, Any] +) -> SwitchGroup: + """Create a preview sensor.""" + return SwitchGroup( + None, + name, + validated_config[CONF_ENTITIES], + validated_config.get(CONF_ALL, False), + ) + + class SwitchGroup(GroupEntity, SwitchEntity): """Representation of a switch group.""" diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index a58e47cae71..d0e90fe61bd 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -466,17 +466,34 @@ async def test_options_flow_hides_members( assert registry.async_get(f"{group_type}.three").hidden_by == hidden_by +COVER_ATTRS = [{"supported_features": 0}, {}] +EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] +FAN_ATTRS = [{"supported_features": 0}, {"assumed_state": True}] +LIGHT_ATTRS = [ + { + "icon": "mdi:lightbulb-group", + "supported_color_modes": ["onoff"], + "supported_features": 0, + }, + {"color_mode": "onoff"}, +] +LOCK_ATTRS = [{"supported_features": 1}, {}] +MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}] +SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}] + + @pytest.mark.parametrize( ("domain", "extra_user_input", "input_states", "group_state", "extra_attributes"), [ ("binary_sensor", {"all": True}, ["on", "off"], "off", [{}, {}]), - ( - "sensor", - {"type": "max"}, - ["10", "20"], - "20.0", - [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}], - ), + ("cover", {}, ["open", "closed"], "open", COVER_ATTRS), + ("event", {}, ["", ""], "unknown", EVENT_ATTRS), + ("fan", {}, ["on", "off"], "on", FAN_ATTRS), + ("light", {}, ["on", "off"], "on", LIGHT_ATTRS), + ("lock", {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("media_player", {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), + ("sensor", {"type": "max"}, ["10", "20"], "20.0", SENSOR_ATTRS), + ("switch", {}, ["on", "off"], "on", [{}, {}]), ], ) async def test_config_flow_preview( @@ -553,15 +570,22 @@ async def test_config_flow_preview( "extra_attributes", ), [ - ("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", {}), + ("binary_sensor", {"all": True}, {"all": False}, ["on", "off"], "on", [{}, {}]), + ("cover", {}, {}, ["open", "closed"], "open", COVER_ATTRS), + ("event", {}, {}, ["", ""], "unknown", EVENT_ATTRS), + ("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS), + ("light", {}, {}, ["on", "off"], "on", LIGHT_ATTRS), + ("lock", {}, {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("media_player", {}, {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), ( "sensor", {"type": "min"}, {"type": "max"}, ["10", "20"], "20.0", - {"icon": "mdi:calculator", "max_entity_id": "sensor.input_two"}, + SENSOR_ATTRS, ), + ("switch", {}, {}, ["on", "off"], "on", [{}, {}]), ], ) async def test_option_flow_preview( @@ -575,8 +599,6 @@ async def test_option_flow_preview( extra_attributes: dict[str, Any], ) -> None: """Test the option flow preview.""" - client = await hass_ws_client(hass) - input_entities = [f"{domain}.input_one", f"{domain}.input_two"] # Setup the config entry @@ -596,6 +618,8 @@ async def test_option_flow_preview( assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + client = await hass_ws_client(hass) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.FORM assert result["errors"] is None @@ -619,7 +643,8 @@ async def test_option_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"entity_id": input_entities, "friendly_name": "My group"} - | extra_attributes, + | extra_attributes[0] + | extra_attributes[1], "state": group_state, } assert len(hass.states.async_all()) == 3