From 4d52d920ee191b134c1b17c0c667c09e613ff453 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 27 May 2024 08:47:02 +0200 Subject: [PATCH] Create EventEntity for Folder Watcher (#116526) --- .../components/folder_watcher/__init__.py | 47 +++++++----- .../components/folder_watcher/const.py | 4 + .../components/folder_watcher/event.py | 75 +++++++++++++++++++ .../components/folder_watcher/strings.json | 15 ++++ tests/components/folder_watcher/conftest.py | 33 ++++++++ .../folder_watcher/snapshots/test_event.ambr | 62 +++++++++++++++ tests/components/folder_watcher/test_event.py | 53 +++++++++++++ tests/components/folder_watcher/test_init.py | 4 +- 8 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 homeassistant/components/folder_watcher/event.py create mode 100644 tests/components/folder_watcher/snapshots/test_event.ambr create mode 100644 tests/components/folder_watcher/test_event.py diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index 3f0b9e8f6da..800a95509c2 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -23,10 +23,11 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN +from .const import CONF_FOLDER, CONF_PATTERNS, DEFAULT_PATTERN, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -103,23 +104,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: learn_more_url="https://www.home-assistant.io/docs/configuration/basic/#allowlist_external_dirs", ) return False - await hass.async_add_executor_job(Watcher, path, patterns, hass) + await hass.async_add_executor_job(Watcher, path, patterns, hass, entry.entry_id) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -def create_event_handler(patterns: list[str], hass: HomeAssistant) -> EventHandler: +def create_event_handler( + patterns: list[str], hass: HomeAssistant, entry_id: str +) -> EventHandler: """Return the Watchdog EventHandler object.""" - - return EventHandler(patterns, hass) + return EventHandler(patterns, hass, entry_id) class EventHandler(PatternMatchingEventHandler): """Class for handling Watcher events.""" - def __init__(self, patterns: list[str], hass: HomeAssistant) -> None: + def __init__(self, patterns: list[str], hass: HomeAssistant, entry_id: str) -> None: """Initialise the EventHandler.""" super().__init__(patterns) self.hass = hass + self.entry_id = entry_id def process(self, event: FileSystemEvent, moved: bool = False) -> None: """On Watcher event, fire HA event.""" @@ -133,20 +137,22 @@ class EventHandler(PatternMatchingEventHandler): "folder": folder, } + _extra = {} if moved: event = cast(FileSystemMovedEvent, event) dest_folder, dest_file_name = os.path.split(event.dest_path) - fireable.update( - { - "dest_path": event.dest_path, - "dest_file": dest_file_name, - "dest_folder": dest_folder, - } - ) + _extra = { + "dest_path": event.dest_path, + "dest_file": dest_file_name, + "dest_folder": dest_folder, + } + fireable.update(_extra) self.hass.bus.fire( DOMAIN, fireable, ) + signal = f"folder_watcher-{self.entry_id}" + dispatcher_send(self.hass, signal, event.event_type, fireable) def on_modified(self, event: FileModifiedEvent) -> None: """File modified.""" @@ -172,20 +178,25 @@ class EventHandler(PatternMatchingEventHandler): class Watcher: """Class for starting Watchdog.""" - def __init__(self, path: str, patterns: list[str], hass: HomeAssistant) -> None: + def __init__( + self, path: str, patterns: list[str], hass: HomeAssistant, entry_id: str + ) -> None: """Initialise the watchdog observer.""" self._observer = Observer() self._observer.schedule( - create_event_handler(patterns, hass), path, recursive=True + create_event_handler(patterns, hass, entry_id), path, recursive=True ) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) + if not hass.is_running: + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup) + else: + self.startup(None) hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown) - def startup(self, event: Event) -> None: + def startup(self, event: Event | None) -> None: """Start the watcher.""" self._observer.start() - def shutdown(self, event: Event) -> None: + def shutdown(self, event: Event | None) -> None: """Shutdown the watcher.""" self._observer.stop() self._observer.join() diff --git a/homeassistant/components/folder_watcher/const.py b/homeassistant/components/folder_watcher/const.py index 22dae3b9164..c95f35a1bc1 100644 --- a/homeassistant/components/folder_watcher/const.py +++ b/homeassistant/components/folder_watcher/const.py @@ -1,6 +1,10 @@ """Constants for Folder watcher.""" +from homeassistant.const import Platform + CONF_FOLDER = "folder" CONF_PATTERNS = "patterns" DEFAULT_PATTERN = "*" DOMAIN = "folder_watcher" + +PLATFORMS = [Platform.EVENT] diff --git a/homeassistant/components/folder_watcher/event.py b/homeassistant/components/folder_watcher/event.py new file mode 100644 index 00000000000..7158930e116 --- /dev/null +++ b/homeassistant/components/folder_watcher/event.py @@ -0,0 +1,75 @@ +"""Support for Folder watcher event entities.""" + +from __future__ import annotations + +from typing import Any + +from watchdog.events import ( + EVENT_TYPE_CLOSED, + EVENT_TYPE_CREATED, + EVENT_TYPE_DELETED, + EVENT_TYPE_MODIFIED, + EVENT_TYPE_MOVED, +) + +from homeassistant.components.event import EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Folder Watcher event.""" + + async_add_entities([FolderWatcherEventEntity(entry)]) + + +class FolderWatcherEventEntity(EventEntity): + """Representation of a Folder watcher event entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_event_types = [ + EVENT_TYPE_CLOSED, + EVENT_TYPE_CREATED, + EVENT_TYPE_DELETED, + EVENT_TYPE_MODIFIED, + EVENT_TYPE_MOVED, + ] + _attr_name = None + _attr_translation_key = DOMAIN + + def __init__( + self, + entry: ConfigEntry, + ) -> None: + """Initialise a Folder watcher event entity.""" + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, entry.entry_id)}, + name=entry.title, + manufacturer="Folder watcher", + ) + self._attr_unique_id = entry.entry_id + self._entry = entry + + @callback + def _async_handle_event(self, event: str, _extra: dict[str, Any]) -> None: + """Handle the event.""" + self._trigger_event(event, _extra) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + signal = f"folder_watcher-{self._entry.entry_id}" + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._async_handle_event) + ) diff --git a/homeassistant/components/folder_watcher/strings.json b/homeassistant/components/folder_watcher/strings.json index bd1742b8ce3..da1e3c1962a 100644 --- a/homeassistant/components/folder_watcher/strings.json +++ b/homeassistant/components/folder_watcher/strings.json @@ -42,5 +42,20 @@ "title": "The Folder Watcher configuration for {path} could not start", "description": "The path {path} is not accessible or not allowed to be accessed.\n\nPlease check the path is accessible and add it to `{config_variable}` in config.yaml and restart Home Assistant to fix this issue." } + }, + "entity": { + "sensor": { + "folder_watcher": { + "state_attributes": { + "event_type": { "name": "Event type" }, + "path": { "name": "Path" }, + "file": { "name": "File" }, + "folder": { "name": "Folder" }, + "dest_path": { "name": "Destination path" }, + "dest_file": { "name": "Destination file" }, + "dest_folder": { "name": "Destination folder" } + } + } + } } } diff --git a/tests/components/folder_watcher/conftest.py b/tests/components/folder_watcher/conftest.py index 06c0a41d49c..875a90f7cbb 100644 --- a/tests/components/folder_watcher/conftest.py +++ b/tests/components/folder_watcher/conftest.py @@ -3,10 +3,18 @@ from __future__ import annotations from collections.abc import Generator +from pathlib import Path from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest +from homeassistant.components.folder_watcher.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + @pytest.fixture def mock_setup_entry() -> Generator[None, None, None]: @@ -15,3 +23,28 @@ def mock_setup_entry() -> Generator[None, None, None]: "homeassistant.components.folder_watcher.async_setup_entry", return_value=True ): yield + + +@pytest.fixture +async def load_int( + hass: HomeAssistant, tmp_path: Path, freezer: FrozenDateTimeFactory +) -> MockConfigEntry: + """Set up the Folder watcher integration in Home Assistant.""" + freezer.move_to("2022-04-19 10:31:02+00:00") + path = tmp_path.as_posix() + hass.config.allowlist_external_dirs = {path} + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + title=f"Folder Watcher {path!s}", + data={}, + options={"folder": str(path), "patterns": ["*"]}, + entry_id="1", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/folder_watcher/snapshots/test_event.ambr b/tests/components/folder_watcher/snapshots/test_event.ambr new file mode 100644 index 00000000000..04405e0694b --- /dev/null +++ b/tests/components/folder_watcher/snapshots/test_event.ambr @@ -0,0 +1,62 @@ +# serializer version: 1 +# name: test_event_entity[1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'event_types': list([ + 'closed', + 'created', + 'deleted', + 'modified', + 'moved', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'event', + 'entity_category': None, + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'folder_watcher', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'folder_watcher', + 'unique_id': '1', + 'unit_of_measurement': None, + }) +# --- +# name: test_event_entity[1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'dest_file': 'hello2.txt', + 'event_type': 'moved', + 'event_types': list([ + 'closed', + 'created', + 'deleted', + 'modified', + 'moved', + ]), + 'file': 'hello.txt', + }), + 'context': , + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2022-04-19T10:31:02.000+00:00', + }) +# --- diff --git a/tests/components/folder_watcher/test_event.py b/tests/components/folder_watcher/test_event.py new file mode 100644 index 00000000000..71f9094f59f --- /dev/null +++ b/tests/components/folder_watcher/test_event.py @@ -0,0 +1,53 @@ +"""The event entity tests for Folder Watcher.""" + +from pathlib import Path +from time import sleep + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_event_entity( + hass: HomeAssistant, + load_int: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + tmp_path: Path, +) -> None: + """Test the event entity.""" + entry = load_int + await hass.async_block_till_done() + + file = tmp_path.joinpath("hello.txt") + file.write_text("Hello, world!") + new_file = tmp_path.joinpath("hello2.txt") + file.rename(new_file) + + await hass.async_add_executor_job(sleep, 0.1) + + entity_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) + assert entity_entries + + def limit_attrs(prop, path): + exclude_attrs = { + "entity_id", + "friendly_name", + "folder", + "path", + "dest_folder", + "dest_path", + } + return prop in exclude_attrs + + for entity_entry in entity_entries: + assert entity_entry == snapshot( + name=f"{entity_entry.unique_id}-entry", exclude=limit_attrs + ) + assert (state := hass.states.get(entity_entry.entity_id)) + assert state == snapshot( + name=f"{entity_entry.unique_id}-state", exclude=limit_attrs + ) diff --git a/tests/components/folder_watcher/test_init.py b/tests/components/folder_watcher/test_init.py index 2e9eb99f678..8309988931a 100644 --- a/tests/components/folder_watcher/test_init.py +++ b/tests/components/folder_watcher/test_init.py @@ -44,7 +44,7 @@ def test_event() -> None: MockPatternMatchingEventHandler, ): hass = Mock() - handler = folder_watcher.create_event_handler(["*"], hass) + handler = folder_watcher.create_event_handler(["*"], hass, "1") handler.on_created( SimpleNamespace( is_directory=False, src_path="/hello/world.txt", event_type="created" @@ -74,7 +74,7 @@ def test_move_event() -> None: MockPatternMatchingEventHandler, ): hass = Mock() - handler = folder_watcher.create_event_handler(["*"], hass) + handler = folder_watcher.create_event_handler(["*"], hass, "1") handler.on_moved( SimpleNamespace( is_directory=False,