diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index 64baba6d1e86..e258c662bc7c 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -282,6 +282,7 @@ class CoverGroup(GroupEntity, CoverEntity): self._attr_is_closed = True self._attr_is_closing = False self._attr_is_opening = False + self._update_assumed_state_from_members() for entity_id in self._entity_ids: if not (state := self.hass.states.get(entity_id)): continue diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 40db70a2eb35..f9d9a62a0ac7 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -115,6 +115,17 @@ class GroupEntity(Entity): def async_update_group_state(self) -> None: """Abstract method to update the entity.""" + @callback + def _update_assumed_state_from_members(self) -> None: + """Update assumed_state based on member entities.""" + self._attr_assumed_state = False + for entity_id in self._entity_ids: + if (state := self.hass.states.get(entity_id)) is None: + continue + if state.attributes.get(ATTR_ASSUMED_STATE): + self._attr_assumed_state = True + return + @callback def async_update_supported_features( self, diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 78745cb74c60..621c00bb1567 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -252,6 +252,7 @@ class FanGroup(GroupEntity, FanEntity): @callback def async_update_group_state(self) -> None: """Update state and attributes.""" + self._update_assumed_state_from_members() states = [ state diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index 259832d6152d..564a9f12ed99 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -205,6 +205,8 @@ class LightGroup(GroupEntity, LightEntity): @callback def async_update_group_state(self) -> None: """Query all members and determine the light group state.""" + self._update_assumed_state_from_members() + states = [ state for entity_id in self._entity_ids diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 29e625ca8e36..0a13e2cf2051 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -156,6 +156,8 @@ class SwitchGroup(GroupEntity, SwitchEntity): @callback def async_update_group_state(self) -> None: """Query all members and determine the switch group state.""" + self._update_assumed_state_from_members() + states = [ state.state for entity_id in self._entity_ids diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index ab92b18cc91f..a8fe0f8d96e0 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -421,13 +421,6 @@ async def test_attributes( assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes - # Group member has set assumed_state - hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {ATTR_ASSUMED_STATE: True}) - await hass.async_block_till_done() - - state = hass.states.get(COVER_GROUP) - assert ATTR_ASSUMED_STATE not in state.attributes - # Test entity registry integration entry = entity_registry.async_get(COVER_GROUP) assert entry @@ -859,6 +852,61 @@ async def test_is_opening_closing(hass: HomeAssistant) -> None: assert hass.states.get(COVER_GROUP).state == CoverState.OPENING +@pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) +@pytest.mark.usefixtures("setup_comp") +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(DEMO_COVER, CoverState.OPEN, {}) + hass.states.async_set(DEMO_COVER_POS, CoverState.OPEN, {}) + hass.states.async_set(DEMO_COVER_TILT, CoverState.CLOSED, {}) + hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set(DEMO_COVER, CoverState.OPEN, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Multiple members with assumed_state=True -> group has assumed_state=True + hass.states.async_set( + DEMO_COVER_TILT, CoverState.CLOSED, {ATTR_ASSUMED_STATE: True} + ) + hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unavailable member with assumed_state=True -> group has assumed_state=True + hass.states.async_set(DEMO_COVER, CoverState.OPEN, {}) + hass.states.async_set(DEMO_COVER_TILT, CoverState.CLOSED, {}) + hass.states.async_set(DEMO_TILT, STATE_UNAVAILABLE, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unknown member with assumed_state=True -> group has assumed_state=True + hass.states.async_set(DEMO_TILT, STATE_UNKNOWN, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(DEMO_TILT, CoverState.CLOSED, {}) + await hass.async_block_till_done() + + state = hass.states.get(COVER_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes + + async def test_nested_group(hass: HomeAssistant) -> None: """Test nested cover group.""" await async_setup_component( diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 93509b5a651e..96c2cb33f6a1 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -587,3 +587,47 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID).state == STATE_ON assert hass.states.get("fan.bedroom_group").state == STATE_ON assert hass.states.get("fan.nested_group").state == STATE_ON + + +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + await async_setup_component( + hass, + FAN_DOMAIN, + { + FAN_DOMAIN: [ + {"platform": "demo"}, + { + "platform": "group", + CONF_ENTITIES: [LIVING_ROOM_FAN_ENTITY_ID, CEILING_FAN_ENTITY_ID], + }, + ] + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set( + LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {ATTR_ASSUMED_STATE: True} + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert ATTR_ASSUMED_STATE not in state.attributes diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index dbd74e957806..a8fbe50970ce 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -30,6 +30,7 @@ from homeassistant.components.light import ( ColorMode, ) from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, EVENT_CALL_SERVICE, @@ -1647,3 +1648,72 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert hass.states.get("light.kitchen_lights").state == STATE_OFF assert hass.states.get("light.bedroom_group").state == STATE_OFF assert hass.states.get("light.nested_group").state == STATE_OFF + + +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + await async_setup_component( + hass, + LIGHT_DOMAIN, + { + LIGHT_DOMAIN: { + "platform": DOMAIN, + "entities": ["light.kitchen", "light.bedroom", "light.living_room"], + "name": "Light Group", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("light.kitchen", STATE_ON, {}) + hass.states.async_set("light.bedroom", STATE_ON, {}) + hass.states.async_set("light.living_room", STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set("light.kitchen", STATE_ON, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Multiple members with assumed_state=True -> group has assumed_state=True + hass.states.async_set("light.bedroom", STATE_OFF, {ATTR_ASSUMED_STATE: True}) + hass.states.async_set("light.living_room", STATE_OFF, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unavailable member with assumed_state=True -> group has assumed_state=True + hass.states.async_set("light.kitchen", STATE_ON, {}) + hass.states.async_set("light.bedroom", STATE_OFF, {}) + hass.states.async_set( + "light.living_room", STATE_UNAVAILABLE, {ATTR_ASSUMED_STATE: True} + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # Unknown member with assumed_state=True -> group has assumed_state=True + hass.states.async_set( + "light.living_room", STATE_UNKNOWN, {ATTR_ASSUMED_STATE: True} + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("light.living_room", STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get("light.light_group") + assert ATTR_ASSUMED_STATE not in state.attributes diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 4230a6ee86fb..b577d2a6e2b2 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -14,6 +14,7 @@ from homeassistant.components.switch import ( SERVICE_TURN_ON, ) from homeassistant.const import ( + ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON, @@ -458,3 +459,43 @@ async def test_nested_group(hass: HomeAssistant) -> None: assert hass.states.get("switch.decorative_lights").state == STATE_OFF assert hass.states.get("switch.some_group").state == STATE_OFF assert hass.states.get("switch.nested_group").state == STATE_OFF + + +async def test_assumed_state(hass: HomeAssistant) -> None: + """Test assumed_state attribute behavior.""" + await async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: { + "platform": DOMAIN, + "entities": ["switch.tv", "switch.soundbar"], + "name": "Media Group", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # No members with assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("switch.tv", STATE_ON, {}) + hass.states.async_set("switch.soundbar", STATE_OFF, {}) + await hass.async_block_till_done() + + state = hass.states.get("switch.media_group") + assert ATTR_ASSUMED_STATE not in state.attributes + + # One member with assumed_state=True -> group has assumed_state=True + hass.states.async_set("switch.tv", STATE_ON, {ATTR_ASSUMED_STATE: True}) + await hass.async_block_till_done() + + state = hass.states.get("switch.media_group") + assert state.attributes.get(ATTR_ASSUMED_STATE) is True + + # All members without assumed_state -> group doesn't have assumed_state in attributes + hass.states.async_set("switch.tv", STATE_ON, {}) + await hass.async_block_till_done() + + state = hass.states.get("switch.media_group") + assert ATTR_ASSUMED_STATE not in state.attributes