Migrate File notify entity platform (#117215)

* Migrate File notify entity platform

* Do not load legacy notify service for new  config entries

* Follow up comment

* mypy

* Correct typing

* Only use the name when importing notify services

* Make sure a name is set on new entires
This commit is contained in:
Jan Bouwhuis 2024-05-13 11:22:13 +02:00 committed by GitHub
parent 0b47bfc823
commit 548eb35b79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 104 additions and 25 deletions

View file

@ -1,7 +1,8 @@
"""The file component.""" """The file component."""
from homeassistant.components.notify import migrate_notify_issue
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_PLATFORM, Platform from homeassistant.const import CONF_FILE_PATH, CONF_NAME, CONF_PLATFORM, Platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import ( from homeassistant.helpers import (
@ -22,9 +23,7 @@ IMPORT_SCHEMA = {
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.SENSOR] PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
YAML_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@ -34,6 +33,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if hass.config_entries.async_entries(DOMAIN): if hass.config_entries.async_entries(DOMAIN):
# We skip import in case we already have config entries # We skip import in case we already have config entries
return True return True
# The use of the legacy notify service was deprecated with HA Core 2024.6.0
# and will be removed with HA Core 2024.12
migrate_notify_issue(hass, DOMAIN, "File", "2024.12.0")
# The YAML config was imported with HA Core 2024.6.0 and will be removed with # The YAML config was imported with HA Core 2024.6.0 and will be removed with
# HA Core 2024.12 # HA Core 2024.12
ir.async_create_issue( ir.async_create_issue(
@ -53,8 +55,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
) )
# Import the YAML config into separate config entries # Import the YAML config into separate config entries
platforms_config = { platforms_config: dict[Platform, list[ConfigType]] = {
domain: config[domain] for domain in YAML_PLATFORMS if domain in config domain: config[domain] for domain in PLATFORMS if domain in config
} }
for domain, items in platforms_config.items(): for domain, items in platforms_config.items():
for item in items: for item in items:
@ -85,14 +87,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
translation_placeholders={"filename": filepath}, translation_placeholders={"filename": filepath},
) )
if entry.data[CONF_PLATFORM] in PLATFORMS: await hass.config_entries.async_forward_entry_setups(
await hass.config_entries.async_forward_entry_setups( entry, [Platform(entry.data[CONF_PLATFORM])]
entry, [Platform(entry.data[CONF_PLATFORM])] )
) if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data:
else: # New notify entities are being setup through the config entry,
# The notify platform is not yet set up as entry, so # but during the deprecation period we want to keep the legacy notify platform,
# forward setup config through discovery to ensure setup notify service. # so we forward the setup config through discovery.
# This is needed as long as the legacy service is not migrated # Only the entities from yaml will still be available as legacy service.
hass.async_create_task( hass.async_create_task(
discovery.async_load_platform( discovery.async_load_platform(
hass, hass,

View file

@ -41,7 +41,6 @@ FILE_SENSOR_SCHEMA = vol.Schema(
FILE_NOTIFY_SCHEMA = vol.Schema( FILE_NOTIFY_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TEXT_SELECTOR,
vol.Required(CONF_FILE_PATH): TEXT_SELECTOR, vol.Required(CONF_FILE_PATH): TEXT_SELECTOR,
vol.Optional(CONF_TIMESTAMP, default=False): BOOLEAN_SELECTOR, vol.Optional(CONF_TIMESTAMP, default=False): BOOLEAN_SELECTOR,
} }
@ -79,8 +78,7 @@ class FileConfigFlowHandler(ConfigFlow, domain=DOMAIN):
if not await self.validate_file_path(user_input[CONF_FILE_PATH]): if not await self.validate_file_path(user_input[CONF_FILE_PATH]):
errors[CONF_FILE_PATH] = "not_allowed" errors[CONF_FILE_PATH] = "not_allowed"
else: else:
name: str = user_input.get(CONF_NAME, DEFAULT_NAME) title = f"{DEFAULT_NAME} [{user_input[CONF_FILE_PATH]}]"
title = f"{name} [{user_input[CONF_FILE_PATH]}]"
return self.async_create_entry(data=user_input, title=title) return self.async_create_entry(data=user_input, title=title)
return self.async_show_form( return self.async_show_form(

View file

@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
from functools import partial
import logging import logging
import os import os
from types import MappingProxyType
from typing import Any, TextIO from typing import Any, TextIO
import voluptuous as vol import voluptuous as vol
@ -13,15 +15,20 @@ from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT, ATTR_TITLE_DEFAULT,
PLATFORM_SCHEMA, PLATFORM_SCHEMA,
BaseNotificationService, BaseNotificationService,
NotifyEntity,
NotifyEntityFeature,
migrate_notify_issue,
) )
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_FILENAME, CONF_NAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
import homeassistant.util.dt as dt_util import homeassistant.util.dt as dt_util
from .const import CONF_TIMESTAMP, DOMAIN from .const import CONF_TIMESTAMP, DEFAULT_NAME, DOMAIN, FILE_ICON
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -58,6 +65,15 @@ class FileNotificationService(BaseNotificationService):
self._file_path = file_path self._file_path = file_path
self.add_timestamp = add_timestamp self.add_timestamp = add_timestamp
async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a file."""
# The use of the legacy notify service was deprecated with HA Core 2024.6.0
# and will be removed with HA Core 2024.12
migrate_notify_issue(self.hass, DOMAIN, "File", "2024.12.0")
await self.hass.async_add_executor_job(
partial(self.send_message, message, **kwargs)
)
def send_message(self, message: str = "", **kwargs: Any) -> None: def send_message(self, message: str = "", **kwargs: Any) -> None:
"""Send a message to a file.""" """Send a message to a file."""
file: TextIO file: TextIO
@ -82,3 +98,53 @@ class FileNotificationService(BaseNotificationService):
translation_key="write_access_failed", translation_key="write_access_failed",
translation_placeholders={"filename": filepath, "exc": f"{exc!r}"}, translation_placeholders={"filename": filepath, "exc": f"{exc!r}"},
) from exc ) from exc
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up notify entity."""
unique_id = entry.entry_id
async_add_entities([FileNotifyEntity(unique_id, entry.data)])
class FileNotifyEntity(NotifyEntity):
"""Implement the notification entity platform for the File service."""
_attr_icon = FILE_ICON
_attr_supported_features = NotifyEntityFeature.TITLE
def __init__(self, unique_id: str, config: MappingProxyType[str, Any]) -> None:
"""Initialize the service."""
self._file_path: str = config[CONF_FILE_PATH]
self._add_timestamp: bool = config.get(CONF_TIMESTAMP, False)
# Only import a name from an imported entity
self._attr_name = config.get(CONF_NAME, DEFAULT_NAME)
self._attr_unique_id = unique_id
def send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to a file."""
file: TextIO
filepath = self._file_path
try:
with open(filepath, "a", encoding="utf8") as file:
if os.stat(filepath).st_size == 0:
title = (
f"{title or ATTR_TITLE_DEFAULT} notifications (Log"
f" started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n"
)
file.write(title)
if self._add_timestamp:
text = f"{dt_util.utcnow().isoformat()} {message}\n"
else:
text = f"{message}\n"
file.write(text)
except OSError as exc:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="write_access_failed",
translation_placeholders={"filename": filepath, "exc": f"{exc!r}"},
) from exc

View file

@ -27,12 +27,10 @@
"description": "Set up a service that allows to write notification to a file.", "description": "Set up a service that allows to write notification to a file.",
"data": { "data": {
"file_path": "[%key:component::file::config::step::sensor::data::file_path%]", "file_path": "[%key:component::file::config::step::sensor::data::file_path%]",
"name": "Name",
"timestamp": "Timestamp" "timestamp": "Timestamp"
}, },
"data_description": { "data_description": {
"file_path": "A local file path to write the notification to", "file_path": "A local file path to write the notification to",
"name": "Name of the notify service",
"timestamp": "Add a timestamp to the notification" "timestamp": "Add a timestamp to the notification"
} }
} }

View file

@ -16,7 +16,6 @@ MOCK_CONFIG_NOTIFY = {
"platform": "notify", "platform": "notify",
"file_path": "some_file", "file_path": "some_file",
"timestamp": True, "timestamp": True,
"name": "File",
} }
MOCK_CONFIG_SENSOR = { MOCK_CONFIG_SENSOR = {
"platform": "sensor", "platform": "sensor",

View file

@ -32,8 +32,13 @@ async def test_bad_config(hass: HomeAssistant) -> None:
("domain", "service", "params"), ("domain", "service", "params"),
[ [
(notify.DOMAIN, "test", {"message": "one, two, testing, testing"}), (notify.DOMAIN, "test", {"message": "one, two, testing, testing"}),
(
notify.DOMAIN,
"send_message",
{"entity_id": "notify.test", "message": "one, two, testing, testing"},
),
], ],
ids=["legacy"], ids=["legacy", "entity"],
) )
@pytest.mark.parametrize( @pytest.mark.parametrize(
("timestamp", "config"), ("timestamp", "config"),
@ -46,6 +51,7 @@ async def test_bad_config(hass: HomeAssistant) -> None:
"name": "test", "name": "test",
"platform": "file", "platform": "file",
"filename": "mock_file", "filename": "mock_file",
"timestamp": False,
} }
] ]
}, },
@ -276,6 +282,16 @@ async def test_legacy_notify_file_not_allowed(
assert "is not allowed" in caplog.text assert "is not allowed" in caplog.text
@pytest.mark.parametrize(
("service", "params"),
[
("test", {"message": "one, two, testing, testing"}),
(
"send_message",
{"entity_id": "notify.test", "message": "one, two, testing, testing"},
),
],
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("data", "is_allowed"), ("data", "is_allowed"),
[ [
@ -295,12 +311,12 @@ async def test_notify_file_write_access_failed(
hass: HomeAssistant, hass: HomeAssistant,
freezer: FrozenDateTimeFactory, freezer: FrozenDateTimeFactory,
mock_is_allowed_path: MagicMock, mock_is_allowed_path: MagicMock,
service: str,
params: dict[str, Any],
data: dict[str, Any], data: dict[str, Any],
) -> None: ) -> None:
"""Test the notify file fails.""" """Test the notify file fails."""
domain = notify.DOMAIN domain = notify.DOMAIN
service = "test"
params = {"message": "one, two, testing, testing"}
entry = MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, data=data, title=f"test [{data['file_path']}]" domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"