From 6f8a8cefc3aa89a702a008fb2d9e79f7fed967d6 Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Sun, 4 Oct 2026 18:47:12 +1100 Subject: [PATCH] Raise a LIFX repair issue for an entry that cannot be migrated (#184191) Signed-off-by: Avi Miller Co-authored-by: Claude Opus 5.5 --- homeassistant/components/lifx/__init__.py | 25 +++++- homeassistant/components/lifx/repairs.py | 37 +++++++++ homeassistant/components/lifx/strings.json | 13 +++ tests/components/lifx/test_repairs.py | 96 ++++++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/lifx/repairs.py create mode 100644 tests/components/lifx/test_repairs.py diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index 7774f0cc5dfa..4e1c26930fba 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -7,7 +7,7 @@ from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.typing import ConfigType from .const import CONF_SERIAL, DATA_LIFX_MANAGER, DOMAIN, LOGGER @@ -55,6 +55,11 @@ PLATFORMS = [ ] +def invalid_serial_issue_id(entry_id: str) -> str: + """Return the repair issue ID for an entry whose serial cannot be migrated.""" + return f"invalid_serial_{entry_id}" + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIFX component.""" async_setup_services(hass) @@ -80,6 +85,19 @@ async def async_migrate_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> bo except ValueError as err: # Nothing can make the entry usable, so it is left for the user to # remove rather than retried + ir.async_create_issue( + hass, + DOMAIN, + invalid_serial_issue_id(entry.entry_id), + data={"entry_id": entry.entry_id}, + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key="invalid_serial", + translation_placeholders={ + "title": entry.title, + "unique_id": entry.unique_id, + }, + ) raise ConfigEntryError( translation_domain=DOMAIN, translation_key="invalid_serial", @@ -137,3 +155,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> boo # is the usual reason an entry is being unloaded in the first place LOGGER.debug("Could not stop the effects running on %s: %s", entry.title, err) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_remove_entry(hass: HomeAssistant, entry: LIFXConfigEntry) -> None: + """Remove the repair issue raised for an entry that could not be migrated.""" + ir.async_delete_issue(hass, DOMAIN, invalid_serial_issue_id(entry.entry_id)) diff --git a/homeassistant/components/lifx/repairs.py b/homeassistant/components/lifx/repairs.py new file mode 100644 index 000000000000..5627089321dc --- /dev/null +++ b/homeassistant/components/lifx/repairs.py @@ -0,0 +1,37 @@ +"""Repairs for LIFX.""" + +from typing import override + +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlowResult +from homeassistant.core import HomeAssistant + + +class InvalidSerialRepairFlow(ConfirmRepairFlow): + """Remove an entry whose serial cannot be migrated.""" + + def __init__(self, entry_id: str) -> None: + """Initialize the flow.""" + self.entry_id = entry_id + + @override + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> RepairsFlowResult: + """Remove the entry once the user confirms.""" + # The entry may have been deleted by hand while this flow was open + if ( + user_input is not None + and self.hass.config_entries.async_get_entry(self.entry_id) is not None + ): + await self.hass.config_entries.async_remove(self.entry_id) + return await super().async_step_confirm(user_input) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> InvalidSerialRepairFlow: + """Create a fix flow for an invalid serial issue.""" + assert data is not None + return InvalidSerialRepairFlow(str(data["entry_id"])) diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json index 75552010b889..bfbfaefeee2c 100644 --- a/homeassistant/components/lifx/strings.json +++ b/homeassistant/components/lifx/strings.json @@ -104,6 +104,19 @@ "message": "Error fetching the state of the LIFX device: {error}" } }, + "issues": { + "invalid_serial": { + "fix_flow": { + "step": { + "confirm": { + "description": "Home Assistant cannot migrate the LIFX entry \"{title}\" because its stored identifier \"{unique_id}\" is not a valid LIFX serial number.\n\nClick **Submit** to delete the entry then either wait for it to be rediscovered automatically or add it manually.", + "title": "[%key:component::lifx::issues::invalid_serial::title%]" + } + } + }, + "title": "LIFX entry {title} cannot be migrated" + } + }, "services": { "effect_colorloop": { "description": "Runs an effect with looping colors.", diff --git a/tests/components/lifx/test_repairs.py b/tests/components/lifx/test_repairs.py new file mode 100644 index 000000000000..2ffe1e70dc73 --- /dev/null +++ b/tests/components/lifx/test_repairs.py @@ -0,0 +1,96 @@ +"""Tests for LIFX repairs.""" + +from homeassistant.components.lifx import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from . import IP_ADDRESS + +from tests.common import MockConfigEntry +from tests.components.repairs import process_repair_fix_flow, start_repair_fix_flow +from tests.typing import ClientSessionGenerator + +MALFORMED_UNIQUE_ID = "gg:bb:cc:dd:ee:cc" + + +async def _setup_malformed_entry(hass: HomeAssistant) -> MockConfigEntry: + """Set up an entry whose stored serial cannot be migrated.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Kitchen", + version=1, + unique_id=MALFORMED_UNIQUE_ID, + data={CONF_HOST: IP_ADDRESS}, + ) + entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is ConfigEntryState.MIGRATION_ERROR + return entry + + +async def test_invalid_serial_fix_flow_removes_entry( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the invalid serial repair removes the entry it was raised for.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await _setup_malformed_entry(hass) + issue_id = f"invalid_serial_{entry.entry_id}" + + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue is not None + assert issue.is_fixable + assert issue.severity is ir.IssueSeverity.ERROR + assert issue.translation_placeholders == { + "title": "Kitchen", + "unique_id": MALFORMED_UNIQUE_ID, + } + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert data["step_id"] == "confirm" + + data = await process_repair_fix_flow(client, data["flow_id"]) + await hass.async_block_till_done() + + assert data["type"] == "create_entry" + assert hass.config_entries.async_get_entry(entry.entry_id) is None + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_removing_entry_deletes_invalid_serial_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test deleting the entry by hand also clears its repair issue.""" + entry = await _setup_malformed_entry(hass) + issue_id = f"invalid_serial_{entry.entry_id}" + assert issue_registry.async_get_issue(DOMAIN, issue_id) is not None + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert issue_registry.async_get_issue(DOMAIN, issue_id) is None + + +async def test_invalid_serial_fix_flow_entry_already_removed( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test confirming the repair after the entry was deleted by hand.""" + assert await async_setup_component(hass, "repairs", {}) + entry = await _setup_malformed_entry(hass) + issue_id = f"invalid_serial_{entry.entry_id}" + + client = await hass_client() + data = await start_repair_fix_flow(client, DOMAIN, issue_id) + assert data["step_id"] == "confirm" + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + data = await process_repair_fix_flow(client, data["flow_id"]) + + assert data["type"] == "create_entry"