From d7d4c8c5608f3dd727b2d94ff00794ba4871dfe0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 30 Sep 2025 12:49:36 -0500 Subject: [PATCH 1/2] Support Home Assistant 2024.8.3 or newer (#91) Drop support for HA versions before 2024.8.3. Move file I/O to executor. Don't force sensor updates. --- README.md | 2 +- custom_components/composite/config.py | 10 ++- custom_components/composite/config_flow.py | 87 ++++++++++++------- custom_components/composite/device_tracker.py | 4 +- custom_components/composite/manifest.json | 4 +- custom_components/composite/sensor.py | 1 - hacs.json | 2 +- 7 files changed, 67 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index e62b3b2..832aa96 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ After it has been downloaded you will need to restart Home Assistant. ### Versions -This custom integration supports HomeAssistant versions 2023.7 or newer. +This custom integration supports HomeAssistant versions 2024.8.3 or newer. ## Configuration diff --git a/custom_components/composite/config.py b/custom_components/composite/config.py index 04372aa..99ceda8 100644 --- a/custom_components/composite/config.py +++ b/custom_components/composite/config.py @@ -64,6 +64,8 @@ def _entities(entities: list[str | dict]) -> list[dict]: def _entity_picture(entity_picture: str) -> str: """Validate entity picture. + Must be run in an executor since it might do file I/O. + Can be an URL or a file in "/local". file can be "/local/file" or just "file" @@ -82,9 +84,7 @@ def _entity_picture(entity_picture: str) -> str: return str(local_dir / local_file) -def _trackers( - trackers: list[dict[vol.Required | vol.Optional, Any]] -) -> list[dict[vol.Required | vol.Optional, Any]]: +def _trackers(trackers: list[dict[str, Any]]) -> list[dict[str, Any]]: """Validate tracker entries. Determine tracker IDs and ensure they are unique. @@ -206,4 +206,6 @@ async def async_validate_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: """Validate configuration.""" - return cast(ConfigType, _CONFIG_SCHEMA(config)) + # Perform _CONFIG_SCHEMA validation in executor since it may indirectly invoke + # _entity_picture which must be run in an executor because it might do file I/O. + return cast(ConfigType, await hass.async_add_executor_job(_CONFIG_SCHEMA, config)) diff --git a/custom_components/composite/config_flow.py b/custom_components/composite/config_flow.py index 2dc1b94..cd144a1 100644 --- a/custom_components/composite/config_flow.py +++ b/custom_components/composite/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from functools import cached_property +from functools import cached_property # pylint: disable=hass-deprecated-import import logging from pathlib import Path import shutil @@ -17,7 +17,9 @@ from homeassistant.config_entries import ( SOURCE_IMPORT, ConfigEntry, + ConfigEntryBaseFlow, ConfigFlow, + ConfigFlowResult, OptionsFlowWithConfigEntry, ) from homeassistant.const import ( @@ -30,7 +32,6 @@ UnitOfSpeed, ) from homeassistant.core import State, callback -from homeassistant.data_entry_flow import FlowHandler, FlowResult from homeassistant.helpers.selector import ( BooleanSelector, EntitySelector, @@ -85,7 +86,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]: } -class CompositeFlow(FlowHandler): +class CompositeFlow(ConfigEntryBaseFlow): """Composite flow mixin.""" @cached_property @@ -103,9 +104,11 @@ def _uploaded_dir(self) -> Path: """Return real path to "/local/uploaded" directory.""" return self._local_dir / "uploaded" - @cached_property def _local_files(self) -> list[str]: - """Return a list of files in "/local" and subdirectories.""" + """Return a list of files in "/local" and subdirectories. + + Must be called in an executor since it does file I/O. + """ if not (local_dir := self._local_dir).is_dir(): _LOGGER.debug("/local directory (%s) does not exist", local_dir) return [] @@ -171,14 +174,14 @@ def _set_entity_picture( def _save_uploaded_file(self, uploaded_file_id: str) -> str: """Save uploaded file. - Must be called in an executor. + Must be called in an executor since it does file I/O. Returns name of file relative to "/local". """ with process_uploaded_file(self.hass, uploaded_file_id) as uf_path: ud = self._uploaded_dir ud.mkdir(parents=True, exist_ok=True) - suffix = MIME_TO_SUFFIX[filetype.guess_mime(uf_path)] + suffix = MIME_TO_SUFFIX[cast(str, filetype.guess_mime(uf_path))] fn = ud / f"x.{suffix}" idx = 0 while (uf := fn.with_stem(f"image{idx:03d}")).exists(): @@ -188,7 +191,7 @@ def _save_uploaded_file(self, uploaded_file_id: str) -> str: async def async_step_options( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Get config options.""" errors = {} @@ -271,7 +274,9 @@ def entity_filter(state: State) -> bool: step_id="options", data_schema=data_schema, errors=errors, last_step=False ) - async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResult: + async def async_step_ep_menu( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Specify where to get composite's picture from.""" entity_id, local_file = self._cur_entity_picture cur_source: Path | str | None @@ -281,7 +286,7 @@ async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResul cur_source = entity_id menu_options = ["all_states", "ep_upload_file", "ep_input_entity"] - if self._local_files: + if await self.hass.async_add_executor_job(self._local_files): menu_options.insert(1, "ep_local_file") if cur_source: menu_options.append("ep_none") @@ -294,7 +299,7 @@ async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResul async def async_step_ep_input_entity( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Specify which input to get composite's picture from.""" if user_input is not None: self._set_entity_picture(entity_id=user_input.get(CONF_ENTITY)) @@ -323,13 +328,13 @@ async def async_step_ep_input_entity( async def async_step_ep_local_file( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Specify a local file for composite's picture.""" if user_input is not None: self._set_entity_picture(local_file=user_input.get(CONF_ENTITY_PICTURE)) return await self.async_step_all_states() - local_files = self._local_files + local_files = await self.hass.async_add_executor_job(self._local_files) _, local_file = self._cur_entity_picture if local_file and local_file not in local_files: local_files.append(local_file) @@ -353,21 +358,31 @@ async def async_step_ep_local_file( async def async_step_ep_upload_file( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Upload a file for composite's picture.""" if user_input is not None: if (uploaded_file_id := user_input.get(CONF_ENTITY_PICTURE)) is None: self._set_entity_picture() return await self.async_step_all_states() - local_dir_exists = self._local_dir.is_dir() - local_file = await self.hass.async_add_executor_job( - self._save_uploaded_file, uploaded_file_id + def save_uploaded_file() -> tuple[bool, str]: + """Save uploaded file. + + Must be called in an executor since it does file I/O. + + Returns if local directory existed beforehand and name of uploaded file. + """ + local_dir_exists = self._local_dir.is_dir() + local_file = self._save_uploaded_file(uploaded_file_id) + return local_dir_exists, local_file + + local_dir_exists, local_file = await self.hass.async_add_executor_job( + save_uploaded_file ) self._set_entity_picture(local_file=local_file) - if local_dir_exists: - return await self.async_step_all_states() - return await self.async_step_ep_warn() + if not local_dir_exists: + return await self.async_step_ep_warn() + return await self.async_step_all_states() accept = ", ".join(f".{ext}" for ext in PICTURE_SUFFIXES) data_schema = vol.Schema( @@ -383,7 +398,7 @@ async def async_step_ep_upload_file( async def async_step_ep_warn( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Warn that since "/local" was created system might need to be restarted.""" if user_input is not None: return await self.async_step_all_states() @@ -394,14 +409,16 @@ async def async_step_ep_warn( last_step=False, ) - async def async_step_ep_none(self, _: dict[str, Any] | None = None) -> FlowResult: + async def async_step_ep_none( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Set composite's entity picture to none.""" self._set_entity_picture() return await self.async_step_all_states() async def async_step_all_states( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Specify if all states should be used for appropriate entities.""" if user_input is not None: entity_ids = user_input.get(CONF_ENTITY, []) @@ -430,7 +447,9 @@ async def async_step_all_states( return self.async_show_form(step_id="all_states", data_schema=data_schema) @abstractmethod - async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + async def async_step_done( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Finish the flow.""" @@ -457,16 +476,14 @@ def async_get_options_flow(config_entry: ConfigEntry) -> CompositeOptionsFlow: @callback def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool: """Return options flow support for this handler.""" - if config_entry.source == SOURCE_IMPORT: - return False - return True + return config_entry.source != SOURCE_IMPORT @property def options(self) -> dict[str, Any]: """Return mutable copy of options.""" return self._options - async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult: """Import config entry from configuration.""" if (driving_speed := data.get(CONF_DRIVING_SPEED)) is not None: data[CONF_DRIVING_SPEED] = SpeedConverter.convert( @@ -483,7 +500,9 @@ async def async_step_import(self, data: dict[str, Any]) -> FlowResult: **split_conf(data), # type: ignore[arg-type] ) - async def async_step_user(self, _: dict[str, Any] | None = None) -> FlowResult: + async def async_step_user( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Start user config flow.""" return await self.async_step_name() @@ -499,7 +518,7 @@ def _name_used(self, name: str) -> bool: async def async_step_name( self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Get name.""" errors = {} @@ -517,7 +536,9 @@ async def async_step_name( step_id="name", data_schema=data_schema, errors=errors, last_step=False ) - async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + async def async_step_done( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Finish the flow.""" return self.async_create_entry(title=self._name, data={}, options=self.options) @@ -525,6 +546,8 @@ async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: class CompositeOptionsFlow(OptionsFlowWithConfigEntry, CompositeFlow): """Composite integration options flow.""" - async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult: + async def async_step_done( + self, _: dict[str, Any] | None = None + ) -> ConfigFlowResult: """Finish the flow.""" return self.async_create_entry(title="", data=self.options) diff --git a/custom_components/composite/device_tracker.py b/custom_components/composite/device_tracker.py index c53c792..2a8b029 100644 --- a/custom_components/composite/device_tracker.py +++ b/custom_components/composite/device_tracker.py @@ -37,7 +37,7 @@ STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State +from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -329,7 +329,7 @@ async def _process_config_options(self) -> None: self._attr_entity_picture = None self._use_entity_picture = False - async def state_listener(event: Event) -> None: + async def state_listener(event: Event[EventStateChangedData]) -> None: """Process input entity state update.""" await self.async_request_call( self._entity_updated(event.data["entity_id"], event.data["new_state"]) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 64bf124..7644c74 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.5/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0b0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.4.5" + "version": "3.5.0b0" } diff --git a/custom_components/composite/sensor.py b/custom_components/composite/sensor.py index 38ad9f4..1bcaa36 100644 --- a/custom_components/composite/sensor.py +++ b/custom_components/composite/sensor.py @@ -98,7 +98,6 @@ def direction(angle: int | None) -> str | None: ] self._attr_native_value = value - self._attr_force_update = bool(value) self._attr_extra_state_attributes = { ATTR_ANGLE: angle, ATTR_DIRECTION: direction(angle), diff --git a/hacs.json b/hacs.json index 04d055c..6259499 100644 --- a/hacs.json +++ b/hacs.json @@ -1,4 +1,4 @@ { "name": "Composite Device Tracker", - "homeassistant": "2023.7" + "homeassistant": "2024.8.3" } From 7d30f7e1137d99d74412071dadfc7d0757af5a69 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 30 Sep 2025 12:50:51 -0500 Subject: [PATCH 2/2] Bump version to 3.5.0 --- custom_components/composite/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/custom_components/composite/manifest.json b/custom_components/composite/manifest.json index 7644c74..364dcc1 100644 --- a/custom_components/composite/manifest.json +++ b/custom_components/composite/manifest.json @@ -4,9 +4,9 @@ "codeowners": ["@pnbruckner"], "config_flow": true, "dependencies": ["file_upload"], - "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0b0/README.md", + "documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0/README.md", "iot_class": "local_polling", "issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues", "requirements": ["filetype==1.2.0"], - "version": "3.5.0b0" + "version": "3.5.0" }