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 <TheJulianJES@users.noreply.github.com>

* Test config flow changes

* Test config flow changes

---------

Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
This commit is contained in:
Joost Lekkerkerker 2024-07-21 20:57:49 +02:00 committed by GitHub
parent 7e1fb88e4e
commit 7d46890804
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 289 additions and 5 deletions

View file

@ -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,
}

View file

@ -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
)

View file

@ -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": {

View file

@ -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",

View file

@ -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