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

Add config flow to local_file #125835

Merged
merged 7 commits into from
Oct 21, 2024
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions homeassistant/components/local_file/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
"""The local_file component."""

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_FILE_PATH, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError

from .const import DOMAIN
from .util import check_file_path_access

PLATFORMS = [Platform.CAMERA]


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Local file from a config entry."""
file_path: str = entry.options[CONF_FILE_PATH]
if not await hass.async_add_executor_job(check_file_path_access, file_path):
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="not_readable_path",
translation_placeholders={"file_path": file_path},
)

await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(update_listener))

return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Local file config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)


async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)
94 changes: 74 additions & 20 deletions homeassistant/components/local_file/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,28 @@

import logging
import mimetypes
import os

import voluptuous as vol

from homeassistant.components.camera import (
PLATFORM_SCHEMA as CAMERA_PLATFORM_SCHEMA,
Camera,
)
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import PlatformNotReady, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
issue_registry as ir,
)
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.util import slugify

from .const import DEFAULT_NAME, SERVICE_UPDATE_FILE_PATH
from .const import DEFAULT_NAME, DOMAIN, SERVICE_UPDATE_FILE_PATH
from .util import check_file_path_access

_LOGGER = logging.getLogger(__name__)

Expand All @@ -31,21 +37,12 @@
)


def check_file_path_access(file_path: str) -> bool:
"""Check that filepath given is readable."""
if not os.access(file_path, os.R_OK):
return False
return True


async def async_setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Camera that works with local files."""
file_path: str = config[CONF_FILE_PATH]
"""Set up the Camera for local file from a config entry."""

platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
Expand All @@ -56,19 +53,76 @@ async def async_setup_platform(
"update_file_path",
)

async_add_entities(
[
LocalFile(
entry.options[CONF_NAME],
entry.options[CONF_FILE_PATH],
entry.entry_id,
)
]
)


async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Camera that works with local files."""
file_path: str = config[CONF_FILE_PATH]
file_path_slug = slugify(file_path)

if not await hass.async_add_executor_job(check_file_path_access, file_path):
raise PlatformNotReady(f"File path {file_path} is not readable")
ir.async_create_issue(
hass,
DOMAIN,
f"no_access_path_{file_path_slug}",
breaks_in_ha_version="2025.5.0",
is_fixable=False,
learn_more_url="https://www.home-assistant.io/integrations/local_file/",
severity=ir.IssueSeverity.WARNING,
translation_key="no_access_path",
translation_placeholders={
"file_path": file_path_slug,
},
)
return

ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.5.0",
is_fixable=False,
issue_domain=DOMAIN,
learn_more_url="https://www.home-assistant.io/integrations/local_file/",
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Local file",
},
)

async_add_entities([LocalFile(config[CONF_NAME], file_path)])
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
)


class LocalFile(Camera):
"""Representation of a local file camera."""

def __init__(self, name: str, file_path: str) -> None:
def __init__(self, name: str, file_path: str, unique_id: str) -> None:
"""Initialize Local File Camera component."""
super().__init__()
self._attr_name = name
self._attr_unique_id = unique_id
self._file_path = file_path
# Set content type of local file
content, _ = mimetypes.guess_type(file_path)
Expand Down
77 changes: 77 additions & 0 deletions homeassistant/components/local_file/config_flow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Config flow for Local file."""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any, cast

import voluptuous as vol

from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
)
from homeassistant.helpers.selector import TextSelector

from .const import DEFAULT_NAME, DOMAIN
from .util import check_file_path_access


async def validate_options(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate options selected."""
file_path: str = user_input[CONF_FILE_PATH]
if not await handler.parent_handler.hass.async_add_executor_job(
check_file_path_access, file_path
):
raise SchemaFlowError("not_readable_path")

handler.parent_handler._async_abort_entries_match( # noqa: SLF001
{CONF_FILE_PATH: user_input[CONF_FILE_PATH]}
)

return user_input


DATA_SCHEMA_OPTIONS = vol.Schema(
{
vol.Required(CONF_FILE_PATH): TextSelector(),
}
)
DATA_SCHEMA_SETUP = vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
}
).extend(DATA_SCHEMA_OPTIONS.schema)

CONFIG_FLOW = {
"user": SchemaFlowFormStep(
schema=DATA_SCHEMA_SETUP,
validate_user_input=validate_options,
),
"import": SchemaFlowFormStep(
schema=DATA_SCHEMA_SETUP,
validate_user_input=validate_options,
),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(
DATA_SCHEMA_OPTIONS,
validate_user_input=validate_options,
)
}


class LocalFileConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config flow for Local file."""

config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW

def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
"""Return config entry title."""
return cast(str, options[CONF_NAME])
46 changes: 45 additions & 1 deletion homeassistant/components/local_file/strings.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,50 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"not_readable_path": "The provided path to the file can not be read"
},
"step": {
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"file_path": "File path"
},
"data_description": {
"name": "Name for the created entity.",
"file_path": "The full path to the image file to be displayed. Be sure the path of the file is in the allowed paths, you can read more about this in the documentation."
}
}
}
},
"options": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
},
"error": {
"not_readable_path": "[%key:component::local_file::config::error::not_readable_path%]"
},
"step": {
"init": {
"data": {
"file_path": "[%key:component::local_file::config::step::user::data::file_path%]"
},
"data_description": {
"file_path": "[%key:component::local_file::config::step::user::data_description::file_path%]"
}
}
}
},
"services": {
"update_file_path": {
"name": "Updates file path",
"description": "Use this action to change the file displayed by the camera.",
"fields": {
"file_path": {
"name": "File path",
"description": "The full path to the new image file to be displayed."
"description": "[%key:component::local_file::config::step::user::data_description::file_path%]"
}
}
}
Expand All @@ -15,5 +53,11 @@
"file_path_not_accessible": {
"message": "Path {file_path} is not accessible"
}
},
"issues": {
"no_access_path": {
"title": "Incorrect file path",
"description": "While trying to import your configuration the provided file path {file_path} could not be read.\nPlease update your configuration to a correct file path and restart to fix this issue."
}
}
}
10 changes: 10 additions & 0 deletions homeassistant/components/local_file/util.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""Utils for local file."""

import os


def check_file_path_access(file_path: str) -> bool:
"""Check that filepath given is readable."""
if not os.access(file_path, os.R_OK):
return False
return True
63 changes: 63 additions & 0 deletions tests/components/local_file/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Fixtures for the Local file integration."""

from __future__ import annotations

from collections.abc import Generator
from typing import Any
from unittest.mock import AsyncMock, Mock, patch

import pytest

from homeassistant.components.local_file.const import DEFAULT_NAME, DOMAIN
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_FILE_PATH, CONF_NAME
from homeassistant.core import HomeAssistant

from tests.common import MockConfigEntry


@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Automatically patch setup."""
with patch(
"homeassistant.components.local_file.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry


@pytest.fixture(name="get_config")
async def get_config_to_integration_load() -> dict[str, Any]:
"""Return configuration.

To override the config, tests can be marked with:
@pytest.mark.parametrize("get_config", [{...}])
"""
return {CONF_NAME: DEFAULT_NAME, CONF_FILE_PATH: "mock.file"}


@pytest.fixture(name="loaded_entry")
async def load_integration(
hass: HomeAssistant, get_config: dict[str, Any]
) -> MockConfigEntry:
"""Set up the Local file integration in Home Assistant."""
config_entry = MockConfigEntry(
domain=DOMAIN,
source=SOURCE_USER,
options=get_config,
entry_id="1",
)

config_entry.add_to_hass(hass)
with (
patch("os.path.isfile", Mock(return_value=True)),
patch("os.access", Mock(return_value=True)),
patch(
"homeassistant.components.local_file.camera.mimetypes.guess_type",
Mock(return_value=(None, None)),
),
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

return config_entry
Loading