Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions custom_components/bhyve/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
HomeAssistantError,
)
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
Expand Down Expand Up @@ -47,8 +48,45 @@
]


def _migrate_zone_switch_to_valve(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""
Remove stale v3 zone-switch registry entries left by the v4 upgrade.

v3 registered zone controls as `switch` entities with unique_id
`{mac}:{device}:{zone}:switch`. v4 moved them to the `valve` platform with
unique_id `{mac}:{device}:{zone}:valve`. Because the unique_id changed and
HA's registry refuses cross-domain entity_id updates, the old switch
entries remain orphaned after the upgrade; HA's automatic entity_id
suggestion then collides with the freshly created valve entity,
manifesting as the Recorder "cannot migrate history" warning and a
red/"Not provided" valve tile (issue #403).

Drop the orphans so the v4 valve entities are the only zone controls in
the registry. Historical data on the old switch entity is not preserved -
the platform migration already broke continuity.
"""
ent_reg = er.async_get(hass)
switch_suffix = ":switch"

for registry_entry in list(
er.async_entries_for_config_entry(ent_reg, entry.entry_id)
):
if registry_entry.domain != "switch" or not registry_entry.unique_id.endswith(
switch_suffix
):
continue

_LOGGER.info(
"Removing stale v3 zone switch %s (replaced by valve platform in v4)",
registry_entry.entity_id,
)
ent_reg.async_remove(registry_entry.entity_id)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up BHyve from a config entry."""
_migrate_zone_switch_to_valve(hass, entry)

client = BHyveClient(
entry.data[CONF_USERNAME],
entry.data[CONF_PASSWORD],
Expand Down
150 changes: 150 additions & 0 deletions tests/test_migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
"""Test registry migration from v3 zone switches to v4 valves."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest
from homeassistant.helpers import entity_registry as er
from pytest_homeassistant_custom_component.common import MockConfigEntry

if TYPE_CHECKING:
from homeassistant.core import HomeAssistant

from custom_components.bhyve import _migrate_zone_switch_to_valve
from custom_components.bhyve.const import DOMAIN


@pytest.mark.asyncio
async def test_removes_orphaned_zone_switch(hass: HomeAssistant) -> None:
"""v3 zone switch with `:switch` unique_id suffix gets dropped."""
config_entry = MockConfigEntry(domain=DOMAIN, title="Test", entry_id="test_entry")
config_entry.add_to_hass(hass)

ent_reg = er.async_get(hass)
ent_reg.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff:device123:1:switch",
suggested_object_id="zone_1_zone",
config_entry=config_entry,
)

_migrate_zone_switch_to_valve(hass, config_entry)

assert ent_reg.async_get("switch.zone_1_zone") is None


@pytest.mark.asyncio
async def test_preserves_existing_valve_when_switch_removed(
hass: HomeAssistant,
) -> None:
"""The v4 valve entity is left alone when the stale v3 switch is deleted."""
config_entry = MockConfigEntry(domain=DOMAIN, title="Test", entry_id="test_entry")
config_entry.add_to_hass(hass)

ent_reg = er.async_get(hass)
valve = ent_reg.async_get_or_create(
domain="valve",
platform=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff:device123:1:valve",
suggested_object_id="pond_zone_1_zone",
config_entry=config_entry,
)
ent_reg.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff:device123:1:switch",
suggested_object_id="zone_1_zone",
config_entry=config_entry,
)

_migrate_zone_switch_to_valve(hass, config_entry)

assert ent_reg.async_get("switch.zone_1_zone") is None
assert ent_reg.async_get(valve.entity_id) is not None


@pytest.mark.asyncio
async def test_leaves_non_zone_switches_untouched(hass: HomeAssistant) -> None:
"""Rain-delay, smart-watering, and program switches are left in place."""
config_entry = MockConfigEntry(domain=DOMAIN, title="Test", entry_id="test_entry")
config_entry.add_to_hass(hass)

ent_reg = er.async_get(hass)
# Rain delay uses `:rain_delay`, smart watering uses `:smart_watering`,
# program switches use `bhyve:program:...` - none end in `:switch`.
keep_entities = [
ent_reg.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff:device123:rain_delay",
suggested_object_id="pond_rain_delay",
config_entry=config_entry,
),
ent_reg.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff:device123:1:smart_watering",
suggested_object_id="pond_smart_watering",
config_entry=config_entry,
),
ent_reg.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id="bhyve:program:program-abc",
suggested_object_id="watering_program",
config_entry=config_entry,
),
]

_migrate_zone_switch_to_valve(hass, config_entry)

for entity in keep_entities:
assert ent_reg.async_get(entity.entity_id) is not None


@pytest.mark.asyncio
async def test_is_idempotent(hass: HomeAssistant) -> None:
"""Running the migration twice is safe."""
config_entry = MockConfigEntry(domain=DOMAIN, title="Test", entry_id="test_entry")
config_entry.add_to_hass(hass)

ent_reg = er.async_get(hass)
ent_reg.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff:device123:1:switch",
suggested_object_id="zone_1_zone",
config_entry=config_entry,
)

_migrate_zone_switch_to_valve(hass, config_entry)
_migrate_zone_switch_to_valve(hass, config_entry)

assert ent_reg.async_get("switch.zone_1_zone") is None


@pytest.mark.asyncio
async def test_only_touches_entities_for_this_config_entry(
hass: HomeAssistant,
) -> None:
"""Stale switches registered against a different config entry aren't touched."""
config_entry = MockConfigEntry(domain=DOMAIN, title="Test", entry_id="test_entry")
config_entry.add_to_hass(hass)

other_entry = MockConfigEntry(domain=DOMAIN, title="Other", entry_id="other_entry")
other_entry.add_to_hass(hass)

ent_reg = er.async_get(hass)
other = ent_reg.async_get_or_create(
domain="switch",
platform=DOMAIN,
unique_id="aa:bb:cc:dd:ee:ff:other:1:switch",
suggested_object_id="other_zone",
config_entry=other_entry,
)

_migrate_zone_switch_to_valve(hass, config_entry)

assert ent_reg.async_get(other.entity_id) is not None
Loading