homematicip_cloud: migrate entity unique IDs to stable format (#166580)

Co-authored-by: Christian Lackas <9592452+lackas@users.noreply.github.com>
This commit is contained in:
Christian Lackas
2026-04-25 23:01:00 +02:00
committed by GitHub
co-authored by Christian Lackas
parent 759ac2eacd
commit f225d8162b
18 changed files with 681 additions and 82 deletions
@@ -1,5 +1,9 @@
"""Support for HomematicIP Cloud devices."""
from __future__ import annotations
import logging
import voluptuous as vol
from homeassistant import config_entries
@@ -21,8 +25,11 @@ from .const import (
HMIPC_NAME,
)
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .migration import _migrate_unique_id
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(DOMAIN, default=[]): vol.All(
@@ -85,8 +92,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomematicIPConfigEntry)
if not await hap.async_setup():
return False
_async_remove_obsolete_entities(hass, entry, hap)
# Register on HA stop event to gracefully shutdown HomematicIP Cloud connection
hap.reset_connection_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, hap.shutdown
@@ -119,22 +124,61 @@ async def async_unload_entry(
return await hap.async_reset()
@callback
def _async_remove_obsolete_entities(
hass: HomeAssistant, entry: HomematicIPConfigEntry, hap: HomematicipHAP
):
"""Remove obsolete entities from entity registry."""
async def async_migrate_entry(
hass: HomeAssistant, config_entry: config_entries.ConfigEntry
) -> bool:
"""Migrate the config entry from version 1 to version 2."""
if config_entry.version > 2:
return False
if hap.home.currentAPVersion < "2.2.12":
return
if config_entry.version == 1:
_LOGGER.debug("Migrating HomematicIP Cloud config entry to version 2")
entity_registry = er.async_get(hass)
er_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
for er_entry in er_entries:
if er_entry.unique_id.startswith("HomematicipAccesspointStatus"):
entity_registry.async_remove(er_entry.entity_id)
continue
# Remove obsolete entities before the bulk unique_id rewrite.
# After rewrite, old-format patterns would no longer be matchable.
# HomematicipAccesspointStatus* entities are always obsolete (removed
# in firmware 2.2.12+). HomematicipBatterySensor_{hapid} entities for
# access points are also obsolete. Those legacy access point battery
# entities do not belong to a device registry device, unlike real
# device battery sensors, so we can safely remove them before rewrite.
entity_registry = er.async_get(hass)
entries = er.async_entries_for_config_entry(
entity_registry, config_entry.entry_id
)
for entry in entries:
if entry.unique_id.startswith("HomematicipAccesspointStatus") or (
entry.unique_id.startswith("HomematicipBatterySensor_")
and entry.device_id is None
):
_LOGGER.debug(
"Removing obsolete entity: %s (%s)",
entry.entity_id,
entry.unique_id,
)
entity_registry.async_remove(entry.entity_id)
for hapid in hap.home.accessPointUpdateStates:
if er_entry.unique_id == f"HomematicipBatterySensor_{hapid}":
entity_registry.async_remove(er_entry.entity_id)
@callback
def _update_unique_id(
entity_entry: er.RegistryEntry,
) -> dict[str, str] | None:
new_unique_id = _migrate_unique_id(entity_entry.unique_id)
if new_unique_id is None:
_LOGGER.debug(
"Skipping unique_id %s (already stable format)",
entity_entry.unique_id,
)
return None
_LOGGER.debug(
"Migrating %s: %s -> %s",
entity_entry.entity_id,
entity_entry.unique_id,
new_unique_id,
)
return {"new_unique_id": new_unique_id}
await er.async_migrate_entries(hass, config_entry.entry_id, _update_unique_id)
hass.config_entries.async_update_entry(config_entry, version=2)
_LOGGER.info("Migration to version 2 successful")
return True
@@ -42,6 +42,7 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity):
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_code_arm_required = False
_feature_id = "alarm"
def __init__(self, hap: HomematicipHAP) -> None:
"""Initialize the alarm control panel."""
@@ -127,4 +128,4 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity):
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return f"{self.__class__.__name__}_{self._home.id}"
return f"{self._home.id}_{self._feature_id}"
@@ -179,7 +179,7 @@ class HomematicipCloudConnectionSensor(HomematicipGenericEntity, BinarySensorEnt
def __init__(self, hap: HomematicipHAP) -> None:
"""Initialize the cloud connection sensor."""
super().__init__(hap, hap.home)
super().__init__(hap, hap.home, feature_id="cloud_connection")
@property
def name(self) -> str:
@@ -245,10 +245,18 @@ class HomematicipBaseActionSensor(HomematicipGenericEntity, BinarySensorEntity):
class HomematicipAccelerationSensor(HomematicipBaseActionSensor):
"""Representation of the HomematicIP acceleration sensor."""
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the acceleration sensor."""
super().__init__(hap, device, feature_id="acceleration")
class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor):
"""Representation of the HomematicIP tilt vibration sensor."""
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the tilt vibration sensor."""
super().__init__(hap, device, feature_id="tilt_vibration")
class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity):
"""Representation of the HomematicIP multi room/area contact interface."""
@@ -262,6 +270,7 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt
channel=1,
is_multi_channel=True,
channel_real_index=None,
feature_id: str = "contact",
) -> None:
"""Initialize the multi contact entity."""
super().__init__(
@@ -270,6 +279,7 @@ class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEnt
channel=channel,
is_multi_channel=is_multi_channel,
channel_real_index=channel_real_index,
feature_id=feature_id,
)
@property
@@ -286,7 +296,7 @@ class HomematicipContactInterface(HomematicipMultiContactInterface, BinarySensor
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the multi contact entity."""
super().__init__(hap, device, is_multi_channel=False)
super().__init__(hap, device, is_multi_channel=False, feature_id="contact")
class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEntity):
@@ -298,7 +308,9 @@ class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEn
self, hap: HomematicipHAP, device, has_additional_state: bool = False
) -> None:
"""Initialize the shutter contact."""
super().__init__(hap, device, is_multi_channel=False)
super().__init__(
hap, device, is_multi_channel=False, feature_id="shutter_contact"
)
self.has_additional_state = has_additional_state
@property
@@ -319,6 +331,10 @@ class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOTION
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the motion detector."""
super().__init__(hap, device, feature_id="motion")
@property
def is_on(self) -> bool:
"""Return true if motion is detected."""
@@ -334,7 +350,7 @@ class HomematicipFullFlushLockControllerLocked(
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the full flush lock controller lock sensor."""
super().__init__(hap, device, post="Locked")
super().__init__(hap, device, post="Locked", feature_id="lock_locked")
@property
def is_on(self) -> bool:
@@ -359,7 +375,7 @@ class HomematicipFullFlushLockControllerGlassBreak(
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the full flush lock controller glass break sensor."""
super().__init__(hap, device, post="Glass break")
super().__init__(hap, device, post="Glass break", feature_id="glass_break")
@property
def is_on(self) -> bool:
@@ -379,6 +395,10 @@ class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.PRESENCE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the presence detector."""
super().__init__(hap, device, feature_id="presence")
@property
def is_on(self) -> bool:
"""Return true if presence is detected."""
@@ -390,6 +410,10 @@ class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.SMOKE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the smoke detector."""
super().__init__(hap, device, feature_id="smoke")
@property
def is_on(self) -> bool:
"""Return true if smoke is detected."""
@@ -410,7 +434,9 @@ class HomematicipSmokeDetectorChamberDegraded(
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize smoke detector chamber health sensor."""
super().__init__(hap, device, post="Chamber Degraded")
super().__init__(
hap, device, post="Chamber Degraded", feature_id="chamber_degraded"
)
@property
def is_on(self) -> bool:
@@ -423,6 +449,10 @@ class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity):
_attr_device_class = BinarySensorDeviceClass.MOISTURE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the water detector."""
super().__init__(hap, device, feature_id="water")
@property
def is_on(self) -> bool:
"""Return true, if moisture or waterlevel is detected."""
@@ -434,7 +464,7 @@ class HomematicipStormSensor(HomematicipGenericEntity, BinarySensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize storm sensor."""
super().__init__(hap, device, "Storm")
super().__init__(hap, device, "Storm", feature_id="storm")
@property
def icon(self) -> str:
@@ -454,7 +484,7 @@ class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize rain sensor."""
super().__init__(hap, device, "Raining")
super().__init__(hap, device, "Raining", feature_id="rain")
@property
def is_on(self) -> bool:
@@ -469,7 +499,7 @@ class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize sunshine sensor."""
super().__init__(hap, device, post="Sunshine")
super().__init__(hap, device, post="Sunshine", feature_id="sunshine")
@property
def is_on(self) -> bool:
@@ -495,7 +525,7 @@ class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize battery sensor."""
super().__init__(hap, device, post="Battery")
super().__init__(hap, device, post="Battery", channel=0, feature_id="battery")
@property
def is_on(self) -> bool:
@@ -512,7 +542,7 @@ class HomematicipPluggableMainsFailureSurveillanceSensor(
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize pluggable mains failure surveillance sensor."""
super().__init__(hap, device)
super().__init__(hap, device, feature_id="mains_failure")
@property
def is_on(self) -> bool:
@@ -525,10 +555,16 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorE
_attr_device_class = BinarySensorDeviceClass.SAFETY
def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None:
def __init__(
self,
hap: HomematicipHAP,
device,
post: str = "SecurityZone",
feature_id: str = "security_zone",
) -> None:
"""Initialize security zone group."""
device.modelType = f"HmIP-{post}"
super().__init__(hap, device, post=post)
super().__init__(hap, device, post=post, feature_id=feature_id)
@property
def available(self) -> bool:
@@ -578,7 +614,7 @@ class HomematicipSecuritySensorGroup(
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize security group."""
super().__init__(hap, device, post="Sensors")
super().__init__(hap, device, post="Sensors", feature_id="security")
@property
def extra_state_attributes(self) -> dict[str, Any]:
@@ -45,7 +45,7 @@ class HomematicipGarageDoorControllerButton(HomematicipGenericEntity, ButtonEnti
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize a wall mounted garage door controller."""
super().__init__(hap, device)
super().__init__(hap, device, feature_id="garage_button")
self._attr_icon = "mdi:arrow-up-down"
async def async_press(self) -> None:
@@ -58,7 +58,9 @@ class HomematicipFullFlushLockControllerButton(HomematicipGenericEntity, ButtonE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the full flush lock controller opener button."""
super().__init__(hap, device, post="Door opener")
super().__init__(
hap, device, post="Door opener", feature_id="lock_opener_button"
)
self._attr_icon = "mdi:door-open"
async def async_press(self) -> None:
@@ -83,7 +83,7 @@ class HomematicipHeatingGroup(HomematicipGenericEntity, ClimateEntity):
def __init__(self, hap: HomematicipHAP, device: HeatingGroup) -> None:
"""Initialize heating group."""
device.modelType = "HmIP-Heating-Group"
super().__init__(hap, device)
super().__init__(hap, device, feature_id="climate")
self._simple_heating = None
if device.actualTemperature is None:
self._simple_heating = self._first_radiator_thermostat
@@ -16,7 +16,7 @@ from .hap import HomematicipAuth
class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
"""Config flow for the HomematicIP Cloud component."""
VERSION = 1
VERSION = 2
auth: HomematicipAuth
@@ -69,6 +69,10 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity):
_attr_device_class = CoverDeviceClass.BLIND
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the blind module entity."""
super().__init__(hap, device, feature_id="blind")
@property
def current_cover_position(self) -> int | None:
"""Return current position of cover."""
@@ -153,10 +157,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity):
device,
channel=1,
is_multi_channel=True,
feature_id="shutter",
) -> None:
"""Initialize the multi cover entity."""
super().__init__(
hap, device, channel=channel, is_multi_channel=is_multi_channel
hap,
device,
channel=channel,
is_multi_channel=is_multi_channel,
feature_id=feature_id,
)
@property
@@ -218,7 +227,11 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity):
) -> None:
"""Initialize the multi slats entity."""
super().__init__(
hap, device, channel=channel, is_multi_channel=is_multi_channel
hap,
device,
channel=channel,
is_multi_channel=is_multi_channel,
feature_id="slats",
)
@property
@@ -269,6 +282,10 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity):
_attr_device_class = CoverDeviceClass.GARAGE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the garage door module entity."""
super().__init__(hap, device, feature_id="garage_door")
@property
def current_cover_position(self) -> int | None:
"""Return current position of cover."""
@@ -310,7 +327,9 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity):
def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None:
"""Initialize switching group."""
device.modelType = f"HmIP-{post}"
super().__init__(hap, device, post, is_multi_channel=False)
super().__init__(
hap, device, post, is_multi_channel=False, feature_id="shutter"
)
@property
def available(self) -> bool:
@@ -86,6 +86,8 @@ class HomematicipGenericEntity(Entity):
channel: int | None = None,
is_multi_channel: bool | None = False,
channel_real_index: int | None = None,
*,
feature_id: str,
) -> None:
"""Initialize the generic entity."""
self._hap = hap
@@ -101,6 +103,7 @@ class HomematicipGenericEntity(Entity):
# Using channel_real_index ensures you reference the correct channel.
self._channel_real_index: int | None = channel_real_index
self._feature_id = feature_id
self._is_multi_channel = is_multi_channel
self.functional_channel = None
with contextlib.suppress(ValueError):
@@ -237,11 +240,10 @@ class HomematicipGenericEntity(Entity):
@property
def unique_id(self) -> str:
"""Return a unique ID."""
unique_id = f"{self.__class__.__name__}_{self._device.id}"
if self._is_multi_channel:
unique_id = f"{self.__class__.__name__}_Channel{self.get_channel_index()}_{self._device.id}"
return unique_id
if not isinstance(self._device, Device):
return f"{self._device.id}_{self._feature_id}"
channel_index = self.get_channel_index()
return f"{self._device.id}_{channel_index}_{self._feature_id}"
@property
def icon(self) -> str | None:
@@ -85,6 +85,7 @@ class HomematicipDoorBellEvent(HomematicipGenericEntity, EventEntity):
post=description.key,
channel=channel,
is_multi_channel=False,
feature_id="doorbell",
)
self.entity_description = description
@@ -126,7 +126,7 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the light entity."""
super().__init__(hap, device)
super().__init__(hap, device, feature_id="light")
@property
def is_on(self) -> bool:
@@ -147,7 +147,13 @@ class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None:
"""Initialize the light entity."""
super().__init__(hap, device, channel=channel_index, is_multi_channel=True)
super().__init__(
hap,
device,
channel=channel_index,
is_multi_channel=True,
feature_id="color_light",
)
def _supports_color(self) -> bool:
"""Return true if device supports hue/saturation color control."""
@@ -243,7 +249,11 @@ class HomematicipMultiDimmer(HomematicipGenericEntity, LightEntity):
) -> None:
"""Initialize the dimmer light entity."""
super().__init__(
hap, device, channel=channel, is_multi_channel=is_multi_channel
hap,
device,
channel=channel,
is_multi_channel=is_multi_channel,
feature_id="dimmer",
)
@property
@@ -290,7 +300,14 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity):
def __init__(self, hap: HomematicipHAP, device, channel: int, post: str) -> None:
"""Initialize the notification light entity."""
super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True)
super().__init__(
hap,
device,
post=post,
channel=channel,
is_multi_channel=True,
feature_id="notification_light",
)
self._color_switcher: dict[str, tuple[float, float]] = {
RGBColorState.WHITE: (0.0, 0.0),
@@ -335,11 +352,6 @@ class HomematicipNotificationLight(HomematicipGenericEntity, LightEntity):
return state_attr
@property
def unique_id(self) -> str:
"""Return a unique ID."""
return f"{self.__class__.__name__}_{self._post}_{self._device.id}"
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
# Use hs_color from kwargs,
@@ -513,6 +525,7 @@ class HomematicipOpticalSignalLight(HomematicipGenericEntity, LightEntity):
channel=channel_index,
is_multi_channel=True,
channel_real_index=channel_index,
feature_id="optical_signal_light",
)
@property
@@ -614,7 +627,13 @@ class HomematicipCombinationSignallingLight(HomematicipGenericEntity, LightEntit
self, hap: HomematicipHAP, device: CombinationSignallingDevice
) -> None:
"""Initialize the combination signalling light entity."""
super().__init__(hap, device, channel=1, is_multi_channel=False)
super().__init__(
hap,
device,
channel=1,
is_multi_channel=False,
feature_id="combination_signalling_light",
)
@property
def _func_channel(self) -> NotificationMp3SoundChannel:
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import HomematicipGenericEntity
from .hap import HomematicIPConfigEntry
from .hap import HomematicIPConfigEntry, HomematicipHAP
from .helpers import handle_errors
_LOGGER = logging.getLogger(__name__)
@@ -53,6 +53,10 @@ class HomematicipDoorLockDrive(HomematicipGenericEntity, LockEntity):
_attr_supported_features = LockEntityFeature.OPEN
def __init__(self, hap: HomematicipHAP, device: DoorLockDrive) -> None:
"""Initialize the door lock drive."""
super().__init__(hap, device, feature_id="lock")
@property
def is_locked(self) -> bool | None:
"""Return true if device is locked."""
@@ -0,0 +1,233 @@
"""Unique ID migration for HomematicIP Cloud entities."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import re
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class _MigrationConfig:
"""Configuration for migrating a single entity class to the new unique_id format."""
feature_id: str
channel: int | None = None
is_group: bool = False
UNIQUE_ID_MIGRATION_MAP: dict[str, _MigrationConfig] = {
# binary_sensor
"HomematicipCloudConnectionSensor": _MigrationConfig(
"cloud_connection", is_group=True
),
"HomematicipAccelerationSensor": _MigrationConfig("acceleration", channel=1),
"HomematicipTiltVibrationSensor": _MigrationConfig("tilt_vibration", channel=1),
"HomematicipMultiContactInterface": _MigrationConfig("contact"),
"HomematicipContactInterface": _MigrationConfig("contact", channel=1),
"HomematicipShutterContact": _MigrationConfig("shutter_contact", channel=1),
"HomematicipMotionDetector": _MigrationConfig("motion", channel=1),
"HomematicipPresenceDetector": _MigrationConfig("presence", channel=1),
"HomematicipSmokeDetector": _MigrationConfig("smoke", channel=1),
"HomematicipWaterDetector": _MigrationConfig("water", channel=1),
"HomematicipStormSensor": _MigrationConfig("storm", channel=1),
"HomematicipRainSensor": _MigrationConfig("rain", channel=1),
"HomematicipSunshineSensor": _MigrationConfig("sunshine", channel=1),
"HomematicipBatterySensor": _MigrationConfig("battery", channel=0),
"HomematicipPluggableMainsFailureSurveillanceSensor": _MigrationConfig(
"mains_failure", channel=1
),
"HomematicipSecurityZoneSensorGroup": _MigrationConfig(
"security_zone", is_group=True
),
"HomematicipSecuritySensorGroup": _MigrationConfig("security", is_group=True),
"HomematicipFullFlushLockControllerLocked": _MigrationConfig(
"lock_locked", channel=1
),
"HomematicipFullFlushLockControllerGlassBreak": _MigrationConfig(
"glass_break", channel=1
),
"HomematicipSmokeDetectorChamberDegraded": _MigrationConfig(
"chamber_degraded", channel=1
),
# sensor
"HomematicipAccesspointDutyCycle": _MigrationConfig("duty_cycle", channel=0),
"HomematicipHeatingThermostat": _MigrationConfig("valve_position", channel=1),
"HomematicipHumiditySensor": _MigrationConfig("humidity", channel=1),
"HomematicipTemperatureSensor": _MigrationConfig("temperature", channel=1),
"HomematicipAbsoluteHumiditySensor": _MigrationConfig(
"absolute_humidity", channel=1
),
"HomematicipIlluminanceSensor": _MigrationConfig("illuminance", channel=1),
"HomematicipPowerSensor": _MigrationConfig("power", channel=1),
"HomematicipEnergySensor": _MigrationConfig("energy", channel=1),
"HomematicipWindspeedSensor": _MigrationConfig("wind_speed", channel=1),
"HomematicipTodayRainSensor": _MigrationConfig("today_rain", channel=1),
"HomematicipPassageDetectorDeltaCounter": _MigrationConfig(
"passage_counter", channel=1
),
"HomematicipWaterFlowSensor": _MigrationConfig("water_flow"),
"HomematicipWaterVolumeSensor": _MigrationConfig("water_volume"),
"HomematicipWaterVolumeSinceOpenSensor": _MigrationConfig(
"water_volume_since_open"
),
"HomematicipTiltAngleSensor": _MigrationConfig("tilt_angle", channel=1),
"HomematicipTiltStateSensor": _MigrationConfig("tilt_state", channel=1),
"HomematicipFloorTerminalBlockMechanicChannelValve": _MigrationConfig(
"ftb_valve_position"
),
"HomematicpTemperatureExternalSensorCh1": _MigrationConfig(
"temperature_external_ch1", channel=1
),
"HomematicpTemperatureExternalSensorCh2": _MigrationConfig(
"temperature_external_ch2", channel=1
),
"HomematicpTemperatureExternalSensorDelta": _MigrationConfig(
"temperature_external_delta", channel=1
),
"HmipEsiIecPowerConsumption": _MigrationConfig("esi_iec_power", channel=1),
"HmipEsiIecEnergyCounterHighTariff": _MigrationConfig(
"esi_iec_energy_high", channel=1
),
"HmipEsiIecEnergyCounterLowTariff": _MigrationConfig(
"esi_iec_energy_low", channel=1
),
"HmipEsiIecEnergyCounterInputSingleTariff": _MigrationConfig(
"esi_iec_energy_input", channel=1
),
"HmipEsiGasCurrentGasFlow": _MigrationConfig("esi_gas_flow", channel=1),
"HmipEsiGasGasVolume": _MigrationConfig("esi_gas_volume", channel=1),
"HmipEsiLedCurrentPowerConsumption": _MigrationConfig("esi_led_power", channel=1),
"HmipEsiLedEnergyCounterHighTariff": _MigrationConfig(
"esi_led_energy_high", channel=1
),
"HomematicipSoilMoistureSensor": _MigrationConfig("soil_moisture", channel=1),
"HomematicipSoilTemperatureSensor": _MigrationConfig("soil_temperature", channel=1),
# light
"HomematicipLight": _MigrationConfig("light", channel=1),
"HomematicipLightHS": _MigrationConfig("light"),
"HomematicipLightMeasuring": _MigrationConfig("light", channel=1),
"HomematicipMultiDimmer": _MigrationConfig("dimmer"),
"HomematicipDimmer": _MigrationConfig("dimmer", channel=1),
"HomematicipNotificationLight": _MigrationConfig("notification_light"),
"HomematicipNotificationLightV2": _MigrationConfig("notification_light"),
"HomematicipColorLight": _MigrationConfig("color_light", channel=1),
"HomematicipOpticalSignalLight": _MigrationConfig(
"optical_signal_light", channel=1
),
"HomematicipCombinationSignallingLight": _MigrationConfig(
"combination_signalling_light", channel=1
),
# switch
"HomematicipMultiSwitch": _MigrationConfig("switch"),
"HomematicipSwitch": _MigrationConfig("switch", channel=1),
"HomematicipGroupSwitch": _MigrationConfig("switch", is_group=True),
"HomematicipSwitchMeasuring": _MigrationConfig("switch", channel=1),
# cover
"HomematicipBlindModule": _MigrationConfig("blind", channel=1),
"HomematicipMultiCoverShutter": _MigrationConfig("shutter"),
"HomematicipCoverShutter": _MigrationConfig("shutter", channel=1),
"HomematicipMultiCoverSlats": _MigrationConfig("slats"),
"HomematicipCoverSlats": _MigrationConfig("slats", channel=1),
"HomematicipGarageDoorModule": _MigrationConfig("garage_door", channel=1),
"HomematicipCoverShutterGroup": _MigrationConfig("shutter", is_group=True),
# climate
"HomematicipHeatingGroup": _MigrationConfig("climate", is_group=True),
# weather
"HomematicipWeatherSensor": _MigrationConfig("weather", channel=1),
"HomematicipWeatherSensorPro": _MigrationConfig("weather", channel=1),
"HomematicipHomeWeather": _MigrationConfig("home_weather", is_group=True),
# valve
"HomematicipWateringValve": _MigrationConfig("watering"),
# lock
"HomematicipDoorLockDrive": _MigrationConfig("lock", channel=1),
# button
"HomematicipGarageDoorControllerButton": _MigrationConfig(
"garage_button", channel=1
),
"HomematicipFullFlushLockControllerButton": _MigrationConfig(
"lock_opener_button", channel=1
),
# event
"HomematicipDoorBellEvent": _MigrationConfig("doorbell", channel=1),
# alarm_control_panel
"HomematicipAlarmControlPanelEntity": _MigrationConfig("alarm", is_group=True),
# siren
"HomematicipMP3Siren": _MigrationConfig("siren", channel=1),
}
# Sorted by length descending so longer class names match before shorter ones
# (e.g., "HomematicipSwitchMeasuring" before "HomematicipSwitch")
_SORTED_CLASS_NAMES = sorted(UNIQUE_ID_MIGRATION_MAP, key=len, reverse=True)
_CHANNEL_RE = re.compile(r"^Channel(\d+)_(.+)$")
_NOTIFICATION_LIGHT_RE = re.compile(r"^(Top|Bottom)_(.+)$")
_NOTIFICATION_LIGHT_CHANNEL_MAP = {"Top": 2, "Bottom": 3}
def _migrate_unique_id(old_unique_id: str) -> str | None:
"""Convert an old-format unique_id to the new format.
Old formats:
{ClassName}_{device_id}
{ClassName}_Channel{N}_{device_id}
{ClassName}_{Top|Bottom}_{device_id} (NotificationLight only)
New format:
{device_id}_{channel}_{feature_id} (device entities)
{device_id}_{feature_id} (group/home entities)
"""
# Find the matching class name (longest first)
matched_class: str | None = None
for class_name in _SORTED_CLASS_NAMES:
prefix = class_name + "_"
if old_unique_id.startswith(prefix):
matched_class = class_name
break
if matched_class is None:
return None
config = UNIQUE_ID_MIGRATION_MAP[matched_class]
remainder = old_unique_id[len(matched_class) + 1 :]
# Parse remainder to extract channel and device_id
channel: int | None = None
device_id: str
# Check for Channel{N}_{rest} pattern
channel_match = _CHANNEL_RE.match(remainder)
if channel_match:
channel = int(channel_match.group(1))
device_id = channel_match.group(2)
elif matched_class in (
"HomematicipNotificationLight",
"HomematicipNotificationLightV2",
):
# Check for Top/Bottom pattern
notif_match = _NOTIFICATION_LIGHT_RE.match(remainder)
if notif_match:
channel = _NOTIFICATION_LIGHT_CHANNEL_MAP[notif_match.group(1)]
device_id = notif_match.group(2)
else:
device_id = remainder
channel = config.channel
else:
device_id = remainder
channel = config.channel
# Build new unique_id
if config.is_group:
return f"{device_id}_{config.feature_id}"
if channel is not None:
return f"{device_id}_{channel}_{config.feature_id}"
_LOGGER.warning(
"Cannot determine channel for unique_id: %s",
old_unique_id,
)
return None
@@ -383,7 +383,14 @@ class HomematicipWaterFlowSensor(HomematicipGenericEntity, SensorEntity):
self, hap: HomematicipHAP, device: Device, channel: int, post: str
) -> None:
"""Initialize the watering flow sensor device."""
super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True)
super().__init__(
hap,
device,
post=post,
channel=channel,
is_multi_channel=True,
feature_id="water_flow",
)
@property
def native_value(self) -> float | None:
@@ -405,9 +412,17 @@ class HomematicipWaterVolumeSensor(HomematicipGenericEntity, SensorEntity):
channel: int,
post: str,
attribute: str,
feature_id: str = "water_volume",
) -> None:
"""Initialize the watering volume sensor device."""
super().__init__(hap, device, post=post, channel=channel, is_multi_channel=True)
super().__init__(
hap,
device,
post=post,
channel=channel,
is_multi_channel=True,
feature_id=feature_id,
)
self._attribute_name = attribute
@property
@@ -430,6 +445,7 @@ class HomematicipWaterVolumeSinceOpenSensor(HomematicipWaterVolumeSensor):
channel=channel,
post="waterVolumeSinceOpen",
attribute="waterVolumeSinceOpen",
feature_id="water_volume_since_open",
)
@@ -441,7 +457,7 @@ class HomematicipTiltAngleSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the tilt angle sensor device."""
super().__init__(hap, device, post="Tilt Angle")
super().__init__(hap, device, post="Tilt Angle", feature_id="tilt_angle")
@property
def native_value(self) -> int | None:
@@ -458,7 +474,7 @@ class HomematicipTiltStateSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the tilt sensor device."""
super().__init__(hap, device, post="Tilt State")
super().__init__(hap, device, post="Tilt State", feature_id="tilt_state")
@property
def native_value(self) -> str | None:
@@ -502,6 +518,7 @@ class HomematicipFloorTerminalBlockMechanicChannelValve(
channel=channel,
is_multi_channel=is_multi_channel,
post="Valve Position",
feature_id="ftb_valve_position",
)
@property
@@ -540,7 +557,9 @@ class HomematicipAccesspointDutyCycle(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize access point status entity."""
super().__init__(hap, device, post="Duty Cycle")
super().__init__(
hap, device, post="Duty Cycle", channel=0, feature_id="duty_cycle"
)
@property
def native_value(self) -> float:
@@ -555,7 +574,7 @@ class HomematicipHeatingThermostat(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize heating thermostat device."""
super().__init__(hap, device, post="Heating")
super().__init__(hap, device, post="Heating", feature_id="valve_position")
@property
def icon(self) -> str | None:
@@ -583,7 +602,7 @@ class HomematicipHumiditySensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the thermometer device."""
super().__init__(hap, device, post="Humidity")
super().__init__(hap, device, post="Humidity", feature_id="humidity")
@property
def native_value(self) -> int:
@@ -600,7 +619,7 @@ class HomematicipTemperatureSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the thermometer device."""
super().__init__(hap, device, post="Temperature")
super().__init__(hap, device, post="Temperature", feature_id="temperature")
@property
def native_value(self) -> float:
@@ -633,7 +652,9 @@ class HomematicipAbsoluteHumiditySensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the thermometer device."""
super().__init__(hap, device, post="Absolute Humidity")
super().__init__(
hap, device, post="Absolute Humidity", feature_id="absolute_humidity"
)
@property
def native_value(self) -> float | None:
@@ -654,7 +675,7 @@ class HomematicipIlluminanceSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the device."""
super().__init__(hap, device, post="Illuminance")
super().__init__(hap, device, post="Illuminance", feature_id="illuminance")
@property
def native_value(self) -> float:
@@ -685,7 +706,7 @@ class HomematicipPowerSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the device."""
super().__init__(hap, device, post="Power")
super().__init__(hap, device, post="Power", feature_id="power")
@property
def native_value(self) -> float:
@@ -702,7 +723,7 @@ class HomematicipEnergySensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the device."""
super().__init__(hap, device, post="Energy")
super().__init__(hap, device, post="Energy", feature_id="energy")
@property
def native_value(self) -> float:
@@ -719,7 +740,7 @@ class HomematicipWindspeedSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the windspeed sensor."""
super().__init__(hap, device, post="Windspeed")
super().__init__(hap, device, post="Windspeed", feature_id="wind_speed")
@property
def native_value(self) -> float:
@@ -751,7 +772,7 @@ class HomematicipTodayRainSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the device."""
super().__init__(hap, device, post="Today Rain")
super().__init__(hap, device, post="Today Rain", feature_id="today_rain")
@property
def native_value(self) -> float:
@@ -768,7 +789,12 @@ class HomematicpTemperatureExternalSensorCh1(HomematicipGenericEntity, SensorEnt
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the device."""
super().__init__(hap, device, post="Channel 1 Temperature")
super().__init__(
hap,
device,
post="Channel 1 Temperature",
feature_id="temperature_external_ch1",
)
@property
def native_value(self) -> float:
@@ -785,7 +811,12 @@ class HomematicpTemperatureExternalSensorCh2(HomematicipGenericEntity, SensorEnt
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the device."""
super().__init__(hap, device, post="Channel 2 Temperature")
super().__init__(
hap,
device,
post="Channel 2 Temperature",
feature_id="temperature_external_ch2",
)
@property
def native_value(self) -> float:
@@ -802,7 +833,12 @@ class HomematicpTemperatureExternalSensorDelta(HomematicipGenericEntity, SensorE
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the device."""
super().__init__(hap, device, post="Delta Temperature")
super().__init__(
hap,
device,
post="Delta Temperature",
feature_id="temperature_external_delta",
)
@property
def native_value(self) -> float:
@@ -820,6 +856,7 @@ class HmipEsiSensorEntity(HomematicipGenericEntity, SensorEntity):
key: str,
value_fn: Callable[[FunctionalChannel], StateType],
type_fn: Callable[[FunctionalChannel], str],
feature_id: str,
) -> None:
"""Initialize Sensor Entity."""
super().__init__(
@@ -828,6 +865,7 @@ class HmipEsiSensorEntity(HomematicipGenericEntity, SensorEntity):
channel=1,
post=key,
is_multi_channel=False,
feature_id=feature_id,
)
self._value_fn = value_fn
@@ -862,6 +900,7 @@ class HmipEsiIecPowerConsumption(HmipEsiSensorEntity):
key="CurrentPowerConsumption",
value_fn=lambda channel: channel.currentPowerConsumption,
type_fn=lambda channel: "CurrentPowerConsumption",
feature_id="esi_iec_power",
)
@@ -880,6 +919,7 @@ class HmipEsiIecEnergyCounterHighTariff(HmipEsiSensorEntity):
key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF,
value_fn=lambda channel: channel.energyCounterOne,
type_fn=lambda channel: channel.energyCounterOneType,
feature_id="esi_iec_energy_high",
)
@@ -898,6 +938,7 @@ class HmipEsiIecEnergyCounterLowTariff(HmipEsiSensorEntity):
key=ESI_TYPE_ENERGY_COUNTER_USAGE_LOW_TARIFF,
value_fn=lambda channel: channel.energyCounterTwo,
type_fn=lambda channel: channel.energyCounterTwoType,
feature_id="esi_iec_energy_low",
)
@@ -916,6 +957,7 @@ class HmipEsiIecEnergyCounterInputSingleTariff(HmipEsiSensorEntity):
key=ESI_TYPE_ENERGY_COUNTER_INPUT_SINGLE_TARIFF,
value_fn=lambda channel: channel.energyCounterThree,
type_fn=lambda channel: channel.energyCounterThreeType,
feature_id="esi_iec_energy_input",
)
@@ -934,6 +976,7 @@ class HmipEsiGasCurrentGasFlow(HmipEsiSensorEntity):
key="CurrentGasFlow",
value_fn=lambda channel: channel.currentGasFlow,
type_fn=lambda channel: "CurrentGasFlow",
feature_id="esi_gas_flow",
)
@@ -952,6 +995,7 @@ class HmipEsiGasGasVolume(HmipEsiSensorEntity):
key="GasVolume",
value_fn=lambda channel: channel.gasVolume,
type_fn=lambda channel: "GasVolume",
feature_id="esi_gas_volume",
)
@@ -970,6 +1014,7 @@ class HmipEsiLedCurrentPowerConsumption(HmipEsiSensorEntity):
key="CurrentPowerConsumption",
value_fn=lambda channel: channel.currentPowerConsumption,
type_fn=lambda channel: "CurrentPowerConsumption",
feature_id="esi_led_power",
)
@@ -988,12 +1033,17 @@ class HmipEsiLedEnergyCounterHighTariff(HmipEsiSensorEntity):
key=ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF,
value_fn=lambda channel: channel.energyCounterOne,
type_fn=lambda channel: ESI_TYPE_ENERGY_COUNTER_USAGE_HIGH_TARIFF,
feature_id="esi_led_energy_high",
)
class HomematicipPassageDetectorDeltaCounter(HomematicipGenericEntity, SensorEntity):
"""Representation of the HomematicIP passage detector delta counter."""
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the passage detector delta counter."""
super().__init__(hap, device, feature_id="passage_counter")
@property
def native_value(self) -> int:
"""Return the passage detector delta counter value."""
@@ -1022,7 +1072,9 @@ class HmipSmokeDetectorSensor(HomematicipGenericEntity, SensorEntity):
description: HmipSmokeDetectorSensorDescription,
) -> None:
"""Initialize the smoke detector sensor."""
super().__init__(hap, device, post=description.key)
super().__init__(
hap, device, post=description.key, feature_id="smoke_detector_sensor"
)
self.entity_description = description
self._sensor_unique_id = f"{device.id}_{description.key}"
@@ -1047,7 +1099,12 @@ class HomematicipSoilMoistureSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the soil moisture sensor device."""
super().__init__(
hap, device, post="Soil Moisture", channel=1, is_multi_channel=True
hap,
device,
post="Soil Moisture",
channel=1,
is_multi_channel=True,
feature_id="soil_moisture",
)
@property
@@ -1068,7 +1125,12 @@ class HomematicipSoilTemperatureSensor(HomematicipGenericEntity, SensorEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the soil temperature sensor device."""
super().__init__(
hap, device, post="Soil Temperature", channel=1, is_multi_channel=True
hap,
device,
post="Soil Temperature",
channel=1,
is_multi_channel=True,
feature_id="soil_temperature",
)
@property
@@ -60,7 +60,14 @@ class HomematicipMP3Siren(HomematicipGenericEntity, SirenEntity):
self, hap: HomematicipHAP, device: CombinationSignallingDevice
) -> None:
"""Initialize the siren entity."""
super().__init__(hap, device, post="Siren", channel=1, is_multi_channel=False)
super().__init__(
hap,
device,
post="Siren",
channel=1,
is_multi_channel=False,
feature_id="siren",
)
@property
def _func_channel(self) -> NotificationMp3SoundChannel:
@@ -109,7 +109,11 @@ class HomematicipMultiSwitch(HomematicipGenericEntity, SwitchEntity):
) -> None:
"""Initialize the multi switch device."""
super().__init__(
hap, device, channel=channel, is_multi_channel=is_multi_channel
hap,
device,
channel=channel,
is_multi_channel=is_multi_channel,
feature_id="switch",
)
@property
@@ -143,7 +147,7 @@ class HomematicipGroupSwitch(HomematicipGenericEntity, SwitchEntity):
def __init__(self, hap: HomematicipHAP, device, post: str = "Group") -> None:
"""Initialize switching group."""
device.modelType = f"HmIP-{post}"
super().__init__(hap, device, post)
super().__init__(hap, device, post, feature_id="switch")
@property
def is_on(self) -> bool:
@@ -42,7 +42,12 @@ class HomematicipWateringValve(HomematicipGenericEntity, ValveEntity):
def __init__(self, hap: HomematicipHAP, device: Device, channel: int) -> None:
"""Initialize the valve."""
super().__init__(
hap, device=device, channel=channel, post="watering", is_multi_channel=True
hap,
device=device,
channel=channel,
post="watering",
is_multi_channel=True,
feature_id="watering",
)
async def async_open_valve(self) -> None:
@@ -72,7 +72,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity):
def __init__(self, hap: HomematicipHAP, device) -> None:
"""Initialize the weather sensor."""
super().__init__(hap, device)
super().__init__(hap, device, feature_id="weather")
@property
def name(self) -> str:
@@ -125,7 +125,7 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity):
def __init__(self, hap: HomematicipHAP) -> None:
"""Initialize the home weather."""
hap.home.modelType = "HmIP-Home-Weather"
super().__init__(hap, hap.home)
super().__init__(hap, hap.home, feature_id="home_weather")
@property
def available(self) -> bool:
@@ -3,6 +3,7 @@
from unittest.mock import AsyncMock, Mock, patch
from homematicip.exceptions.connection_exceptions import HmipConnectionError
import pytest
from homeassistant.components.homematicip_cloud.const import (
CONF_ACCESSPOINT,
@@ -16,6 +17,7 @@ from homeassistant.components.homematicip_cloud.hap import HomematicipHAP
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@@ -202,3 +204,161 @@ async def test_setup_services(hass: HomeAssistant) -> None:
assert len(config_entries) == 1
await hass.config_entries.async_unload(config_entries[0].entry_id)
# --- Unique ID migration tests ---
@pytest.fixture
def mock_config_entry_v1(hass: HomeAssistant) -> MockConfigEntry:
"""Create a v1 config entry for migration testing."""
entry = MockConfigEntry(
domain=DOMAIN,
data={HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "token", HMIPC_NAME: ""},
version=1,
)
entry.add_to_hass(hass)
return entry
@pytest.mark.parametrize(
("platform", "old_unique_id", "new_unique_id"),
[
(
"binary_sensor",
"HomematicipMotionDetector_3014F711ABCD",
"3014F711ABCD_1_motion",
),
(
"switch",
"HomematicipMultiSwitch_Channel3_3014F711ABCD",
"3014F711ABCD_3_switch",
),
(
"light",
"HomematicipNotificationLight_Top_3014F711ABCD",
"3014F711ABCD_2_notification_light",
),
("climate", "HomematicipHeatingGroup_UUID-GROUP-123", "UUID-GROUP-123_climate"),
],
ids=["single_channel", "multi_channel", "notification_light", "group"],
)
async def test_migrate_unique_id(
hass: HomeAssistant,
mock_config_entry_v1: MockConfigEntry,
entity_registry: er.EntityRegistry,
platform: str,
old_unique_id: str,
new_unique_id: str,
) -> None:
"""Test unique_id migration for different entity types."""
entity_registry.async_get_or_create(
platform,
DOMAIN,
old_unique_id,
config_entry=mock_config_entry_v1,
)
with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap:
instance = mock_hap.return_value
instance.async_setup = AsyncMock(return_value=True)
instance.home.id = "1"
instance.home.modelType = "mock-type"
instance.home.name = "mock-name"
instance.home.label = "mock-label"
instance.home.currentAPVersion = "mock-ap-version"
instance.async_reset = AsyncMock(return_value=True)
await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_v1.version == 2
assert entity_registry.async_get_entity_id(platform, DOMAIN, new_unique_id)
async def test_migrate_stable_unique_id_skipped(
hass: HomeAssistant,
mock_config_entry_v1: MockConfigEntry,
entity_registry: er.EntityRegistry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that a non-class-name unique_id is silently skipped and preserved."""
entity_registry.async_get_or_create(
"sensor",
DOMAIN,
"HomematicipFutureEntity_3014F711ABCD",
config_entry=mock_config_entry_v1,
)
with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap:
instance = mock_hap.return_value
instance.async_setup = AsyncMock(return_value=True)
instance.home.id = "1"
instance.home.modelType = "mock-type"
instance.home.name = "mock-name"
instance.home.label = "mock-label"
instance.home.currentAPVersion = "mock-ap-version"
instance.async_reset = AsyncMock(return_value=True)
await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_v1.version == 2
# Unknown prefix is not a known class name, so it's treated as already
# stable and skipped silently (no warning, just debug).
assert "already stable format" in caplog.text
# Old unique_id is preserved (not migrated)
assert entity_registry.async_get_entity_id(
"sensor", DOMAIN, "HomematicipFutureEntity_3014F711ABCD"
)
async def test_migrate_battery_and_obsolete_access_point(
hass: HomeAssistant,
mock_config_entry_v1: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test battery migration and obsolete access point entity removal."""
# Obsolete access point battery entity: legacy unique_id, no linked device.
obsolete_entity_id = entity_registry.async_get_or_create(
"binary_sensor",
DOMAIN,
"HomematicipBatterySensor_ABC123",
config_entry=mock_config_entry_v1,
).entity_id
# Real device battery entity: same legacy class prefix, but attached to a device.
device_entry = device_registry.async_get_or_create(
config_entry_id=mock_config_entry_v1.entry_id,
identifiers={(DOMAIN, "3014F711ABCD")},
)
entity_registry.async_get_or_create(
"binary_sensor",
DOMAIN,
"HomematicipBatterySensor_3014F711ABCD",
config_entry=mock_config_entry_v1,
device_id=device_entry.id,
)
with patch("homeassistant.components.homematicip_cloud.HomematicipHAP") as mock_hap:
instance = mock_hap.return_value
instance.async_setup = AsyncMock(return_value=True)
instance.home.id = "1"
instance.home.modelType = "mock-type"
instance.home.name = "mock-name"
instance.home.label = "mock-label"
instance.home.currentAPVersion = "mock-ap-version"
instance.async_reset = AsyncMock(return_value=True)
await hass.config_entries.async_setup(mock_config_entry_v1.entry_id)
await hass.async_block_till_done()
assert mock_config_entry_v1.version == 2
# Obsolete access point battery entity removed
assert entity_registry.async_get(obsolete_entity_id) is None
# Real device battery entity migrated
assert entity_registry.async_get_entity_id(
"binary_sensor", DOMAIN, "3014F711ABCD_0_battery"
)