Skip to content

Commit fe21b51

Browse files
0xAHAclaude
andcommitted
refactor: resolve a device's config entry without the deprecated attribute
DeviceEntry.config_entries is deprecated in Core 2026.8 and removed in 2027.8 — a device now belongs to exactly one config entry, exposed as config_entry_id. Reading the old attribute on a new core writes a deprecation warning naming this integration into the user's log, which is how another project's rename turns into bug reports here. Seven service handlers each carried a byte-identical copy of the same five-line lookup. They now share _config_entry_id_for_device(), so the version handling lives in one place — the same argument as the v1.3.5/v1.3.6 block-size bug, where a fix applied to one of two identical blocks left the other raising every poll. Selects on hasattr rather than on the value: the deprecated attribute still exists behind a compatibility shim on a new core, so testing its value would touch it and emit the warning being avoided. Backwards compatible both ways — the legacy set is still read on cores below 2026.8, and a device spanning several integrations still resolves to whichever entry is a loaded Growatt one. No version bump; this rides along with the next release. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 473faef commit fe21b51

2 files changed

Lines changed: 147 additions & 35 deletions

File tree

custom_components/growatt_modbus/diagnostic.py

Lines changed: 36 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,35 @@ def _coordinator_for_entry(hass: HomeAssistant, entry_id: str):
4747
return getattr(entry, "runtime_data", None) if entry else None
4848

4949

50+
def _config_entry_id_for_device(hass: HomeAssistant, device_entry) -> str | None:
51+
"""The loaded Growatt config entry backing a device, or None.
52+
53+
Every service that takes a `device_id` needs this, and each had its own copy of the
54+
same five lines. That is the duplication pattern this project has been bitten by
55+
before — v1.3.5 fixed a stored-format bug in one of two byte-identical blocks and
56+
left the other raising on every poll.
57+
58+
It also isolates a deprecation. `DeviceEntry.config_entries` is a set of entry ids;
59+
Core 2026.8 deprecates it in favour of the single `config_entry_id`, with removal in
60+
2027.8, because a device now belongs to exactly one config entry. Reading the old
61+
attribute on a new core writes a deprecation warning naming this integration into the
62+
user's log — which is how somebody else's rename turns into bug reports here.
63+
64+
`hasattr` rather than a value check, deliberately: on a new core the old attribute
65+
still exists behind a compatibility shim, so testing the value would touch it and
66+
emit the warning we are avoiding.
67+
"""
68+
if hasattr(device_entry, "config_entry_id"):
69+
entry_ids = [device_entry.config_entry_id] if device_entry.config_entry_id else []
70+
else: # Core < 2026.8
71+
entry_ids = list(device_entry.config_entries)
72+
73+
for entry_id in entry_ids:
74+
if _coordinator_for_entry(hass, entry_id) is not None:
75+
return entry_id
76+
return None
77+
78+
5079
def _all_coordinators(hass: HomeAssistant):
5180
"""Every loaded Growatt coordinator, as (entry_id, coordinator) pairs.
5281
@@ -587,11 +616,7 @@ async def write_register(call: ServiceCall) -> None:
587616
raise ValueError(f"Device {device_id} not found")
588617

589618
# Find the config entry for this device
590-
config_entry_id = None
591-
for entry_id in device_entry.config_entries:
592-
if _coordinator_for_entry(hass, entry_id) is not None:
593-
config_entry_id = entry_id
594-
break
619+
config_entry_id = _config_entry_id_for_device(hass, device_entry)
595620

596621
if not config_entry_id:
597622
_LOGGER.error("No config entry found for device %s", device_id)
@@ -642,11 +667,7 @@ async def write_registers(call: ServiceCall) -> None:
642667
raise ValueError(f"Device {device_id} not found")
643668

644669
# Find the config entry for this device
645-
config_entry_id = None
646-
for entry_id in device_entry.config_entries:
647-
if _coordinator_for_entry(hass, entry_id) is not None:
648-
config_entry_id = entry_id
649-
break
670+
config_entry_id = _config_entry_id_for_device(hass, device_entry)
650671

651672
if not config_entry_id:
652673
_LOGGER.error("No config entry found for device %s", device_id)
@@ -683,11 +704,7 @@ async def detect_grid_orientation(call: ServiceCall) -> None:
683704
if not device_entry:
684705
raise ValueError(f"Device {device_id} not found")
685706

686-
config_entry_id = None
687-
for entry_id in device_entry.config_entries:
688-
if _coordinator_for_entry(hass, entry_id) is not None:
689-
config_entry_id = entry_id
690-
break
707+
config_entry_id = _config_entry_id_for_device(hass, device_entry)
691708

692709
if not config_entry_id:
693710
raise ValueError(f"No config entry found for device {device_id}")
@@ -890,11 +907,7 @@ async def read_register(call: ServiceCall) -> None:
890907
raise ValueError(f"Device {device_id} not found")
891908

892909
# Find the config entry for this device
893-
config_entry_id = None
894-
for entry_id in device_entry.config_entries:
895-
if _coordinator_for_entry(hass, entry_id) is not None:
896-
config_entry_id = entry_id
897-
break
910+
config_entry_id = _config_entry_id_for_device(hass, device_entry)
898911

899912
if not config_entry_id:
900913
_LOGGER.error("No config entry found for device %s", device_id)
@@ -1087,11 +1100,7 @@ async def set_battery_mode(call: ServiceCall) -> None:
10871100
raise ValueError(f"Device {device_id} not found")
10881101

10891102
# Find the config entry for this device
1090-
config_entry_id = None
1091-
for entry_id in device_entry.config_entries:
1092-
if _coordinator_for_entry(hass, entry_id) is not None:
1093-
config_entry_id = entry_id
1094-
break
1103+
config_entry_id = _config_entry_id_for_device(hass, device_entry)
10951104

10961105
if not config_entry_id:
10971106
raise ValueError(f"No config entry found for device {device_id}")
@@ -1192,11 +1201,7 @@ async def get_register_data(call: ServiceCall):
11921201
raise ValueError(f"Device {device_id} not found")
11931202

11941203
# Find the config entry for this device
1195-
config_entry_id = None
1196-
for entry_id in device_entry.config_entries:
1197-
if _coordinator_for_entry(hass, entry_id) is not None:
1198-
config_entry_id = entry_id
1199-
break
1204+
config_entry_id = _config_entry_id_for_device(hass, device_entry)
12001205

12011206
if not config_entry_id:
12021207
raise ValueError(f"No config entry found for device {device_id}")
@@ -1261,11 +1266,7 @@ async def sync_tou_schedule(call: ServiceCall) -> None:
12611266
if not device_entry:
12621267
raise ValueError(f"Device {device_id} not found")
12631268

1264-
config_entry_id = None
1265-
for entry_id in device_entry.config_entries:
1266-
if _coordinator_for_entry(hass, entry_id) is not None:
1267-
config_entry_id = entry_id
1268-
break
1269+
config_entry_id = _config_entry_id_for_device(hass, device_entry)
12691270

12701271
if not config_entry_id:
12711272
raise ValueError(f"No config entry found for device {device_id}")
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""Resolving a device to its config entry, across Home Assistant versions.
2+
3+
`DeviceEntry.config_entries` is a set of config entry ids. Core 2026.8 deprecates it in
4+
favour of a single `config_entry_id` — a device now belongs to exactly one config entry —
5+
with removal in Core 2027.8.
6+
7+
Reading the deprecated attribute on a new core writes a warning naming this integration
8+
into the user's log. That is how another project's rename becomes bug reports here, so the
9+
old attribute must not be touched at all when the new one is available.
10+
11+
Seven service handlers each carried their own byte-identical copy of this lookup. They now
12+
share one helper — the same de-duplication the v1.3.5/v1.3.6 block-size bug argued for,
13+
where a fix applied to one of two identical blocks left the other raising on every poll.
14+
"""
15+
from __future__ import annotations
16+
17+
import importlib
18+
19+
import pytest
20+
21+
_diag = importlib.import_module("growatt_under_test.diagnostic")
22+
23+
24+
class _ModernDeviceEntry:
25+
"""Core >= 2026.8: one config entry, exposed singly."""
26+
27+
def __init__(self, entry_id):
28+
self.config_entry_id = entry_id
29+
30+
@property
31+
def config_entries(self): # pragma: no cover - must never be reached
32+
raise AssertionError(
33+
"the deprecated config_entries attribute was read on a modern core"
34+
)
35+
36+
37+
class _LegacyDeviceEntry:
38+
"""Core < 2026.8: a set, and no config_entry_id at all."""
39+
40+
def __init__(self, *entry_ids):
41+
self.config_entries = set(entry_ids)
42+
43+
44+
class _Hass:
45+
"""Only what the helper touches: entry lookup returning runtime_data."""
46+
47+
def __init__(self, loaded):
48+
self._loaded = loaded
49+
self.config_entries = self
50+
51+
def async_get_entry(self, entry_id):
52+
if entry_id not in self._loaded:
53+
return None
54+
return type("Entry", (), {"runtime_data": self._loaded[entry_id]})()
55+
56+
57+
def test_a_modern_core_uses_the_single_attribute():
58+
hass = _Hass({"abc": object()})
59+
assert _diag._config_entry_id_for_device(hass, _ModernDeviceEntry("abc")) == "abc"
60+
61+
62+
def test_a_modern_core_never_touches_the_deprecated_attribute():
63+
"""The point of the change. _ModernDeviceEntry raises if config_entries is read, so
64+
this fails loudly rather than silently emitting a deprecation warning in production."""
65+
hass = _Hass({"abc": object()})
66+
_diag._config_entry_id_for_device(hass, _ModernDeviceEntry("abc")) # must not raise
67+
68+
69+
def test_a_legacy_core_still_works():
70+
hass = _Hass({"abc": object()})
71+
assert _diag._config_entry_id_for_device(hass, _LegacyDeviceEntry("abc")) == "abc"
72+
73+
74+
def test_an_unloaded_entry_is_not_returned():
75+
"""A device can reference an entry that is disabled or not set up. Callers treat None
76+
as 'no config entry found' and raise a clear error; returning the id would have them
77+
resolve a coordinator that does not exist."""
78+
hass = _Hass({})
79+
assert _diag._config_entry_id_for_device(hass, _ModernDeviceEntry("abc")) is None
80+
assert _diag._config_entry_id_for_device(hass, _LegacyDeviceEntry("abc")) is None
81+
82+
83+
def test_a_device_with_no_config_entry_is_handled():
84+
"""config_entry_id can be None on a modern core for an orphaned device."""
85+
hass = _Hass({"abc": object()})
86+
assert _diag._config_entry_id_for_device(hass, _ModernDeviceEntry(None)) is None
87+
88+
89+
def test_a_legacy_device_picks_the_entry_that_is_actually_ours():
90+
"""Before the single-entry rule a device could span several integrations. Only one of
91+
them is a loaded Growatt entry, and that is the one to return."""
92+
ours = object()
93+
hass = _Hass({"growatt": ours})
94+
device = _LegacyDeviceEntry("somebody_else", "growatt")
95+
assert _diag._config_entry_id_for_device(hass, device) == "growatt"
96+
97+
98+
def test_the_duplicated_lookup_blocks_are_gone():
99+
"""Seven handlers had their own copy. Guards against a new service reintroducing one."""
100+
from pathlib import Path
101+
102+
source = (Path(__file__).parent.parent / "custom_components" / "growatt_modbus"
103+
/ "diagnostic.py").read_text(encoding="utf-8")
104+
105+
assert "for entry_id in device_entry.config_entries:" not in source, (
106+
"a service handler is reading DeviceEntry.config_entries directly again — it is "
107+
"deprecated in Core 2026.8 and removed in 2027.8"
108+
)
109+
# The compatibility branch inside the helper is the one permitted read.
110+
assert source.count("device_entry.config_entries") == 1
111+
assert source.count("_config_entry_id_for_device(hass, device_entry)") == 7

0 commit comments

Comments
 (0)