diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py index 3272384b387..9e91aa07103 100644 --- a/homeassistant/components/file/__init__.py +++ b/homeassistant/components/file/__init__.py @@ -1,7 +1,8 @@ """The file component.""" +from homeassistant.components.notify import migrate_notify_issue 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.exceptions import ConfigEntryNotReady from homeassistant.helpers import ( @@ -22,9 +23,7 @@ IMPORT_SCHEMA = { CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -PLATFORMS = [Platform.SENSOR] - -YAML_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] +PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] 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): # We skip import in case we already have config entries 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 # HA Core 2024.12 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 - platforms_config = { - domain: config[domain] for domain in YAML_PLATFORMS if domain in config + platforms_config: dict[Platform, list[ConfigType]] = { + domain: config[domain] for domain in PLATFORMS if domain in config } for domain, items in platforms_config.items(): for item in items: @@ -85,14 +87,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: translation_placeholders={"filename": filepath}, ) - if entry.data[CONF_PLATFORM] in PLATFORMS: - await hass.config_entries.async_forward_entry_setups( - entry, [Platform(entry.data[CONF_PLATFORM])] - ) - else: - # The notify platform is not yet set up as entry, so - # forward setup config through discovery to ensure setup notify service. - # This is needed as long as the legacy service is not migrated + await hass.config_entries.async_forward_entry_setups( + entry, [Platform(entry.data[CONF_PLATFORM])] + ) + if entry.data[CONF_PLATFORM] == Platform.NOTIFY and CONF_NAME in entry.data: + # New notify entities are being setup through the config entry, + # but during the deprecation period we want to keep the legacy notify platform, + # so we forward the setup config through discovery. + # Only the entities from yaml will still be available as legacy service. hass.async_create_task( discovery.async_load_platform( hass, diff --git a/homeassistant/components/file/config_flow.py b/homeassistant/components/file/config_flow.py index a3f59dd8b3f..3b63854b76b 100644 --- a/homeassistant/components/file/config_flow.py +++ b/homeassistant/components/file/config_flow.py @@ -41,7 +41,6 @@ FILE_SENSOR_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.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]): errors[CONF_FILE_PATH] = "not_allowed" else: - name: str = user_input.get(CONF_NAME, DEFAULT_NAME) - title = f"{name} [{user_input[CONF_FILE_PATH]}]" + title = f"{DEFAULT_NAME} [{user_input[CONF_FILE_PATH]}]" return self.async_create_entry(data=user_input, title=title) return self.async_show_form( diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index f89c608b455..b51be280e75 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -2,8 +2,10 @@ from __future__ import annotations +from functools import partial import logging import os +from types import MappingProxyType from typing import Any, TextIO import voluptuous as vol @@ -13,15 +15,20 @@ from homeassistant.components.notify import ( ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, 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.exceptions import ServiceValidationError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType 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__) @@ -58,6 +65,15 @@ class FileNotificationService(BaseNotificationService): self._file_path = file_path 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: """Send a message to a file.""" file: TextIO @@ -82,3 +98,53 @@ class FileNotificationService(BaseNotificationService): translation_key="write_access_failed", translation_placeholders={"filename": filepath, "exc": f"{exc!r}"}, ) 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 diff --git a/homeassistant/components/file/strings.json b/homeassistant/components/file/strings.json index 8d686285765..9d49e6300e9 100644 --- a/homeassistant/components/file/strings.json +++ b/homeassistant/components/file/strings.json @@ -27,12 +27,10 @@ "description": "Set up a service that allows to write notification to a file.", "data": { "file_path": "[%key:component::file::config::step::sensor::data::file_path%]", - "name": "Name", "timestamp": "Timestamp" }, "data_description": { "file_path": "A local file path to write the notification to", - "name": "Name of the notify service", "timestamp": "Add a timestamp to the notification" } } diff --git a/tests/components/file/test_config_flow.py b/tests/components/file/test_config_flow.py index f9535270693..86ada1fec61 100644 --- a/tests/components/file/test_config_flow.py +++ b/tests/components/file/test_config_flow.py @@ -16,7 +16,6 @@ MOCK_CONFIG_NOTIFY = { "platform": "notify", "file_path": "some_file", "timestamp": True, - "name": "File", } MOCK_CONFIG_SENSOR = { "platform": "sensor", diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 53c8ad2d6b4..faa9027aa21 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -32,8 +32,13 @@ async def test_bad_config(hass: HomeAssistant) -> None: ("domain", "service", "params"), [ (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( ("timestamp", "config"), @@ -46,6 +51,7 @@ async def test_bad_config(hass: HomeAssistant) -> None: "name": "test", "platform": "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 +@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( ("data", "is_allowed"), [ @@ -295,12 +311,12 @@ async def test_notify_file_write_access_failed( hass: HomeAssistant, freezer: FrozenDateTimeFactory, mock_is_allowed_path: MagicMock, + service: str, + params: dict[str, Any], data: dict[str, Any], ) -> None: """Test the notify file fails.""" domain = notify.DOMAIN - service = "test" - params = {"message": "one, two, testing, testing"} entry = MockConfigEntry( domain=DOMAIN, data=data, title=f"test [{data['file_path']}]"