diff --git a/custom_components/tinxylocal/__init__.py b/custom_components/tinxylocal/__init__.py index 2aeeb7c..7789d6e 100644 --- a/custom_components/tinxylocal/__init__.py +++ b/custom_components/tinxylocal/__init__.py @@ -7,7 +7,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import slugify from .const import ( CONF_DEVICE, @@ -34,6 +36,40 @@ ] +def _async_repair_doubled_entity_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Strip a duplicated device name from generated entity ids. + + Upgrading from 2.x produced ids like `sensor.hall_hall_ip_address`: the + device name appears twice. Fresh installs are unaffected. On one instance, + same version, a device added fresh got `sensor.hub_ip_address` while three + upgraded ones doubled, so it is specific to that upgrade rather than to the + sensor code. + + It has not been reproduced outside a real upgraded instance, so this repairs + the result rather than the cause. The rename keeps the unique_id, so recorder + history follows the entity. Only entities this integration created are + touched, only where the doubled prefix is present, and only when the + corrected id is free. + """ + registry = er.async_get(hass) + for item in er.async_entries_for_config_entry(registry, entry.entry_id): + if not item.has_entity_name: + continue + + domain, _, object_id = item.entity_id.partition(".") + prefix = slugify(entry.title) + doubled = f"{prefix}_{prefix}_" + if not object_id.startswith(doubled): + continue + + fixed = f"{domain}.{object_id.replace(doubled, f'{prefix}_', 1)}" + if registry.async_get(fixed): + continue + + _LOGGER.info("Renaming %s to %s", item.entity_id, fixed) + registry.async_update_entity(item.entity_id, new_entity_id=fixed) + + def _async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Bring entries created before 3.0.0 up to date, in place. @@ -61,6 +97,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TinxyConfigEntry) -> boo """Set up Tinxy from a config entry.""" _async_migrate_entry(hass, entry) + _async_repair_doubled_entity_ids(hass, entry) web_session = async_get_clientsession(hass) diff --git a/custom_components/tinxylocal/manifest.json b/custom_components/tinxylocal/manifest.json index e2fc3e2..12d6132 100644 --- a/custom_components/tinxylocal/manifest.json +++ b/custom_components/tinxylocal/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "issue_tracker": "https://github.com/arevindh/tinxylocal/issues", "requirements": [], - "version": "3.0.0b1", + "version": "3.0.0b2", "zeroconf": [ { "name": "tinxy*", diff --git a/tests/test_init.py b/tests/test_init.py index eb3bec0..0b93c52 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -91,3 +91,55 @@ async def test_options_are_applied_to_the_hub( # the coordinator must poll through the same hubs the platforms command assert coordinator.hubs[0].request_timeout == 9 assert coordinator.hubs[0].rate_limit_delay == 3 + + +async def test_doubled_entity_ids_are_repaired( + hass: HomeAssistant, device_online: AiohttpClientMocker +) -> None: + """Upgrading from 2.x produced ids with the device name twice. + + The entity keeps its unique_id, so recorder history follows it across. + """ + from homeassistant.helpers import entity_registry as er + + from .const import DEVICE_ID + + entry = MockConfigEntry( + domain=DOMAIN, data=ENTRY_DATA, unique_id=CHIP_ID, title="Hall" + ) + entry.add_to_hass(hass) + + registry = er.async_get(hass) + broken = registry.async_get_or_create( + "sensor", + DOMAIN, + f"{DEVICE_ID}_ip", + config_entry=entry, + original_name="IP address", + has_entity_name=True, + suggested_object_id="hall_hall_ip_address", + ) + assert broken.entity_id == "sensor.hall_hall_ip_address" + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert registry.async_get("sensor.hall_hall_ip_address") is None + fixed = registry.async_get("sensor.hall_ip_address") + assert fixed is not None + assert fixed.unique_id == f"{DEVICE_ID}_ip" + + +async def test_repair_leaves_correct_ids_alone( + hass: HomeAssistant, loaded_entry: MockConfigEntry +) -> None: + """A fresh install must not be touched by the repair.""" + from homeassistant.helpers import entity_registry as er + + registry = er.async_get(hass) + ids = { + e.entity_id + for e in er.async_entries_for_config_entry(registry, loaded_entry.entry_id) + } + assert "sensor.hall_ip_address" in ids + assert not any(".hall_hall_" in i for i in ids)