Add notify entity component (#110950)

* Add notify entity component

* Device classes, restore state, icons

* Add icons file

* Add tests for kitchen_sink

* Remove notify from no_entity_platforms in hassfest icons, translation link

* ruff

* Remove `data` feature

* Only message support

* Complete initial device classes

* mypy pylint

* Remove device_class implementation

* format

* Follow up comments

* Remove _attr_supported_features

* Use setup_test_component_platform

* User helper at other places

* last comment

* Add entry unload test and non async test

* Avoid default mutable object in constructor
This commit is contained in:
Jan Bouwhuis 2024-04-11 12:04:08 +02:00 committed by GitHub
parent df5d818c08
commit 10076e6523
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 493 additions and 25 deletions

View file

@ -32,6 +32,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
Platform.IMAGE,
Platform.LAWN_MOWER,
Platform.LOCK,
Platform.NOTIFY,
Platform.SENSOR,
Platform.SWITCH,
Platform.WEATHER,
@ -70,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
return True
def _create_issues(hass):
def _create_issues(hass: HomeAssistant) -> None:
"""Create some issue registry issues."""
async_create_issue(
hass,

View file

@ -0,0 +1,54 @@
"""Demo platform that offers a fake notify entity."""
from __future__ import annotations
from homeassistant.components import persistent_notification
from homeassistant.components.notify import NotifyEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the demo notify entity platform."""
async_add_entities(
[
DemoNotify(
unique_id="just_notify_me",
device_name="MyBox",
entity_name="Personal notifier",
),
]
)
class DemoNotify(NotifyEntity):
"""Representation of a demo notify entity."""
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
unique_id: str,
device_name: str,
entity_name: str | None,
) -> None:
"""Initialize the Demo button entity."""
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)},
name=device_name,
)
self._attr_name = entity_name
async def async_send_message(self, message: str) -> None:
"""Send out a persistent notification."""
persistent_notification.async_create(self.hass, message, "Demo notification")

View file

@ -2,24 +2,36 @@
from __future__ import annotations
from datetime import timedelta
from functools import cached_property, partial
import logging
from typing import Any, final, override
import voluptuous as vol
import homeassistant.components.persistent_notification as pn
from homeassistant.const import CONF_NAME, CONF_PLATFORM
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, ServiceCall
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from .const import ( # noqa: F401
ATTR_DATA,
ATTR_MESSAGE,
ATTR_RECIPIENTS,
ATTR_TARGET,
ATTR_TITLE,
DOMAIN,
NOTIFY_SERVICE_SCHEMA,
SERVICE_NOTIFY,
SERVICE_PERSISTENT_NOTIFICATION,
SERVICE_SEND_MESSAGE,
)
from .legacy import ( # noqa: F401
BaseNotificationService,
@ -29,9 +41,17 @@ from .legacy import ( # noqa: F401
check_templates_warn,
)
# mypy: disallow-any-generics
# Platform specific data
ATTR_TITLE_DEFAULT = "Home Assistant"
ENTITY_ID_FORMAT = DOMAIN + ".{}"
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = vol.Schema(
{vol.Required(CONF_PLATFORM): cv.string, vol.Optional(CONF_NAME): cv.string},
extra=vol.ALLOW_EXTRA,
@ -50,6 +70,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# legacy platforms to finish setting up.
hass.async_create_task(setup, eager_start=True)
component = hass.data[DOMAIN] = EntityComponent[NotifyEntity](_LOGGER, DOMAIN, hass)
component.async_register_entity_service(
SERVICE_SEND_MESSAGE,
{vol.Required(ATTR_MESSAGE): cv.string},
"_async_send_message",
)
async def persistent_notification(service: ServiceCall) -> None:
"""Send notification via the built-in persistent_notify integration."""
message: Template = service.data[ATTR_MESSAGE]
@ -79,3 +106,66 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
)
return True
class NotifyEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes button entities."""
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
component: EntityComponent[NotifyEntity] = hass.data[DOMAIN]
return await component.async_setup_entry(entry)
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
component: EntityComponent[NotifyEntity] = hass.data[DOMAIN]
return await component.async_unload_entry(entry)
class NotifyEntity(RestoreEntity):
"""Representation of a notify entity."""
entity_description: NotifyEntityDescription
_attr_should_poll = False
_attr_device_class: None
_attr_state: None = None
__last_notified_isoformat: str | None = None
@cached_property
@final
@override
def state(self) -> str | None:
"""Return the entity state."""
return self.__last_notified_isoformat
def __set_state(self, state: str | None) -> None:
"""Invalidate the cache of the cached property."""
self.__dict__.pop("state", None)
self.__last_notified_isoformat = state
async def async_internal_added_to_hass(self) -> None:
"""Call when the notify entity is added to hass."""
await super().async_internal_added_to_hass()
state = await self.async_get_last_state()
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__set_state(state.state)
@final
async def _async_send_message(self, **kwargs: Any) -> None:
"""Send a notification message (from e.g., service call).
Should not be overridden, handle setting last notification timestamp.
"""
self.__set_state(dt_util.utcnow().isoformat())
self.async_write_ha_state()
await self.async_send_message(**kwargs)
def send_message(self, message: str) -> None:
"""Send a message."""
raise NotImplementedError
async def async_send_message(self, message: str) -> None:
"""Send a message."""
await self.hass.async_add_executor_job(partial(self.send_message, message))

View file

@ -11,9 +11,12 @@ ATTR_DATA = "data"
# Text to notify user of
ATTR_MESSAGE = "message"
# Target of the notification (user, device, etc)
# Target of the (legacy) notification (user, device, etc)
ATTR_TARGET = "target"
# Recipients for a notification
ATTR_RECIPIENTS = "recipients"
# Title of notification
ATTR_TITLE = "title"
@ -22,6 +25,7 @@ DOMAIN = "notify"
LOGGER = logging.getLogger(__package__)
SERVICE_NOTIFY = "notify"
SERVICE_SEND_MESSAGE = "send_message"
SERVICE_PERSISTENT_NOTIFICATION = "persistent_notification"
NOTIFY_SERVICE_SCHEMA = vol.Schema(

View file

@ -1,6 +1,12 @@
{
"entity_component": {
"_": {
"default": "mdi:message"
}
},
"services": {
"notify": "mdi:bell-ring",
"persistent_notification": "mdi:bell-badge"
"persistent_notification": "mdi:bell-badge",
"send_message": "mdi:message-arrow-right"
}
}

View file

@ -20,6 +20,16 @@ notify:
selector:
object:
send_message:
target:
entity:
domain: notify
fields:
message:
required: true
selector:
text:
persistent_notification:
fields:
message:

View file

@ -1,5 +1,10 @@
{
"title": "Notifications",
"entity_component": {
"_": {
"name": "[%key:component::notify::title%]"
}
},
"services": {
"notify": {
"name": "Send a notification",
@ -23,6 +28,16 @@
}
}
},
"send_message": {
"name": "Send a notification message",
"description": "Sends a notification message.",
"fields": {
"message": {
"name": "Message",
"description": "Your notification message."
}
}
},
"persistent_notification": {
"name": "Send a persistent notification",
"description": "Sends a notification that is visible in the **Notifications** panel.",

View file

@ -93,6 +93,7 @@ def _base_components() -> dict[str, ModuleType]:
light,
lock,
media_player,
notify,
remote,
siren,
todo,
@ -112,6 +113,7 @@ def _base_components() -> dict[str, ModuleType]:
"light": light,
"lock": lock,
"media_player": media_player,
"notify": notify,
"remote": remote,
"siren": siren,
"todo": todo,

View file

@ -0,0 +1,66 @@
"""The tests for the demo button component."""
from collections.abc import AsyncGenerator
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.kitchen_sink import DOMAIN
from homeassistant.components.notify import (
DOMAIN as NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
)
from homeassistant.components.notify.const import ATTR_MESSAGE
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
ENTITY_DIRECT_MESSAGE = "notify.mybox_personal_notifier"
@pytest.fixture
async def notify_only() -> AsyncGenerator[None, None]:
"""Enable only the button platform."""
with patch(
"homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
[Platform.NOTIFY],
):
yield
@pytest.fixture(autouse=True)
async def setup_comp(hass: HomeAssistant, notify_only: None):
"""Set up demo component."""
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
def test_setup_params(hass: HomeAssistant) -> None:
"""Test the initial parameters."""
state = hass.states.get(ENTITY_DIRECT_MESSAGE)
assert state
assert state.state == STATE_UNKNOWN
async def test_send_message(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test pressing the button."""
state = hass.states.get(ENTITY_DIRECT_MESSAGE)
assert state
assert state.state == STATE_UNKNOWN
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
freezer.move_to(now)
await hass.services.async_call(
NOTIFY_DOMAIN,
SERVICE_SEND_MESSAGE,
{ATTR_ENTITY_ID: ENTITY_DIRECT_MESSAGE, ATTR_MESSAGE: "You have an update!"},
blocking=True,
)
state = hass.states.get(ENTITY_DIRECT_MESSAGE)
assert state
assert state.state == now.isoformat()

View file

@ -0,0 +1,23 @@
"""Fixtures for Notify platform tests."""
from collections.abc import Generator
import pytest
from homeassistant.config_entries import ConfigFlow
from homeassistant.core import HomeAssistant
from tests.common import mock_config_flow, mock_platform
class MockFlow(ConfigFlow):
"""Test flow."""
@pytest.fixture
def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]:
"""Mock config flow."""
mock_platform(hass, "test.config_flow")
with mock_config_flow("test", MockFlow):
yield

View file

@ -1,28 +1,216 @@
"""The tests for notify services that change targets."""
import asyncio
import copy
from pathlib import Path
from unittest.mock import Mock, patch
from typing import Any
from unittest.mock import MagicMock, Mock, patch
import pytest
import yaml
from homeassistant import config as hass_config
from homeassistant.components import notify
from homeassistant.const import SERVICE_RELOAD, Platform
from homeassistant.core import HomeAssistant
from homeassistant.components.notify import (
DOMAIN,
SERVICE_SEND_MESSAGE,
NotifyEntity,
NotifyEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
SERVICE_RELOAD,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.setup import async_setup_component
from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform
from tests.common import (
MockConfigEntry,
MockEntity,
MockModule,
MockPlatform,
async_get_persistent_notifications,
mock_integration,
mock_platform,
mock_restore_cache,
setup_test_component_platform,
)
TEST_KWARGS = {"message": "Test message"}
class MockNotifyEntity(MockEntity, NotifyEntity):
"""Mock Email notitier entity to use in tests."""
send_message_mock_calls = MagicMock()
async def async_send_message(self, message: str) -> None:
"""Send a notification message."""
self.send_message_mock_calls(message=message)
class MockNotifyEntityNonAsync(MockEntity, NotifyEntity):
"""Mock Email notitier entity to use in tests."""
send_message_mock_calls = MagicMock()
def send_message(self, message: str) -> None:
"""Send a notification message."""
self.send_message_mock_calls(message=message)
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_setup(config_entry, DOMAIN)
return True
async def help_async_unload_entry(
hass: HomeAssistant, config_entry: ConfigEntry
) -> bool:
"""Unload test config emntry."""
return await hass.config_entries.async_unload_platforms(
config_entry, [Platform.NOTIFY]
)
@pytest.mark.parametrize(
"entity",
[
MockNotifyEntityNonAsync(name="test", entity_id="notify.test"),
MockNotifyEntity(name="test", entity_id="notify.test"),
],
ids=["non_async", "async"],
)
async def test_send_message_service(
hass: HomeAssistant, config_flow_fixture: None, entity: NotifyEntity
) -> None:
"""Test send_message service."""
config_entry = MockConfigEntry(domain="test")
config_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, DOMAIN, [entity], from_config_entry=True)
assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get("notify.test")
assert state.state is STATE_UNKNOWN
await hass.services.async_call(
DOMAIN,
SERVICE_SEND_MESSAGE,
copy.deepcopy(TEST_KWARGS) | {"entity_id": "notify.test"},
blocking=True,
)
await hass.async_block_till_done()
entity.send_message_mock_calls.assert_called_once()
# Test unloading the entry succeeds
assert await hass.config_entries.async_unload(config_entry.entry_id)
@pytest.mark.parametrize(
("state", "init_state"),
[
("2021-01-01T23:59:59+00:00", "2021-01-01T23:59:59+00:00"),
(STATE_UNAVAILABLE, STATE_UNKNOWN),
],
)
async def test_restore_state(
hass: HomeAssistant, config_flow_fixture: None, state: str, init_state: str
) -> None:
"""Test we restore state integration."""
mock_restore_cache(hass, (State("notify.test", state),))
mock_integration(
hass,
MockModule(
"test",
async_setup_entry=help_async_setup_entry_init,
),
)
entity = MockNotifyEntity(name="test", entity_id="notify.test")
setup_test_component_platform(hass, DOMAIN, [entity], from_config_entry=True)
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
state = hass.states.get("notify.test")
assert state is not None
assert state.state is init_state
async def test_name(hass: HomeAssistant, config_flow_fixture: None) -> None:
"""Test notify name."""
mock_platform(hass, "test.config_flow")
mock_integration(
hass,
MockModule(
"test",
async_setup_entry=help_async_setup_entry_init,
),
)
# Unnamed notify entity -> no name
entity1 = NotifyEntity()
entity1.entity_id = "notify.test1"
# Unnamed notify entity and has_entity_name True -> unnamed
entity2 = NotifyEntity()
entity2.entity_id = "notify.test3"
entity2._attr_has_entity_name = True
# Named notify entity and has_entity_name True -> named
entity3 = NotifyEntity()
entity3.entity_id = "notify.test4"
entity3.entity_description = NotifyEntityDescription("test", has_entity_name=True)
setup_test_component_platform(
hass, DOMAIN, [entity1, entity2, entity3], from_config_entry=True
)
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(entity1.entity_id)
assert state
assert state.attributes == {}
state = hass.states.get(entity2.entity_id)
assert state
assert state.attributes == {}
state = hass.states.get(entity3.entity_id)
assert state
assert state.attributes == {}
class MockNotifyPlatform(MockPlatform):
"""Help to set up test notify service."""
"""Help to set up a legacy test notify service."""
def __init__(self, async_get_service=None, get_service=None):
"""Return the notify service."""
def __init__(self, async_get_service: Any = None, get_service: Any = None) -> None:
"""Return a legacy notify service."""
super().__init__()
if get_service:
self.get_service = get_service
@ -31,9 +219,13 @@ class MockNotifyPlatform(MockPlatform):
def mock_notify_platform(
hass, tmp_path, integration="notify", async_get_service=None, get_service=None
hass: HomeAssistant,
tmp_path: Path,
integration: str = "notify",
async_get_service: Any = None,
get_service: Any = None,
):
"""Specialize the mock platform for notify."""
"""Specialize the mock platform for legacy notify service."""
loaded_platform = MockNotifyPlatform(async_get_service, get_service)
mock_platform(hass, f"{integration}.notify", loaded_platform)
@ -41,7 +233,7 @@ def mock_notify_platform(
async def test_same_targets(hass: HomeAssistant) -> None:
"""Test not changing the targets in a notify service."""
"""Test not changing the targets in a legacy notify service."""
test = NotificationService(hass)
await test.async_setup(hass, "notify", "test")
await test.async_register_services()
@ -56,7 +248,7 @@ async def test_same_targets(hass: HomeAssistant) -> None:
async def test_change_targets(hass: HomeAssistant) -> None:
"""Test changing the targets in a notify service."""
"""Test changing the targets in a legacy notify service."""
test = NotificationService(hass)
await test.async_setup(hass, "notify", "test")
await test.async_register_services()
@ -73,7 +265,7 @@ async def test_change_targets(hass: HomeAssistant) -> None:
async def test_add_targets(hass: HomeAssistant) -> None:
"""Test adding the targets in a notify service."""
"""Test adding the targets in a legacy notify service."""
test = NotificationService(hass)
await test.async_setup(hass, "notify", "test")
await test.async_register_services()
@ -90,7 +282,7 @@ async def test_add_targets(hass: HomeAssistant) -> None:
async def test_remove_targets(hass: HomeAssistant) -> None:
"""Test removing targets from the targets in a notify service."""
"""Test removing targets from the targets in a legacy notify service."""
test = NotificationService(hass)
await test.async_setup(hass, "notify", "test")
await test.async_register_services()
@ -107,17 +299,22 @@ async def test_remove_targets(hass: HomeAssistant) -> None:
class NotificationService(notify.BaseNotificationService):
"""A test class for notification services."""
"""A test class for legacy notification services."""
def __init__(self, hass, target_list={"a": 1, "b": 2}, name="notify"):
def __init__(
self,
hass: HomeAssistant,
target_list: dict[str, Any] | None = None,
name="notify",
) -> None:
"""Initialize the service."""
async def _async_make_reloadable(hass):
async def _async_make_reloadable(hass: HomeAssistant) -> None:
"""Initialize the reload service."""
await async_setup_reload_service(hass, name, [notify.DOMAIN])
self.hass = hass
self.target_list = target_list
self.target_list = target_list or {"a": 1, "b": 2}
hass.async_create_task(_async_make_reloadable(hass))
@property
@ -229,7 +426,7 @@ async def test_platform_setup_with_error(
async def test_reload_with_notify_builtin_platform_reload(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
) -> None:
"""Test reload using the notify platform reload method."""
"""Test reload using the legacy notify platform reload method."""
async def async_get_service(hass, config, discovery_info=None):
"""Get notify service for mocked platform."""
@ -271,7 +468,7 @@ async def test_setup_platform_and_reload(
return NotificationService(hass, targetlist, "testnotify")
async def async_get_service2(hass, config, discovery_info=None):
"""Get notify service for mocked platform."""
"""Get legacy notify service for mocked platform."""
get_service_called(config, discovery_info)
targetlist = {"c": 3, "d": 4}
return NotificationService(hass, targetlist, "testnotify2")
@ -351,7 +548,7 @@ async def test_setup_platform_and_reload(
async def test_setup_platform_before_notify_setup(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
) -> None:
"""Test trying to setup a platform before notify is setup."""
"""Test trying to setup a platform before legacy notify service is setup."""
get_service_called = Mock()
async def async_get_service(hass, config, discovery_info=None):
@ -401,7 +598,7 @@ async def test_setup_platform_before_notify_setup(
async def test_setup_platform_after_notify_setup(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, tmp_path: Path
) -> None:
"""Test trying to setup a platform after notify is setup."""
"""Test trying to setup a platform after legacy notify service is set up."""
get_service_called = Mock()
async def async_get_service(hass, config, discovery_info=None):