Skip to content

Commit 8cd34d2

Browse files
frenckatronclaude
andcommitted
Extract base classes for unknown-reference repair boilerplate
Ten repair modules ground out near-identical async_inspect bodies — six under ectoplasms/automation/repairs/unknown_*_references.py and four unknown_source.py files across switch_as_x, integration, utility_meter and trend. Each variant differed only in the registry queried, the attribute looked up on the entity, and a placeholder key. This pulls the boilerplate into two abstract bases on repairs.py: - AbstractSpookEntityComponentUnknownReferencesRepair walks an EntityComponent (DATA_INSTANCES) and exposes three hooks: _async_setup_inspection (cache known IDs once per cycle), _should_inspect_entity (defaults True, overridden for "skip disabled automations"), and _async_compute_unknown_references (per-entity lookup). The issue payload and debug logging are built once at the base. - AbstractSpookEntityPlatformUnknownSourceRepair walks an EntityPlatform (DATA_ENTITY_PLATFORM), optionally filtered to a single source_platform_domain, and asks subclasses only for the per-entity _get_source_entity_id. Net: ~440 lines deleted, ~155 of base infra added. The 10 leaf modules now read as the ~5 attributes that actually differentiate them. A side effect is that pylint's duplicate-code can be re-enabled without flooding the report. The four remaining pre-existing duplicates outside this mission's scope (person services pair, proximity repairs pair) carry a local pylint: disable=duplicate-code explaining the intent; min-similarity-lines is also bumped to 12 so trivial header overlap (imports, inspect_events sets) doesn't trigger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 92bd52d commit 8cd34d2

16 files changed

Lines changed: 357 additions & 436 deletions

custom_components/spook/ectoplasms/automation/repairs/unknown_area_references.py

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
from __future__ import annotations
44

5+
from typing import TYPE_CHECKING
6+
57
from homeassistant.components import automation
68
from homeassistant.helpers import area_registry as ar
7-
from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent
89

9-
from ....const import LOGGER
10-
from ....repairs import AbstractSpookRepair
10+
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
1111
from ....util import async_filter_known_area_ids, async_get_all_area_ids
1212

13+
if TYPE_CHECKING:
14+
from typing import Any
15+
1316

14-
class SpookRepair(AbstractSpookRepair):
17+
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
1518
"""Spook repair tries to find unknown referenced areas in automations."""
1619

1720
domain = automation.DOMAIN
@@ -22,44 +25,21 @@ class SpookRepair(AbstractSpookRepair):
2225
}
2326
inspect_on_reload = True
2427

25-
automatically_clean_up_issues = True
26-
27-
async def async_inspect(self) -> None:
28-
"""Trigger a inspection."""
29-
if self.domain not in self.hass.data[DATA_INSTANCES]:
30-
return
31-
32-
entity_component: EntityComponent[automation.AutomationEntity] = self.hass.data[
33-
DATA_INSTANCES
34-
][self.domain]
28+
unavailable_entity_class = automation.UnavailableAutomationEntity
29+
entity_label = "automation"
30+
reference_label = "areas"
31+
edit_url_pattern = "/config/automation/edit/{unique_id}"
3532

36-
LOGGER.debug("Spook is inspecting: %s", self.repair)
33+
_known_area_ids: set[str]
3734

38-
known_area_ids = async_get_all_area_ids(self.hass)
35+
async def _async_setup_inspection(self) -> None:
36+
"""Cache known area IDs for this inspection cycle."""
37+
self._known_area_ids = async_get_all_area_ids(self.hass)
3938

40-
for entity in entity_component.entities:
41-
self.possible_issue_ids.add(entity.entity_id)
42-
if not isinstance(entity, automation.UnavailableAutomationEntity) and (
43-
unknown_areas := async_filter_known_area_ids(
44-
self.hass,
45-
area_ids=entity.referenced_areas,
46-
known_area_ids=known_area_ids,
47-
)
48-
):
49-
self.async_create_issue(
50-
issue_id=entity.entity_id,
51-
translation_placeholders={
52-
"areas": "\n".join(f"- `{area}`" for area in unknown_areas),
53-
"automation": entity.name,
54-
"edit": f"/config/automation/edit/{entity.unique_id}",
55-
"entity_id": entity.entity_id,
56-
},
57-
)
58-
LOGGER.debug(
59-
(
60-
"Spook found unknown areas in %s "
61-
"and created an issue for it; Areas: %s",
62-
),
63-
entity.entity_id,
64-
", ".join(unknown_areas),
65-
)
39+
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
40+
"""Return unknown area IDs referenced by ``entity``."""
41+
return async_filter_known_area_ids(
42+
self.hass,
43+
area_ids=entity.referenced_areas,
44+
known_area_ids=self._known_area_ids,
45+
)

custom_components/spook/ectoplasms/automation/repairs/unknown_device_references.py

Lines changed: 22 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
from __future__ import annotations
44

5+
from typing import TYPE_CHECKING
6+
57
from homeassistant.components import automation
68
from homeassistant.helpers import device_registry as dr
7-
from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent
89

9-
from ....const import LOGGER
10-
from ....repairs import AbstractSpookRepair
10+
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
1111
from ....util import async_filter_known_device_ids, async_get_all_device_ids
1212

13+
if TYPE_CHECKING:
14+
from typing import Any
15+
1316

14-
class SpookRepair(AbstractSpookRepair):
17+
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
1518
"""Spook repair tries to find unknown referenced devices in automations."""
1619

1720
domain = automation.DOMAIN
@@ -23,46 +26,21 @@ class SpookRepair(AbstractSpookRepair):
2326
inspect_config_entry_changed = True
2427
inspect_on_reload = True
2528

26-
automatically_clean_up_issues = True
27-
28-
async def async_inspect(self) -> None:
29-
"""Trigger a inspection."""
30-
if self.domain not in self.hass.data[DATA_INSTANCES]:
31-
return
32-
33-
entity_component: EntityComponent[automation.AutomationEntity] = self.hass.data[
34-
DATA_INSTANCES
35-
][self.domain]
29+
unavailable_entity_class = automation.UnavailableAutomationEntity
30+
entity_label = "automation"
31+
reference_label = "devices"
32+
edit_url_pattern = "/config/automation/edit/{unique_id}"
3633

37-
LOGGER.debug("Spook is inspecting: %s", self.repair)
34+
_known_device_ids: set[str]
3835

39-
known_device_ids = async_get_all_device_ids(self.hass)
36+
async def _async_setup_inspection(self) -> None:
37+
"""Cache known device IDs for this inspection cycle."""
38+
self._known_device_ids = async_get_all_device_ids(self.hass)
4039

41-
for entity in entity_component.entities:
42-
self.possible_issue_ids.add(entity.entity_id)
43-
if not isinstance(entity, automation.UnavailableAutomationEntity) and (
44-
unknown_devices := async_filter_known_device_ids(
45-
self.hass,
46-
device_ids=entity.referenced_devices,
47-
known_device_ids=known_device_ids,
48-
)
49-
):
50-
self.async_create_issue(
51-
issue_id=entity.entity_id,
52-
translation_placeholders={
53-
"devices": "\n".join(
54-
f"- `{device}`" for device in unknown_devices
55-
),
56-
"automation": entity.name,
57-
"edit": f"/config/automation/edit/{entity.unique_id}",
58-
"entity_id": entity.entity_id,
59-
},
60-
)
61-
LOGGER.debug(
62-
(
63-
"Spook found unknown devices in %s "
64-
"and created an issue for it; Areas: %s",
65-
),
66-
entity.entity_id,
67-
", ".join(unknown_devices),
68-
)
40+
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
41+
"""Return unknown device IDs referenced by ``entity``."""
42+
return async_filter_known_device_ids(
43+
self.hass,
44+
device_ids=entity.referenced_devices,
45+
known_device_ids=self._known_device_ids,
46+
)

custom_components/spook/ectoplasms/automation/repairs/unknown_entity_references.py

Lines changed: 32 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
from homeassistant.components import automation
99
from homeassistant.const import EVENT_COMPONENT_LOADED
1010
from homeassistant.helpers import entity_registry as er
11-
from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent
1211

1312
from ....const import LOGGER
14-
from ....repairs import AbstractSpookRepair
13+
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
1514
from ....util import (
1615
ENTITY_ID_PATTERN,
1716
async_extract_entities_from_config,
@@ -259,7 +258,7 @@ async def extract_entities_from_value(hass: HomeAssistant, value: Any) -> set[st
259258
return entities
260259

261260

262-
class SpookRepair(AbstractSpookRepair):
261+
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
263262
"""Spook repair tries to find unknown referenced entity in automations."""
264263

265264
domain = automation.DOMAIN
@@ -271,67 +270,42 @@ class SpookRepair(AbstractSpookRepair):
271270
inspect_config_entry_changed = True
272271
inspect_on_reload = True
273272

274-
automatically_clean_up_issues = True
273+
unavailable_entity_class = automation.UnavailableAutomationEntity
274+
entity_label = "automation"
275+
reference_label = "entities"
276+
edit_url_pattern = "/config/automation/edit/{unique_id}"
275277

276-
async def async_inspect(self) -> None:
277-
"""Trigger a inspection."""
278-
if self.domain not in self.hass.data[DATA_INSTANCES]:
279-
return
278+
_known_entity_ids: set[str]
280279

281-
entity_component: EntityComponent[automation.AutomationEntity] = self.hass.data[
282-
DATA_INSTANCES
283-
][self.domain]
284-
285-
LOGGER.debug("Spook is inspecting: %s", self.repair)
286-
287-
known_entity_ids = async_get_all_entity_ids(self.hass, include_all_none=True)
288-
289-
for entity in entity_component.entities:
290-
self.possible_issue_ids.add(entity.entity_id)
280+
async def _async_setup_inspection(self) -> None:
281+
"""Cache known entity IDs (including ALL/NONE) for this inspection cycle."""
282+
self._known_entity_ids = async_get_all_entity_ids(
283+
self.hass, include_all_none=True
284+
)
291285

292-
# Skip disabled automations
293-
if not entity.enabled:
294-
continue
286+
def _should_inspect_entity(self, entity: Any) -> bool:
287+
"""Skip disabled automations."""
288+
return entity.enabled
295289

296-
# Collect entities from multiple sources
297-
all_entities = set(entity.referenced_entities)
290+
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
291+
"""Return unknown entity IDs referenced by ``entity`` (incl. templates)."""
292+
all_entities = set(entity.referenced_entities)
298293

299-
# Also extract entities directly from raw configuration if available
300-
if hasattr(entity, "raw_config") and entity.raw_config:
301-
config_entities = await extract_entities_from_automation_config(
294+
# Also extract entities directly from raw configuration if available
295+
if hasattr(entity, "raw_config") and entity.raw_config:
296+
all_entities.update(
297+
await extract_entities_from_automation_config(
302298
self.hass, entity.raw_config
303299
)
304-
all_entities.update(config_entities)
305-
306-
# Extract entities from Template objects within the automation entity
307-
template_entities = await extract_template_entities_from_automation_entity(
308-
self.hass, entity
309300
)
310-
all_entities.update(template_entities)
311301

312-
if not isinstance(entity, automation.UnavailableAutomationEntity) and (
313-
unknown_entities := await async_filter_known_entity_ids_with_templates(
314-
self.hass,
315-
entity_ids=all_entities,
316-
known_entity_ids=known_entity_ids,
317-
)
318-
):
319-
self.async_create_issue(
320-
issue_id=entity.entity_id,
321-
translation_placeholders={
322-
"entities": "\n".join(
323-
f"- `{entity_id}`" for entity_id in unknown_entities
324-
),
325-
"automation": entity.name,
326-
"edit": f"/config/automation/edit/{entity.unique_id}",
327-
"entity_id": entity.entity_id,
328-
},
329-
)
330-
LOGGER.debug(
331-
(
332-
"Spook found unknown entities in %s "
333-
"and created an issue for it; Entities: %s",
334-
),
335-
entity.entity_id,
336-
", ".join(unknown_entities),
337-
)
302+
# Extract entities from Template objects within the automation entity
303+
all_entities.update(
304+
await extract_template_entities_from_automation_entity(self.hass, entity)
305+
)
306+
307+
return await async_filter_known_entity_ids_with_templates(
308+
self.hass,
309+
entity_ids=all_entities,
310+
known_entity_ids=self._known_entity_ids,
311+
)

custom_components/spook/ectoplasms/automation/repairs/unknown_floor_references.py

Lines changed: 22 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22

33
from __future__ import annotations
44

5+
from typing import TYPE_CHECKING
6+
57
from homeassistant.components import automation
68
from homeassistant.helpers import floor_registry as fr
7-
from homeassistant.helpers.entity_component import DATA_INSTANCES, EntityComponent
89

9-
from ....const import LOGGER
10-
from ....repairs import AbstractSpookRepair
10+
from ....repairs import AbstractSpookEntityComponentUnknownReferencesRepair
1111
from ....util import async_filter_known_floor_ids, async_get_all_floor_ids
1212

13+
if TYPE_CHECKING:
14+
from typing import Any
15+
1316

14-
class SpookRepair(AbstractSpookRepair):
17+
class SpookRepair(AbstractSpookEntityComponentUnknownReferencesRepair):
1518
"""Spook repair tries to find unknown referenced floors in automations."""
1619

1720
domain = automation.DOMAIN
@@ -21,44 +24,21 @@ class SpookRepair(AbstractSpookRepair):
2124
}
2225
inspect_on_reload = True
2326

24-
automatically_clean_up_issues = True
25-
26-
async def async_inspect(self) -> None:
27-
"""Trigger a inspection."""
28-
if self.domain not in self.hass.data[DATA_INSTANCES]:
29-
return
30-
31-
entity_component: EntityComponent[automation.AutomationEntity] = self.hass.data[
32-
DATA_INSTANCES
33-
][self.domain]
27+
unavailable_entity_class = automation.UnavailableAutomationEntity
28+
entity_label = "automation"
29+
reference_label = "floors"
30+
edit_url_pattern = "/config/automation/edit/{unique_id}"
3431

35-
LOGGER.debug("Spook is inspecting: %s", self.repair)
32+
_known_floor_ids: set[str]
3633

37-
known_floor_ids = async_get_all_floor_ids(self.hass)
34+
async def _async_setup_inspection(self) -> None:
35+
"""Cache known floor IDs for this inspection cycle."""
36+
self._known_floor_ids = async_get_all_floor_ids(self.hass)
3837

39-
for entity in entity_component.entities:
40-
self.possible_issue_ids.add(entity.entity_id)
41-
if not isinstance(entity, automation.UnavailableAutomationEntity) and (
42-
unknown_floors := async_filter_known_floor_ids(
43-
self.hass,
44-
floor_ids=entity.referenced_floors,
45-
known_floor_ids=known_floor_ids,
46-
)
47-
):
48-
self.async_create_issue(
49-
issue_id=entity.entity_id,
50-
translation_placeholders={
51-
"floors": "\n".join(f"- `{floor}`" for floor in unknown_floors),
52-
"automation": entity.name,
53-
"edit": f"/config/automation/edit/{entity.unique_id}",
54-
"entity_id": entity.entity_id,
55-
},
56-
)
57-
LOGGER.debug(
58-
(
59-
"Spook found unknown floors in %s "
60-
"and created an issue for it; Floors: %s",
61-
),
62-
entity.entity_id,
63-
", ".join(unknown_floors),
64-
)
38+
async def _async_compute_unknown_references(self, entity: Any) -> set[str]:
39+
"""Return unknown floor IDs referenced by ``entity``."""
40+
return async_filter_known_floor_ids(
41+
self.hass,
42+
floor_ids=entity.referenced_floors,
43+
known_floor_ids=self._known_floor_ids,
44+
)

0 commit comments

Comments
 (0)