diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4badbe6df51..7d09c9573b5 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -32,6 +32,8 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, 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 @@ -98,6 +100,7 @@ async def async_setup_entry( class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" + _attr_available: bool = False _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: @@ -109,7 +112,7 @@ class FanGroup(GroupEntity, FanEntity): self._direction = None self._supported_features = 0 self._speed_count = 100 - self._is_on = False + self._is_on: bool | None = False self._attr_name = name self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} self._attr_unique_id = unique_id @@ -125,7 +128,7 @@ class FanGroup(GroupEntity, FanEntity): return self._speed_count @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the entity is on.""" return self._is_on @@ -270,11 +273,25 @@ class FanGroup(GroupEntity, FanEntity): """Update state and attributes.""" self._attr_assumed_state = False - on_states: list[State] = list( - filter(None, [self.hass.states.get(x) for x in self._entities]) + states = [ + state + for entity_id in self._entities + if (state := self.hass.states.get(entity_id)) is not None + ] + self._attr_assumed_state |= not states_equal(states) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) + + valid_state = any( + state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) - self._is_on = any(state.state == STATE_ON for state in on_states) - self._attr_assumed_state |= not states_equal(on_states) + if not valid_state: + # Set as unknown if all members are unknown or unavailable + self._is_on = None + else: + # Set as ON if any member is ON + self._is_on = any(state.state == STATE_ON for state in states) percentage_states = self._async_states_by_support_flag( FanEntityFeature.SET_SPEED @@ -306,5 +323,5 @@ class FanGroup(GroupEntity, FanEntity): ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 ) self._attr_assumed_state |= any( - state.attributes.get(ATTR_ASSUMED_STATE) for state in on_states + state.attributes.get(ATTR_ASSUMED_STATE) for state in states ) diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 8aefd12c93a..bb2cf311191 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -119,15 +119,45 @@ async def test_state(hass, setup_comp): Otherwise, the group state is off. """ state = hass.states.get(FAN_GROUP) - # No entity has a valid state -> group state off - assert state.state == STATE_OFF + # No entity has a valid state -> group state unavailable + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert ATTR_ENTITY_ID not in state.attributes + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Test group members exposed as attribute + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) assert state.attributes[ATTR_ENTITY_ID] == [ *FULL_FAN_ENTITY_IDS, *LIMITED_FAN_ENTITY_IDS, ] - assert ATTR_ASSUMED_STATE not in state.attributes - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # All group members unavailable -> unavailable + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_UNAVAILABLE + + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + print("meh") + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set( + PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNKNOWN, {} + ) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_UNKNOWN # The group state is off if all group members are off, unknown or unavailable. for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): @@ -141,32 +171,6 @@ async def test_state(hass, setup_comp): state = hass.states.get(FAN_GROUP) assert state.state == STATE_OFF - for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) - hass.states.async_set( - PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNAVAILABLE, {} - ) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF - - for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) - hass.states.async_set( - PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNKNOWN, {} - ) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF - # At least one member on -> group on for state_1 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): for state_2 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): @@ -183,7 +187,7 @@ async def test_state(hass, setup_comp): hass.states.async_remove(PERCENTAGE_LIMITED_FAN_ENTITY_ID) await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -193,7 +197,7 @@ async def test_state(hass, setup_comp): hass.states.async_remove(PERCENTAGE_FULL_FAN_ENTITY_ID) await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -208,12 +212,9 @@ async def test_state(hass, setup_comp): async def test_attributes(hass, setup_comp): """Test handling of state attributes.""" state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME - assert state.attributes[ATTR_ENTITY_ID] == [ - *FULL_FAN_ENTITY_IDS, - *LIMITED_FAN_ENTITY_IDS, - ] + assert ATTR_ENTITY_ID not in state.attributes assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) @@ -223,6 +224,10 @@ async def test_attributes(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON + assert state.attributes[ATTR_ENTITY_ID] == [ + *FULL_FAN_ENTITY_IDS, + *LIMITED_FAN_ENTITY_IDS, + ] # Add Entity that supports speed hass.states.async_set(