Skip to content

Commit f57b728

Browse files
Stabilize entity unique IDs across config migrations (#388)
## Summary - collapse all migration work into `v1.3` so this release uses a single migration step - in `v1.3`, migrate legacy pricing fields into policy rules and normalize section-prefixed unique IDs to stable IDs - in `v1.3`, handle duplicate conflicts by removing section-prefixed duplicates when stable unique IDs already exist ## Test plan - [x] `uv run pytest custom_components/haeo/migrations/tests/test_v1_3.py custom_components/haeo/migrations/tests/test_runner.py custom_components/haeo/entities/tests/test_haeo_number.py custom_components/haeo/entities/tests/test_haeo_switch.py` - [x] `uv run ruff check custom_components/haeo/migrations/__init__.py custom_components/haeo/migrations/v1_3.py custom_components/haeo/migrations/tests/test_v1_3.py`
1 parent 27b3235 commit f57b728

14 files changed

Lines changed: 893 additions & 685 deletions

File tree

custom_components/haeo/entities/haeo_number.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,23 @@
3434

3535
# Attributes to exclude from recorder when forecast recording is disabled
3636
FORECAST_UNRECORDED_ATTRIBUTES: frozenset[str] = frozenset({"forecast"})
37+
LIST_ITEM_FIELD_PATH_LENGTH = 3
38+
SECTION_FIELD_PATH_LENGTH = 2
39+
40+
41+
def _field_name_is_reused_in_other_sections(
42+
subentry_data: Mapping[str, Any],
43+
*,
44+
current_section: str,
45+
field_name: str,
46+
) -> bool:
47+
"""Return True when another section reuses this field name."""
48+
for section_key, section_data in subentry_data.items():
49+
if section_key == current_section or not isinstance(section_data, Mapping):
50+
continue
51+
if field_name in section_data:
52+
return True
53+
return False
3754

3855

3956
class ConfigEntityMode(Enum):
@@ -107,9 +124,23 @@ def __init__(
107124
msg = f"Invalid config value for field {field_info.field_name}"
108125
raise RuntimeError(msg)
109126

110-
# Unique ID for multi-hub safety: entry_id + subentry_id + field_name
127+
# Unique ID: entry_id + subentry_id + stable_key.
128+
# Keep leaf-only keys for simple fields, but disambiguate section fields
129+
# when the same leaf appears in multiple sections.
111130
field_path_key = ".".join(self._field_path)
112-
self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{field_path_key}"
131+
is_list_item_field = len(self._field_path) >= LIST_ITEM_FIELD_PATH_LENGTH
132+
unique_key = field_info.field_name
133+
if is_list_item_field:
134+
unique_key = field_path_key
135+
elif len(self._field_path) == SECTION_FIELD_PATH_LENGTH and _field_name_is_reused_in_other_sections(
136+
subentry.data,
137+
current_section=self._field_path[0],
138+
field_name=field_info.field_name,
139+
):
140+
unique_key = (
141+
f"{field_info.device_type}.{field_info.field_name}" if field_info.device_type else field_path_key
142+
)
143+
self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{unique_key}"
113144

114145
# Use entity description directly from field info
115146
self.entity_description = field_info.entity_description
@@ -166,8 +197,8 @@ def format_placeholder(value: Any) -> str:
166197
self._base_extra_attrs["direction"] = field_info.direction
167198

168199
# For list item fields, expose sibling fields from the list item
169-
if len(self._field_path) > 2: # noqa: PLR2004
170-
own_field = self._field_path[2]
200+
if len(self._field_path) >= LIST_ITEM_FIELD_PATH_LENGTH:
201+
own_field = field_info.field_name
171202
item = get_nested_config_value_by_path(subentry.data, self._field_path[:2])
172203
if isinstance(item, Mapping):
173204
for key, value in item.items():

custom_components/haeo/entities/haeo_switch.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,23 @@
3131

3232
# Attributes to exclude from recorder when forecast recording is disabled
3333
FORECAST_UNRECORDED_ATTRIBUTES: frozenset[str] = frozenset({"forecast"})
34+
LIST_ITEM_FIELD_PATH_LENGTH = 3
35+
SECTION_FIELD_PATH_LENGTH = 2
36+
37+
38+
def _field_name_is_reused_in_other_sections(
39+
subentry_data: Mapping[str, Any],
40+
*,
41+
current_section: str,
42+
field_name: str,
43+
) -> bool:
44+
"""Return True when another section reuses this field name."""
45+
for section_key, section_data in subentry_data.items():
46+
if section_key == current_section or not isinstance(section_data, Mapping):
47+
continue
48+
if field_name in section_data:
49+
return True
50+
return False
3451

3552

3653
class HaeoInputSwitch(SwitchEntity):
@@ -101,9 +118,21 @@ def __init__(
101118
msg = f"Invalid config value for field {field_info.field_name}"
102119
raise RuntimeError(msg)
103120

104-
# Unique ID for multi-hub safety: entry_id + subentry_id + field_name
121+
# Unique ID: stable key with section disambiguation when required.
105122
field_path_key = ".".join(self._field_path)
106-
self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{field_path_key}"
123+
is_list_item_field = len(self._field_path) >= LIST_ITEM_FIELD_PATH_LENGTH
124+
unique_key = field_info.field_name
125+
if is_list_item_field:
126+
unique_key = field_path_key
127+
elif len(self._field_path) == SECTION_FIELD_PATH_LENGTH and _field_name_is_reused_in_other_sections(
128+
subentry.data,
129+
current_section=self._field_path[0],
130+
field_name=field_info.field_name,
131+
):
132+
unique_key = (
133+
f"{field_info.device_type}.{field_info.field_name}" if field_info.device_type else field_path_key
134+
)
135+
self._attr_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{unique_key}"
107136

108137
# Use entity description directly from field info
109138
self.entity_description = field_info.entity_description

custom_components/haeo/entities/tests/test_haeo_number.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,57 @@ async def test_unique_id_includes_all_components(
658658
horizon_manager=horizon_manager,
659659
)
660660

661-
expected_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_efficiency.{power_field_info.field_name}"
661+
expected_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{power_field_info.field_name}"
662+
assert entity.unique_id == expected_unique_id
663+
664+
665+
async def test_unique_id_disambiguates_reused_section_field_names(
666+
hass: HomeAssistant,
667+
config_entry: MockConfigEntry,
668+
device_entry: Mock,
669+
horizon_manager: Mock,
670+
) -> None:
671+
"""Section fields with repeated leaf names use a collision-proof key."""
672+
partition_field_info = InputFieldInfo(
673+
field_name="partition_percentage",
674+
entity_description=NumberEntityDescription(
675+
key="partition_percentage",
676+
translation_key="partition_percentage",
677+
native_unit_of_measurement="%",
678+
native_min_value=0.0,
679+
native_max_value=100.0,
680+
native_step=1.0,
681+
),
682+
output_type=OutputType.STATE_OF_CHARGE,
683+
time_series=True,
684+
boundaries=True,
685+
device_type="undercharge_partition",
686+
)
687+
subentry = ConfigSubentry(
688+
data=MappingProxyType(
689+
{
690+
"element_type": "battery",
691+
CONF_NAME: "Test Battery",
692+
"undercharge": {"partition_percentage": as_constant_value(5.0)},
693+
"overcharge": {"partition_percentage": as_constant_value(95.0)},
694+
}
695+
),
696+
subentry_type="battery",
697+
title="Test Battery",
698+
unique_id=None,
699+
)
700+
config_entry.runtime_data = None
701+
702+
entity = HaeoInputNumber(
703+
config_entry=config_entry,
704+
subentry=subentry,
705+
field_info=partition_field_info,
706+
device_entry=device_entry,
707+
horizon_manager=horizon_manager,
708+
field_path=("undercharge", "partition_percentage"),
709+
)
710+
711+
expected_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_undercharge_partition.partition_percentage"
662712
assert entity.unique_id == expected_unique_id
663713

664714

custom_components/haeo/entities/tests/test_haeo_switch.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -485,9 +485,51 @@ async def test_unique_id_includes_all_components(
485485
horizon_manager=horizon_manager,
486486
)
487487

488-
expected_unique_id = (
489-
f"{config_entry.entry_id}_{subentry.subentry_id}_curtailment.{curtailment_field_info.field_name}"
488+
expected_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_{curtailment_field_info.field_name}"
489+
assert entity.unique_id == expected_unique_id
490+
491+
492+
async def test_unique_id_disambiguates_reused_section_field_names(
493+
hass: HomeAssistant,
494+
config_entry: MockConfigEntry,
495+
device_entry: Mock,
496+
horizon_manager: Mock,
497+
) -> None:
498+
"""Section switch fields with repeated leaf names use a unique stable key."""
499+
field_info = InputFieldInfo(
500+
field_name="enabled",
501+
entity_description=SwitchEntityDescription(
502+
key="enabled",
503+
translation_key="enabled",
504+
),
505+
output_type=OutputType.STATUS,
506+
device_type="undercharge_toggle",
490507
)
508+
subentry = ConfigSubentry(
509+
data=MappingProxyType(
510+
{
511+
"element_type": "battery",
512+
CONF_NAME: "Test Battery",
513+
"undercharge": {"enabled": as_constant_value(True)},
514+
"overcharge": {"enabled": as_constant_value(False)},
515+
}
516+
),
517+
subentry_type="battery",
518+
title="Test Battery",
519+
unique_id=None,
520+
)
521+
config_entry.runtime_data = None
522+
523+
entity = HaeoInputSwitch(
524+
config_entry=config_entry,
525+
subentry=subentry,
526+
field_info=field_info,
527+
device_entry=device_entry,
528+
horizon_manager=horizon_manager,
529+
field_path=("undercharge", "enabled"),
530+
)
531+
532+
expected_unique_id = f"{config_entry.entry_id}_{subentry.subentry_id}_undercharge_toggle.enabled"
491533
assert entity.unique_id == expected_unique_id
492534

493535

custom_components/haeo/migrations/__init__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,11 @@
77
from homeassistant.config_entries import ConfigEntry
88
from homeassistant.core import HomeAssistant
99

10-
from . import v1_3, v1_4
10+
from . import v1_3
1111

1212
type MigrationHandler = Callable[[HomeAssistant, ConfigEntry], Awaitable[bool]]
1313

14-
MIGRATIONS: tuple[tuple[int, MigrationHandler], ...] = (
15-
(v1_3.MINOR_VERSION, v1_3.async_migrate_entry),
16-
(v1_4.MINOR_VERSION, v1_4.async_migrate_entry),
17-
)
14+
MIGRATIONS: tuple[tuple[int, MigrationHandler], ...] = ((v1_3.MINOR_VERSION, v1_3.async_migrate_entry),)
1815

1916
MIGRATION_MINOR_VERSION = MIGRATIONS[-1][0] if MIGRATIONS else 0
2017

0 commit comments

Comments
 (0)