From e638e5bb4214fef83b64fd42fa02d956b521711d Mon Sep 17 00:00:00 2001 From: Brian Egge Date: Mon, 13 Sep 2021 09:28:37 -0400 Subject: [PATCH] Add component for binary sensor groups (#55365) * Add component for binary sensor groups https://github.com/home-assistant/home-assistant.io/pull/19239 * Accidental push over prior commit * Add test for any case * Add unavailable attribute and tests for unique_id * Added tests for attributes link to documentation: https://github.com/home-assistant/home-assistant.io/pull/19297 --- homeassistant/components/group/__init__.py | 2 +- .../components/group/binary_sensor.py | 133 +++++++++++++++ tests/components/group/test_binary_sensor.py | 151 ++++++++++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/group/binary_sensor.py create mode 100644 tests/components/group/test_binary_sensor.py diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 096108b460e..dad8f943328 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"] +PLATFORMS = ["light", "cover", "notify", "binary_sensor"] REG_KEY = f"{DOMAIN}_registry" diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py new file mode 100644 index 00000000000..93d4e1e066e --- /dev/null +++ b/homeassistant/components/group/binary_sensor.py @@ -0,0 +1,133 @@ +"""This platform allows several binary sensor to be grouped into one binary sensor.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, + PLATFORM_SCHEMA, + BinarySensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_CLASS, + CONF_ENTITIES, + CONF_NAME, + CONF_UNIQUE_ID, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import CoreState, Event, HomeAssistant +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 + +DEFAULT_NAME = "Binary Sensor Group" + +CONF_ALL = "all" +REG_KEY = f"{BINARY_SENSOR_DOMAIN}_registry" + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ENTITIES): cv.entities_domain(BINARY_SENSOR_DOMAIN), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_ALL): cv.boolean, + } +) + + +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 Binary Sensor platform.""" + async_add_entities( + [ + BinarySensorGroup( + config.get(CONF_UNIQUE_ID), + config[CONF_NAME], + config.get(CONF_DEVICE_CLASS), + config[CONF_ENTITIES], + config.get(CONF_ALL), + ) + ] + ) + + +class BinarySensorGroup(GroupEntity, BinarySensorEntity): + """Representation of a BinarySensorGroup.""" + + _attr_assumed_state: bool = True + + def __init__( + self, + unique_id: str | None, + name: str, + device_class: str | None, + entity_ids: list[str], + mode: str | None, + ) -> None: + """Initialize a BinarySensorGroup entity.""" + super().__init__() + self._entity_ids = entity_ids + self._attr_name = name + self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids} + self._attr_unique_id = unique_id + self._device_class = device_class + self._state: str | None = None + self.mode = any + if mode: + self.mode = all + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + + async def async_state_changed_listener(event: Event) -> None: + """Handle child updates.""" + self.async_set_context(event.context) + await self.async_defer_or_update_ha_state() + + self.async_on_remove( + async_track_state_change_event( + self.hass, self._entity_ids, async_state_changed_listener + ) + ) + + if self.hass.state == CoreState.running: + await self.async_update() + return + + await super().async_added_to_hass() + + async def async_update(self) -> None: + """Query all members and determine the binary sensor group state.""" + all_states = [self.hass.states.get(x) for x in self._entity_ids] + filtered_states: list[str] = [x.state for x in all_states if x is not None] + self._attr_available = any( + state != STATE_UNAVAILABLE for state in filtered_states + ) + if STATE_UNAVAILABLE in filtered_states: + self._attr_is_on = None + else: + states = list(map(lambda x: x == STATE_ON, filtered_states)) + state = self.mode(states) + self._attr_is_on = state + self.async_write_ha_state() + + @property + def device_class(self) -> str | None: + """Return the sensor class of the binary sensor.""" + return self._device_class diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py new file mode 100644 index 00000000000..7bf62a16a42 --- /dev/null +++ b/tests/components/group/test_binary_sensor.py @@ -0,0 +1,151 @@ +"""The tests for the Group Binary Sensor platform.""" +from os import path + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.group import DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +async def test_default_state(hass): + """Test binary_sensor group default state.""" + hass.states.async_set("binary_sensor.kitchen", "on") + hass.states.async_set("binary_sensor.bedroom", "on") + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.kitchen", "binary_sensor.bedroom"], + "name": "Bedroom Group", + "unique_id": "unique_identifier", + "device_class": "presence", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.bedroom_group") + assert state is not None + assert state.state == STATE_ON + assert state.attributes.get(ATTR_ENTITY_ID) == [ + "binary_sensor.kitchen", + "binary_sensor.bedroom", + ] + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.bedroom_group") + assert entry + assert entry.unique_id == "unique_identifier" + assert entry.original_name == "Bedroom Group" + assert entry.device_class == "presence" + + +async def test_state_reporting_all(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.test1", "binary_sensor.test2"], + "name": "Binary Sensor Group", + "device_class": "presence", + "all": "true", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + + +async def test_state_reporting_any(hass): + """Test the state reporting.""" + await async_setup_component( + hass, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": DOMAIN, + "entities": ["binary_sensor.test1", "binary_sensor.test2"], + "name": "Binary Sensor Group", + "device_class": "presence", + "all": "false", + "unique_id": "unique_identifier", + } + }, + ) + await hass.async_block_till_done() + await hass.async_start() + await hass.async_block_till_done() + + # binary sensors have state off if unavailable + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + # binary sensors have state off if unavailable + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.binary_sensor_group") + assert entry + assert entry.unique_id == "unique_identifier" + + +def _get_fixtures_base_path(): + return path.dirname(path.dirname(path.dirname(__file__)))