From 7d468908043ae5469258b0d5fe6c1cbf199eb3ac Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 21 Jul 2024 20:57:49 +0200 Subject: [PATCH] Add support for grouping notify entities (#122123) * Add support for grouping notify entities * Add support for grouping notify entities * Add support for grouping notify entities * Fix test * Fix feedback * Update homeassistant/components/group/notify.py Co-authored-by: TheJulianJES * Test config flow changes * Test config flow changes --------- Co-authored-by: TheJulianJES --- homeassistant/components/group/config_flow.py | 12 ++ homeassistant/components/group/notify.py | 87 ++++++++- homeassistant/components/group/strings.json | 15 ++ tests/components/group/test_config_flow.py | 7 + tests/components/group/test_notify.py | 173 +++++++++++++++++- 5 files changed, 289 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 54ef7d0626f..ee8d11d035d 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -32,6 +32,7 @@ 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 .notify import async_create_preview_notify from .sensor import async_create_preview_sensor from .switch import async_create_preview_switch @@ -154,6 +155,7 @@ GROUP_TYPES = [ "light", "lock", "media_player", + "notify", "sensor", "switch", ] @@ -222,6 +224,11 @@ CONFIG_FLOW = { preview="group", validate_user_input=set_group_type("media_player"), ), + "notify": SchemaFlowFormStep( + basic_group_config_schema("notify"), + preview="group", + validate_user_input=set_group_type("notify"), + ), "sensor": SchemaFlowFormStep( SENSOR_CONFIG_SCHEMA, preview="group", @@ -269,6 +276,10 @@ OPTIONS_FLOW = { partial(basic_group_options_schema, "media_player"), preview="group", ), + "notify": SchemaFlowFormStep( + partial(basic_group_options_schema, "notify"), + preview="group", + ), "sensor": SchemaFlowFormStep( partial(sensor_options_schema, "sensor"), preview="group", @@ -293,6 +304,7 @@ CREATE_PREVIEW_ENTITY: dict[ "light": async_create_preview_light, "lock": async_create_preview_lock, "media_player": async_create_preview_media_player, + "notify": async_create_preview_notify, "sensor": async_create_preview_sensor, "switch": async_create_preview_switch, } diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py index 444658a6112..8294b55be5e 100644 --- a/homeassistant/components/group/notify.py +++ b/homeassistant/components/group/notify.py @@ -12,15 +12,28 @@ import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, ATTR_MESSAGE, + ATTR_TITLE, DOMAIN, PLATFORM_SCHEMA as NOTIFY_PLATFORM_SCHEMA, + SERVICE_SEND_MESSAGE, BaseNotificationService, + NotifyEntity, ) -from homeassistant.const import ATTR_SERVICE -from homeassistant.core import HomeAssistant +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SERVICE, + CONF_ENTITIES, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .entity import GroupEntity + CONF_SERVICES = "services" PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend( @@ -82,3 +95,73 @@ class GroupNotifyPlatform(BaseNotificationService): if tasks: await asyncio.wait(tasks) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Notify Group config entry.""" + registry = er.async_get(hass) + entities = er.async_validate_entity_ids( + registry, config_entry.options[CONF_ENTITIES] + ) + + async_add_entities( + [NotifyGroup(config_entry.entry_id, config_entry.title, entities)] + ) + + +@callback +def async_create_preview_notify( + hass: HomeAssistant, name: str, validated_config: dict[str, Any] +) -> NotifyGroup: + """Create a preview notify group.""" + return NotifyGroup( + None, + name, + validated_config[CONF_ENTITIES], + ) + + +class NotifyGroup(GroupEntity, NotifyEntity): + """Representation of a NotifyGroup.""" + + _attr_available: bool = False + + def __init__( + self, + unique_id: str | None, + name: str, + entity_ids: list[str], + ) -> None: + """Initialize a NotifyGroup.""" + 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 + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message to all members of the group.""" + await self.hass.services.async_call( + DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: message, + ATTR_TITLE: title, + ATTR_ENTITY_ID: self._entity_ids, + }, + blocking=True, + context=self._context, + ) + + @callback + def async_update_group_state(self) -> None: + """Query all members and determine the notify group state.""" + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any( + state.state != STATE_UNAVAILABLE + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ) diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index dc850804d94..dbb6fb01f7b 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -14,6 +14,7 @@ "light": "Light group", "lock": "Lock group", "media_player": "Media player group", + "notify": "Notify group", "sensor": "Sensor group", "switch": "Switch group" } @@ -84,6 +85,14 @@ "name": "[%key:common::config_flow::data::name%]" } }, + "notify": { + "title": "[%key:component::group::config::step::user::title%]", + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", + "name": "[%key:common::config_flow::data::name%]" + } + }, "sensor": { "title": "[%key:component::group::config::step::user::title%]", "data": { @@ -156,6 +165,12 @@ "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" } }, + "notify": { + "data": { + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]" + } + }, "sensor": { "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index dc40b647e2e..461df19ebf8 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -46,6 +46,7 @@ from tests.typing import WebSocketGenerator ("fan", "on", "on", {}, {}, {}, {}), ("light", "on", "on", {}, {}, {}, {}), ("lock", "locked", "locked", {}, {}, {}, {}), + ("notify", STATE_UNKNOWN, "2021-01-01T23:59:59.123+00:00", {}, {}, {}, {}), ("media_player", "on", "on", {}, {}, {}, {}), ( "sensor", @@ -142,6 +143,7 @@ async def test_config_flow( ("fan", {}), ("light", {}), ("lock", {}), + ("notify", {}), ("media_player", {}), ("switch", {}), ], @@ -220,6 +222,7 @@ def get_suggested(schema, key): ("fan", "on", {}, {}), ("light", "on", {"all": False}, {}), ("lock", "locked", {}, {}), + ("notify", "2021-01-01T23:59:59.123+00:00", {}, {}), ("media_player", "on", {}, {}), ( "sensor", @@ -405,6 +408,7 @@ async def test_all_options( ("fan", {}), ("light", {}), ("lock", {}), + ("notify", {}), ("media_player", {}), ("switch", {}), ], @@ -487,6 +491,7 @@ LIGHT_ATTRS = [ {"color_mode": "unknown"}, ] LOCK_ATTRS = [{"supported_features": 1}, {}] +NOTIFY_ATTRS = [{"supported_features": 0}, {}] MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}] SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}] @@ -501,6 +506,7 @@ SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two" ("fan", {}, ["on", "off"], "on", FAN_ATTRS), ("light", {}, ["on", "off"], "on", LIGHT_ATTRS), ("lock", {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("notify", {}, ["", ""], "unknown", NOTIFY_ATTRS), ("media_player", {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), ("sensor", {"type": "max"}, ["10", "20"], "20.0", SENSOR_ATTRS), ("switch", {}, ["on", "off"], "on", [{}, {}]), @@ -611,6 +617,7 @@ async def test_config_flow_preview( ("fan", {}, {}, ["on", "off"], "on", FAN_ATTRS), ("light", {}, {}, ["on", "off"], "on", LIGHT_ATTRS), ("lock", {}, {}, ["unlocked", "locked"], "unlocked", LOCK_ATTRS), + ("notify", {}, {}, ["", ""], "unknown", NOTIFY_ATTRS), ("media_player", {}, {}, ["on", "off"], "on", MEDIA_PLAYER_ATTRS), ( "sensor", diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py index dfd200a1542..2595b211dae 100644 --- a/tests/components/group/test_notify.py +++ b/tests/components/group/test_notify.py @@ -1,18 +1,44 @@ """The tests for the notify.group platform.""" -from collections.abc import Mapping +from collections.abc import Generator, Mapping from pathlib import Path from typing import Any from unittest.mock import MagicMock, call, patch +import pytest + from homeassistant import config as hass_config from homeassistant.components import notify -from homeassistant.components.group import SERVICE_RELOAD +from homeassistant.components.group import DOMAIN, SERVICE_RELOAD +from homeassistant.components.notify import ( + ATTR_MESSAGE, + ATTR_TITLE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + NotifyEntity, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import ( + ATTR_ENTITY_ID, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, get_fixture_path, mock_platform +from tests.common import ( + MockConfigEntry, + MockEntity, + MockModule, + MockPlatform, + get_fixture_path, + mock_config_flow, + mock_integration, + mock_platform, + setup_test_component_platform, +) class MockNotifyPlatform(MockPlatform): @@ -217,3 +243,144 @@ async def test_reload_notify(hass: HomeAssistant, tmp_path: Path) -> None: assert hass.services.has_service(notify.DOMAIN, "test_service2") assert not hass.services.has_service(notify.DOMAIN, "group_notify") assert hass.services.has_service(notify.DOMAIN, "new_group_notify") + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield + + +class MockNotifyEntity(MockEntity, NotifyEntity): + """Mock Email notifier entity to use in tests.""" + + def __init__(self, **values: Any) -> None: + """Initialize the mock entity.""" + super().__init__(**values) + self.send_message_mock_calls = MagicMock() + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a notification message.""" + self.send_message_mock_calls(message, title=title) + + +async def help_async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups( + config_entry, [Platform.NOTIFY] + ) + return True + + +async def help_async_unload_entry( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Unload test config entry.""" + return await hass.config_entries.async_unload_platforms( + config_entry, [Platform.NOTIFY] + ) + + +@pytest.fixture +async def mock_notifiers( + hass: HomeAssistant, config_flow_fixture: None +) -> list[NotifyEntity]: + """Set up the notify entities.""" + entity = MockNotifyEntity(name="test", entity_id="notify.test") + entity2 = MockNotifyEntity(name="test2", entity_id="notify.test2") + entities = [entity, entity2] + test_entry = MockConfigEntry(domain="test") + test_entry.add_to_hass(hass) + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=help_async_setup_entry_init, + async_unload_entry=help_async_unload_entry, + ), + ) + setup_test_component_platform(hass, NOTIFY_DOMAIN, entities, from_config_entry=True) + assert await hass.config_entries.async_setup(test_entry.entry_id) + await hass.async_block_till_done() + return entities + + +async def test_notify_entity_group( + hass: HomeAssistant, mock_notifiers: list[NotifyEntity] +) -> None: + """Test sending a message to a notify group.""" + entity, entity2 = mock_notifiers + assert entity.send_message_mock_calls.call_count == 0 + assert entity2.send_message_mock_calls.call_count == 0 + + config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "group_type": "notify", + "name": "Test Group", + "entities": ["notify.test", "notify.test2"], + "hide_members": True, + }, + title="Test Group", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_MESSAGE: "Hello", + ATTR_TITLE: "Test notification", + ATTR_ENTITY_ID: "notify.test_group", + }, + blocking=True, + ) + + assert entity.send_message_mock_calls.call_count == 1 + assert entity.send_message_mock_calls.call_args == call( + "Hello", title="Test notification" + ) + assert entity2.send_message_mock_calls.call_count == 1 + assert entity2.send_message_mock_calls.call_args == call( + "Hello", title="Test notification" + ) + + +async def test_state_reporting(hass: HomeAssistant) -> None: + """Test sending a message to a notify group.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + options={ + "group_type": "notify", + "name": "Test Group", + "entities": ["notify.test", "notify.test2"], + "hide_members": True, + }, + title="Test Group", + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("notify.test_group").state == STATE_UNAVAILABLE + + hass.states.async_set("notify.test", STATE_UNAVAILABLE) + hass.states.async_set("notify.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("notify.test_group").state == STATE_UNAVAILABLE + + hass.states.async_set("notify.test", "2021-01-01T23:59:59.123+00:00") + hass.states.async_set("notify.test2", "2021-01-01T23:59:59.123+00:00") + await hass.async_block_till_done() + assert hass.states.get("notify.test_group").state == STATE_UNKNOWN