Create EventEntity for Folder Watcher (#116526)

This commit is contained in:
G Johansson 2024-05-27 08:47:02 +02:00 committed by GitHub
parent 5b608bea01
commit 4d52d920ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 273 additions and 20 deletions

View File

@ -23,10 +23,11 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
import homeassistant.helpers.config_validation as cv 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.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType 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__) _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", learn_more_url="https://www.home-assistant.io/docs/configuration/basic/#allowlist_external_dirs",
) )
return False 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 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 the Watchdog EventHandler object."""
return EventHandler(patterns, hass, entry_id)
return EventHandler(patterns, hass)
class EventHandler(PatternMatchingEventHandler): class EventHandler(PatternMatchingEventHandler):
"""Class for handling Watcher events.""" """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.""" """Initialise the EventHandler."""
super().__init__(patterns) super().__init__(patterns)
self.hass = hass self.hass = hass
self.entry_id = entry_id
def process(self, event: FileSystemEvent, moved: bool = False) -> None: def process(self, event: FileSystemEvent, moved: bool = False) -> None:
"""On Watcher event, fire HA event.""" """On Watcher event, fire HA event."""
@ -133,20 +137,22 @@ class EventHandler(PatternMatchingEventHandler):
"folder": folder, "folder": folder,
} }
_extra = {}
if moved: if moved:
event = cast(FileSystemMovedEvent, event) event = cast(FileSystemMovedEvent, event)
dest_folder, dest_file_name = os.path.split(event.dest_path) dest_folder, dest_file_name = os.path.split(event.dest_path)
fireable.update( _extra = {
{ "dest_path": event.dest_path,
"dest_path": event.dest_path, "dest_file": dest_file_name,
"dest_file": dest_file_name, "dest_folder": dest_folder,
"dest_folder": dest_folder, }
} fireable.update(_extra)
)
self.hass.bus.fire( self.hass.bus.fire(
DOMAIN, DOMAIN,
fireable, fireable,
) )
signal = f"folder_watcher-{self.entry_id}"
dispatcher_send(self.hass, signal, event.event_type, fireable)
def on_modified(self, event: FileModifiedEvent) -> None: def on_modified(self, event: FileModifiedEvent) -> None:
"""File modified.""" """File modified."""
@ -172,20 +178,25 @@ class EventHandler(PatternMatchingEventHandler):
class Watcher: class Watcher:
"""Class for starting Watchdog.""" """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.""" """Initialise the watchdog observer."""
self._observer = Observer() self._observer = Observer()
self._observer.schedule( 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) 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.""" """Start the watcher."""
self._observer.start() self._observer.start()
def shutdown(self, event: Event) -> None: def shutdown(self, event: Event | None) -> None:
"""Shutdown the watcher.""" """Shutdown the watcher."""
self._observer.stop() self._observer.stop()
self._observer.join() self._observer.join()

View File

@ -1,6 +1,10 @@
"""Constants for Folder watcher.""" """Constants for Folder watcher."""
from homeassistant.const import Platform
CONF_FOLDER = "folder" CONF_FOLDER = "folder"
CONF_PATTERNS = "patterns" CONF_PATTERNS = "patterns"
DEFAULT_PATTERN = "*" DEFAULT_PATTERN = "*"
DOMAIN = "folder_watcher" DOMAIN = "folder_watcher"
PLATFORMS = [Platform.EVENT]

View File

@ -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)
)

View File

@ -42,5 +42,20 @@
"title": "The Folder Watcher configuration for {path} could not start", "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." "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" }
}
}
}
} }
} }

View File

@ -3,10 +3,18 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Generator from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import pytest 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 @pytest.fixture
def mock_setup_entry() -> Generator[None, None, None]: 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 "homeassistant.components.folder_watcher.async_setup_entry", return_value=True
): ):
yield 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

View File

@ -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': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'event',
'entity_category': None,
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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': <ANY>,
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2022-04-19T10:31:02.000+00:00',
})
# ---

View File

@ -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
)

View File

@ -44,7 +44,7 @@ def test_event() -> None:
MockPatternMatchingEventHandler, MockPatternMatchingEventHandler,
): ):
hass = Mock() hass = Mock()
handler = folder_watcher.create_event_handler(["*"], hass) handler = folder_watcher.create_event_handler(["*"], hass, "1")
handler.on_created( handler.on_created(
SimpleNamespace( SimpleNamespace(
is_directory=False, src_path="/hello/world.txt", event_type="created" is_directory=False, src_path="/hello/world.txt", event_type="created"
@ -74,7 +74,7 @@ def test_move_event() -> None:
MockPatternMatchingEventHandler, MockPatternMatchingEventHandler,
): ):
hass = Mock() hass = Mock()
handler = folder_watcher.create_event_handler(["*"], hass) handler = folder_watcher.create_event_handler(["*"], hass, "1")
handler.on_moved( handler.on_moved(
SimpleNamespace( SimpleNamespace(
is_directory=False, is_directory=False,