From def7c80e71e387b39b5ca2a14dc7a4f01559a07c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 26 Oct 2021 03:32:49 -0500 Subject: [PATCH] Add support for fan groups (#57941) * Add support for fan groups * dry * dry * fix refactor error * tweaks * wip * tweaks * tweaks * fix * fixes * coverage * tweaks --- homeassistant/components/group/__init__.py | 2 +- homeassistant/components/group/fan.py | 284 +++++++++++ homeassistant/components/group/util.py | 32 +- tests/components/group/test_fan.py | 508 ++++++++++++++++++++ tests/fixtures/group/fan_configuration.yaml | 13 + 5 files changed, 836 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/group/fan.py create mode 100644 tests/components/group/test_fan.py create mode 100644 tests/fixtures/group/fan_configuration.yaml diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index b06c25f48c9..523b45a94f7 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -56,7 +56,7 @@ ATTR_ALL = "all" SERVICE_SET = "set" SERVICE_REMOVE = "remove" -PLATFORMS = ["light", "cover", "notify", "binary_sensor"] +PLATFORMS = ["light", "cover", "notify", "fan", "binary_sensor"] REG_KEY = f"{DOMAIN}_registry" diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py new file mode 100644 index 00000000000..d36fcc39f43 --- /dev/null +++ b/homeassistant/components/group/fan.py @@ -0,0 +1,284 @@ +"""This platform allows several fans to be grouped into one fan.""" +from __future__ import annotations + +from functools import reduce +import logging +from operator import ior +from typing import Any + +import voluptuous as vol + +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + DOMAIN, + PLATFORM_SCHEMA, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, + FanEntity, +) +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_ON, +) +from homeassistant.core import CoreState, Event, HomeAssistant, State +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.typing import ConfigType + +from . import GroupEntity +from .util import ( + attribute_equal, + most_frequent_attribute, + reduce_attribute, + states_equal, +) + +SUPPORTED_FLAGS = {SUPPORT_SET_SPEED, SUPPORT_DIRECTION, SUPPORT_OSCILLATE} + +DEFAULT_NAME = "Fan Group" + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + } +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: dict[str, Any] | None = None, +) -> None: + """Set up the Group Cover platform.""" + async_add_entities( + [FanGroup(config.get(CONF_UNIQUE_ID), config[CONF_NAME], config[CONF_ENTITIES])] + ) + + +class FanGroup(GroupEntity, FanEntity): + """Representation of a FanGroup.""" + + _attr_assumed_state: bool = True + + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: + """Initialize a FanGroup entity.""" + self._entities = entities + self._fans: dict[int, set[str]] = {flag: set() for flag in SUPPORTED_FLAGS} + self._percentage = None + self._oscillating = None + self._direction = None + self._supported_features = 0 + self._speed_count = 100 + self._is_on = False + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} + self._attr_unique_id = unique_id + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return self._supported_features + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return self._speed_count + + @property + def is_on(self) -> bool: + """Return true if the entity is on.""" + return self._is_on + + @property + def percentage(self) -> int | None: + """Return the current speed as a percentage.""" + return self._percentage + + @property + def current_direction(self) -> str | None: + """Return the current direction of the fan.""" + return self._direction + + @property + def oscillating(self) -> bool | None: + """Return whether or not the fan is currently oscillating.""" + return self._oscillating + + async def _update_supported_features_event(self, event: Event) -> None: + self.async_set_context(event.context) + if (entity := event.data.get("entity_id")) is not None: + await self.async_update_supported_features( + entity, event.data.get("new_state") + ) + + async def async_update_supported_features( + self, + entity_id: str, + new_state: State | None, + update_state: bool = True, + ) -> None: + """Update dictionaries with supported features.""" + if not new_state: + for values in self._fans.values(): + values.discard(entity_id) + else: + features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + for feature in SUPPORTED_FLAGS: + if features & feature: + self._fans[feature].add(entity_id) + else: + self._fans[feature].discard(entity_id) + + if update_state: + await self.async_defer_or_update_ha_state() + + async def async_added_to_hass(self) -> None: + """Register listeners.""" + for entity_id in self._entities: + if (new_state := self.hass.states.get(entity_id)) is None: + continue + await self.async_update_supported_features( + entity_id, new_state, update_state=False + ) + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entities, self._update_supported_features_event + ) + ) + + if self.hass.state == CoreState.running: + await self.async_update() + return + await super().async_added_to_hass() + + async def async_set_percentage(self, percentage: int) -> None: + """Set the speed of the fan, as a percentage.""" + if percentage == 0: + await self.async_turn_off() + await self._async_call_supported_entities( + SERVICE_SET_PERCENTAGE, SUPPORT_SET_SPEED, {ATTR_PERCENTAGE: percentage} + ) + + async def async_oscillate(self, oscillating: bool) -> None: + """Oscillate the fan.""" + await self._async_call_supported_entities( + SERVICE_OSCILLATE, SUPPORT_OSCILLATE, {ATTR_OSCILLATING: oscillating} + ) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + await self._async_call_supported_entities( + SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, {ATTR_DIRECTION: direction} + ) + + async def async_turn_on( + self, + speed: str | None = None, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is not None: + await self.async_set_percentage(percentage) + return + await self._async_call_all_entities(SERVICE_TURN_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the fans off.""" + await self._async_call_all_entities(SERVICE_TURN_OFF) + + async def _async_call_supported_entities( + self, service: str, support_flag: int, data: dict[str, Any] + ) -> None: + """Call a service with all entities.""" + await self.hass.services.async_call( + DOMAIN, + service, + {**data, ATTR_ENTITY_ID: self._fans[support_flag]}, + blocking=True, + context=self._context, + ) + + async def _async_call_all_entities(self, service: str) -> None: + """Call a service with all entities.""" + await self.hass.services.async_call( + DOMAIN, + service, + {ATTR_ENTITY_ID: self._entities}, + blocking=True, + context=self._context, + ) + + def _async_states_by_support_flag(self, flag: int) -> list[State]: + """Return all the entity states for a supported flag.""" + states: list[State] = list( + filter(None, [self.hass.states.get(x) for x in self._fans[flag]]) + ) + return states + + def _set_attr_most_frequent(self, attr: str, flag: int, entity_attr: str) -> None: + """Set an attribute based on most frequent supported entities attributes.""" + states = self._async_states_by_support_flag(flag) + setattr(self, attr, most_frequent_attribute(states, entity_attr)) + self._attr_assumed_state |= not attribute_equal(states, entity_attr) + + async def async_update(self) -> None: + """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]) + ) + self._is_on = any(state.state == STATE_ON for state in on_states) + self._attr_assumed_state |= not states_equal(on_states) + + percentage_states = self._async_states_by_support_flag(SUPPORT_SET_SPEED) + self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE) + self._attr_assumed_state |= not attribute_equal( + percentage_states, ATTR_PERCENTAGE + ) + if ( + percentage_states + and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP) + and attribute_equal(percentage_states, ATTR_PERCENTAGE_STEP) + ): + self._speed_count = ( + round(100 / percentage_states[0].attributes[ATTR_PERCENTAGE_STEP]) + or 100 + ) + else: + self._speed_count = 100 + + self._set_attr_most_frequent( + "_oscillating", SUPPORT_OSCILLATE, ATTR_OSCILLATING + ) + self._set_attr_most_frequent("_direction", SUPPORT_DIRECTION, ATTR_DIRECTION) + + self._supported_features = reduce( + 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 + ) diff --git a/homeassistant/components/group/util.py b/homeassistant/components/group/util.py index d1e40f616d4..da67e071f27 100644 --- a/homeassistant/components/group/util.py +++ b/homeassistant/components/group/util.py @@ -15,6 +15,12 @@ def find_state_attributes(states: list[State], key: str) -> Iterator[Any]: yield value +def find_state(states: list[State]) -> Iterator[Any]: + """Find state from states.""" + for state in states: + yield state.state + + def mean_int(*args: Any) -> int: """Return the mean of the supplied values.""" return int(sum(args) / len(args)) @@ -30,8 +36,30 @@ def attribute_equal(states: list[State], key: str) -> bool: Note: Returns True if no matching attribute is found. """ - attrs = find_state_attributes(states, key) - grp = groupby(attrs) + return _values_equal(find_state_attributes(states, key)) + + +def most_frequent_attribute(states: list[State], key: str) -> Any | None: + """Find attributes with matching key from states.""" + if attrs := list(find_state_attributes(states, key)): + return max(set(attrs), key=attrs.count) + return None + + +def states_equal(states: list[State]) -> bool: + """Return True if all states are equal. + + Note: Returns True if no matching attribute is found. + """ + return _values_equal(find_state(states)) + + +def _values_equal(values: Iterator[Any]) -> bool: + """Return True if all values are equal. + + Note: Returns True if no matching attribute is found. + """ + grp = groupby(values) return bool(next(grp, True) and not next(grp, False)) diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py new file mode 100644 index 00000000000..10770e3de06 --- /dev/null +++ b/tests/components/group/test_fan.py @@ -0,0 +1,508 @@ +"""The tests for the group fan platform.""" +from os import path +from unittest.mock import patch + +import pytest + +from homeassistant import config as hass_config +from homeassistant.components.fan import ( + ATTR_DIRECTION, + ATTR_OSCILLATING, + ATTR_PERCENTAGE, + ATTR_PERCENTAGE_STEP, + DIRECTION_FORWARD, + DIRECTION_REVERSE, + DOMAIN, + SERVICE_OSCILLATE, + SERVICE_SET_DIRECTION, + SERVICE_SET_PERCENTAGE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SUPPORT_DIRECTION, + SUPPORT_OSCILLATE, + SUPPORT_SET_SPEED, +) +from homeassistant.components.group import SERVICE_RELOAD +from homeassistant.components.group.fan import DEFAULT_NAME +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_SUPPORTED_FEATURES, + CONF_ENTITIES, + CONF_UNIQUE_ID, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import CoreState +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import assert_setup_component + +FAN_GROUP = "fan.fan_group" + +MISSING_FAN_ENTITY_ID = "fan.missing" +LIVING_ROOM_FAN_ENTITY_ID = "fan.living_room_fan" +PERCENTAGE_FULL_FAN_ENTITY_ID = "fan.percentage_full_fan" +CEILING_FAN_ENTITY_ID = "fan.ceiling_fan" +PERCENTAGE_LIMITED_FAN_ENTITY_ID = "fan.percentage_limited_fan" + +FULL_FAN_ENTITY_IDS = [LIVING_ROOM_FAN_ENTITY_ID, PERCENTAGE_FULL_FAN_ENTITY_ID] +LIMITED_FAN_ENTITY_IDS = [CEILING_FAN_ENTITY_ID, PERCENTAGE_LIMITED_FAN_ENTITY_ID] + + +FULL_SUPPORT_FEATURES = SUPPORT_SET_SPEED | SUPPORT_DIRECTION | SUPPORT_OSCILLATE + + +CONFIG_MISSING_FAN = { + DOMAIN: [ + {"platform": "demo"}, + { + "platform": "group", + CONF_ENTITIES: [ + MISSING_FAN_ENTITY_ID, + *FULL_FAN_ENTITY_IDS, + *LIMITED_FAN_ENTITY_IDS, + ], + }, + ] +} + +CONFIG_FULL_SUPPORT = { + DOMAIN: [ + {"platform": "demo"}, + { + "platform": "group", + CONF_ENTITIES: [*FULL_FAN_ENTITY_IDS], + }, + ] +} + +CONFIG_LIMITED_SUPPORT = { + DOMAIN: [ + { + "platform": "group", + CONF_ENTITIES: [*LIMITED_FAN_ENTITY_IDS], + }, + ] +} + + +CONFIG_ATTRIBUTES = { + DOMAIN: { + "platform": "group", + CONF_ENTITIES: [*FULL_FAN_ENTITY_IDS, *LIMITED_FAN_ENTITY_IDS], + CONF_UNIQUE_ID: "unique_identifier", + } +} + + +@pytest.fixture +async def setup_comp(hass, config_count): + """Set up group fan component.""" + config, count = config_count + with assert_setup_component(count, DOMAIN): + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + +@pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) +async def test_state(hass, setup_comp): + """Test handling of state.""" + state = hass.states.get(FAN_GROUP) + # No entity has a valid state -> group state off + assert state.state == STATE_OFF + assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + 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 + + # Set all entities as on -> group state on + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + + # Set all entities as off -> group state off + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_OFF + + # Set first entity as on -> group state on + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + + # Set last entity as on -> group state on + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + + # now remove an entity + 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 ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Test entity registry integration + entity_registry = er.async_get(hass) + entry = entity_registry.async_get(FAN_GROUP) + assert entry + assert entry.unique_id == "unique_identifier" + + +@pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) +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.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + 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 + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + + # Add Entity that supports speed + hass.states.async_set( + CEILING_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED, + ATTR_PERCENTAGE: 50, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SET_SPEED + assert ATTR_PERCENTAGE in state.attributes + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert ATTR_ASSUMED_STATE not in state.attributes + + # Add Entity that supports + # ### Test assumed state ### + # ########################## + + # Add Entity with a different speed should set assumed state + hass.states.async_set( + PERCENTAGE_LIMITED_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED, + ATTR_PERCENTAGE: 75, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + assert state.attributes[ATTR_ASSUMED_STATE] is True + assert state.attributes[ATTR_PERCENTAGE] == int((50 + 75) / 2) + + +@pytest.mark.parametrize("config_count", [(CONFIG_FULL_SUPPORT, 2)]) +async def test_direction_oscillating(hass, setup_comp): + """Test handling of direction and oscillating attributes.""" + + hass.states.async_set( + LIVING_ROOM_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FULL_SUPPORT_FEATURES, + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 50, + }, + ) + hass.states.async_set( + PERCENTAGE_FULL_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FULL_SUPPORT_FEATURES, + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 50, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert state.attributes[ATTR_ENTITY_ID] == [*FULL_FAN_ENTITY_IDS] + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == FULL_SUPPORT_FEATURES + assert ATTR_PERCENTAGE in state.attributes + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_OSCILLATING] is True + assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD + assert ATTR_ASSUMED_STATE not in state.attributes + + # Add Entity that supports + # ### Test assumed state ### + # ########################## + + # Add Entity with a different direction should set assumed state + hass.states.async_set( + PERCENTAGE_FULL_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FULL_SUPPORT_FEATURES, + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_REVERSE, + ATTR_PERCENTAGE: 50, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_PERCENTAGE in state.attributes + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_OSCILLATING] is True + assert ATTR_ASSUMED_STATE in state.attributes + + # Now that everything is the same, no longer assumed state + + hass.states.async_set( + LIVING_ROOM_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FULL_SUPPORT_FEATURES, + ATTR_OSCILLATING: True, + ATTR_DIRECTION: DIRECTION_REVERSE, + ATTR_PERCENTAGE: 50, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + assert ATTR_PERCENTAGE in state.attributes + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_OSCILLATING] is True + assert state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE + assert ATTR_ASSUMED_STATE not in state.attributes + + hass.states.async_set( + LIVING_ROOM_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FULL_SUPPORT_FEATURES, + ATTR_OSCILLATING: False, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 50, + }, + ) + hass.states.async_set( + PERCENTAGE_FULL_FAN_ENTITY_ID, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FULL_SUPPORT_FEATURES, + ATTR_OSCILLATING: False, + ATTR_DIRECTION: DIRECTION_FORWARD, + ATTR_PERCENTAGE: 50, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON + assert ATTR_PERCENTAGE in state.attributes + assert state.attributes[ATTR_PERCENTAGE] == 50 + assert state.attributes[ATTR_OSCILLATING] is False + assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD + assert ATTR_ASSUMED_STATE not in state.attributes + + +@pytest.mark.parametrize("config_count", [(CONFIG_MISSING_FAN, 2)]) +async def test_state_missing_entity_id(hass, setup_comp): + """Test we can still setup with a missing entity id.""" + state = hass.states.get(FAN_GROUP) + await hass.async_block_till_done() + assert state.state == STATE_OFF + + +async def test_setup_before_started(hass): + """Test we can setup before starting.""" + hass.state = CoreState.stopped + assert await async_setup_component(hass, DOMAIN, CONFIG_MISSING_FAN) + + await hass.async_block_till_done() + await hass.async_start() + + await hass.async_block_till_done() + assert hass.states.get(FAN_GROUP).state == STATE_OFF + + +@pytest.mark.parametrize("config_count", [(CONFIG_MISSING_FAN, 2)]) +async def test_reload(hass, setup_comp): + """Test the ability to reload fans.""" + await hass.async_block_till_done() + await hass.async_start() + + await hass.async_block_till_done() + assert hass.states.get(FAN_GROUP).state == STATE_OFF + + yaml_path = path.join( + _get_fixtures_base_path(), + "fixtures", + "group/fan_configuration.yaml", + ) + with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path): + await hass.services.async_call( + "group", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get(FAN_GROUP) is None + assert hass.states.get("fan.upstairs_fans") is not None + + +def _get_fixtures_base_path(): + return path.dirname(path.dirname(path.dirname(__file__))) + + +@pytest.mark.parametrize("config_count", [(CONFIG_FULL_SUPPORT, 2)]) +async def test_service_calls(hass, setup_comp): + """Test calling services.""" + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: FAN_GROUP}, blocking=True + ) + assert hass.states.get(LIVING_ROOM_FAN_ENTITY_ID).state == STATE_ON + assert hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID).state == STATE_ON + assert hass.states.get(FAN_GROUP).state == STATE_ON + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: FAN_GROUP, ATTR_PERCENTAGE: 66}, + blocking=True, + ) + living_room_fan_state = hass.states.get(LIVING_ROOM_FAN_ENTITY_ID) + assert living_room_fan_state.attributes[ATTR_PERCENTAGE] == 66 + percentage_full_fan_state = hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID) + assert percentage_full_fan_state.attributes[ATTR_PERCENTAGE] == 66 + fan_group_state = hass.states.get(FAN_GROUP) + assert fan_group_state.attributes[ATTR_PERCENTAGE] == 66 + assert fan_group_state.attributes[ATTR_PERCENTAGE_STEP] == 100 / 3 + + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: FAN_GROUP}, blocking=True + ) + assert hass.states.get(LIVING_ROOM_FAN_ENTITY_ID).state == STATE_OFF + assert hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID).state == STATE_OFF + assert hass.states.get(FAN_GROUP).state == STATE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_PERCENTAGE, + {ATTR_ENTITY_ID: FAN_GROUP, ATTR_PERCENTAGE: 100}, + blocking=True, + ) + living_room_fan_state = hass.states.get(LIVING_ROOM_FAN_ENTITY_ID) + assert living_room_fan_state.attributes[ATTR_PERCENTAGE] == 100 + percentage_full_fan_state = hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID) + assert percentage_full_fan_state.attributes[ATTR_PERCENTAGE] == 100 + fan_group_state = hass.states.get(FAN_GROUP) + assert fan_group_state.attributes[ATTR_PERCENTAGE] == 100 + + await hass.services.async_call( + DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: FAN_GROUP, ATTR_PERCENTAGE: 0}, + blocking=True, + ) + assert hass.states.get(LIVING_ROOM_FAN_ENTITY_ID).state == STATE_OFF + assert hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID).state == STATE_OFF + assert hass.states.get(FAN_GROUP).state == STATE_OFF + + await hass.services.async_call( + DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: FAN_GROUP, ATTR_OSCILLATING: True}, + blocking=True, + ) + living_room_fan_state = hass.states.get(LIVING_ROOM_FAN_ENTITY_ID) + assert living_room_fan_state.attributes[ATTR_OSCILLATING] is True + percentage_full_fan_state = hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID) + assert percentage_full_fan_state.attributes[ATTR_OSCILLATING] is True + fan_group_state = hass.states.get(FAN_GROUP) + assert fan_group_state.attributes[ATTR_OSCILLATING] is True + + await hass.services.async_call( + DOMAIN, + SERVICE_OSCILLATE, + {ATTR_ENTITY_ID: FAN_GROUP, ATTR_OSCILLATING: False}, + blocking=True, + ) + living_room_fan_state = hass.states.get(LIVING_ROOM_FAN_ENTITY_ID) + assert living_room_fan_state.attributes[ATTR_OSCILLATING] is False + percentage_full_fan_state = hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID) + assert percentage_full_fan_state.attributes[ATTR_OSCILLATING] is False + fan_group_state = hass.states.get(FAN_GROUP) + assert fan_group_state.attributes[ATTR_OSCILLATING] is False + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: FAN_GROUP, ATTR_DIRECTION: DIRECTION_FORWARD}, + blocking=True, + ) + living_room_fan_state = hass.states.get(LIVING_ROOM_FAN_ENTITY_ID) + assert living_room_fan_state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD + percentage_full_fan_state = hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID) + assert percentage_full_fan_state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD + fan_group_state = hass.states.get(FAN_GROUP) + assert fan_group_state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_DIRECTION, + {ATTR_ENTITY_ID: FAN_GROUP, ATTR_DIRECTION: DIRECTION_REVERSE}, + blocking=True, + ) + living_room_fan_state = hass.states.get(LIVING_ROOM_FAN_ENTITY_ID) + assert living_room_fan_state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE + percentage_full_fan_state = hass.states.get(PERCENTAGE_FULL_FAN_ENTITY_ID) + assert percentage_full_fan_state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE + fan_group_state = hass.states.get(FAN_GROUP) + assert fan_group_state.attributes[ATTR_DIRECTION] == DIRECTION_REVERSE diff --git a/tests/fixtures/group/fan_configuration.yaml b/tests/fixtures/group/fan_configuration.yaml new file mode 100644 index 00000000000..0a33d1819b6 --- /dev/null +++ b/tests/fixtures/group/fan_configuration.yaml @@ -0,0 +1,13 @@ +fan: + - platform: group + name: Upstairs Fans + entities: + - fan.living_room_fan + - fan.percentage_full_fan + +notify: + - platform: group + name: new_group_notify + services: + - service: demo1 + - service: demo2