Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Complete persistent notifications migration #92828

Merged
merged 20 commits into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
188 changes: 109 additions & 79 deletions homeassistant/components/persistent_notification/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,67 @@
from __future__ import annotations

from collections.abc import Mapping
from datetime import datetime
import logging
from typing import Any
from typing import Any, Final, TypedDict

import voluptuous as vol

from homeassistant.backports.enum import StrEnum
from homeassistant.components import websocket_api
from homeassistant.const import ATTR_FRIENDLY_NAME
from homeassistant.core import Context, HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv, singleton
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
from homeassistant.util import slugify
import homeassistant.util.dt as dt_util

ATTR_CREATED_AT = "created_at"
ATTR_MESSAGE = "message"
ATTR_NOTIFICATION_ID = "notification_id"
ATTR_TITLE = "title"
ATTR_STATUS = "status"
from homeassistant.util.uuid import random_uuid_hex

DOMAIN = "persistent_notification"

ENTITY_ID_FORMAT = DOMAIN + ".{}"
ATTR_CREATED_AT: Final = "created_at"
ATTR_MESSAGE: Final = "message"
ATTR_NOTIFICATION_ID: Final = "notification_id"
ATTR_TITLE: Final = "title"
ATTR_STATUS: Final = "status"

STATUS_UNREAD = "unread"
STATUS_READ = "read"

# Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9
EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated"


class Notification(TypedDict):
"""Persistent notification."""

created_at: datetime
message: str
notification_id: str
title: str | None
status: str


class UpdateType(StrEnum):
"""Persistent notification update type."""

CURRENT = "current"
ADDED = "added"
REMOVED = "removed"
UPDATED = "updated"


SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated"

SCHEMA_SERVICE_NOTIFICATION = vol.Schema(
{vol.Required(ATTR_NOTIFICATION_ID): cv.string}
)

DEFAULT_OBJECT_ID = "notification"
_LOGGER = logging.getLogger(__name__)

STATE = "notifying"
STATUS_UNREAD = "unread"
STATUS_READ = "read"


@bind_hass
def create(
Expand All @@ -65,64 +88,52 @@ def async_create(
message: str,
title: str | None = None,
notification_id: str | None = None,
*,
context: Context | None = None,
) -> None:
"""Generate a notification."""
if (notifications := hass.data.get(DOMAIN)) is None:
notifications = hass.data[DOMAIN] = {}

if notification_id is not None:
entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
else:
entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass
)
notification_id = entity_id.split(".")[1]

attr: dict[str, str] = {ATTR_MESSAGE: message}
if title is not None:
attr[ATTR_TITLE] = title
attr[ATTR_FRIENDLY_NAME] = title

hass.states.async_set(entity_id, STATE, attr, context=context)

# Store notification and fire event
# This will eventually replace state machine storage
notifications[entity_id] = {
notifications = _async_get_or_create_notifications(hass)
if notification_id is None:
notification_id = random_uuid_hex()
notifications[notification_id] = {
ATTR_MESSAGE: message,
ATTR_NOTIFICATION_ID: notification_id,
ATTR_STATUS: STATUS_UNREAD,
ATTR_TITLE: title,
ATTR_CREATED_AT: dt_util.utcnow(),
}

hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=context)
async_dispatcher_send(
hass,
SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED,
UpdateType.ADDED,
{notification_id: notifications[notification_id]},
)


@callback
@bind_hass
def async_dismiss(
hass: HomeAssistant, notification_id: str, *, context: Context | None = None
) -> None:
"""Remove a notification."""
if (notifications := hass.data.get(DOMAIN)) is None:
notifications = hass.data[DOMAIN] = {}
@singleton.singleton(DOMAIN)
def _async_get_or_create_notifications(hass: HomeAssistant) -> dict[str, Notification]:
"""Get or create notifications data."""
return {}

entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))

if entity_id not in notifications:
@callback
@bind_hass
def async_dismiss(hass: HomeAssistant, notification_id: str) -> None:
"""Remove a notification."""
notifications = _async_get_or_create_notifications(hass)
if not (notification := notifications.pop(notification_id, None)):
return

hass.states.async_remove(entity_id, context)

del notifications[entity_id]
hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED)
async_dispatcher_send(
hass,
SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED,
UpdateType.REMOVED,
{notification_id: notification},
)


async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the persistent notification component."""
notifications = hass.data.setdefault(DOMAIN, {})
notifications = _async_get_or_create_notifications(hass)

@callback
def create_service(call: ServiceCall) -> None:
Expand All @@ -132,21 +143,18 @@ def create_service(call: ServiceCall) -> None:
call.data[ATTR_MESSAGE],
call.data.get(ATTR_TITLE),
call.data.get(ATTR_NOTIFICATION_ID),
context=call.context,
)

@callback
def dismiss_service(call: ServiceCall) -> None:
"""Handle the dismiss notification service call."""
async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID], context=call.context)
async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID])

@callback
def mark_read_service(call: ServiceCall) -> None:
"""Handle the mark_read notification service call."""
notification_id = call.data.get(ATTR_NOTIFICATION_ID)
entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))

if entity_id not in notifications:
if notification_id not in notifications:
_LOGGER.error(
(
"Marking persistent_notification read failed: "
Expand All @@ -156,9 +164,13 @@ def mark_read_service(call: ServiceCall) -> None:
)
return

notifications[entity_id][ATTR_STATUS] = STATUS_READ
hass.bus.async_fire(
EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=call.context
notification = notifications[notification_id]
notification[ATTR_STATUS] = STATUS_READ
async_dispatcher_send(
hass,
SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED,
UpdateType.UPDATED,
{notification_id: notification},
)

hass.services.async_register(
Expand All @@ -183,6 +195,7 @@ def mark_read_service(call: ServiceCall) -> None:
)

websocket_api.async_register_command(hass, websocket_get_notifications)
websocket_api.async_register_command(hass, websocket_subscribe_notifications)

return True

Expand All @@ -197,19 +210,36 @@ def websocket_get_notifications(
"""Return a list of persistent_notifications."""
connection.send_message(
websocket_api.result_message(
msg["id"],
[
{
key: data[key]
for key in (
ATTR_NOTIFICATION_ID,
ATTR_MESSAGE,
ATTR_STATUS,
ATTR_TITLE,
ATTR_CREATED_AT,
)
}
for data in hass.data[DOMAIN].values()
],
msg["id"], list(_async_get_or_create_notifications(hass).values())
)
)


@callback
@websocket_api.websocket_command(
{vol.Required("type"): "persistent_notification/subscribe"}
)
def websocket_subscribe_notifications(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: Mapping[str, Any],
) -> None:
"""Return a list of persistent_notifications."""
notifications = _async_get_or_create_notifications(hass)
msg_id = msg["id"]

@callback
def _async_send_notification_update(
update_type: UpdateType, notifications: dict[str, Notification]
) -> None:
connection.send_message(
websocket_api.event_message(
msg["id"], {"type": update_type, "notifications": notifications}
)
)

connection.subscriptions[msg_id] = async_dispatcher_connect(
hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _async_send_notification_update
)
connection.send_result(msg_id)
_async_send_notification_update(UpdateType.CURRENT, notifications)
6 changes: 6 additions & 0 deletions homeassistant/components/person/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
)
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.helpers.integration_platform import (
async_process_integration_platform_for_component,
)
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
Expand Down Expand Up @@ -330,6 +333,9 @@ async def filter_yaml_data(hass: HomeAssistant, persons: list[dict]) -> list[dic

async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the person component."""
# Process integration platforms right away since
# we will create entities before firing EVENT_COMPONENT_LOADED
await async_process_integration_platform_for_component(hass, DOMAIN)
entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass)
id_manager = collection.IDManager()
yaml_collection = collection.YamlCollection(
Expand Down
10 changes: 9 additions & 1 deletion tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
providers as auth_providers,
)
from homeassistant.auth.permissions import system_policies
from homeassistant.components import device_automation
from homeassistant.components import device_automation, persistent_notification as pn
from homeassistant.components.device_automation import ( # noqa: F401
_async_get_device_automation_capabilities as async_get_device_automation_capabilities,
)
Expand Down Expand Up @@ -1396,3 +1396,11 @@ def raise_contains_mocks(val: Any) -> None:
if isinstance(val, list):
for dict_value in val:
raise_contains_mocks(dict_value)


@callback
def async_get_persistent_notifications(
hass: HomeAssistant,
) -> dict[str, pn.Notification]:
"""Get the current persistent notifications."""
return pn._async_get_or_create_notifications(hass)
2 changes: 1 addition & 1 deletion tests/components/govee_ble/test_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ async def test_gvh5178_error(hass: HomeAssistant) -> None:
assert len(hass.states.async_all()) == 0
inject_bluetooth_service_info(hass, GVH5178_SERVICE_INFO_ERROR)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 4
assert len(hass.states.async_all()) == 3

temp_sensor = hass.states.get("sensor.b51782bc8_remote_temperature")
assert temp_sensor.state == STATE_UNAVAILABLE
Expand Down
8 changes: 4 additions & 4 deletions tests/components/http/test_ban.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from . import mock_real_ip

from tests.common import async_get_persistent_notifications
from tests.typing import ClientSessionGenerator

SUPERVISOR_IP = "1.2.3.4"
Expand Down Expand Up @@ -307,11 +308,10 @@ async def unauth_handler(request):
assert resp.status == HTTPStatus.FORBIDDEN
assert m_open.call_count == 1

notifications = async_get_persistent_notifications(hass)
assert len(notifications) == 2
assert (
len(notifications := hass.states.async_all("persistent_notification")) == 2
)
assert (
notifications[0].attributes["message"]
notifications["http-login"]["message"]
== "Login attempt or request with invalid authentication from example.com (200.201.202.204). See the log for details."
)

Expand Down
8 changes: 4 additions & 4 deletions tests/components/hue/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_get_persistent_notifications


@pytest.fixture
Expand Down Expand Up @@ -162,6 +162,6 @@ async def test_security_vuln_check(hass: HomeAssistant) -> None:

await hass.async_block_till_done()

state = hass.states.get("persistent_notification.hue_hub_firmware")
assert state is not None
assert "CVE-2020-6007" in state.attributes["message"]
notifications = async_get_persistent_notifications(hass)
assert "hue_hub_firmware" in notifications
assert "CVE-2020-6007" in notifications["hue_hub_firmware"]["message"]
2 changes: 1 addition & 1 deletion tests/components/humidifier/test_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant)
states = await hass.async_add_executor_job(
get_significant_states, hass, now, None, hass.states.async_entity_ids()
)
assert len(states) > 1
assert len(states) >= 1
for entity_states in states.values():
for state in entity_states:
assert ATTR_MIN_HUMIDITY not in state.attributes
Expand Down