Skip to content

Commit d7d4c8c

Browse files
authored
Support Home Assistant 2024.8.3 or newer (pnbruckner#91)
Drop support for HA versions before 2024.8.3. Move file I/O to executor. Don't force sensor updates.
1 parent 3429c56 commit d7d4c8c

File tree

7 files changed

+67
-43
lines changed

7 files changed

+67
-43
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ After it has been downloaded you will need to restart Home Assistant.
4545

4646
### Versions
4747

48-
This custom integration supports HomeAssistant versions 2023.7 or newer.
48+
This custom integration supports HomeAssistant versions 2024.8.3 or newer.
4949

5050
## Configuration
5151

custom_components/composite/config.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def _entities(entities: list[str | dict]) -> list[dict]:
6464
def _entity_picture(entity_picture: str) -> str:
6565
"""Validate entity picture.
6666
67+
Must be run in an executor since it might do file I/O.
68+
6769
Can be an URL or a file in "/local".
6870
6971
file can be "/local/file" or just "file"
@@ -82,9 +84,7 @@ def _entity_picture(entity_picture: str) -> str:
8284
return str(local_dir / local_file)
8385

8486

85-
def _trackers(
86-
trackers: list[dict[vol.Required | vol.Optional, Any]]
87-
) -> list[dict[vol.Required | vol.Optional, Any]]:
87+
def _trackers(trackers: list[dict[str, Any]]) -> list[dict[str, Any]]:
8888
"""Validate tracker entries.
8989
9090
Determine tracker IDs and ensure they are unique.
@@ -206,4 +206,6 @@ async def async_validate_config(
206206
hass: HomeAssistant, config: ConfigType
207207
) -> ConfigType | None:
208208
"""Validate configuration."""
209-
return cast(ConfigType, _CONFIG_SCHEMA(config))
209+
# Perform _CONFIG_SCHEMA validation in executor since it may indirectly invoke
210+
# _entity_picture which must be run in an executor because it might do file I/O.
211+
return cast(ConfigType, await hass.async_add_executor_job(_CONFIG_SCHEMA, config))

custom_components/composite/config_flow.py

Lines changed: 55 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from __future__ import annotations
33

44
from abc import abstractmethod
5-
from functools import cached_property
5+
from functools import cached_property # pylint: disable=hass-deprecated-import
66
import logging
77
from pathlib import Path
88
import shutil
@@ -17,7 +17,9 @@
1717
from homeassistant.config_entries import (
1818
SOURCE_IMPORT,
1919
ConfigEntry,
20+
ConfigEntryBaseFlow,
2021
ConfigFlow,
22+
ConfigFlowResult,
2123
OptionsFlowWithConfigEntry,
2224
)
2325
from homeassistant.const import (
@@ -30,7 +32,6 @@
3032
UnitOfSpeed,
3133
)
3234
from homeassistant.core import State, callback
33-
from homeassistant.data_entry_flow import FlowHandler, FlowResult
3435
from homeassistant.helpers.selector import (
3536
BooleanSelector,
3637
EntitySelector,
@@ -85,7 +86,7 @@ def split_conf(conf: dict[str, Any]) -> dict[str, dict[str, Any]]:
8586
}
8687

8788

88-
class CompositeFlow(FlowHandler):
89+
class CompositeFlow(ConfigEntryBaseFlow):
8990
"""Composite flow mixin."""
9091

9192
@cached_property
@@ -103,9 +104,11 @@ def _uploaded_dir(self) -> Path:
103104
"""Return real path to "/local/uploaded" directory."""
104105
return self._local_dir / "uploaded"
105106

106-
@cached_property
107107
def _local_files(self) -> list[str]:
108-
"""Return a list of files in "/local" and subdirectories."""
108+
"""Return a list of files in "/local" and subdirectories.
109+
110+
Must be called in an executor since it does file I/O.
111+
"""
109112
if not (local_dir := self._local_dir).is_dir():
110113
_LOGGER.debug("/local directory (%s) does not exist", local_dir)
111114
return []
@@ -171,14 +174,14 @@ def _set_entity_picture(
171174
def _save_uploaded_file(self, uploaded_file_id: str) -> str:
172175
"""Save uploaded file.
173176
174-
Must be called in an executor.
177+
Must be called in an executor since it does file I/O.
175178
176179
Returns name of file relative to "/local".
177180
"""
178181
with process_uploaded_file(self.hass, uploaded_file_id) as uf_path:
179182
ud = self._uploaded_dir
180183
ud.mkdir(parents=True, exist_ok=True)
181-
suffix = MIME_TO_SUFFIX[filetype.guess_mime(uf_path)]
184+
suffix = MIME_TO_SUFFIX[cast(str, filetype.guess_mime(uf_path))]
182185
fn = ud / f"x.{suffix}"
183186
idx = 0
184187
while (uf := fn.with_stem(f"image{idx:03d}")).exists():
@@ -188,7 +191,7 @@ def _save_uploaded_file(self, uploaded_file_id: str) -> str:
188191

189192
async def async_step_options(
190193
self, user_input: dict[str, Any] | None = None
191-
) -> FlowResult:
194+
) -> ConfigFlowResult:
192195
"""Get config options."""
193196
errors = {}
194197

@@ -271,7 +274,9 @@ def entity_filter(state: State) -> bool:
271274
step_id="options", data_schema=data_schema, errors=errors, last_step=False
272275
)
273276

274-
async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResult:
277+
async def async_step_ep_menu(
278+
self, _: dict[str, Any] | None = None
279+
) -> ConfigFlowResult:
275280
"""Specify where to get composite's picture from."""
276281
entity_id, local_file = self._cur_entity_picture
277282
cur_source: Path | str | None
@@ -281,7 +286,7 @@ async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResul
281286
cur_source = entity_id
282287

283288
menu_options = ["all_states", "ep_upload_file", "ep_input_entity"]
284-
if self._local_files:
289+
if await self.hass.async_add_executor_job(self._local_files):
285290
menu_options.insert(1, "ep_local_file")
286291
if cur_source:
287292
menu_options.append("ep_none")
@@ -294,7 +299,7 @@ async def async_step_ep_menu(self, _: dict[str, Any] | None = None) -> FlowResul
294299

295300
async def async_step_ep_input_entity(
296301
self, user_input: dict[str, Any] | None = None
297-
) -> FlowResult:
302+
) -> ConfigFlowResult:
298303
"""Specify which input to get composite's picture from."""
299304
if user_input is not None:
300305
self._set_entity_picture(entity_id=user_input.get(CONF_ENTITY))
@@ -323,13 +328,13 @@ async def async_step_ep_input_entity(
323328

324329
async def async_step_ep_local_file(
325330
self, user_input: dict[str, Any] | None = None
326-
) -> FlowResult:
331+
) -> ConfigFlowResult:
327332
"""Specify a local file for composite's picture."""
328333
if user_input is not None:
329334
self._set_entity_picture(local_file=user_input.get(CONF_ENTITY_PICTURE))
330335
return await self.async_step_all_states()
331336

332-
local_files = self._local_files
337+
local_files = await self.hass.async_add_executor_job(self._local_files)
333338
_, local_file = self._cur_entity_picture
334339
if local_file and local_file not in local_files:
335340
local_files.append(local_file)
@@ -353,21 +358,31 @@ async def async_step_ep_local_file(
353358

354359
async def async_step_ep_upload_file(
355360
self, user_input: dict[str, Any] | None = None
356-
) -> FlowResult:
361+
) -> ConfigFlowResult:
357362
"""Upload a file for composite's picture."""
358363
if user_input is not None:
359364
if (uploaded_file_id := user_input.get(CONF_ENTITY_PICTURE)) is None:
360365
self._set_entity_picture()
361366
return await self.async_step_all_states()
362367

363-
local_dir_exists = self._local_dir.is_dir()
364-
local_file = await self.hass.async_add_executor_job(
365-
self._save_uploaded_file, uploaded_file_id
368+
def save_uploaded_file() -> tuple[bool, str]:
369+
"""Save uploaded file.
370+
371+
Must be called in an executor since it does file I/O.
372+
373+
Returns if local directory existed beforehand and name of uploaded file.
374+
"""
375+
local_dir_exists = self._local_dir.is_dir()
376+
local_file = self._save_uploaded_file(uploaded_file_id)
377+
return local_dir_exists, local_file
378+
379+
local_dir_exists, local_file = await self.hass.async_add_executor_job(
380+
save_uploaded_file
366381
)
367382
self._set_entity_picture(local_file=local_file)
368-
if local_dir_exists:
369-
return await self.async_step_all_states()
370-
return await self.async_step_ep_warn()
383+
if not local_dir_exists:
384+
return await self.async_step_ep_warn()
385+
return await self.async_step_all_states()
371386

372387
accept = ", ".join(f".{ext}" for ext in PICTURE_SUFFIXES)
373388
data_schema = vol.Schema(
@@ -383,7 +398,7 @@ async def async_step_ep_upload_file(
383398

384399
async def async_step_ep_warn(
385400
self, user_input: dict[str, Any] | None = None
386-
) -> FlowResult:
401+
) -> ConfigFlowResult:
387402
"""Warn that since "/local" was created system might need to be restarted."""
388403
if user_input is not None:
389404
return await self.async_step_all_states()
@@ -394,14 +409,16 @@ async def async_step_ep_warn(
394409
last_step=False,
395410
)
396411

397-
async def async_step_ep_none(self, _: dict[str, Any] | None = None) -> FlowResult:
412+
async def async_step_ep_none(
413+
self, _: dict[str, Any] | None = None
414+
) -> ConfigFlowResult:
398415
"""Set composite's entity picture to none."""
399416
self._set_entity_picture()
400417
return await self.async_step_all_states()
401418

402419
async def async_step_all_states(
403420
self, user_input: dict[str, Any] | None = None
404-
) -> FlowResult:
421+
) -> ConfigFlowResult:
405422
"""Specify if all states should be used for appropriate entities."""
406423
if user_input is not None:
407424
entity_ids = user_input.get(CONF_ENTITY, [])
@@ -430,7 +447,9 @@ async def async_step_all_states(
430447
return self.async_show_form(step_id="all_states", data_schema=data_schema)
431448

432449
@abstractmethod
433-
async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult:
450+
async def async_step_done(
451+
self, _: dict[str, Any] | None = None
452+
) -> ConfigFlowResult:
434453
"""Finish the flow."""
435454

436455

@@ -457,16 +476,14 @@ def async_get_options_flow(config_entry: ConfigEntry) -> CompositeOptionsFlow:
457476
@callback
458477
def async_supports_options_flow(cls, config_entry: ConfigEntry) -> bool:
459478
"""Return options flow support for this handler."""
460-
if config_entry.source == SOURCE_IMPORT:
461-
return False
462-
return True
479+
return config_entry.source != SOURCE_IMPORT
463480

464481
@property
465482
def options(self) -> dict[str, Any]:
466483
"""Return mutable copy of options."""
467484
return self._options
468485

469-
async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
486+
async def async_step_import(self, data: dict[str, Any]) -> ConfigFlowResult:
470487
"""Import config entry from configuration."""
471488
if (driving_speed := data.get(CONF_DRIVING_SPEED)) is not None:
472489
data[CONF_DRIVING_SPEED] = SpeedConverter.convert(
@@ -483,7 +500,9 @@ async def async_step_import(self, data: dict[str, Any]) -> FlowResult:
483500
**split_conf(data), # type: ignore[arg-type]
484501
)
485502

486-
async def async_step_user(self, _: dict[str, Any] | None = None) -> FlowResult:
503+
async def async_step_user(
504+
self, _: dict[str, Any] | None = None
505+
) -> ConfigFlowResult:
487506
"""Start user config flow."""
488507
return await self.async_step_name()
489508

@@ -499,7 +518,7 @@ def _name_used(self, name: str) -> bool:
499518

500519
async def async_step_name(
501520
self, user_input: dict[str, Any] | None = None
502-
) -> FlowResult:
521+
) -> ConfigFlowResult:
503522
"""Get name."""
504523
errors = {}
505524

@@ -517,14 +536,18 @@ async def async_step_name(
517536
step_id="name", data_schema=data_schema, errors=errors, last_step=False
518537
)
519538

520-
async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult:
539+
async def async_step_done(
540+
self, _: dict[str, Any] | None = None
541+
) -> ConfigFlowResult:
521542
"""Finish the flow."""
522543
return self.async_create_entry(title=self._name, data={}, options=self.options)
523544

524545

525546
class CompositeOptionsFlow(OptionsFlowWithConfigEntry, CompositeFlow):
526547
"""Composite integration options flow."""
527548

528-
async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult:
549+
async def async_step_done(
550+
self, _: dict[str, Any] | None = None
551+
) -> ConfigFlowResult:
529552
"""Finish the flow."""
530553
return self.async_create_entry(title="", data=self.options)

custom_components/composite/device_tracker.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
STATE_UNAVAILABLE,
3838
STATE_UNKNOWN,
3939
)
40-
from homeassistant.core import Event, HomeAssistant, State
40+
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, State
4141
from homeassistant.helpers import entity_registry as er
4242
from homeassistant.helpers.dispatcher import async_dispatcher_send
4343
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -329,7 +329,7 @@ async def _process_config_options(self) -> None:
329329
self._attr_entity_picture = None
330330
self._use_entity_picture = False
331331

332-
async def state_listener(event: Event) -> None:
332+
async def state_listener(event: Event[EventStateChangedData]) -> None:
333333
"""Process input entity state update."""
334334
await self.async_request_call(
335335
self._entity_updated(event.data["entity_id"], event.data["new_state"])

custom_components/composite/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
"codeowners": ["@pnbruckner"],
55
"config_flow": true,
66
"dependencies": ["file_upload"],
7-
"documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.4.5/README.md",
7+
"documentation": "https://github.com/pnbruckner/ha-composite-tracker/blob/3.5.0b0/README.md",
88
"iot_class": "local_polling",
99
"issue_tracker": "https://github.com/pnbruckner/ha-composite-tracker/issues",
1010
"requirements": ["filetype==1.2.0"],
11-
"version": "3.4.5"
11+
"version": "3.5.0b0"
1212
}

custom_components/composite/sensor.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,6 @@ def direction(angle: int | None) -> str | None:
9898
]
9999

100100
self._attr_native_value = value
101-
self._attr_force_update = bool(value)
102101
self._attr_extra_state_attributes = {
103102
ATTR_ANGLE: angle,
104103
ATTR_DIRECTION: direction(angle),

hacs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"name": "Composite Device Tracker",
3-
"homeassistant": "2023.7"
3+
"homeassistant": "2024.8.3"
44
}

0 commit comments

Comments
 (0)