diff --git a/README.md b/README.md index aa4bee5..baec99f 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,6 @@ Configuration via Homeassistant UI. The recommended way to install this is via HACS: - - [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?category=custom_respository&owner=iluvdata&repository=pdf_scrape) #### Semi-manual install diff --git a/custom_components/pdf_scrape/__init__.py b/custom_components/pdf_scrape/__init__.py index 52a38ff..0855e06 100644 --- a/custom_components/pdf_scrape/__init__.py +++ b/custom_components/pdf_scrape/__init__.py @@ -1,13 +1,12 @@ """PDF Scrape Integration.""" import logging +from pathlib import Path from typing import Any import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file - -# import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CONFIG_ENTRY_ID, @@ -21,20 +20,21 @@ ServiceCall, ServiceResponse, SupportsResponse, + callback, ) from homeassistant.exceptions import ConfigEntryError, ServiceValidationError import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.network import get_url from homeassistant.helpers.selector import FileSelector, FileSelectorConfig -from homeassistant.helpers.storage import Store +from homeassistant.helpers.storage import STORAGE_DIR, Store from homeassistant.helpers.typing import ConfigType from .const import ( CONF_FILE, - CONF_MD5_CHECKSUM, CONF_MODIFIED, CONF_MODIFIED_SOURCE, - CONF_PDF_PAGES, + CONF_SHA256_CHECKSUM, DOMAIN, ConfType, ErrorTypes, @@ -42,22 +42,23 @@ from .coordinator import ( PDFScrapeConfigEntry, PDFScrapeCoordinator, + PDFScrapeFileCoordinator, PDFScrapeHTTPCoordinator, - PDFScrapeLocalCoordinator, PDFScrapeUploadCoordinator, async_raise_error, ) +from .http import PDFView from .pdf import ( + PDF, + FileError, HTTPError, - PDFParseError, + PDFScrapeFile, PDFScrapeHTTP, - PDFScrapeLocal, PDFScrapeUpload, - StoredFile, get_store, ) -_PLATFORMS: list[Platform] = [Platform.SENSOR] +_PLATFORMS: list[Platform] = [Platform.IMAGE, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -86,24 +87,20 @@ async def upload_pdf(call: ServiceCall) -> ServiceResponse: with await hass.async_add_executor_job( process_uploaded_file, hass, call.data[CONF_FILE] ) as pdf_path: - try: - pdf: PDFScrapeUpload = await PDFScrapeUpload.pdfscrape( - call.hass, - path=pdf_path, - config_entry_id=config_entry.entry_id, - ) - # Reload the config entry to pick up the new file - hass.config_entries.async_schedule_reload(config_entry.entry_id) - if pdf.modified is not None: - return { - CONF_MODIFIED: pdf.modified.isoformat(), - CONF_MODIFIED_SOURCE: pdf.modified_source, - CONF_MD5_CHECKSUM: pdf.md5_checksum, - } - error = "Unable to parse uploaded PDF" - except PDFParseError as ex: - _LOGGER.exception() - error = f"Unable to parse uploaded PDF {ex}" + pdf: PDFScrapeUpload = await PDFScrapeUpload.pdfscrape( + call.hass, + path=pdf_path, + config_entry_id=config_entry.entry_id, + ) + # Reload the config entry to pick up the new file + hass.config_entries.async_schedule_reload(config_entry.entry_id) + if pdf.pdf.modified is not None: + return { + CONF_MODIFIED: pdf.pdf.modified.isoformat(), + CONF_MODIFIED_SOURCE: pdf.pdf.modified_source, + CONF_SHA256_CHECKSUM: pdf.pdf.md5_checksum, + } + error = "Unable to parse uploaded PDF" else: error = "Invalid config_entry_id or device_id" raise ServiceValidationError( @@ -124,7 +121,7 @@ def _one_of(data: dict[str, Any]) -> dict[str, Any]: raise vol.Invalid("Either device_id or config_entry_id must be specified.") return data - SCHEMA: vol.Schema = vol.Schema( + schema: vol.Schema = vol.Schema( vol.All( { vol.Optional(ATTR_CONFIG_ENTRY_ID): vol.All(cv.ensure_list, _only_one), @@ -141,9 +138,56 @@ def _one_of(data: dict[str, Any]) -> dict[str, Any]: DOMAIN, "upload_pdf", upload_pdf, - SCHEMA, + schema, SupportsResponse.OPTIONAL, ) + + # Clean up orphaned files if present + # List of valid entries (and temp_ids) + config_entry_ids: list[str] = [ + entry.entry_id + if entry.data.get("temp_storage_id") is None + else entry.data.get("temp_storage_id") + for entry in hass.config_entries.async_entries(DOMAIN) + ] + path: Path = Path(hass.config.path(STORAGE_DIR)) + + @callback + def _cleanup_orphaned_stores() -> None: + """Wrapper function for blocking code.""" + if path.exists(): + for file in path.iterdir(): + if file.is_file() and file.name.startswith(f"{DOMAIN}_"): + entry_or_flow_id: str = file.name.removeprefix(f"{DOMAIN}_") + if entry_or_flow_id not in config_entry_ids: + _LOGGER.warning( + "Removing orphaned store: %s. Note: This is not an error but indicates that the store is not associated with any config entry", + file, + ) + file.unlink() + + await hass.async_add_executor_job(_cleanup_orphaned_stores) + + path = path.joinpath(DOMAIN) + + def _cleanup_orphaned_files() -> None: + """Wrapper function for blocking code.""" + if path.exists(): + for file in path.iterdir(): + if ( + file.suffix in [".pdf", ".webp"] + and file.stem not in config_entry_ids + ): + _LOGGER.warning( + "Removing orphaned file: %s. Note: This is not an error but indicates that the file is not associated with any config entry", + file, + ) + file.unlink() + + await hass.async_add_executor_job(_cleanup_orphaned_files) + + hass.http.register_view(PDFView(hass)) + return True @@ -151,6 +195,7 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: PDFScrapeConfigEntry ) -> bool: """Set up the config entry.""" + try: coordinator: PDFScrapeCoordinator match config_entry.data[ @@ -168,14 +213,31 @@ async def async_setup_entry( # Rename the storage file that was created by the config flow (one time only) if temp_store := get_store(hass, temp_key): if data := await temp_store.async_load(): - new_store: Store[StoredFile] = get_store( + new_store: Store[PDF] = get_store( hass, config_entry.entry_id ) await new_store.async_save(data) await temp_store.async_remove() hass.config_entries.async_update_entry( - config_entry, data={"type": "upload"} + config_entry, data={"type": ConfType.UPLOAD} ) + # rename the pdf file. + path: Path = Path(hass.config.path(STORAGE_DIR), DOMAIN) + + def _rename_files() -> None: + """Wrapper function for blocking code.""" + if path.exists(): + for file in path.iterdir(): + if file.stem == temp_key and file.suffix in [ + ".pdf", + ".webp", + ]: + new_name = ( + f"{config_entry.entry_id}{file.suffix}" + ) + file.rename(path.joinpath(new_name)) + + await hass.async_add_executor_job(_rename_files) else: raise ConfigEntryError("Temp store empty") else: @@ -186,24 +248,39 @@ async def async_setup_entry( ) coordinator = PDFScrapeUploadCoordinator(hass, config_entry, pdfupload) case ConfType.LOCAL: - pdflocal: PDFScrapeLocal = await PDFScrapeLocal.pdfscrape( + pdflocal: PDFScrapeFile = await PDFScrapeFile.pdfscrape( hass, config_entry.data[CONF_FILE], config_entry_id=config_entry.entry_id, ) - coordinator = PDFScrapeLocalCoordinator(hass, config_entry, pdflocal) + coordinator = PDFScrapeFileCoordinator(hass, config_entry, pdflocal) await coordinator.async_config_entry_first_refresh() - config_entry.async_on_unload( - config_entry.add_update_listener(_async_update_listener) - ) - config_entry.runtime_data = coordinator + device_info = dr.DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + name=config_entry.title, + entry_type=dr.DeviceEntryType.SERVICE, + configuration_url=f"{get_url(config_entry.runtime_data.hass)}/api/pdf_scrape/pdf/{config_entry.entry_id}.pdf?token={config_entry.runtime_data.access_token}", + ) + match config_entry.data[CONF_TYPE]: + case ConfType.LOCAL: + device_info["model"] = config_entry.data[CONF_FILE] + case ConfType.HTTP: + device_info["configuration_url"] = config_entry.data[CONF_URL] + device_info["model"] = config_entry.data[CONF_URL] + case ConfType.UPLOAD: + device_info["model"] = config_entry.title + dev_reg = dr.async_get(hass) + dev_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, **device_info + ) + await hass.config_entries.async_forward_entry_setups(config_entry, _PLATFORMS) - except (HTTPError, TimeoutError, PDFParseError) as ex: + except (HTTPError, TimeoutError, FileError) as ex: async_raise_error( hass=hass, error_key=ErrorTypes.PDF_ERROR, @@ -214,12 +291,28 @@ async def async_setup_entry( return True -async def _async_update_listener( +async def async_migrate_entry( hass: HomeAssistant, config_entry: PDFScrapeConfigEntry -): - """Handle config options update.""" - # Reload the integration when the options change. - await hass.config_entries.async_reload(config_entry.entry_id) +) -> bool: + """Migrate old entry.""" + + if config_entry.version == 1 and config_entry.minor_version == 2: + _LOGGER.debug( + "Migrating configuration from version %s.%s", + config_entry.version, + config_entry.minor_version, + ) + for subentry_id, subentry in config_entry.subentries.items(): + if subentry.subentry_type == "document": + hass.config_entries.async_remove_subentry(config_entry, subentry_id) + break + _LOGGER.debug( + "Migration to configuration version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -227,8 +320,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) +@callback +def async_cleanup(hass: HomeAssistant, entry_or_flow_id: str) -> None: + """Clean up orphaned files and stores.""" + + path: Path = Path(hass.config.path(STORAGE_DIR), DOMAIN) + + async def _remove_files() -> None: + """Wrapper function for blocking code.""" + if await hass.async_add_executor_job(path.exists): + for file in await hass.async_add_executor_job(path.iterdir): + if file.stem == entry_or_flow_id and file.suffix in [".pdf", ".webp"]: + await hass.async_add_executor_job(file.unlink) + + hass.create_task(_remove_files(), f"{DOMAIN}._remove_files") + if store := get_store(hass, entry_or_flow_id): + hass.create_task(store.async_remove(), f"{DOMAIN}.remove_store") + + async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle removal of an entry.""" - # Delete the store - if store := get_store(hass, entry.entry_id): - await store.async_remove() + async_cleanup(hass, entry.entry_id) diff --git a/custom_components/pdf_scrape/config_flow.py b/custom_components/pdf_scrape/config_flow.py index faa3870..0edeae5 100644 --- a/custom_components/pdf_scrape/config_flow.py +++ b/custom_components/pdf_scrape/config_flow.py @@ -1,9 +1,9 @@ """Config flow for PDF Scrape Integration.""" +from asyncio import Task from collections.abc import Callable, Mapping from datetime import timedelta import logging -from logging import Logger from pathlib import Path import re from typing import Any, cast @@ -12,9 +12,11 @@ from homeassistant.components import websocket_api from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN, SelectEntity from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASS_UNITS, + DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorEntity, SensorStateClass, @@ -28,6 +30,8 @@ ConfigFlow, ConfigFlowResult, ConfigSubentryFlow, + FlowType, + SubentryFlowContext, SubentryFlowResult, ) from homeassistant.const import ( @@ -47,9 +51,10 @@ path as pathcheck, url, ) -from homeassistant.helpers.entity import CalculatedState +from homeassistant.helpers.entity import CalculatedState, Entity import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.selector import ( + BooleanSelector, DurationSelector, DurationSelectorConfig, FileSelector, @@ -65,11 +70,12 @@ ) from homeassistant.helpers.template import Template, TemplateError, TemplateVarsType -from . import PDFScrapeConfigEntry +from . import PDFScrapeConfigEntry, async_cleanup from .const import ( CONF_DEFAULT_SCAN_INTERVAL, CONF_FILE, CONF_MIN_SCAN_INTERVAL, + CONF_OCR, CONF_PDF_PAGES, CONF_REGEX_MATCH_INDEX, CONF_REGEX_SEARCH, @@ -83,21 +89,28 @@ from .pdf import ( FileError, HTTPError, - PDFParseError, PDFScrape, + PDFScrapeFile, PDFScrapeHTTP, - PDFScrapeLocal, PDFScrapeUpload, ) -_LOGGER: Logger = logging.getLogger(__name__) +_LOGGER: logging.Logger = logging.getLogger(__name__) class PDFScrapeConfigFlow(ConfigFlow, domain=DOMAIN): """PDF Scrape Config Flow Class.""" VERSION: int = 1 - MINOR_VERSION: int = 2 + MINOR_VERSION: int = 3 + + data: dict[str, str | timedelta | None] = {} + placeholders: dict[str, str] | None = {} + reason: str = "already_configured" + pdf: PDFScrape + title_fun: Callable[[], str] | None = None + unique_fun: Callable[[], str] + process_task: Task | None = None @classmethod @callback @@ -115,12 +128,83 @@ async def async_step_user( step_id="user", menu_options=list(ConfType), sort=True ) - def _async_clear_issue(self) -> None: + async def async_step_finish( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Finish this flow.""" + await self.async_set_unique_id(self.unique_fun()) + if entry := self.hass.config_entries.async_entry_for_domain_unique_id( + DOMAIN, self.unique_id + ): + if self.source == SOURCE_USER or ( + self.source == SOURCE_RECONFIGURE + and entry.entry_id != self._get_reconfigure_entry().entry_id + ): + return self.async_abort( + reason=self.reason, + description_placeholders=self.placeholders, + ) + title: str = self.data[CONF_NAME] if not self.title_fun else self.title_fun() + if self.source == SOURCE_USER: + return self.async_create_entry(title=title, data=self.data) ir.async_delete_issue( self.hass, DOMAIN, f"{ErrorTypes.PDF_ERROR}_{self._get_reconfigure_entry().entry_id}", ) + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + title=title, + data=self.data, + ) + + async def async_on_create_entry(self, result: ConfigFlowResult) -> ConfigFlowResult: + """Next flow for create flow.""" + subentry_flow: SubentryFlowResult = ( + await self.hass.config_entries.subentries.async_init( + (result["result"].entry_id, "target"), + context=SubentryFlowContext(source=SOURCE_USER), + ) + ) + result["next_flow"] = ( + FlowType.CONFIG_SUBENTRIES_FLOW, + subentry_flow["flow_id"], + ) + return result + + async def async_step_process_error( + self, + user_input: dict[str, Any] | None = None, + ) -> ConfigFlowResult: + """Handle errors during processing.""" + async_cleanup(self.hass, self.flow_id) + return self.async_abort( + reason=self.reason, + description_placeholders=self.placeholders, + ) + + def _async_progress(self) -> ConfigFlowResult | str | None: + if self.process_task is not None: + if not self.process_task.done(): + self.async_update_progress(self.pdf.progress) + return self.async_show_progress( + step_id=self.cur_step["step_id"], + progress_action="pdf_process", + progress_task=self.process_task, + ) + if exception := self.process_task.exception(): + _LOGGER.debug("Progress task exception", exc_info=exception) + self.placeholders["msg"] = str(exception) + if isinstance(exception, HTTPError): + self.reason = "http_error" + elif isinstance(exception, FileError): + self.reason = "file_error" + else: + self.reason = "exception" + return self.async_show_progress_done(next_step_id="process_error") + self.process_task = None + return self.async_show_progress_done(next_step_id="finish") + return None async def async_step_http( self, user_input: dict[str, Any] | None = None @@ -138,47 +222,27 @@ async def async_step_http( else: try: url(user_input[CONF_URL]) - pdf: PDFScrapeHTTP = await PDFScrapeHTTP.pdfscrape( - self.hass, user_input[CONF_URL] + self.data[CONF_URL] = user_input[CONF_URL] + self.data[CONF_SCAN_INTERVAL] = {"seconds": td.total_seconds()} + self.data[CONF_TYPE] = ConfType.HTTP + self.placeholders = {"url": user_input[CONF_URL]} + self.reason = "http_already_configured" + self.pdf = PDFScrapeHTTP(self.hass, user_input[CONF_URL]) + self.title_fun = lambda: ( + user_input.get(CONF_NAME) + or self.pdf.pdf.title + or user_input[CONF_URL] ) - pdf.close() - # Store the token in the config entry data - title: str = user_input.get(CONF_NAME, user_input[CONF_URL]) - data: dict[str, Any] = { - CONF_URL: user_input[CONF_URL], - CONF_SCAN_INTERVAL: {"seconds": td.total_seconds()}, - CONF_TYPE: ConfType.HTTP, - } - if user_input.get(CONF_NAME): - data[CONF_NAME] = user_input[CONF_NAME] - if self.source == SOURCE_USER: - await self.async_set_unique_id( - f"{DOMAIN}_{user_input[CONF_URL]}" - ) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=title, - data=data, + self.unique_fun = lambda: self.data[CONF_URL] + if self.process_task is None: + self.process_task = self.hass.async_create_task( + self.pdf.update(), "pdfscrape_process" ) - if self.source == SOURCE_RECONFIGURE: - await self.async_set_unique_id( - f"{DOMAIN}_{user_input[CONF_URL]}" - ) - self._async_clear_issue() - return self.async_update_reload_and_abort( - self._get_reconfigure_entry(), - title=title, - data=data, - ) - _LOGGER.error("Accessed from invalid source: %s", self.source) - errors["base"] = "invalid_source" except vol.Invalid: errors[CONF_URL] = "invalid_url" - except PDFParseError: - errors[CONF_URL] = "pdf_parse" - except FileError as err: - _LOGGER.warning("File/OS Error %s", err) - errors[CONF_FILE] = "file_error" + if not errors and (result := self._async_progress()): + return result + flow_schema: vol.Schema = vol.Schema( { vol.Optional(CONF_NAME): TextSelector(), @@ -199,7 +263,13 @@ async def async_step_http( "minutes": minutes, "seconds": seconds, } - flow_schema = self.add_suggested_values_to_schema(flow_schema, data) + flow_schema = self.add_suggested_values_to_schema( + flow_schema, + { + **data, + CONF_NAME: self._get_reconfigure_entry().title, + }, + ) else: hours, remainder = divmod(CONF_DEFAULT_SCAN_INTERVAL.total_seconds(), 3600) minutes, seconds = divmod(remainder, 60) @@ -228,47 +298,41 @@ async def async_step_upload( """Handle config flow.""" errors: dict[str, str] = {} if user_input: - try: - with await self.hass.async_add_executor_job( - process_uploaded_file, self.hass, user_input[CONF_FILE] + + def _process_pdf() -> PDFScrapeUpload: + with process_uploaded_file( + self.hass, user_input[CONF_FILE] ) as pdf_path: # Assign a flow_id for now as the file is deleted when we are done. - pdf: PDFScrapeUpload = await PDFScrapeUpload.pdfscrape( - self.hass, path=pdf_path, config_entry_id=self.flow_id - ) - title: str = ( - user_input.get(CONF_NAME) or pdf.metadata_name or "Uploaded PDF" + pdf: PDFScrapeUpload = PDFScrapeUpload( + self.hass, + ( + self.flow_id + if self.source == SOURCE_USER + else self._get_reconfigure_entry().entry_id + ), + pdf_path, ) - data: dict[str, Any] = { - "temp_storage_id": self.flow_id, - CONF_TYPE: ConfType.UPLOAD, - } - if self.source == SOURCE_USER: - await self.async_set_unique_id( - f"{DOMAIN}_{user_input[CONF_FILE]}" - ) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=title, - data=data, - ) - if self.source == SOURCE_RECONFIGURE: - await self.async_set_unique_id( - f"{DOMAIN}_{user_input[CONF_FILE]}" - ) - # self._abort_if_unique_id_mismatch() - return self.async_update_reload_and_abort( - self._get_reconfigure_entry(), - title=title, - data=data, - ) - _LOGGER.error("Accessed from invalid source: %s", self.source) - errors["base"] = "invalid_source" - except PDFParseError: - errors[CONF_FILE] = "pdf_parse" - except HTTPError as err: - _LOGGER.warning("HTTP Error %s", err) - errors[CONF_FILE] = "http_error" + return pdf + + self.pdf: PDFScrapeUpload = await self.hass.async_add_executor_job( + _process_pdf + ) + self.title_fun = lambda: ( + self.data.get(CONF_NAME) or self.pdf.pdf.title or "Uploaded PDF" + ) + self.unique_fun = lambda: self.pdf.pdf.sha256_checksum + self.reason = "upload_already_configured" + self.data["temp_storage_id"] = self.flow_id + self.data[CONF_TYPE] = ConfType.UPLOAD + if self.process_task is None: + self.process_task = self.hass.async_create_task( + self.pdf.update(), "pdfscrape_process" + ) + + if result := self._async_progress(): + return result + flow_schema: vol.Schema = vol.Schema( { vol.Optional(CONF_NAME): TextSelector(), @@ -279,7 +343,11 @@ async def async_step_upload( ) if self.source == SOURCE_RECONFIGURE: flow_schema = self.add_suggested_values_to_schema( - flow_schema, self._get_reconfigure_entry().data + flow_schema, + { + **self._get_reconfigure_entry().data, + CONF_NAME: self._get_reconfigure_entry().title, + }, ) return self.async_show_form( data_schema=flow_schema, @@ -290,45 +358,22 @@ async def async_step_local( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle config flow.""" - errors: dict[str, str] = {} - error_placeholders: dict[str, str] = {} if user_input: - try: - await PDFScrapeLocal.pdfscrape( - self.hass, path=Path(isfile(pathcheck(user_input[CONF_FILE]))) - ) - data: dict[str, Any] = { - CONF_TYPE: ConfType.LOCAL, - CONF_FILE: user_input[CONF_FILE], - } - title: str = ( - user_input.get(CONF_NAME) or user_input[CONF_FILE] or "Local PDF" + self.pdf = PDFScrapeFile( + self.hass, path=Path(isfile(pathcheck(user_input[CONF_FILE]))) + ) + self.data[CONF_TYPE] = ConfType.LOCAL + self.data[CONF_FILE] = user_input[CONF_FILE] + self.title_fun = lambda: ( + user_input.get(CONF_NAME) or user_input[CONF_FILE] or "Local PDF" + ) + self.unique_fun = lambda: self.data[CONF_FILE] + if self.process_task is None: + self.process_task = self.hass.async_create_task( + self.pdf.update(), "process_pdf" ) - if self.source == SOURCE_USER: - await self.async_set_unique_id(f"{DOMAIN}_{user_input[CONF_FILE]}") - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=title, - data=data, - ) - if self.source == SOURCE_RECONFIGURE: - await self.async_set_unique_id(f"{DOMAIN}_{user_input[CONF_FILE]}") - # self._abort_if_unique_id_mismatch() - self._async_clear_issue() - return self.async_update_reload_and_abort( - self._get_reconfigure_entry(), - title=title, - data=data, - ) - _LOGGER.error("Accessed from invalid source: %s", self.source) - errors["base"] = "invalid_source" - except PDFParseError: - errors[CONF_FILE] = "pdf_parse" - except (FileError, vol.Invalid) as err: - _LOGGER.warning("File Error %s", err) - if isinstance(err, vol.Invalid): - error_placeholders[CONF_FILE] = err.msg - errors[CONF_FILE] = "file_error" + if result := await self._async_progress(): + return result flow_schema: vol.Schema = vol.Schema( { vol.Optional(CONF_NAME): TextSelector(), @@ -337,17 +382,19 @@ async def async_step_local( ) if self.source == SOURCE_RECONFIGURE: flow_schema = self.add_suggested_values_to_schema( - flow_schema, self._get_reconfigure_entry().data + flow_schema, + { + **self._get_reconfigure_entry().data, + CONF_NAME: self._get_reconfigure_entry().title, + }, ) elif user_input is not None: flow_schema = self.add_suggested_values_to_schema(flow_schema, user_input) return self.async_show_form( step_id="local", data_schema=flow_schema, - errors=errors, description_placeholders={ "url_file_integration": URL_FILE_INTEGRATION, - **error_placeholders, }, ) @@ -371,6 +418,12 @@ class TargetSubentryFlowHandler(ConfigSubentryFlow): data: dict[str, Any] = {} pdf: PDFScrape + preview_task: Task[str] | None = None + _progress_task: Task[str] | None = None + + def get_config_entry(self) -> PDFScrapeConfigEntry: + """Return the config entry this subentry flow belongs to.""" + return cast(PDFScrapeConfigEntry, self._get_entry()) async def async_step_reconfigure( self, user_input: dict[str, Any] | None = None @@ -397,7 +450,7 @@ async def async_step_user( config_entry_id=config_entry.entry_id, ) case ConfType.LOCAL: - self.pdf = await PDFScrapeLocal.pdfscrape( + self.pdf = await PDFScrapeFile.pdfscrape( self.hass, config_entry.data[CONF_FILE], config_entry_id=config_entry.entry_id, @@ -410,30 +463,57 @@ async def async_step_user( if user_input: if not re.match(REGEX_PAGE_RANGE_PATTERN, user_input[CONF_PDF_PAGES]): errors[CONF_PDF_PAGES] = "invalid_page_range" - try: - self.pdf.get_pages(user_input[CONF_PDF_PAGES]) - except IndexError: - errors[CONF_PDF_PAGES] = "pages_out_of_range" - if not errors: - self.data[CONF_PDF_PAGES] = user_input[CONF_PDF_PAGES] - return await self.async_step_regex(None) + self.data[CONF_PDF_PAGES] = user_input[CONF_PDF_PAGES] + self.data[CONF_OCR] = user_input[CONF_OCR] + + self._progress_task = self.get_config_entry().async_create_task( + self.hass, + self.pdf.get_pages( + user_input[CONF_PDF_PAGES], bool(user_input.get(CONF_OCR, False)) + ), + "step_user_get_pages", + True, + ) + if self._progress_task is not None: + if self._progress_task.done(): + if self._progress_task.exception(): + if isinstance(self._progress_task.exception(), IndexError): + errors[CONF_PDF_PAGES] = "pages_out_of_range" + else: + errors["base"] = self._progress_task.exception() + self._progress_task = None + else: + self._progress_task = None + # Save the new pages + await self.pdf.save_to_store() + return self.async_show_progress_done(next_step_id="regex") + else: + return self.async_show_progress( + step_id="user", + progress_action="getting_pages", + progress_task=self._progress_task, + ) default_pages: str = "1" + ocr: bool = False if self.source == SOURCE_RECONFIGURE: default_pages = str( self._get_reconfigure_subentry().data.get(CONF_PDF_PAGES, 0) ) + ocr = self._get_reconfigure_subentry().data.get(CONF_OCR, False) return self.async_show_form( + step_id="user", data_schema=vol.Schema( { vol.Required(CONF_PDF_PAGES, default=default_pages): TextSelector( TextSelectorConfig(type=TextSelectorType.TEXT) ), + vol.Optional(CONF_OCR, default=ocr): BooleanSelector(), } ), description_placeholders={ "title": self._get_entry().title, - "pages": len(self.pdf.pages), + "pages": self.pdf.pdf.page_count, }, errors=errors, last_step=False, @@ -443,7 +523,6 @@ async def async_step_user( @staticmethod async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview WS API.""" - # try: websocket_api.async_register_command(hass, ws_start_preview) async def async_step_regex( @@ -451,25 +530,27 @@ async def async_step_regex( ) -> SubentryFlowResult: """Get the regex.""" errors: dict[str, str] = {} - text: str = self.pdf.get_pages(self.data[CONF_PDF_PAGES]) + text: str = await self.pdf.get_pages( + self.data[CONF_PDF_PAGES], self.data[CONF_OCR] + ) - if user_input and user_input.get(CONF_REGEX_SEARCH): + if user_input and CONF_REGEX_SEARCH in user_input: # Validate that it's a valid regex try: matches: list[str] = re.findall(user_input[CONF_REGEX_SEARCH], text) # Do we get matches? - if len(matches): - # Forward to matches + if len(matches) > 0: + # Forward to sensor self.data[CONF_REGEX_SEARCH] = user_input.get(CONF_REGEX_SEARCH) - return await self.async_step_matches(None) - errors["base"] = "no_matches" + return await self.async_step_sensor(None) + errors[CONF_REGEX_SEARCH] = "no_matches" except re.PatternError as err: _LOGGER.warning("Invalid Regular Expression: %s", err.msg) - errors["base"] = "bad_pattern" + errors[CONF_REGEX_SEARCH] = "bad_pattern" - if user_input and user_input.get("page_text"): + if user_input and user_input.get("page_text") and not errors: # User wants all the txt. - return await self.async_step_matches(None) + return await self.async_step_sensor(None) schema: vol.Schema = vol.Schema( { @@ -497,13 +578,13 @@ async def async_step_regex( preview="target", ) - async def async_step_matches( + async def async_step_sensor( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Get the regex.""" errors: dict[str, str] = {} - text: str = self.pdf.get_pages(self.data[CONF_PDF_PAGES]) + text: str = await self.pdf.get_pages(self.data[CONF_PDF_PAGES]) pattern: str | None = self.data.get(CONF_REGEX_SEARCH) matches: list[str] = re.findall(pattern, text) if pattern else [] @@ -517,7 +598,7 @@ async def async_step_matches( else: value = text preview: PreviewSensorEntity | None = None - errors, preview = _validate_step_matches( + errors, preview = _validate_step_sensor( self.hass, config=user_input, value=value ) if preview: @@ -529,11 +610,20 @@ async def async_step_matches( errors["base"] = errors["base"][:255] + " " if not errors: if self.source != SOURCE_RECONFIGURE: - config_id: str = str(len(self._get_entry().subentries)) + unique_ids: set[int] = { + int(unique_id) + for unique_id in { + subentry.unique_id + for subentry in self._get_entry().subentries.values() + } + if unique_id != "document" + } return self.async_create_entry( title=user_input.get(CONF_NAME), data=self.data | user_input, - unique_id=config_id, + unique_id=str( + max(unique_ids) + 1 if len(unique_ids) > 0 else 0 + ), ) # Was there an issue? iss_reg: ir.IssueRegistry = ir.async_get(self.hass) @@ -543,7 +633,6 @@ async def async_step_matches( f"{error_type}_{self._get_entry().entry_id}_{self._get_reconfigure_subentry().subentry_id}", ): ir.async_delete_issue(self.hass, DOMAIN, issue.issue_id) - return self.async_update_reload_and_abort( self._get_entry(), self._get_reconfigure_subentry(), @@ -614,33 +703,40 @@ async def async_step_matches( schema: vol.Schema = vol.Schema(step_schema) return self.async_show_form( - step_id="matches", + step_id="sensor", data_schema=schema, last_step=True, errors=errors, preview="target", ) + def async_remove(self) -> None: + """Handle removal of this subentry.""" + if self.preview_task is not None and not self.preview_task.done(): + self.preview_task.cancel() + if self._progress_task is not None and not self._progress_task.done(): + self._progress_task.cancel() + if self.pdf is not None: + self.hass.add_job(self.pdf.close) + @websocket_api.websocket_command( { vol.Required("type"): "target/start_preview", vol.Required("flow_id"): str, - vol.Required("flow_type"): vol.Any("config_subentries_flow"), + vol.Required("flow_type"): vol.All("config_subentries_flow"), vol.Required("user_input"): dict, } ) -@callback -def ws_start_preview( +@websocket_api.async_response +async def ws_start_preview( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], ) -> None: """Generate a preview.""" - # entity_registry_entry: er.RegistryEntry | None = None - if msg["flow_type"] != "config_subentries_flow": - raise HomeAssistantError("invalid_flow") - # Get the config flow status + + # Get the config flow status flow_status: SubentryFlowResult = hass.config_entries.subentries.async_get( msg["flow_id"] ) @@ -651,20 +747,19 @@ def ws_start_preview( ) if not config_entry: raise HomeAssistantError - # pdf: PDFScrape = config_entry.runtime_data.pdf errors: dict[str, str] = {} user_input: dict[str, Any] = msg["user_input"] @callback - def async_preview_updated( + def async_preview_callback( state: str | None, attributes: Mapping[str, Any] | None, - listeners: dict[str, bool | set[str]] | None, error: str | None, + domain: str | None, ) -> None: - """Forward config entry state events to websocket.""" + """Forward updates to websocket.""" if error is not None: connection.send_message( websocket_api.event_message(msg["id"], {"error": error}) @@ -673,18 +768,21 @@ def async_preview_updated( connection.send_message( websocket_api.event_message( msg["id"], - {"attributes": attributes, "listeners": listeners, "state": state}, + { + "attributes": attributes, + "domain": domain, + "state": state, + }, ) ) - pages: int = 0 value: str | list[str] | None = None flow: TargetSubentryFlowHandler = cast( TargetSubentryFlowHandler, hass.config_entries.subentries._progress.get(msg["flow_id"]), # noqa: SLF001 ) pdf: PDFScrape = flow.pdf - preview: PreviewSensorEntity | None = None + preview: PDFPreviewEntity | None = None if step in ["user", "reconfigure"]: user_input[CONF_NAME] = "Text" user_input[CONF_ICON] = "mdi:file-pdf-box" @@ -692,13 +790,24 @@ def async_preview_updated( if re.fullmatch(REGEX_PAGE_RANGE_PATTERN, pages) is None: errors[CONF_PDF_PAGES] = "invalid_page_range" else: + if flow.preview_task is not None and not flow.preview_task.done(): + flow.preview_task.cancel() + await flow.preview_task + flow.preview_task = flow.get_config_entry().async_create_task( + hass, + pdf.get_pages(pages, bool(user_input.get(CONF_OCR, False))), + "pdf_preview_pages", + True, + ) try: - value = pdf.get_pages(pages) + await flow.preview_task except IndexError: - errors[CONF_PDF_PAGES] = "invalid_page_range" + errors[CONF_PDF_PAGES] = "pages_out_of_range" + if not errors: + value = flow.preview_task.result() else: pages = flow.data[CONF_PDF_PAGES] - value = pdf.get_pages(pages) + value = await pdf.get_pages(pages) if step == "regex": pattern: str | None = user_input.get(CONF_REGEX_SEARCH) if pattern: @@ -711,32 +820,38 @@ def async_preview_updated( ) if len(matches) > 1: user_input[CONF_NAME] = f"{len(matches)} Matches" - value = ", ".join(matches) + matches = [ + match if len(match) < 30 else match[:25] + " ***" + for match in matches + ] + preview = PreviewSelectEntity( + matches, + user_input, + ) elif len(matches) == 1: value = matches[0] user_input[CONF_NAME] = "1 Match" else: - value = "No matches found." - user_input[CONF_NAME] = "?" + errors[CONF_REGEX_SEARCH] = "no_matches" except re.PatternError as ex: errors[CONF_REGEX_SEARCH] = str(ex.msg) else: user_input[CONF_ICON] = "mdi:file-pdf-box" user_input[CONF_NAME] = "Text" - elif step == "matches": + elif step == "sensor": # Generate preview regex: str | None = flow.data.get(CONF_REGEX_SEARCH) if regex: matches: list[str] = re.findall( regex, - pdf.get_pages(pages), + await pdf.get_pages(pages), ) match_idx: int = int(user_input[CONF_REGEX_MATCH_INDEX]) if match_idx >= 0: value = matches[match_idx] else: value = matches - errors, preview = _validate_step_matches( + errors, preview = _validate_step_sensor( hass, config=user_input, value=value ) else: @@ -753,65 +868,91 @@ def async_preview_updated( ) return - if not preview: - preview = PreviewSensorEntity(hass, config=user_input, value=value) + if preview is None: + preview = PreviewSensorEntity(value, user_input) connection.send_result(msg["id"]) - - connection.subscriptions[msg["id"]] = preview.async_start_preview( - async_preview_updated + connection.subscriptions[msg["id"]] = preview.async_show_preview( + async_preview_callback ) -class PreviewSensorEntity(SensorEntity): +class PDFPreviewEntity(Entity): """Preview entity for frontend.""" - def __init__(self, hass: HomeAssistant, config: dict[str, Any], value: str) -> None: + def __init__(self, config: dict[str, Any], domain: str) -> None: """Initialize a preview entity.""" - self.hass: HomeAssistant = hass self._attr_name = config.get(CONF_NAME, "Preview") - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - self._attr_state_class = config.get(CONF_STATE_CLASS) self._attr_icon = config.get(CONF_ICON, "mdi:eye") - self._attr_native_value = ( - value - if not isinstance(value, str) - else (value if len(value) < 255 else value[:251] + " ***") - ) + self.domain: str = domain + self._preview_callback: ( + Callable[ + [str | None, Mapping[str, Any] | None, str | None, str | None], + None, + ] + | None + ) = None @callback - def async_start_preview( + def async_show_preview( self, preview_callback: Callable[ [ - str | None, - Mapping[str, Any] | None, - dict[str, bool | set[str]] | None, - str | None, + str | None, # state + Mapping[str, Any] | None, # attributes + str | None, # errors + str | None, # domain ], None, ], ) -> CALLBACK_TYPE: - """Render a preview.""" - errors: str | None = None + """Start a preview.""" + error: str | None = None try: calculated_state: CalculatedState = self._async_calculate_state() preview_callback( - calculated_state.state, calculated_state.attributes, None, None + calculated_state.state, + calculated_state.attributes, + None, + self.domain, ) except ValueError as ex: - errors = str(ex) - if len(errors) > 255: - errors = errors[:250] + " ***" - if errors: - preview_callback(None, None, None, errors) - + error = str(ex) + if len(error) > 255: + error = error[:250] + " ***" + if error: + preview_callback(None, None, error, None) return self._call_on_remove_callbacks -def _validate_step_matches( +class PreviewSensorEntity(PDFPreviewEntity, SensorEntity): + """Preview sensor entity for frontend.""" + + def __init__(self, value: str, config: dict[str, Any]) -> None: + """Initialize a preview entity.""" + super().__init__(config, SENSOR_DOMAIN) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_state_class = config.get(CONF_STATE_CLASS) + self._attr_native_value = value if len(value) < 255 else value[:251] + " ***" + + +class PreviewSelectEntity(PDFPreviewEntity, SelectEntity): + """Preview sensor entity for frontend.""" + + def __init__( + self, + options: list[str], + config: dict[str, Any], + ) -> None: + """Initialize a preview entity.""" + super().__init__(config, SELECT_DOMAIN) + self._attr_options = options + self._attr_current_option = options[0] if len(options) > 0 else None + + +def _validate_step_sensor( hass: HomeAssistant, config: dict[str, Any], value: str | list[str] ) -> tuple[dict[str, str], PreviewSensorEntity | None]: """Validate the matches step.""" @@ -841,5 +982,5 @@ def _validate_step_matches( errors[CONF_STATE_CLASS] = str(ex.msg) if errors: return errors, None - preview: PreviewSensorEntity = PreviewSensorEntity(hass, config=config, value=value) + preview: PreviewSensorEntity = PreviewSensorEntity(value=value, config=config) return errors, preview diff --git a/custom_components/pdf_scrape/const.py b/custom_components/pdf_scrape/const.py index 3fd2150..5cf71fe 100644 --- a/custom_components/pdf_scrape/const.py +++ b/custom_components/pdf_scrape/const.py @@ -6,16 +6,17 @@ from typing import Final CONF_DEFAULT_SCAN_INTERVAL: Final[timedelta] = timedelta(minutes=5) -CONF_MIN_SCAN_INTERVAL: Final[timedelta] = timedelta(seconds=30) +CONF_MIN_SCAN_INTERVAL: Final[timedelta] = timedelta(minutes=1) DOMAIN: Final[str] = "pdf_scrape" CONF_PDF_PAGES: Final[str] = "pdf_pages" CONF_REGEX_SEARCH: Final[str] = "regex_search" CONF_REGEX_MATCH_INDEX: Final[str] = "regex_match_index" CONF_VALUE_TEMPLATE: Final[str] = "value_template" -CONF_MD5_CHECKSUM: Final = "md5_checksum" +CONF_SHA256_CHECKSUM: Final = "sha256_checksum" CONF_MODIFIED: Final[str] = "modified" CONF_MODIFIED_SOURCE: Final[str] = "modified_source" CONF_FILE: Final[str] = "file" +CONF_OCR: Final[str] = "ocr" class ErrorTypes(StrEnum): diff --git a/custom_components/pdf_scrape/coordinator.py b/custom_components/pdf_scrape/coordinator.py index 5ca023c..eb9e4f1 100644 --- a/custom_components/pdf_scrape/coordinator.py +++ b/custom_components/pdf_scrape/coordinator.py @@ -2,6 +2,7 @@ from datetime import timedelta import logging +from random import SystemRandom import re from typing import Any @@ -12,7 +13,7 @@ import homeassistant.helpers.issue_registry as ir from homeassistant.helpers.template import Template, TemplateVarsType from homeassistant.helpers.translation import async_get_exception_message -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_DEFAULT_SCAN_INTERVAL, @@ -24,14 +25,7 @@ DOMAIN, ErrorTypes, ) -from .pdf import ( - HTTPError, - PDFParseError, - PDFScrape, - PDFScrapeHTTP, - PDFScrapeLocal, - PDFScrapeUpload, -) +from .pdf import HTTPError, PDFScrape, PDFScrapeFile, PDFScrapeHTTP, PDFScrapeUpload _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -60,19 +54,22 @@ def __init__( ) self.pdf: PDFScrape = pdf self.data = {} + self.http_error_count: int = 0 + self.access_token: str = hex(SystemRandom().getrandbits(256))[2:] async def _async_update_data(self) -> dict[str, str]: """Perform the update.""" try: if not self.data or await self.pdf.update(): - for subentry_conf_key in self.config_entry.subentries: - subentry_conf: ConfigSubentry = self.config_entry.subentries[ - subentry_conf_key - ] + for subentry_key, subentry_conf in self.config_entry.subentries.items(): + if subentry_conf.subentry_type == "document": + continue txt: str = "" try: - txt = self.pdf.get_pages(subentry_conf.data[CONF_PDF_PAGES]) + txt = await self.pdf.get_pages( + subentry_conf.data[CONF_PDF_PAGES] + ) except IndexError as ex: async_raise_error( hass=self.hass, @@ -133,14 +130,19 @@ async def _async_update_data(self) -> dict[str, str]: exception=ex, config_subentry=subentry_conf, ) - self.data[subentry_conf_key] = txt - except (HTTPError, PDFParseError) as ex: + self.data[subentry_key] = txt + except HTTPError as ex: + if isinstance(ex, HTTPError) and self.http_error_count < 3: + self.http_error_count += 1 + raise UpdateFailed(retry_after=30) from ex async_raise_error( hass=self.hass, error_key=ErrorTypes.PDF_ERROR, config_entry=self.config_entry, exception=ex, + error_type=UpdateFailed, ) + self.http_error_count = 0 return self.data @@ -183,14 +185,14 @@ async def async_upload_pdf(self, pdf: PDFScrapeUpload) -> None: await self._async_update_data() -class PDFScrapeLocalCoordinator(PDFScrapeCoordinator): +class PDFScrapeFileCoordinator(PDFScrapeCoordinator): """Data coordinator to download and parse the files.""" def __init__( self, hass: HomeAssistant, config_entry: PDFScrapeConfigEntry, - pdf: PDFScrapeLocal, + pdf: PDFScrapeFile, ) -> None: """Initialize coordinator.""" super().__init__(hass, config_entry, pdf, CONF_MIN_SCAN_INTERVAL) @@ -200,9 +202,10 @@ def async_raise_error( hass: HomeAssistant, error_key: str, config_entry: PDFScrapeConfigEntry, - exception: Exception, + exception: Exception | None = None, translation_placeholders: dict[str, Any] | None = None, config_subentry: ConfigSubentry | None = None, + error_type: ConfigEntryError | UpdateFailed = ConfigEntryError, ) -> None: """Log issues, create repairs, and raise exceptions.""" @@ -211,11 +214,9 @@ def async_raise_error( translation_placeholders["conf"] = ( config_entry.title if config_subentry is None else config_subentry.title ) - msg = ( - str(exception) - if not isinstance(exception, PDFParseError) - else "Unable to parse pdfS" - ) + msg: str = "" + if exception is not None: + msg = str(exception) translation_placeholders["msg"] = msg data: dict[str, Any] = { "entry_id": config_entry.entry_id, @@ -235,12 +236,12 @@ def async_raise_error( translation_placeholders=translation_placeholders, ) if exception is not None: - raise ConfigEntryError( + raise error_type( translation_domain=DOMAIN, translation_key=error_key, translation_placeholders=translation_placeholders, ) from exception - raise ConfigEntryError( + raise error_type( translation_domain=DOMAIN, translation_key=error_key, translation_placeholders=translation_placeholders, diff --git a/custom_components/pdf_scrape/http.py b/custom_components/pdf_scrape/http.py new file mode 100644 index 0000000..b8c9252 --- /dev/null +++ b/custom_components/pdf_scrape/http.py @@ -0,0 +1,78 @@ +"""View to load local pdfs.""" + +import os + +from aiohttp import hdrs, web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant +from homeassistant.helpers.http import KEY_AUTHENTICATED +from homeassistant.helpers.storage import STORAGE_DIR + +from .const import DOMAIN, ConfType +from .coordinator import PDFScrapeConfigEntry + + +async def async_setup(hass: HomeAssistant) -> None: + """Set up the PDF view.""" + hass.http.register_view(PDFView(hass)) + + +class PDFView(HomeAssistantView): + """PDF View.""" + + name = "api:pdfscrape:pdf" + url = "/api/pdf_scrape/pdf/{entry_id}.pdf" + requires_auth = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize a PDFView.""" + self.hass = hass + + async def head(self, request: web.Request, entry_id: str) -> web.Response: + """Head just for length and last-modified.""" + entry: PDFScrapeConfigEntry = self._get_entry_and_authenticatey( + request, entry_id + ) + path: str = self.hass.config.path(STORAGE_DIR, DOMAIN, f"{entry_id}.pdf") + try: + size: int = await self.hass.async_add_executor_job(os.path.getsize, path) + except FileNotFoundError as exc: + raise web.HTTPFound(reason="PDF is missing from file system") from exc + return web.Response( + content_type="application/pdf", + content_length=size, + last_modified=entry.runtime_data.pdf.pdf.modified, + ) + + async def get(self, request: web.Request, entry_id: str) -> web.FileResponse: + """Serve the pdf.""" + entry: PDFScrapeConfigEntry = self._get_entry_and_authenticate( + request, entry_id + ) + response: web.FileResponse = web.FileResponse( + self.hass.config.path(STORAGE_DIR, DOMAIN, f"{entry_id}.pdf") + ) + response.last_modified = entry.runtime_data.pdf.pdf.modified + return response + + def _get_entry_and_authenticate( + self, request: web.Request, entry_id: str + ) -> PDFScrapeConfigEntry: + if entry := self.hass.config_entries.async_get_entry(entry_id): + if entry.data["type"] == ConfType.HTTP: + raise web.HTTPBadRequest(reason="Can't request for HTTP/HTTPS pdfs") + authenticated = ( + request[KEY_AUTHENTICATED] + or request.query.get("token") == entry.runtime_data.access_token + ) + if not authenticated: + # Attempt with invalid bearer token, raise unauthorized + # so ban middleware can handle it. + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized + # Invalid sigAuth or image entity access token + raise web.HTTPForbidden + + return entry + raise web.HTTPNotFound(reason="PDF not found") diff --git a/custom_components/pdf_scrape/image.py b/custom_components/pdf_scrape/image.py new file mode 100644 index 0000000..877c64d --- /dev/null +++ b/custom_components/pdf_scrape/image.py @@ -0,0 +1,82 @@ +"""Image entityfor PDFScape.""" + +from datetime import datetime +import logging +from pathlib import Path + +from homeassistant.components.image import ImageEntity +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +import homeassistant.helpers.device_registry as dr +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.storage import STORAGE_DIR +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PDFScrapeConfigEntry, PDFScrapeCoordinator + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PDFScrapeConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PDFScrape Entity from a subconfig entry.""" + + dev_reg = dr.async_get(hass) + + device_entry = dev_reg.async_get_device_by_identifier( + (DOMAIN, config_entry.entry_id), config_entry.entry_id + ) + async_add_entities( + [PDFImageEntity(config_entry.runtime_data, device_entry)], + ) + + +class PDFImageEntity(ImageEntity, CoordinatorEntity[PDFScrapeCoordinator]): + """Image entity for PDFScrape.""" + + def __init__( + self, coordinator: PDFScrapeCoordinator, device_entry: dr.DeviceEntry + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator.hass) + super(ImageEntity, self).__init__(coordinator) + self.hass = coordinator.hass + self.device_entry = device_entry + self.unique_id = f"{DOMAIN}_thumbnail_{self.coordinator.config_entry.entry_id}" + self._attr_name = "Thumbnail" + self.has_entity_name = True + self._attr_entity_category = EntityCategory.DIAGNOSTIC + + @property + def image_last_updated(self) -> datetime | None: + """Return the last updated time of the pdf.""" + return self.coordinator.pdf.pdf.modified + + def image(self) -> bytes | None: + """Return the image.""" + try: + with Path.open( + self.coordinator.hass.config.path( + STORAGE_DIR, + DOMAIN, + f"{self.coordinator.config_entry.entry_id}.webp", + ), + "rb", + encoding="base64", + ) as file: + return file.read() + except FileNotFoundError: + _LOGGER.error( + "Image file not found for entry %s", + self.coordinator.config_entry.entry_id, + ) + return None + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_write_ha_state() diff --git a/custom_components/pdf_scrape/manifest.json b/custom_components/pdf_scrape/manifest.json index 4399ec5..9de88ef 100644 --- a/custom_components/pdf_scrape/manifest.json +++ b/custom_components/pdf_scrape/manifest.json @@ -9,6 +9,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "issue_tracker": "https://github.com/iluvdata/pdf_scrpae/issues", - "requirements": ["pypdf~=5.2.0"], - "version": "2026.6.0" + "requirements": ["pymupdf==1.28.2","pymupdf4llm==1.28.2","rapidocr==3.9.2","onnxruntime==1.29.0"], + "version": "2026.9.0" } diff --git a/custom_components/pdf_scrape/pdf.py b/custom_components/pdf_scrape/pdf.py index cba9e78..018f219 100644 --- a/custom_components/pdf_scrape/pdf.py +++ b/custom_components/pdf_scrape/pdf.py @@ -1,31 +1,33 @@ -"""PDFScrape API.""" +"""PDF Scrape File Handling.""" from abc import ABC, abstractmethod +from asyncio import Task, TaskGroup import datetime from enum import StrEnum from functools import partial -from hashlib import md5 -from io import BufferedReader, BytesIO +from hashlib import file_digest +from io import BytesIO +import logging from pathlib import Path -from typing import IO, Any, Final, cast - -from aiohttp import ( - ClientConnectorError, - ClientResponse, - ClientResponseError, - ClientSession, -) -from pypdf import DocumentInformation, PdfReader -from pypdf.errors import PyPdfError - -from homeassistant.components.hassio import async_get_clientsession +import re +from typing import Final + +from httpx import HTTPStatusError, RequestError, Response +from PIL import Image +from pydantic import BaseModel, Field +from pymupdf import Document, Pixmap, TextPage +from pymupdf4llm import to_text + from homeassistant.core import HomeAssistant -from homeassistant.helpers.storage import Store +from homeassistant.helpers.httpx_client import get_async_client +from homeassistant.helpers.storage import STORAGE_DIR, Store from .const import DOMAIN STORE_VERSION: Final[int] = 1 +_LOGGER = logging.getLogger(__name__) + class ModifiedDateSource(StrEnum): """Enum for how the date gets updated.""" @@ -38,14 +40,97 @@ class ModifiedDateSource(StrEnum): UPLOAD = "upload" -type StoredFile = dict[ - str, list[str] | datetime.datetime | ModifiedDateSource | str | None -] +class PDF(BaseModel): + """Class for the stored file in storage.""" + + modified: datetime.datetime | None = None + title: str | None = None + sha256_checksum: str | None = None + modified_source: ModifiedDateSource | None = None + page_count: int = 0 + pages: dict[int, PDFPage] = {} + http_headers: HTTPHeaders | None = None + loaded_from_store: bool = Field(exclude=True, default=False) + + +class HTTPHeaders(BaseModel): + """Class for HTTP headers (to avoid constantly fetching the entire pdf).""" + last_modified: datetime.datetime + content_length: int -def get_store(hass: HomeAssistant, key: str) -> Store[StoredFile]: + +class PDFPage(BaseModel): + """Class for a PDF Page.""" + + ocr: bool + text: str + + +def get_store(hass: HomeAssistant, key: str) -> Store[PDF]: """Get a store.""" - return Store[StoredFile](hass, STORE_VERSION, f"{DOMAIN}_{key}") + return Store[PDF](hass, STORE_VERSION, f"{DOMAIN}_{key}") + + +class Progress: + """Class to track progress of loading and processing the pdf.""" + + def __init__(self) -> None: + """Initialize progress.""" + self._progress_tasks: list[(float, float)] = [] + self._cur_task_index: int = 0 + + def define_tasks(self, tasks: list[float]) -> None: + """Add a progress task.""" + if 0 in tasks: + raise ValueError("task estimates must be > 0") + if not sum(tasks) == 1: + raise ValueError("task estimate must sum to 1") + if len(self._progress_tasks) == 0: + self._progress_tasks = [(task, 0) for task in tasks] + return + # Normalize the task estimates so they add up to 1 + normalized_tasks: list[float] = [ + task * self._progress_tasks[self._cur_task_index][0] for task in tasks + ] + old_tasks = self._progress_tasks.copy() + for i, pt in enumerate( + old_tasks[self._cur_task_index :], start=self._cur_task_index + ): + if i == self._cur_task_index: + for j, task in enumerate(normalized_tasks): + if i + j < len(self._progress_tasks): + self._progress_tasks[i + j] = (task, 0) + else: + self._progress_tasks.append((task, 0)) + elif i + len(tasks) < len(self._progress_tasks): + self._progress_tasks[i + len(tasks)] = pt + else: + self._progress_tasks.append(pt) + + def clear_tasks(self) -> None: + """Clear the task list.""" + self._progress_tasks = [] + self._cur_task_index = 0 + + @property + def progress(self) -> float: + """Get the current progress.""" + return sum([cur for _, cur in self._progress_tasks]) + + def advance_steps(self, tasks: int = 1) -> float: + """Notify progress listeners.""" + for i in range(self._cur_task_index, self._cur_task_index + tasks): + self._progress_tasks[i] = ( + self._progress_tasks[i][0], + self._progress_tasks[i][0], + ) + self._cur_task_index += tasks + update: float = self.progress + if update == 1: + self.clear_tasks() + _LOGGER.debug("Progress updated: %s", update) + return update class PDFScrape(ABC): @@ -54,104 +139,229 @@ class PDFScrape(ABC): def __init__(self, hass: HomeAssistant, config_entry_id: str | None) -> None: """Called by classmethod with is called by the subclass.""" self.hass: HomeAssistant = hass - self.pages: list[str] = [] - self.modified: datetime.datetime | None = None - self.modified_source: ModifiedDateSource | None = None - self.metadata_name: str | None = None - self.md5_checksum: str - self.stored_file: StoredFile | None = None + self._document: Document + self.pdf: PDF = PDF() self.config_entry_id: str | None = config_entry_id - # if config_entry_id is None that means this a config flow and so just sents the file - self.store: Store[StoredFile] | None = ( + self._stream: BytesIO + self._progress = Progress() + + # if config_entry_id is None that means this a config flow and so just sends the file + self.store: Store[PDF] | None = ( get_store(hass, self.config_entry_id) if self.config_entry_id is not None else None ) + @property + def progress(self) -> float: + """Get the progress in progress.""" + return self._progress.progress + async def _pdf_scrape(self): if self.store is not None: - self.stored_file = await self.store.async_load() - if not self.stored_file: - self.stored_file = {} + if stored_file := await self.store.async_load(): + self.pdf = PDF(**stored_file, loaded_from_store=True) await self.update() async def _process_pdf( self, - stream: IO[Any], alt_timestamp: tuple[datetime.datetime, ModifiedDateSource] | None = None, - upload: bool = False, ) -> bool: - """(Re)load a pdf from a url.""" - # return true is updated. - try: - pdfr: PdfReader = PdfReader(stream) - metadata: DocumentInformation | None = pdfr.metadata - if metadata: - self.modified = metadata.modification_date - self.modified_source = ModifiedDateSource.PDF_METADATA - if upload: - self.metadata_name = metadata.title - if self.modified is not None: - self.modified = self.modified.replace(tzinfo=datetime.UTC) - if self.modified is None and alt_timestamp is not None: - self.modified, self.modified_source = alt_timestamp - hash_md5 = md5() - for chunk in iter(lambda: stream.read(4096), b""): - hash_md5.update(chunk) - self.md5_checksum = hash_md5.hexdigest() - # Check if there are changes, otherwise we should stop to save comp time - if ( - self.stored_file - and self.modified - == datetime.datetime.fromisoformat(self.stored_file.get("modified")) - and self.md5_checksum == self.stored_file.get("md5_checksum") - ): - pdfr.close() - if isinstance(stream, BytesIO): - stream.close() - return False - self.pages = [page.extract_text() for page in pdfr.pages] - pdfr.close() - if isinstance(stream, BytesIO): - stream.close() - except PyPdfError as err: - raise PDFParseError from err - if self.store is not None: - if self.stored_file is None: - self.stored_file = {} - self.stored_file["modified"] = self.modified.isoformat() - self.stored_file["md5_checksum"] = self.md5_checksum - self.stored_file["modified_source"] = self.modified_source - self.stored_file["pages"] = self.pages - await self.store.async_save(self.stored_file) - return True - - async def _load_from_storage(self) -> None: - if self.store is not None: - self.stored_file = await self.store.async_load() - if self.stored_file is not None: - self.pages = cast(list[str], self.stored_file.get("pages")) - self.modified = datetime.datetime.fromisoformat( - self.stored_file.get("modified") + """(Re)load a pdf from a url. + + returns true if the pdf was updated (either modified date or checksum), false if not. + """ + _LOGGER.debug("Start processing PDF") + tasks: list[float] = [0.05, 0.05, 0.65, 0.25] + self._progress.define_tasks(tasks) + self._document = await self.hass.async_add_executor_job( + partial(Document, stream=self._stream) + ) + self._progress.advance_steps() + modified: datetime.datetime | None = None + title: str | None = None + if self._document.metadata: + if "title" in self._document.metadata: + title = self._document.metadata["title"] + if "modDate" in self._document.metadata: + matches: re.Match[str] | None = re.search( + r"(\d{14}-\d{2})(?:')(\d{2})(?:')", + self._document.metadata["modDate"], ) - self.modified_source = cast( - ModifiedDateSource, self.stored_file.get("modified_source") + if matches: + modified = datetime.datetime.strptime( + f"{matches.group(1)}{matches.group(2)}", "%Y%m%d%H%M%S%z" + ) + self.pdf.modified_source = ModifiedDateSource.PDF_METADATA + modified.replace(tzinfo=datetime.UTC) + if modified is None and alt_timestamp is not None: + modified, self.pdf.modified_source = alt_timestamp + self._stream.seek(0) # reset pointer + digest_sha256 = await self.hass.async_add_executor_job( + file_digest, self._stream, "sha256" + ) + sha256_checksum: str = digest_sha256.hexdigest() + self._progress.advance_steps() + # Check if there are changes, otherwise we should stop to save comp time + if ( + self.pdf.loaded_from_store + and modified == self.pdf.modified + and sha256_checksum == self.pdf.sha256_checksum + ): + await self.close() + self._progress.advance_steps(2) + _LOGGER.debug("PDF not modified since last load, skipping processing") + return False + self.pdf.modified = modified + self.pdf.sha256_checksum = sha256_checksum + self.pdf.title = title + self.pdf.page_count = self._document.page_count + if self.store is not None: + if len(self.pdf.pages) > 0: + # already loaded pages, do we need to re-ocr them? + await self._get_pages(set(self.pdf.pdf.pages.keys()), update=True) + await self.save_to_store() + # Generate a thumbnail + pixmap: Pixmap = await self.hass.async_add_executor_job( + self._document[0].get_pixmap + ) + # Resize to 512x512 maintining aspect ratio + pil_image: Image.Image = pixmap.pil_image() + await self.hass.async_add_executor_job( + pil_image.thumbnail, (512, 512), Image.Resampling.BICUBIC + ) + pdf_storage_path: Path = Path( + self.hass.config.path( + STORAGE_DIR, + DOMAIN, ) - self.md5_checksum = cast(str, self.stored_file["md5_checksum"]) - return - raise StoredFileError + ) + if not pdf_storage_path.exists(): + await self.hass.async_add_executor_job(pdf_storage_path.mkdir) + await self.hass.async_add_executor_job( + pil_image.save, + f"{pdf_storage_path}/{self.config_entry_id}.webp", + "WEBP", + ) + self._progress.advance_steps() + # save the actual pdf file (if neeeded) + if isinstance(self, (PDFScrapeHTTP, PDFScrapeUpload)): + self._stream.seek(0) + path: Path = Path(f"{pdf_storage_path}/{self.config_entry_id}.pdf") + with await self.hass.async_add_executor_job( + partial( + path.open, + mode="wb", + ) + ) as f: + memview = self._stream.getbuffer() + await self.hass.async_add_executor_job(f.write, memview) + memview.release() + await self.close() + _LOGGER.debug("PDF Finished Processing") + return True @abstractmethod async def update(self) -> bool: """Must be implemented by sub_classes.""" - def close(self) -> None: - """Close to free up memory occupied by the pdf txt.""" - self.pages = [] + async def close(self) -> None: + """Close to free up memory occupied by the pdf and file lock.""" + if hasattr(self, "_document") and not self._document.is_closed: + await self.hass.async_add_executor_job(self._document.close) + if hasattr(self, "_stream") and not self._stream.closed: + self._stream.close() - def get_pages(self, page_range: str) -> str: - """Parse page range string into list of page numbers.""" + async def save_to_store(self) -> None: + """Save the PDF to the store.""" + if self.store is not None: + await self.store.async_save(self.pdf.model_dump()) + async def _get_pages( + self, + page_nums: set[int], + ocr: bool = False, + update: bool = False, + ) -> int: + """Get txt on a pages.""" + progress_value: float = 1 / len(page_nums) + self._progress.define_tasks(tasks=[progress_value] * len(page_nums)) + tasks: dict[int, Task] = {} + async with TaskGroup() as tg: + for page in page_nums: + page_index = page - 1 + if page_index not in self.pdf.pages or ( + ocr != self.pdf.pages[page_index].ocr + ): + tasks[page_index] = tg.create_task( + self._get_page_text(page_index, ocr) + ) + elif update: + tasks[page_index] = tg.create_task( + self._get_page_text(page_index, self.pdf.pages[page_index].ocr) + ) + for page_index, task in tasks.items(): + if task.exception(): + _LOGGER.exception( + "Error processing page %s", + page_index + 1, + exc_info=task.exception(), + ) + self.pdf.pages[page_index] = PDFPage( + ocr=ocr or (update and self.pdf.pages[page_index].ocr), + text=task.result(), + ) + + async def _load_stream_from_file(self, file: Path) -> None: + """Load the file into a stream.""" + with await self.hass.async_add_executor_job(partial(file.open, mode="rb")) as f: + self._stream = BytesIO(await self.hass.async_add_executor_job(f.read)) + + async def _load_document_from_file_or_cache(self) -> None: + """Load the document from file or cache.""" + if ( + hasattr(self, "_document") + and self._document is not None + and not self._document.is_closed + ): + return + if hasattr(self, "file"): + await self._load_stream_from_file(self.file) + else: + await self._load_stream_from_file( + Path( + self.hass.config.path( + STORAGE_DIR, DOMAIN, f"{self.config_entry_id}.pdf" + ) + ) + ) + self._document = await self.hass.async_add_executor_job( + partial(Document, stream=self._stream) + ) + + async def _get_page_text(self, page_index: int, ocr: bool = False) -> str: + """Get text from a specific page.""" + await self._load_document_from_file_or_cache() + if not ocr: + + def wrap_extract_text() -> str: + text_page: TextPage = self._document[page_index].get_textpage() + return text_page.extractText() + + return await self.hass.async_add_executor_job(wrap_extract_text) + return await self.hass.async_add_executor_job( + partial( + to_text, + self._document, + use_ocr=True, + header=True, + footer=True, + pages=[page_index], + ) + ) + + async def get_pages(self, page_range: str, ocr: bool = False) -> str: + """Parse page range string into list of page numbers.""" page_nums: set[int] = set() for part in page_range.split(","): if "-" in part: @@ -160,19 +370,23 @@ def get_pages(self, page_range: str) -> str: page_nums.update(range(start, end + 1)) else: page_nums.add(int(part)) - if max(page_nums) > len(self.pages) or min(page_nums) < 1: + if max(page_nums) > self.pdf.page_count or min(page_nums) < 1: raise IndexError("Page number out of range") - return "\n".join(self.pages[page - 1] for page in sorted(page_nums)) + await self._get_pages(page_nums, ocr) + return "\n".join( + self.pdf.pages[page - 1].text or "" for page in sorted(page_nums) + ) class PDFScrapeHTTP(PDFScrape): """Parse pdf from http/https source.""" - def __init__(self, hass: HomeAssistant, config_entry_id: str | None) -> None: - """Call only from classmethod.""" + def __init__( + self, hass: HomeAssistant, url: str, config_entry_id: str | None = None + ) -> None: + """Call from class method unless need to monitor progress on first load.""" super().__init__(hass, config_entry_id) - self.url: str - self.session: ClientSession + self.url: str = url @classmethod async def pdfscrape( @@ -183,9 +397,7 @@ async def pdfscrape( config_entry_id: str | None = None, ): """Instantiate a pdfscrape class.""" - self = cls(hass, config_entry_id) - self.url = url - self.session = async_get_clientsession(self.hass) + self = cls(hass, url, config_entry_id) await self._pdf_scrape() return self @@ -196,129 +408,169 @@ def __repr__(self) -> str: async def update(self) -> bool: """(Re)load a pdf from a URL.""" try: - resp: ClientResponse = await self.session.get(self.url) - stream: BytesIO = BytesIO(await resp.read()) - alt_modified: datetime - alt_modified_source: ModifiedDateSource - if resp.headers.get("late-modified"): - alt_modified = datetime.strptime( - resp.headers["last-modified"], "%a, %d %b %Y %H:%M:%S %Z" - ) - alt_modified_source = ModifiedDateSource.HTTP_HEADER - else: - alt_modified = datetime.datetime.now(datetime.UTC) - alt_modified_source = ModifiedDateSource.FIRST_CHECK - if await self._process_pdf( - stream, - (alt_modified, alt_modified_source), + if self.pdf.http_headers is not None: + async with get_async_client(self.hass) as client: + r: Response = await client.head(self.url) + if "last-modified" in r.headers and "content-length" in r.headers: + new_headers = HTTPHeaders( + last_modified=convert_header_date( + r.headers["last-modified"] + ), + content_length=int(r.headers["content-length"]), + ) + + if new_headers == self.pdf.http_headers: + _LOGGER.debug( + "HTTP headers indicate PDF has not changed, skipping download" + ) + return False + self._progress.clear_tasks() + self._progress.define_tasks([0.2, 0.8]) + async with ( + get_async_client(self.hass) as client, + client.stream("GET", self.url) as r, ): - return True - await self._load_from_storage() - - except (ClientResponseError, ClientConnectorError) as err: + r.raise_for_status() + self._stream = BytesIO() + async for chunk in r.aiter_bytes(): + self._stream.write(chunk) + self.pdf.http_headers = HTTPHeaders( + last_modified=convert_header_date(r.headers["last-modified"]), + content_length=int(r.headers.get("content-length")), + ) + alt_modified: datetime + alt_modified_source: ModifiedDateSource + if self.pdf.http_headers.last_modified is not None: + alt_modified = self.pdf.http_headers.last_modified + alt_modified_source = ModifiedDateSource.HTTP_HEADER + else: + alt_modified = datetime.datetime.now(datetime.UTC) + alt_modified_source = ModifiedDateSource.FIRST_CHECK + self._progress.advance_steps() + return await self._process_pdf((alt_modified, alt_modified_source)) + except (RequestError, HTTPStatusError) as err: raise HTTPError(str(err)) from err - return False + +def convert_header_date(date_str: str) -> datetime.datetime: + """Convert HTTP header date to datetime.""" + return datetime.datetime.strptime(date_str, "%a, %d %b %Y %H:%M:%S %Z").replace( + tzinfo=datetime.UTC + ) class PDFScrapeFile(PDFScrape): """Parse pdf from file.""" def __init__( - self, hass: HomeAssistant, config_entry_id: str | None, path: Path | str | None + self, + hass: HomeAssistant, + config_entry_id: str | None, + file: Path | str, ) -> None: """Call only from classmethod.""" super().__init__(hass, config_entry_id) - self.path: Path | None = ( - path if isinstance(path, Path) or path is None else Path(path) - ) + self.file: Path = file if isinstance(file, Path) else Path(file) @classmethod async def pdfscrape( cls, hass: HomeAssistant, - path: Path | str | None, + file: Path | str, *, config_entry_id: str | None = None, ): """Initialize a PDFScrapeFile class.""" - self = cls(hass, config_entry_id, path) + self = cls(hass, config_entry_id, file) await self._pdf_scrape() return self - async def _update(self, upload: bool = False) -> bool: + async def update(self) -> bool: """Check for an update.""" - if self.path is not None: + if self.file is not None: try: - stream: BufferedReader = await self.hass.async_add_executor_job( - partial(self.path.open, mode="rb") + modified: datetime = datetime.datetime.fromtimestamp( + (await self.hass.async_add_job_executor(self.file.stat)).st_mtime, + datetime.UTC, ) - modified: datetime = ( - datetime.datetime.now(datetime.UTC) - if upload - else datetime.datetime.fromtimestamp( - self.path.stat().st_mtime, datetime.UTC + with await self.hass.async_add_executor_job( + partial(self.file.open, mode="rb") + ) as f: + self._stream = BytesIO( + await self.hass.async_add_executor_job(f.read) ) - ) if await self._process_pdf( - stream, ( modified, - ModifiedDateSource.UPLOAD - if upload - else ModifiedDateSource.FILE_MTIME, - ), - upload=upload, + ModifiedDateSource.FILE_MTIME, + ) ): return True except OSError as err: raise FileError(str(err)) from err - # We have an upload without an update, pull from file. - await self._load_from_storage() return False + def __repr__(self): + """Representation.""" + return f"PDF({self.file})" if self.file is not None else "PDF(Local File)" + -class PDFScrapeUpload(PDFScrapeFile): +class PDFScrapeUpload(PDFScrape): """Upload PDF Scape.""" + def __init__( + self, hass: HomeAssistant, config_entry_id: str, file: Path | str | None = None + ) -> None: + """Initialize for cached files only.""" + super().__init__(hass, config_entry_id) + if file: + if isinstance(file, str): + file = Path(file) + with file.open(mode="rb") as pdf_file: + self._stream = BytesIO() + self._stream.write(pdf_file.read()) + @classmethod - async def pdfscrape( + async def async_from_file( cls, hass: HomeAssistant, - *, - path: Path | None = None, - config_entry_id: str | None = None, + file: Path | str, + config_entry_id: str, + ): + """Initialize a PDFScrapeUpload class but do not process.""" + self = cls(hass, config_entry_id) + if isinstance(file, str): + file = Path(file) + with await self.hass.async_add_executor_job( + partial(file.open, mode="rb") + ) as pdf_file: + self._stream = BytesIO() + self._stream.write(await self.hass.async_add_executor_job(pdf_file.read)) + return self + + @classmethod + async def pdfscrape( + cls, hass: HomeAssistant, config_entry_id: str, file: Path | str | None = None ): """Initialize a PDFScrapeUpload class.""" - if path is None and config_entry_id is None: - raise ValueError("Either path or config_entry_id must be specified") - self = cls(hass, config_entry_id, path) + if file is None: + self = cls(hass, config_entry_id) + else: + self = await cls.from_file(hass, file, config_entry_id) await self._pdf_scrape() return self - def __repr__(self): - """Representation.""" - return "PDF(Uploaded)" - async def update(self) -> bool: - """(Re)load a pdf from an upload.""" - return await self._update(upload=True) - - -class PDFScrapeLocal(PDFScrapeFile): - """Upload PDF Scape.""" + """Check for an update.""" + if hasattr(self, "_stream"): + return await self._process_pdf( + (datetime.datetime.now(datetime.UTC), ModifiedDateSource.UPLOAD) + ) + return False def __repr__(self): """Representation.""" - return f"PDF({self.path})" - - async def update(self) -> bool: - """(Re)load a pdf from an upload.""" - return await self._update() - - -class PDFParseError(PyPdfError): - """Unable to parse pdf.""" + return f"PDF Uploaded - {self.pdf.title}" if self.pdf.title else "PDF Uploaded" class StoredFileError(Exception): diff --git a/custom_components/pdf_scrape/quality_scale.yaml b/custom_components/pdf_scrape/quality_scale.yaml new file mode 100644 index 0000000..4778c97 --- /dev/null +++ b/custom_components/pdf_scrape/quality_scale.yaml @@ -0,0 +1,64 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: todo + config-flow: done + dependency-transparency: todo + docs-actions: todo + docs-high-level-description: todo + docs-installation-instructions: todo + docs-removal-instructions: todo + entity-event-setup: todo + entity-unique-id: todo + has-entity-name: todo + runtime-data: todo + test-before-configure: todo + test-before-setup: todo + unique-config-entry: + status: done + comment: | + Using the api-key which maps 1:1 to an account and the devices/entities will all generate consistent unique IDs from the + device_id provided. Additionally the integration only allows for single entry (single_config_entry: true). + + # Silver + action-exceptions: todo + config-entry-unloading: todo + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: todo + integration-owner: todo + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: todo + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: todo \ No newline at end of file diff --git a/custom_components/pdf_scrape/repairs.py b/custom_components/pdf_scrape/repairs.py index 2cd2852..4a20022 100644 --- a/custom_components/pdf_scrape/repairs.py +++ b/custom_components/pdf_scrape/repairs.py @@ -5,13 +5,13 @@ import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.components.repairs import RepairsFlow +from homeassistant.components.repairs import FlowType, RepairsFlow, RepairsFlowResult from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlowContext, ConfigFlowResult, - FlowType, - # SubentryFlowContext, + SubentryFlowContext, + SubentryFlowResult, ) from homeassistant.core import HomeAssistant @@ -23,13 +23,13 @@ class PDFScrapeRepairFlow(RepairsFlow): async def async_step_init( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Start reconfigure flow.""" return await self.async_step_confirm() async def async_step_confirm( self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: + ) -> RepairsFlowResult: """Start reconfigure flow.""" if user_input is not None: return await self._async_get_next_flow() @@ -40,40 +40,43 @@ async def async_step_confirm( description_placeholders={"msg": self.data["msg"]}, ) - async def _async_get_next_flow(self) -> ConfigFlowResult: + async def _async_get_next_flow(self) -> data_entry_flow.FlowResult: raise NotImplementedError("Must be implemented by subclasses.") class PDFRepairFlow(PDFScrapeRepairFlow): """Repair for PDF errors.""" - async def _async_get_next_flow(self) -> ConfigFlowResult: + async def _async_get_next_flow(self) -> RepairsFlowResult: next_flow: ConfigFlowResult = await self.hass.config_entries.flow.async_init( DOMAIN, context=ConfigFlowContext( entry_id=self.data["entry_id"], source=SOURCE_RECONFIGURE ), ) - result: ConfigFlowResult = self.async_abort(reason="next_flow") - result["next_flow"] = (FlowType.CONFIG_FLOW, next_flow["flow_id"]) - return result + return self.async_abort( + next_flow=(FlowType.CONFIG_FLOW, next_flow["flow_id"]), + reason="pdf_error_reconfigure", + ) class TargetRepairFlow(PDFScrapeRepairFlow): """Repair for Target Errors.""" - async def _async_get_next_flow(self) -> ConfigFlowResult: - return self.async_abort(reason="manual_fix") - - # async def _async_get_next_flow(self) -> ConfigFlowResult: - # return await self.hass.config_entries.subentries.async_init( - # (self.data["entry_id"], "target"), - # context=SubentryFlowContext( - # entry_id=self.data["entry_id"], - # subentry_id=self.data["subentry_id"], - # source=SOURCE_RECONFIGURE, - # ), - # ) + async def _async_get_next_flow(self) -> RepairsFlowResult: + next_flow: SubentryFlowResult = ( + await self.hass.config_entries.subentries.async_init( + (self.data["entry_id"], "target"), + context=SubentryFlowContext( + subentry_id=self.data["subentry_id"], + source=SOURCE_RECONFIGURE, + ), + ) + ) + return self.async_abort( + next_flow=(FlowType.CONFIG_SUBENTRIES_FLOW, next_flow["flow_id"]), + reason="target_error_reconfigure", + ) async def async_create_fix_flow( diff --git a/custom_components/pdf_scrape/sensor.py b/custom_components/pdf_scrape/sensor.py index 8ff52c3..d092efc 100644 --- a/custom_components/pdf_scrape/sensor.py +++ b/custom_components/pdf_scrape/sensor.py @@ -17,14 +17,19 @@ EntityCategory, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryError, HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.exceptions import ConfigEntryError +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import PDFScrapeConfigEntry -from .const import CONF_FILE, CONF_MD5_CHECKSUM, CONF_MODIFIED_SOURCE, DOMAIN, ConfType +from .const import ( + CONF_FILE, + CONF_MODIFIED_SOURCE, + CONF_SHA256_CHECKSUM, + DOMAIN, + ConfType, +) from .coordinator import PDFScrapeCoordinator @@ -38,25 +43,11 @@ async def async_setup_entry( async_add_entities([PDFDocumentSensor(coordinator)]) - for subentry_config_key in coordinator.data: - async_add_entities([PDFScrapeSensor(coordinator, subentry_config_key)]) - - -def _async_get_device_info(config_entry: PDFScrapeConfigEntry) -> DeviceInfo: - device_info: DeviceInfo = DeviceInfo( - identifiers={(DOMAIN, config_entry.entry_id)}, - name=config_entry.title, - entry_type=DeviceEntryType.SERVICE, - ) - match config_entry.data[CONF_TYPE]: - case ConfType.LOCAL: - device_info["model"] = config_entry.data[CONF_FILE] - case ConfType.HTTP: - device_info["configuration_url"] = config_entry.data[CONF_URL] - device_info["model"] = config_entry.data[CONF_URL] - case ConfType.UPLOAD: - device_info["model"] = config_entry.title - return device_info + for subentry_id, subentry in config_entry.subentries.items(): + async_add_entities( + [PDFScrapeSensor(coordinator, subentry)], + config_subentry_id=subentry_id, + ) class PDFDocumentSensor(CoordinatorEntity[PDFScrapeCoordinator], SensorEntity): # type: ignore[reportIncompatibleVariableOverride] @@ -65,11 +56,15 @@ class PDFDocumentSensor(CoordinatorEntity[PDFScrapeCoordinator], SensorEntity): def __init__(self, coordinator: PDFScrapeCoordinator) -> None: """Initialize PDFDocument Sensor.""" super().__init__(coordinator) - self._attr_name = f"{self.coordinator.config_entry.title} Last Modified" + self._attr_name = "Last Modified" self._attr_device_class = SensorDeviceClass.TIMESTAMP - self.unique_id = f"{DOMAIN}_{self.coordinator.config_entry.entry_id}" + self.unique_id = f"{DOMAIN}_document_{self.coordinator.config_entry.entry_id}" + dev_reg = dr.async_get(coordinator.hass) + self.device_entry = dev_reg.async_get_device_by_identifier( + (DOMAIN, coordinator.config_entry.entry_id), + coordinator.config_entry.entry_id, + ) self._attr_has_entity_name = True - self._attr_device_info = _async_get_device_info(coordinator.config_entry) self._attr_icon = "mdi:update" self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_translation_key = "modified" @@ -82,14 +77,14 @@ def _handle_coordinator_update(self) -> None: @cached_property def native_value(self) -> datetime | None: """Return the state of the sensor.""" - return self.coordinator.pdf.modified + return self.coordinator.pdf.pdf.modified @cached_property def extra_state_attributes(self) -> dict[str, str]: """Return Extra Attributes.""" return { - CONF_MODIFIED_SOURCE: self.coordinator.pdf.modified_source, - CONF_MD5_CHECKSUM: self.coordinator.pdf.md5_checksum, + CONF_MODIFIED_SOURCE: self.coordinator.pdf.pdf.modified_source, + CONF_SHA256_CHECKSUM: self.coordinator.pdf.pdf.sha256_checksum, } @@ -97,30 +92,24 @@ class PDFScrapeSensor(CoordinatorEntity[PDFScrapeCoordinator], SensorEntity): # """PDFScrape Sensor Entity.""" def __init__( - self, coordinator: PDFScrapeCoordinator, subentry_config_key: str + self, + coordinator: PDFScrapeCoordinator, + subentry: ConfigSubentry, ) -> None: """Initialize PDFScrape Sensor.""" super().__init__(coordinator) if coordinator.config_entry is None: raise ConfigEntryError("This should never be raised") - self.subentry_config: ConfigSubentry = coordinator.config_entry.subentries[ - subentry_config_key - ] - if self.subentry_config is None: - raise HomeAssistantError( - f"Subentry config not found: {subentry_config_key}" - ) - self.subentry_config_key = subentry_config_key - self._attr_name = self.subentry_config.title + self.subentry_id: str = subentry.subentry_id + self._attr_name = subentry.title self._attr_has_entity_name = True - self._attr_device_info = _async_get_device_info(coordinator.config_entry) - self._attr_native_unit_of_measurement = self.subentry_config.data.get( + self._attr_native_unit_of_measurement = subentry.data.get( CONF_UNIT_OF_MEASUREMENT ) - self._attr_state_class = self.subentry_config.data.get(CONF_STATE_CLASS) - self._attr_device_class = self.subentry_config.data.get(CONF_DEVICE_CLASS) + self._attr_state_class = subentry.data.get(CONF_STATE_CLASS) + self._attr_device_class = subentry.data.get(CONF_DEVICE_CLASS) self._attr_icon = "mdi:file-pdf-box" - self.unique_id = f"{DOMAIN}_{subentry_config_key}" + self.unique_id = f"{DOMAIN}_target_{self.subentry_id}" @callback def _handle_coordinator_update(self) -> None: @@ -130,5 +119,5 @@ def _handle_coordinator_update(self) -> None: @cached_property def native_value(self) -> str: """Return the state of the sensor.""" - value: str = self.coordinator.data[self.subentry_config_key] + value: str = self.coordinator.data[self.subentry_id] return value if len(value) < 255 else value[:242] + " " diff --git a/custom_components/pdf_scrape/translations/en.json b/custom_components/pdf_scrape/translations/en.json index 6d2ffb5..502156b 100644 --- a/custom_components/pdf_scrape/translations/en.json +++ b/custom_components/pdf_scrape/translations/en.json @@ -18,7 +18,7 @@ "scan_interval": "Update Interval" }, "data_description": { - "name": "If name not provided, the URL will be used as name", + "name": "If name not provided, will attempt to assign the name from the PDF metadata or URL will be used as name", "url": "URL (http:// or https:// only)", "scan_interval": "Minimum is {min_int}" } @@ -49,15 +49,26 @@ }, "error": { "invalid_url": "Bad URL", - "pdf_parse": "Cannot parse PDF", - "http_error": "Cannot access PDF @ provided URL", + "http_error": "Cannot access PDF @ provided URL: {msg}", "invalid_source": "Accessed configuration from an invalid flow", "unknown": "Unknown error", - "file_error": "File access error: {file}" + "file_error": "File access error: {msg}", + "pdf_parse": "Cannot parse PDF: {msg}", + "exception": "Error: {msg}" + }, + "progress": { + "pdf_process": "Processing PDF ..." }, "abort": { "already_configured": "PDF is already configured", - "reconfigure_successful": "PDF Reconfiguration successful." + "http_already_configured": "A PDF @ {url} is already configured", + "upload_already_configured": "A PDF with the same sha256 checksum is already uploaded. While there is an exceedingly small chance that 2 different pdf files will have the same checksum it's very unlikely. If you are certain that they pdf files are different, alter the metadata of the pdf file you are uploading which will create a unique checksum.", + "file_already_configured": "A PDF @ {file} is already configured", + "reconfigure_successful": "PDF Reconfiguration successful.", + "pdf_parse": "Cannot parse PDF: {msg}", + "exception": "Error: {msg}", + "http_error": "Cannot access PDF @ provided URL: {msg}", + "file_error": "File access error: {msg}" } }, "config_subentries": { @@ -65,16 +76,18 @@ "initiate_flow": { "user": "Add Search Target" }, - "entry_type": "Scrape Target", + "entry_type": "Search Target", "step": { "user": { - "title": "Target Page", + "title": "Target Page(s)", "description": "{title}", "data": { - "pdf_pages": "Pages" + "pdf_pages": "Pages", + "ocr": "Use OCR (use with caution)" }, "data_description": { - "pdf_pages": "Pages in PDF: {pages}. Use comma to separate multiple pages or hyphen for page ranges (e.g., 1,3-5,7)." + "pdf_pages": "Pages in PDF: {pages}. Use comma to separate multiple pages or hyphen for page ranges (e.g., 1,3-5,7).", + "ocr": "Use OCR to extract text. This can be very slow and resource intensive, particularly for a large number of pages. Use only if the PDF contains images and text extraction is not working. OCR results can be highly variable and may not produce usable results depending on the quality of the source PDF. Currently only English documents are supported. When possible, consider using an OCR tool prior to configuring the PDF in Home Assistant." } }, "regex": { @@ -88,12 +101,12 @@ "regex_search": "Leave blank to use all text on selected page" } }, - "matches": { + "sensor": { "title": "Configure Sensor", "data": { "name": "Name", "regex_match_index": "Select match", - "value_template": "Template", + "value_template": "Limited Template", "unit_of_measurement": "Unit of Measurement", "device_class": "Device Class", "state_class": "State Class", @@ -247,6 +260,28 @@ } }, "issues":{ + "test_issue": { + "title": "Test Issue", + "fix_flow": { + "step": { + "confirm": { + "title": "Test Issue", + "description": "{msg} \n \nReconfigure PDF?" + } + } + } + }, + "test_issue_a": { + "title": "Test Issue A", + "fix_flow": { + "step": { + "confirm": { + "title": "Test Issue A", + "description": "{msg} \n \nReconfigure PDF?" + } + } + } + }, "pdf_error": { "title": "Error Opening PDF ({conf})", "fix_flow": { @@ -266,9 +301,6 @@ "title": "Page(s) Out of Range ({conf})", "description": "{msg} \n \nContinue?" } - }, - "abort": { - "manual_fix": "This issue requires a manual fix. Please open the subentry configuration, and change the settings to fix this issue." } } }, @@ -280,9 +312,6 @@ "title": "Regular Expression Error ({conf})", "description": "{msg} \n \nContinue?" } - }, - "abort": { - "manual_fix": "This issue requires a manual fix. Please open the subentry configuration, and change the settings to fix this issue." } } }, @@ -294,9 +323,6 @@ "title": "No Matches Found ({conf})", "description": "{msg} \n \nContinue?" } - }, - "abort": { - "manual_fix": "This issue requires a manual fix. Please open the subentry configuration, and change the settings to fix this issue." } } }, @@ -308,9 +334,6 @@ "title": "Template Error ({conf})", "description": "{msg} \n \nContinue?" } - }, - "abort": { - "manual_fix": "This issue requires a manual fix. Please open the subentry configuration, and change the settings to fix this issue." } } } diff --git a/hacs.json b/hacs.json index 1d6aa83..a34a380 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "PDF Scrape", - "homeassistant": "2025.7.2" + "homeassistant": "2026.9.0" } diff --git a/logo.svg b/logo.svg deleted file mode 100644 index 80199f6..0000000 --- a/logo.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - Layer 1 - - - - - - - {{scrape}} - - diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..1772374 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Test suite for PDF Scrape integration.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4fc5432 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Fixtures for PDF Scrape tests.""" diff --git a/tests/sample.pdf b/tests/sample.pdf new file mode 100644 index 0000000..a27ca26 Binary files /dev/null and b/tests/sample.pdf differ diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..1ed85aa --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,155 @@ +"""Tests for config, options, subentry flows and previews.""" + +import asyncio +import os + +import pytest + +from homeassistant import config_entries +from homeassistant.components.pdf_scrape import ConfType +from homeassistant.components.pdf_scrape.const import ( + CONF_FILE, + CONF_PDF_PAGES, + DOMAIN, + CONF_OCR, +) +from homeassistant.const import CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.setup import async_setup_component + +from tests.typing import ClientSessionGenerator, WebSocketGenerator + + +@pytest.fixture +def hass_config_dir(hass_tmp_config_dir: str) -> str: + """Temp dir for config.""" + return hass_tmp_config_dir + + +async def test_user_flow_upload( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test user initiated config flow with file upload.""" + + assert await async_setup_component(hass, "http", {}) + assert await async_setup_component(hass, "file_upload", {}) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context=config_entries.ConfigFlowContext(source=config_entries.SOURCE_USER), + ) + assert result.get(CONF_TYPE) is FlowResultType.MENU + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"next_step_id": "upload"} + ) + + assert result.get(CONF_TYPE) is FlowResultType.FORM + assert result.get("step_id") == "upload" + + client = await hass_client() + + with open("tests/components/pdf_scrape/sample.pdf", "rb") as sample_pdf: + os.mkdir(os.path.join(hass.config.path(), ".storage")) + + async with client.post( + "/api/file_upload", data={"file": sample_pdf} + ) as response: + assert response.status == 200 + data = await response.json() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_FILE: data["file_id"]} + ) + + assert result[CONF_TYPE] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "upload" + assert result["progress_action"] == "pdf_process" + + while result[CONF_TYPE] is FlowResultType.SHOW_PROGRESS: + await asyncio.sleep(0.1) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result[CONF_TYPE] is FlowResultType.CREATE_ENTRY + assert result["subentries"][0]["subentry_type"] == "document" + assert result["result"].data[CONF_TYPE] == ConfType.UPLOAD + next_flow_type, next_flow_id = result["next_flow"] + assert next_flow_type == config_entries.FlowType.CONFIG_SUBENTRIES_FLOW + + # test subentry flow + + result = await hass.config_entries.subentries.async_configure(next_flow_id) + + assert result[CONF_TYPE] is FlowResultType.FORM + assert "target" in result["handler"] + assert result["description_placeholders"]["pages"] == 4 + + # test preview + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 1, + "type": "target/start_preview", + "flow_id": next_flow_id, + "flow_type": config_entries.FlowType.CONFIG_SUBENTRIES_FLOW, + "user_input": {CONF_PDF_PAGES: "2"}, + } + ) + + ws_response = await ws_client.receive_json() + assert ws_response["id"] == 1 + assert ws_response["success"] + + ws_response = await ws_client.receive_json() + + assert ws_response["id"] == 1 + assert ( + "1\nFoo\nHello, here is some text without a meaning." + in ws_response["event"]["state"] + ) + + result = await hass.config_entries.subentries.async_configure( + next_flow_id, user_input={CONF_PDF_PAGES: "2", CONF_OCR: True} + ) + + assert result[CONF_TYPE] is FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "getting_pages" + + while result[CONF_TYPE] is FlowResultType.SHOW_PROGRESS: + await asyncio.sleep(0.1) + result = await hass.config_entries.subentries.async_configure(next_flow_id) + + assert result[CONF_TYPE] is FlowResultType.FORM + assert result["step_id"] == "regex" + + assert ( + "1 Foo \n\nHello, here is some text without a meaning." + in result["data_schema"]({})["page_text"] + ) + + # test preview with regex + await ws_client.send_json( + { + "id": 2, + "type": "target/start_preview", + "flow_id": next_flow_id, + "flow_type": config_entries.FlowType.CONFIG_SUBENTRIES_FLOW, + "user_input": {"regex": r"This\stext"}, + } + ) + + ws_response = await ws_client.receive_json() + assert ws_response["id"] == 2 + assert ws_response["success"] + + ws_response = await ws_client.receive_json() + assert ws_response["id"] == 2 + + response = await hass.config_entries.subentries.async_configure( + next_flow_id, user_input={"regex": r"This text should show"} + )