Skip to content

Commit 10076e6

Browse files
authored
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
1 parent df5d818 commit 10076e6

File tree

11 files changed

+494
-26
lines changed

11 files changed

+494
-26
lines changed

homeassistant/components/kitchen_sink/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
Platform.IMAGE,
3333
Platform.LAWN_MOWER,
3434
Platform.LOCK,
35+
Platform.NOTIFY,
3536
Platform.SENSOR,
3637
Platform.SWITCH,
3738
Platform.WEATHER,
@@ -70,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
7071
return True
7172

7273

73-
def _create_issues(hass):
74+
def _create_issues(hass: HomeAssistant) -> None:
7475
"""Create some issue registry issues."""
7576
async_create_issue(
7677
hass,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""Demo platform that offers a fake notify entity."""
2+
3+
from __future__ import annotations
4+
5+
from homeassistant.components import persistent_notification
6+
from homeassistant.components.notify import NotifyEntity
7+
from homeassistant.config_entries import ConfigEntry
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers.device_registry import DeviceInfo
10+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
11+
12+
from . import DOMAIN
13+
14+
15+
async def async_setup_entry(
16+
hass: HomeAssistant,
17+
config_entry: ConfigEntry,
18+
async_add_entities: AddEntitiesCallback,
19+
) -> None:
20+
"""Set up the demo notify entity platform."""
21+
async_add_entities(
22+
[
23+
DemoNotify(
24+
unique_id="just_notify_me",
25+
device_name="MyBox",
26+
entity_name="Personal notifier",
27+
),
28+
]
29+
)
30+
31+
32+
class DemoNotify(NotifyEntity):
33+
"""Representation of a demo notify entity."""
34+
35+
_attr_has_entity_name = True
36+
_attr_should_poll = False
37+
38+
def __init__(
39+
self,
40+
unique_id: str,
41+
device_name: str,
42+
entity_name: str | None,
43+
) -> None:
44+
"""Initialize the Demo button entity."""
45+
self._attr_unique_id = unique_id
46+
self._attr_device_info = DeviceInfo(
47+
identifiers={(DOMAIN, unique_id)},
48+
name=device_name,
49+
)
50+
self._attr_name = entity_name
51+
52+
async def async_send_message(self, message: str) -> None:
53+
"""Send out a persistent notification."""
54+
persistent_notification.async_create(self.hass, message, "Demo notification")

homeassistant/components/notify/__init__.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,36 @@
22

33
from __future__ import annotations
44

5+
from datetime import timedelta
6+
from functools import cached_property, partial
7+
import logging
8+
from typing import Any, final, override
9+
510
import voluptuous as vol
611

712
import homeassistant.components.persistent_notification as pn
8-
from homeassistant.const import CONF_NAME, CONF_PLATFORM
13+
from homeassistant.config_entries import ConfigEntry
14+
from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE
915
from homeassistant.core import HomeAssistant, ServiceCall
1016
import homeassistant.helpers.config_validation as cv
17+
from homeassistant.helpers.entity import EntityDescription
18+
from homeassistant.helpers.entity_component import EntityComponent
19+
from homeassistant.helpers.restore_state import RestoreEntity
1120
from homeassistant.helpers.template import Template
1221
from homeassistant.helpers.typing import ConfigType
22+
from homeassistant.util import dt as dt_util
1323

1424
from .const import ( # noqa: F401
1525
ATTR_DATA,
1626
ATTR_MESSAGE,
27+
ATTR_RECIPIENTS,
1728
ATTR_TARGET,
1829
ATTR_TITLE,
1930
DOMAIN,
2031
NOTIFY_SERVICE_SCHEMA,
2132
SERVICE_NOTIFY,
2233
SERVICE_PERSISTENT_NOTIFICATION,
34+
SERVICE_SEND_MESSAGE,
2335
)
2436
from .legacy import ( # noqa: F401
2537
BaseNotificationService,
@@ -29,9 +41,17 @@
2941
check_templates_warn,
3042
)
3143

44+
# mypy: disallow-any-generics
45+
3246
# Platform specific data
3347
ATTR_TITLE_DEFAULT = "Home Assistant"
3448

49+
ENTITY_ID_FORMAT = DOMAIN + ".{}"
50+
51+
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
52+
53+
_LOGGER = logging.getLogger(__name__)
54+
3555
PLATFORM_SCHEMA = vol.Schema(
3656
{vol.Required(CONF_PLATFORM): cv.string, vol.Optional(CONF_NAME): cv.string},
3757
extra=vol.ALLOW_EXTRA,
@@ -50,6 +70,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
5070
# legacy platforms to finish setting up.
5171
hass.async_create_task(setup, eager_start=True)
5272

73+
component = hass.data[DOMAIN] = EntityComponent[NotifyEntity](_LOGGER, DOMAIN, hass)
74+
component.async_register_entity_service(
75+
SERVICE_SEND_MESSAGE,
76+
{vol.Required(ATTR_MESSAGE): cv.string},
77+
"_async_send_message",
78+
)
79+
5380
async def persistent_notification(service: ServiceCall) -> None:
5481
"""Send notification via the built-in persistent_notify integration."""
5582
message: Template = service.data[ATTR_MESSAGE]
@@ -79,3 +106,66 @@ async def persistent_notification(service: ServiceCall) -> None:
79106
)
80107

81108
return True
109+
110+
111+
class NotifyEntityDescription(EntityDescription, frozen_or_thawed=True):
112+
"""A class that describes button entities."""
113+
114+
115+
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
116+
"""Set up a config entry."""
117+
component: EntityComponent[NotifyEntity] = hass.data[DOMAIN]
118+
return await component.async_setup_entry(entry)
119+
120+
121+
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
122+
"""Unload a config entry."""
123+
component: EntityComponent[NotifyEntity] = hass.data[DOMAIN]
124+
return await component.async_unload_entry(entry)
125+
126+
127+
class NotifyEntity(RestoreEntity):
128+
"""Representation of a notify entity."""
129+
130+
entity_description: NotifyEntityDescription
131+
_attr_should_poll = False
132+
_attr_device_class: None
133+
_attr_state: None = None
134+
__last_notified_isoformat: str | None = None
135+
136+
@cached_property
137+
@final
138+
@override
139+
def state(self) -> str | None:
140+
"""Return the entity state."""
141+
return self.__last_notified_isoformat
142+
143+
def __set_state(self, state: str | None) -> None:
144+
"""Invalidate the cache of the cached property."""
145+
self.__dict__.pop("state", None)
146+
self.__last_notified_isoformat = state
147+
148+
async def async_internal_added_to_hass(self) -> None:
149+
"""Call when the notify entity is added to hass."""
150+
await super().async_internal_added_to_hass()
151+
state = await self.async_get_last_state()
152+
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
153+
self.__set_state(state.state)
154+
155+
@final
156+
async def _async_send_message(self, **kwargs: Any) -> None:
157+
"""Send a notification message (from e.g., service call).
158+
159+
Should not be overridden, handle setting last notification timestamp.
160+
"""
161+
self.__set_state(dt_util.utcnow().isoformat())
162+
self.async_write_ha_state()
163+
await self.async_send_message(**kwargs)
164+
165+
def send_message(self, message: str) -> None:
166+
"""Send a message."""
167+
raise NotImplementedError
168+
169+
async def async_send_message(self, message: str) -> None:
170+
"""Send a message."""
171+
await self.hass.async_add_executor_job(partial(self.send_message, message))

homeassistant/components/notify/const.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
# Text to notify user of
1212
ATTR_MESSAGE = "message"
1313

14-
# Target of the notification (user, device, etc)
14+
# Target of the (legacy) notification (user, device, etc)
1515
ATTR_TARGET = "target"
1616

17+
# Recipients for a notification
18+
ATTR_RECIPIENTS = "recipients"
19+
1720
# Title of notification
1821
ATTR_TITLE = "title"
1922

@@ -22,6 +25,7 @@
2225
LOGGER = logging.getLogger(__package__)
2326

2427
SERVICE_NOTIFY = "notify"
28+
SERVICE_SEND_MESSAGE = "send_message"
2529
SERVICE_PERSISTENT_NOTIFICATION = "persistent_notification"
2630

2731
NOTIFY_SERVICE_SCHEMA = vol.Schema(
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
{
2+
"entity_component": {
3+
"_": {
4+
"default": "mdi:message"
5+
}
6+
},
27
"services": {
38
"notify": "mdi:bell-ring",
4-
"persistent_notification": "mdi:bell-badge"
9+
"persistent_notification": "mdi:bell-badge",
10+
"send_message": "mdi:message-arrow-right"
511
}
612
}

homeassistant/components/notify/services.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ notify:
2020
selector:
2121
object:
2222

23+
send_message:
24+
target:
25+
entity:
26+
domain: notify
27+
fields:
28+
message:
29+
required: true
30+
selector:
31+
text:
32+
2333
persistent_notification:
2434
fields:
2535
message:

homeassistant/components/notify/strings.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
{
22
"title": "Notifications",
3+
"entity_component": {
4+
"_": {
5+
"name": "[%key:component::notify::title%]"
6+
}
7+
},
38
"services": {
49
"notify": {
510
"name": "Send a notification",
@@ -23,6 +28,16 @@
2328
}
2429
}
2530
},
31+
"send_message": {
32+
"name": "Send a notification message",
33+
"description": "Sends a notification message.",
34+
"fields": {
35+
"message": {
36+
"name": "Message",
37+
"description": "Your notification message."
38+
}
39+
}
40+
},
2641
"persistent_notification": {
2742
"name": "Send a persistent notification",
2843
"description": "Sends a notification that is visible in the **Notifications** panel.",

homeassistant/helpers/service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def _base_components() -> dict[str, ModuleType]:
9393
light,
9494
lock,
9595
media_player,
96+
notify,
9697
remote,
9798
siren,
9899
todo,
@@ -112,6 +113,7 @@ def _base_components() -> dict[str, ModuleType]:
112113
"light": light,
113114
"lock": lock,
114115
"media_player": media_player,
116+
"notify": notify,
115117
"remote": remote,
116118
"siren": siren,
117119
"todo": todo,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""The tests for the demo button component."""
2+
3+
from collections.abc import AsyncGenerator
4+
from unittest.mock import patch
5+
6+
from freezegun.api import FrozenDateTimeFactory
7+
import pytest
8+
9+
from homeassistant.components.kitchen_sink import DOMAIN
10+
from homeassistant.components.notify import (
11+
DOMAIN as NOTIFY_DOMAIN,
12+
SERVICE_SEND_MESSAGE,
13+
)
14+
from homeassistant.components.notify.const import ATTR_MESSAGE
15+
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
16+
from homeassistant.core import HomeAssistant
17+
from homeassistant.setup import async_setup_component
18+
from homeassistant.util import dt as dt_util
19+
20+
ENTITY_DIRECT_MESSAGE = "notify.mybox_personal_notifier"
21+
22+
23+
@pytest.fixture
24+
async def notify_only() -> AsyncGenerator[None, None]:
25+
"""Enable only the button platform."""
26+
with patch(
27+
"homeassistant.components.kitchen_sink.COMPONENTS_WITH_DEMO_PLATFORM",
28+
[Platform.NOTIFY],
29+
):
30+
yield
31+
32+
33+
@pytest.fixture(autouse=True)
34+
async def setup_comp(hass: HomeAssistant, notify_only: None):
35+
"""Set up demo component."""
36+
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
37+
await hass.async_block_till_done()
38+
39+
40+
def test_setup_params(hass: HomeAssistant) -> None:
41+
"""Test the initial parameters."""
42+
state = hass.states.get(ENTITY_DIRECT_MESSAGE)
43+
assert state
44+
assert state.state == STATE_UNKNOWN
45+
46+
47+
async def test_send_message(
48+
hass: HomeAssistant, freezer: FrozenDateTimeFactory
49+
) -> None:
50+
"""Test pressing the button."""
51+
state = hass.states.get(ENTITY_DIRECT_MESSAGE)
52+
assert state
53+
assert state.state == STATE_UNKNOWN
54+
55+
now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00")
56+
freezer.move_to(now)
57+
await hass.services.async_call(
58+
NOTIFY_DOMAIN,
59+
SERVICE_SEND_MESSAGE,
60+
{ATTR_ENTITY_ID: ENTITY_DIRECT_MESSAGE, ATTR_MESSAGE: "You have an update!"},
61+
blocking=True,
62+
)
63+
64+
state = hass.states.get(ENTITY_DIRECT_MESSAGE)
65+
assert state
66+
assert state.state == now.isoformat()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""Fixtures for Notify platform tests."""
2+
3+
from collections.abc import Generator
4+
5+
import pytest
6+
7+
from homeassistant.config_entries import ConfigFlow
8+
from homeassistant.core import HomeAssistant
9+
10+
from tests.common import mock_config_flow, mock_platform
11+
12+
13+
class MockFlow(ConfigFlow):
14+
"""Test flow."""
15+
16+
17+
@pytest.fixture
18+
def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]:
19+
"""Mock config flow."""
20+
mock_platform(hass, "test.config_flow")
21+
22+
with mock_config_flow("test", MockFlow):
23+
yield

0 commit comments

Comments
 (0)