diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 28cdd83a198139..8d9c71eb1242a3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@v3.29.11 + uses: github/codeql-action/init@v3.30.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3.29.11 + uses: github/codeql-action/analyze@v3.30.0 with: category: "/language:python" diff --git a/build.yaml b/build.yaml index 00df41965231d7..8c7de3a46c1c4f 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.05.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.05.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.05.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.05.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.05.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2025.09.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2025.09.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2025.09.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2025.09.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2025.09.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index a476ee6cd70a39..e6c1b07680b431 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -385,6 +385,18 @@ class TuyaSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + TuyaSensorEntityDescription( + key=DPCode.FORWARD_ENERGY_TOTAL, + translation_key="total_energy", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + TuyaSensorEntityDescription( + key=DPCode.REVERSE_ENERGY_TOTAL, + translation_key="total_production", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), TuyaSensorEntityDescription( key=DPCode.SUPPLY_FREQUENCY, translation_key="supply_frequency", @@ -1400,6 +1412,12 @@ class TuyaSensorEntityDescription(SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + TuyaSensorEntityDescription( + key=DPCode.POWER_TOTAL, + translation_key="total_power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), TuyaSensorEntityDescription( key=DPCode.TOTAL_POWER, translation_key="total_power", diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index fa15e34694c452..a0d129b00ca379 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -598,6 +598,9 @@ "total_energy": { "name": "Total energy" }, + "total_power": { + "name": "Total power" + }, "total_production": { "name": "Total production" }, diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index b6f01ff31aef2d..501928ca5e062d 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -463,7 +463,7 @@ class DeletedDeviceEntry: validator=_normalize_connections_validator ) created_at: datetime = attr.ib() - disabled_by: DeviceEntryDisabler | None = attr.ib() + disabled_by: DeviceEntryDisabler | UndefinedType | None = attr.ib() id: str = attr.ib() identifiers: set[tuple[str, str]] = attr.ib() labels: set[str] = attr.ib() @@ -478,15 +478,19 @@ def to_device_entry( config_subentry_id: str | None, connections: set[tuple[str, str]], identifiers: set[tuple[str, str]], + disabled_by: DeviceEntryDisabler | UndefinedType | None, ) -> DeviceEntry: """Create DeviceEntry from DeletedDeviceEntry.""" # Adjust disabled_by based on config entry state - disabled_by = self.disabled_by - if config_entry.disabled_by: - if disabled_by is None: - disabled_by = DeviceEntryDisabler.CONFIG_ENTRY - elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: - disabled_by = None + if self.disabled_by is not UNDEFINED: + disabled_by = self.disabled_by + if config_entry.disabled_by: + if disabled_by is None: + disabled_by = DeviceEntryDisabler.CONFIG_ENTRY + elif disabled_by == DeviceEntryDisabler.CONFIG_ENTRY: + disabled_by = None + else: + disabled_by = disabled_by if disabled_by is not UNDEFINED else None return DeviceEntry( area_id=self.area_id, # type ignores: likely https://github.com/python/mypy/issues/8625 @@ -517,7 +521,10 @@ def as_storage_fragment(self) -> json_fragment: }, "connections": list(self.connections), "created_at": self.created_at, - "disabled_by": self.disabled_by, + "disabled_by": self.disabled_by + if self.disabled_by is not UNDEFINED + else None, + "disabled_by_undefined": self.disabled_by is UNDEFINED, "identifiers": list(self.identifiers), "id": self.id, "labels": list(self.labels), @@ -618,6 +625,11 @@ async def _async_migrate_func( # noqa: C901 device["connections"] = _normalize_connections( device["connections"] ) + if old_minor_version < 12: + # Version 1.12 adds undefined flags to deleted devices, this is a bugfix + # of version 1.10 + for device in old_data["deleted_devices"]: + device["disabled_by_undefined"] = old_minor_version < 10 if old_major_version > 2: raise NotImplementedError @@ -935,6 +947,7 @@ def async_get_or_create( config_subentry_id if config_subentry_id is not UNDEFINED else None, connections, identifiers, + disabled_by, ) disabled_by = UNDEFINED @@ -1502,7 +1515,21 @@ async def async_load(self) -> None: sw_version=device["sw_version"], via_device_id=device["via_device_id"], ) + # Introduced in 0.111 + def get_optional_enum[_EnumT: StrEnum]( + cls: type[_EnumT], value: str | None, undefined: bool + ) -> _EnumT | UndefinedType | None: + """Convert string to the passed enum, UNDEFINED or None.""" + if undefined: + return UNDEFINED + if value is None: + return None + try: + return cls(value) + except ValueError: + return None + for device in data["deleted_devices"]: deleted_devices[device["id"]] = DeletedDeviceEntry( area_id=device["area_id"], @@ -1515,10 +1542,10 @@ async def async_load(self) -> None: }, connections={tuple(conn) for conn in device["connections"]}, created_at=datetime.fromisoformat(device["created_at"]), - disabled_by=( - DeviceEntryDisabler(device["disabled_by"]) - if device["disabled_by"] - else None + disabled_by=get_optional_enum( + DeviceEntryDisabler, + device["disabled_by"], + device["disabled_by_undefined"], ), identifiers={tuple(iden) for iden in device["identifiers"]}, id=device["id"], diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5529d78e13a0f2..e8f1dea06397c9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -79,7 +79,7 @@ _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 18 +STORAGE_VERSION_MINOR = 19 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -164,6 +164,17 @@ def _protect_entity_options( return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) +def _protect_optional_entity_options( + data: EntityOptionsType | UndefinedType | None, +) -> ReadOnlyEntityOptionsType | UndefinedType: + """Protect entity options from being modified.""" + if data is UNDEFINED: + return UNDEFINED + if data is None: + return ReadOnlyDict({}) + return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) + + @attr.s(frozen=True, kw_only=True, slots=True) class RegistryEntry: """Entity Registry Entry.""" @@ -414,15 +425,17 @@ class DeletedRegistryEntry: config_subentry_id: str | None = attr.ib() created_at: datetime = attr.ib() device_class: str | None = attr.ib() - disabled_by: RegistryEntryDisabler | None = attr.ib() + disabled_by: RegistryEntryDisabler | UndefinedType | None = attr.ib() domain: str = attr.ib(init=False, repr=False) - hidden_by: RegistryEntryHider | None = attr.ib() + hidden_by: RegistryEntryHider | UndefinedType | None = attr.ib() icon: str | None = attr.ib() id: str = attr.ib() labels: set[str] = attr.ib() modified_at: datetime = attr.ib() name: str | None = attr.ib() - options: ReadOnlyEntityOptionsType = attr.ib(converter=_protect_entity_options) + options: ReadOnlyEntityOptionsType | UndefinedType = attr.ib( + converter=_protect_optional_entity_options + ) orphaned_timestamp: float | None = attr.ib() _cache: dict[str, Any] = attr.ib(factory=dict, eq=False, init=False) @@ -445,15 +458,22 @@ def as_storage_fragment(self) -> json_fragment: "config_subentry_id": self.config_subentry_id, "created_at": self.created_at, "device_class": self.device_class, - "disabled_by": self.disabled_by, + "disabled_by": self.disabled_by + if self.disabled_by is not UNDEFINED + else None, + "disabled_by_undefined": self.disabled_by is UNDEFINED, "entity_id": self.entity_id, - "hidden_by": self.hidden_by, + "hidden_by": self.hidden_by + if self.hidden_by is not UNDEFINED + else None, + "hidden_by_undefined": self.hidden_by is UNDEFINED, "icon": self.icon, "id": self.id, "labels": list(self.labels), "modified_at": self.modified_at, "name": self.name, - "options": self.options, + "options": self.options if self.options is not UNDEFINED else {}, + "options_undefined": self.options is UNDEFINED, "orphaned_timestamp": self.orphaned_timestamp, "platform": self.platform, "unique_id": self.unique_id, @@ -590,6 +610,14 @@ async def _async_migrate_func( # noqa: C901 entity["labels"] = [] entity["name"] = None entity["options"] = {} + if old_minor_version < 19: + # Version 1.19 adds undefined flags to deleted entities, this is a bugfix + # of version 1.18 + set_to_undefined = old_minor_version < 18 + for entity in data["deleted_entities"]: + entity["disabled_by_undefined"] = set_to_undefined + entity["hidden_by_undefined"] = set_to_undefined + entity["options_undefined"] = set_to_undefined if old_major_version > 1: raise NotImplementedError @@ -959,25 +987,30 @@ def async_get_or_create( categories = deleted_entity.categories created_at = deleted_entity.created_at device_class = deleted_entity.device_class - disabled_by = deleted_entity.disabled_by - # Adjust disabled_by based on config entry state - if config_entry and config_entry is not UNDEFINED: - if config_entry.disabled_by: - if disabled_by is None: - disabled_by = RegistryEntryDisabler.CONFIG_ENTRY + if deleted_entity.disabled_by is not UNDEFINED: + disabled_by = deleted_entity.disabled_by + # Adjust disabled_by based on config entry state + if config_entry and config_entry is not UNDEFINED: + if config_entry.disabled_by: + if disabled_by is None: + disabled_by = RegistryEntryDisabler.CONFIG_ENTRY + elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY: + disabled_by = None elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY: disabled_by = None - elif disabled_by == RegistryEntryDisabler.CONFIG_ENTRY: - disabled_by = None # Restore entity_id if it's available if self._entity_id_available(deleted_entity.entity_id): entity_id = deleted_entity.entity_id entity_registry_id = deleted_entity.id - hidden_by = deleted_entity.hidden_by + if deleted_entity.hidden_by is not UNDEFINED: + hidden_by = deleted_entity.hidden_by icon = deleted_entity.icon labels = deleted_entity.labels name = deleted_entity.name - options = deleted_entity.options + if deleted_entity.options is not UNDEFINED: + options = deleted_entity.options + else: + options = get_initial_options() if get_initial_options else None else: aliases = set() area_id = None @@ -1530,6 +1563,20 @@ async def async_load(self) -> None: previous_unique_id=entity["previous_unique_id"], unit_of_measurement=entity["unit_of_measurement"], ) + + def get_optional_enum[_EnumT: StrEnum]( + cls: type[_EnumT], value: str | None, undefined: bool + ) -> _EnumT | UndefinedType | None: + """Convert string to the passed enum, UNDEFINED or None.""" + if undefined: + return UNDEFINED + if value is None: + return None + try: + return cls(value) + except ValueError: + return None + for entity in data["deleted_entities"]: try: domain = split_entity_id(entity["entity_id"])[0] @@ -1555,23 +1602,25 @@ async def async_load(self) -> None: config_subentry_id=entity["config_subentry_id"], created_at=datetime.fromisoformat(entity["created_at"]), device_class=entity["device_class"], - disabled_by=( - RegistryEntryDisabler(entity["disabled_by"]) - if entity["disabled_by"] - else None + disabled_by=get_optional_enum( + RegistryEntryDisabler, + entity["disabled_by"], + entity["disabled_by_undefined"], ), entity_id=entity["entity_id"], - hidden_by=( - RegistryEntryHider(entity["hidden_by"]) - if entity["hidden_by"] - else None + hidden_by=get_optional_enum( + RegistryEntryHider, + entity["hidden_by"], + entity["hidden_by_undefined"], ), icon=entity["icon"], id=entity["id"], labels=set(entity["labels"]), modified_at=datetime.fromisoformat(entity["modified_at"]), name=entity["name"], - options=entity["options"], + options=entity["options"] + if not entity["options_undefined"] + else UNDEFINED, orphaned_timestamp=entity["orphaned_timestamp"], platform=entity["platform"], unique_id=entity["unique_id"], diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index c25a3b64562c51..6c162dc08fc792 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -47,14 +47,7 @@ def selector(config: Any) -> Selector: def validate_selector(config: Any) -> dict: """Validate a selector.""" selector_type, selector_class = _get_selector_type_and_class(config) - - # Selectors can be empty - if config[selector_type] is None: - config = {selector_type: {}} - - return { - selector_type: cast(dict, selector_class.CONFIG_SCHEMA(config[selector_type])) - } + return {selector_type: selector_class.CONFIG_SCHEMA(config[selector_type])} class Selector[_T: Mapping[str, Any]]: @@ -66,10 +59,6 @@ class Selector[_T: Mapping[str, Any]]: def __init__(self, config: Mapping[str, Any] | None = None) -> None: """Instantiate a selector.""" - # Selectors can be empty - if config is None: - config = {} - self.config = self.CONFIG_SCHEMA(config) def __eq__(self, other: object) -> bool: @@ -125,11 +114,25 @@ def _validate_supported_features(supported_features: list[str]) -> int: return feature_mask -BASE_SELECTOR_CONFIG_SCHEMA = vol.Schema( - { - vol.Optional("read_only"): bool, - } -) +def make_selector_config_schema(schema_dict: dict | None = None) -> vol.Schema: + """Make selector config schema.""" + if schema_dict is None: + schema_dict = {} + + def none_to_empty_dict(value: Any) -> Any: + if value is None: + return {} + return value + + return vol.Schema( + vol.All( + none_to_empty_dict, + { + vol.Optional("read_only"): bool, + **schema_dict, + }, + ) + ) class BaseSelectorConfig(TypedDict, total=False): @@ -224,7 +227,7 @@ class ActionSelector(Selector[ActionSelectorConfig]): selector_type = "action" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: ActionSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -248,7 +251,7 @@ class AddonSelector(Selector[AddonSelectorConfig]): selector_type = "addon" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("name"): str, vol.Optional("slug"): str, @@ -279,7 +282,7 @@ class AreaSelector(Selector[AreaSelectorConfig]): selector_type = "area" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("entity"): vol.All( cv.ensure_list, @@ -317,7 +320,7 @@ class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]): selector_type = "assist_pipeline" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: AssistPipelineSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -342,7 +345,7 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): selector_type = "attribute" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Required("entity_id"): cv.entity_id, # hide_attributes is used to hide attributes in the frontend. @@ -371,7 +374,7 @@ class BackupLocationSelector(Selector[BackupLocationSelectorConfig]): selector_type = "backup_location" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: BackupLocationSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -393,7 +396,7 @@ class BooleanSelector(Selector[BooleanSelectorConfig]): selector_type = "boolean" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: BooleanSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -415,7 +418,7 @@ class ColorRGBSelector(Selector[ColorRGBSelectorConfig]): selector_type = "color_rgb" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: ColorRGBSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -450,7 +453,7 @@ class ColorTempSelector(Selector[ColorTempSelectorConfig]): selector_type = "color_temp" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("unit", default=ColorTempSelectorUnit.MIRED): vol.All( vol.Coerce(ColorTempSelectorUnit), lambda val: val.value @@ -497,7 +500,7 @@ class ConditionSelector(Selector[ConditionSelectorConfig]): selector_type = "condition" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: ConditionSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -520,7 +523,7 @@ class ConfigEntrySelector(Selector[ConfigEntrySelectorConfig]): selector_type = "config_entry" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("integration"): str, } @@ -550,7 +553,7 @@ class ConstantSelector(Selector[ConstantSelectorConfig]): selector_type = "constant" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("label"): str, vol.Optional("translation_key"): cv.string, @@ -580,7 +583,7 @@ class ConversationAgentSelector(Selector[ConversationAgentSelectorConfig]): selector_type = "conversation_agent" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("language"): str, } @@ -609,7 +612,7 @@ class CountrySelector(Selector[CountrySelectorConfig]): selector_type = "country" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("countries"): [str], vol.Optional("no_sort", default=False): cv.boolean, @@ -640,7 +643,7 @@ class DateSelector(Selector[DateSelectorConfig]): selector_type = "date" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: DateSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -662,7 +665,7 @@ class DateTimeSelector(Selector[DateTimeSelectorConfig]): selector_type = "datetime" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: DateTimeSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -688,7 +691,7 @@ class DeviceSelector(Selector[DeviceSelectorConfig]): selector_type = "device" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { **_LEGACY_DEVICE_SELECTOR_CONFIG_SCHEMA_DICT, # Device has to contain entities matching this selector @@ -731,7 +734,7 @@ class DurationSelector(Selector[DurationSelectorConfig]): selector_type = "duration" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { # Enable day field in frontend. A selection with `days` set is allowed # even if `enable_day` is not set @@ -772,7 +775,7 @@ class EntitySelector(Selector[EntitySelectorConfig]): selector_type = "entity" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { **_LEGACY_ENTITY_SELECTOR_CONFIG_SCHEMA_DICT, vol.Optional("exclude_entities"): [str], @@ -832,7 +835,7 @@ class FileSelector(Selector[FileSelectorConfig]): selector_type = "file" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { # https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept vol.Required("accept"): str, @@ -867,7 +870,7 @@ class FloorSelector(Selector[FloorSelectorConfig]): selector_type = "floor" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("entity"): vol.All( cv.ensure_list, @@ -907,7 +910,7 @@ class IconSelector(Selector[IconSelectorConfig]): selector_type = "icon" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( {vol.Optional("placeholder"): str} # Frontend also has a fallbackPath option, this is not used by core ) @@ -934,7 +937,7 @@ class LabelSelector(Selector[LabelSelectorConfig]): selector_type = "label" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("multiple", default=False): cv.boolean, } @@ -968,7 +971,7 @@ class LanguageSelector(Selector[LanguageSelectorConfig]): selector_type = "language" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("languages"): [str], vol.Optional("native_name", default=False): cv.boolean, @@ -1001,7 +1004,7 @@ class LocationSelector(Selector[LocationSelectorConfig]): selector_type = "location" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( {vol.Optional("radius"): bool, vol.Optional("icon"): str} ) DATA_SCHEMA = vol.Schema( @@ -1034,7 +1037,7 @@ class MediaSelector(Selector[MediaSelectorConfig]): selector_type = "media" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("accept"): [str], } @@ -1109,7 +1112,7 @@ class NumberSelector(Selector[NumberSelectorConfig]): selector_type = "number" CONFIG_SCHEMA = vol.All( - BASE_SELECTOR_CONFIG_SCHEMA.extend( + make_selector_config_schema( { vol.Optional("min"): vol.Coerce(float), vol.Optional("max"): vol.Coerce(float), @@ -1169,7 +1172,7 @@ class ObjectSelector(Selector[ObjectSelectorConfig]): selector_type = "object" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("fields"): { str: { @@ -1217,7 +1220,7 @@ class QrCodeSelector(Selector[QrCodeSelectorConfig]): selector_type = "qr_code" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Required("data"): str, vol.Optional("scale"): int, @@ -1279,7 +1282,7 @@ class SelectSelector(Selector[SelectSelectorConfig]): selector_type = "select" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Required("options"): vol.All(vol.Any([str], [select_option])), vol.Optional("multiple", default=False): cv.boolean, @@ -1333,7 +1336,7 @@ class StateSelector(Selector[StateSelectorConfig]): selector_type = "state" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("entity_id"): cv.entity_id, vol.Optional("hide_states"): [str], @@ -1372,7 +1375,7 @@ class StatisticSelector(Selector[StatisticSelectorConfig]): selector_type = "statistic" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("multiple", default=False): cv.boolean, } @@ -1409,7 +1412,7 @@ class TargetSelector(Selector[TargetSelectorConfig]): selector_type = "target" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("entity"): vol.All( cv.ensure_list, @@ -1444,7 +1447,7 @@ class TemplateSelector(Selector[TemplateSelectorConfig]): selector_type = "template" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: TemplateSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -1491,7 +1494,7 @@ class TextSelector(Selector[TextSelectorConfig]): selector_type = "text" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("multiline", default=False): bool, vol.Optional("prefix"): str, @@ -1530,7 +1533,7 @@ class ThemeSelector(Selector[ThemeSelectorConfig]): selector_type = "theme" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA.extend( + CONFIG_SCHEMA = make_selector_config_schema( { vol.Optional("include_default", default=False): cv.boolean, } @@ -1556,7 +1559,7 @@ class TimeSelector(Selector[TimeSelectorConfig]): selector_type = "time" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: TimeSelectorConfig | None = None) -> None: """Instantiate a selector.""" @@ -1578,7 +1581,7 @@ class TriggerSelector(Selector[TriggerSelectorConfig]): selector_type = "trigger" - CONFIG_SCHEMA = BASE_SELECTOR_CONFIG_SCHEMA + CONFIG_SCHEMA = make_selector_config_schema() def __init__(self, config: TriggerSelectorConfig | None = None) -> None: """Instantiate a selector.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index f9c846c60faacb..a30d5c67cefb91 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -190,7 +190,7 @@ def validate_supported_feature(supported_feature: str) -> Any: _SERVICE_SCHEMA = vol.Schema( { - vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), + vol.Optional("target"): TargetSelector.CONFIG_SCHEMA, vol.Optional("fields"): vol.Schema( {str: vol.Any(_SECTION_SCHEMA, _FIELD_SCHEMA)} ), diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py index 741fac3fcf7a21..2351ab9468bd26 100644 --- a/homeassistant/helpers/trigger.py +++ b/homeassistant/helpers/trigger.py @@ -83,7 +83,7 @@ _TRIGGER_SCHEMA = vol.Schema( { - vol.Optional("target"): vol.Any(TargetSelector.CONFIG_SCHEMA, None), + vol.Optional("target"): TargetSelector.CONFIG_SCHEMA, vol.Optional("fields"): vol.Schema({str: _FIELD_SCHEMA}), }, extra=vol.ALLOW_EXTRA, diff --git a/script/hassfest/services.py b/script/hassfest/services.py index 84d3aaefa885ac..844a8955470de5 100644 --- a/script/hassfest/services.py +++ b/script/hassfest/services.py @@ -119,9 +119,7 @@ def _service_schema(targeted: bool, custom: bool) -> vol.Schema: } if targeted: - schema_dict[vol.Required("target")] = vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ) + schema_dict[vol.Required("target")] = selector.TargetSelector.CONFIG_SCHEMA if custom: schema_dict |= CUSTOM_INTEGRATION_EXTRA_SCHEMA_DICT diff --git a/script/hassfest/triggers.py b/script/hassfest/triggers.py index 7406e6f98ea673..4eb376c435fb84 100644 --- a/script/hassfest/triggers.py +++ b/script/hassfest/triggers.py @@ -38,9 +38,7 @@ def exists(value: Any) -> Any: TRIGGER_SCHEMA = vol.Any( vol.Schema( { - vol.Optional("target"): vol.Any( - selector.TargetSelector.CONFIG_SCHEMA, None - ), + vol.Optional("target"): selector.TargetSelector.CONFIG_SCHEMA, vol.Optional("fields"): vol.Schema({str: FIELD_SCHEMA}), } ), diff --git a/tests/components/tuya/fixtures/dlq_dikb3dp6.json b/tests/components/tuya/fixtures/dlq_dikb3dp6.json new file mode 100644 index 00000000000000..80f6581805a6d2 --- /dev/null +++ b/tests/components/tuya/fixtures/dlq_dikb3dp6.json @@ -0,0 +1,148 @@ +{ + "endpoint": "https://apigw.tuyaus.com", + "terminal_id": "1747852059900mCJdQO", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "eb40f2a309edea3892f0o2", + "name": "Medidor de Energia", + "category": "dlq", + "product_id": "dikb3dp6", + "product_name": "Metering_3PN_ZB", + "online": true, + "sub": true, + "time_zone": "-03:00", + "active_time": "2025-09-01T18:39:27+00:00", + "create_time": "2025-09-01T18:39:27+00:00", + "update_time": "2025-09-01T18:39:27+00:00", + "function": { + "clear_energy": { + "type": "Boolean", + "value": {} + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status_range": { + "forward_energy_total": { + "type": "Integer", + "value": { + "unit": "kW.h", + "min": 0, + "max": 999999999, + "scale": 2, + "step": 1 + } + }, + "phase_a": { + "type": "Raw", + "value": {} + }, + "phase_b": { + "type": "Raw", + "value": {} + }, + "phase_c": { + "type": "Raw", + "value": {} + }, + "fault": { + "type": "Bitmap", + "value": { + "label": [ + "short_circuit_alarm", + "surge_alarm", + "overload_alarm", + "leakagecurr_alarm", + "temp_dif_fault", + "fire_alarm", + "high_power_alarm", + "self_test_alarm", + "ov_cr", + "unbalance_alarm", + "ov_vol", + "undervoltage_alarm", + "miss_phase_alarm", + "outage_alarm", + "magnetism_alarm", + "credit_alarm", + "no_balance_alarm" + ] + } + }, + "energy_reset": { + "type": "Enum", + "value": { + "range": ["empty"] + } + }, + "alarm_set_1": { + "type": "Raw", + "value": {} + }, + "alarm_set_2": { + "type": "Raw", + "value": {} + }, + "breaker_number": { + "type": "String", + "value": { + "maxlen": 255 + } + }, + "reverse_energy_total": { + "type": "Integer", + "value": { + "unit": "kW.h", + "min": 0, + "max": 999999999, + "scale": 2, + "step": 1 + } + }, + "supply_frequency": { + "type": "Integer", + "value": { + "unit": "Hz", + "min": 0, + "max": 9999, + "scale": 2, + "step": 1 + } + }, + "online_state": { + "type": "Enum", + "value": { + "range": ["online", "offline"] + } + } + }, + "status": { + "forward_energy_total": 13540, + "phase_a": "CREANUkADG8=", + "phase_b": "CTIAVfcAFGw=", + "phase_c": "CPQAI58ACBA=", + "fault": 16896, + "energy_reset": "empty", + "alarm_set_1": "BwAAGQ==", + "alarm_set_2": "AQAAPwIBAA8DAQD9BAAAtAUAAAAHAQAA", + "breaker_number": "dik24350001", + "reverse_energy_total": 12552, + "supply_frequency": 6002, + "online_state": "online" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json index 92f507abaca0ee..8b46157b0a4fd6 100644 --- a/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json +++ b/tests/components/tuya/fixtures/zndb_4ggkyflayu1h1ho9.json @@ -68,6 +68,16 @@ "type": "Json", "value": {} }, + "power_total": { + "type": "Integer", + "value": { + "unit": "kW", + "min": -99999999, + "max": 999999999, + "scale": 3, + "step": 1 + } + }, "fault": { "type": "Bitmap", "value": { @@ -192,6 +202,7 @@ "power": 6.912, "voltage": 52.7 }, + "power_total": 1500, "fault": 0, "frozen_time_set": { "day": 158, diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index f5d1f229c66f4b..b16cc6a73d82b3 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -3431,6 +3431,62 @@ 'state': '0.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.duan_lu_qi_ha_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.qi94v9dmdx4fkpncqldforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.duan_lu_qi_ha_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': '断路器HA Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.duan_lu_qi_ha_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.12', + }) +# --- # name: test_platform_setup_and_discovery[sensor.eau_chaude_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -8516,6 +8572,62 @@ 'state': '50.0', }) # --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_total_energy-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.metering_3pn_wifi_stable_total_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total energy', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_energy', + 'unique_id': 'tuya.obb7p55c0us6rdxkqldforward_energy_total', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[sensor.metering_3pn_wifi_stable_total_energy-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Metering_3PN_WiFi_stable Total energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.metering_3pn_wifi_stable_total_energy', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '24354.16', + }) +# --- # name: test_platform_setup_and_discovery[sensor.motion_sensor_lidl_zigbee_battery-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -10104,13 +10216,13 @@ 'state': '0.0', }) # --- -# name: test_platform_setup_and_discovery[sensor.production_power-entry] +# name: test_platform_setup_and_discovery[sensor.production_total_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -10119,7 +10231,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.production_power', + 'entity_id': 'sensor.production_total_energy', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10129,44 +10241,44 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 0, + 'suggested_display_precision': 2, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Power', + 'original_name': 'Total energy', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'total_power', - 'unique_id': 'tuya.3phkffywh5nnlj5vbdnztotal_powerpower', - 'unit_of_measurement': 'W', + 'translation_key': 'total_energy', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnzforward_energy_total', + 'unit_of_measurement': , }) # --- -# name: test_platform_setup_and_discovery[sensor.production_power-state] +# name: test_platform_setup_and_discovery[sensor.production_total_energy-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'Production Power', - 'state_class': , - 'unit_of_measurement': 'W', + 'device_class': 'energy', + 'friendly_name': 'Production Total energy', + 'state_class': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.production_power', + 'entity_id': 'sensor.production_total_energy', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '2314.6', + 'state': '1520.21', }) # --- -# name: test_platform_setup_and_discovery[sensor.production_total_energy-entry] +# name: test_platform_setup_and_discovery[sensor.production_total_power-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ }), 'area_id': None, 'capabilities': dict({ - 'state_class': , + 'state_class': , }), 'config_entry_id': , 'config_subentry_id': , @@ -10175,7 +10287,7 @@ 'disabled_by': None, 'domain': 'sensor', 'entity_category': None, - 'entity_id': 'sensor.production_total_energy', + 'entity_id': 'sensor.production_total_power', 'has_entity_name': True, 'hidden_by': None, 'icon': None, @@ -10185,35 +10297,35 @@ 'name': None, 'options': dict({ 'sensor': dict({ - 'suggested_display_precision': 2, + 'suggested_display_precision': 0, }), }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, - 'original_name': 'Total energy', + 'original_name': 'Total power', 'platform': 'tuya', 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': 0, - 'translation_key': 'total_energy', - 'unique_id': 'tuya.3phkffywh5nnlj5vbdnzforward_energy_total', - 'unit_of_measurement': , + 'translation_key': 'total_power', + 'unique_id': 'tuya.3phkffywh5nnlj5vbdnztotal_powerpower', + 'unit_of_measurement': 'W', }) # --- -# name: test_platform_setup_and_discovery[sensor.production_total_energy-state] +# name: test_platform_setup_and_discovery[sensor.production_total_power-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'Production Total energy', - 'state_class': , - 'unit_of_measurement': , + 'device_class': 'power', + 'friendly_name': 'Production Total power', + 'state_class': , + 'unit_of_measurement': 'W', }), 'context': , - 'entity_id': 'sensor.production_total_energy', + 'entity_id': 'sensor.production_total_power', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1520.21', + 'state': '2314.6', }) # --- # name: test_platform_setup_and_discovery[sensor.production_total_production-entry] @@ -14311,6 +14423,62 @@ 'state': '1.2', }) # --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_power-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total power', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_power', + 'unique_id': 'tuya.9oh1h1uyalfykgg4bdnzpower_total', + 'unit_of_measurement': 'kW', + }) +# --- +# name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_power-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'XOCA-DAC212XC V2-S1 Total power', + 'state_class': , + 'unit_of_measurement': 'kW', + }), + 'context': , + 'entity_id': 'sensor.xoca_dac212xc_v2_s1_total_power', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.5', + }) +# --- # name: test_platform_setup_and_discovery[sensor.xoca_dac212xc_v2_s1_total_production-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 9690b2a52fae59..51818cfaa9cc13 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import ANY, patch +import attr from freezegun.api import FrozenDateTimeFactory import pytest from yarl import URL @@ -21,6 +22,7 @@ device_registry as dr, entity_registry as er, ) +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry, async_capture_events, flush_store @@ -349,6 +351,7 @@ async def test_loading_from_storage( "connections": [["Zigbee", "23.45.67.89.01"]], "created_at": created_at, "disabled_by": dr.DeviceEntryDisabler.USER, + "disabled_by_undefined": False, "id": "bcdefghijklmn", "identifiers": [["serial", "3456ABCDEF12"]], "labels": {"label1", "label2"}, @@ -508,6 +511,9 @@ async def test_migration_from_1_1( ) assert entry.id == "abcdefghijklm" + deleted_entry = registry.deleted_devices["deletedid"] + assert deleted_entry.disabled_by is UNDEFINED + # Update to trigger a store entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, @@ -582,6 +588,7 @@ async def test_migration_from_1_1( "connections": [], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": True, "id": "deletedid", "identifiers": [["serial", "123456ABCDFF"]], "labels": [], @@ -1477,6 +1484,144 @@ async def test_migration_from_1_10( "connections": [["mac", "123456ABCDAB"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": False, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + ) + assert entry.id == "abcdefghijklm" + deleted_entry = registry.deleted_devices.get_entry( + connections=set(), + identifiers={("serial", "123456ABCDAB")}, + ) + assert deleted_entry.id == "abcdefghijklm2" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={("serial", "123456ABCDEF")}, + sw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + + assert hass_storage[dr.STORAGE_KEY] == { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "12:34:56:ab:cd:ef"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "12:34:56:ab:cd:ab"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, + "id": "abcdefghijklm2", + "identifiers": [["serial", "123456ABCDAB"]], + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "orphaned_timestamp": "1970-01-01T00:00:00+00:00", + }, + ], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +@pytest.mark.usefixtures("freezer") +async def test_migration_from_1_11( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +) -> None: + """Test migration from version 1.11.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 10, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "config_entries_subentries": {mock_config_entry.entry_id: [None]}, + "configuration_url": None, + "connections": [["mac", "123456ABCDEF"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "labels": ["blah"], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "model_id": None, + "modified_at": "1970-01-01T00:00:00+00:00", + "name_by_user": None, + "primary_config_entry": mock_config_entry.entry_id, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + ], + "deleted_devices": [ + { + "area_id": None, + "config_entries": ["234567"], + "config_entries_subentries": {"234567": [None]}, + "connections": [["mac", "123456ABCDAB"]], + "created_at": "1970-01-01T00:00:00+00:00", + "disabled_by": None, + "disabled_by_undefined": False, "id": "abcdefghijklm2", "identifiers": [["serial", "123456ABCDAB"]], "labels": [], @@ -1553,6 +1698,7 @@ async def test_migration_from_1_10( "connections": [["mac", "12:34:56:ab:cd:ab"]], "created_at": "1970-01-01T00:00:00+00:00", "disabled_by": None, + "disabled_by_undefined": False, "id": "abcdefghijklm2", "identifiers": [["serial", "123456ABCDAB"]], "labels": [], @@ -3833,6 +3979,130 @@ async def test_restore_device( } +@pytest.mark.parametrize( + ("device_disabled_by", "expected_disabled_by"), + [ + (None, None), + (dr.DeviceEntryDisabler.CONFIG_ENTRY, dr.DeviceEntryDisabler.CONFIG_ENTRY), + (dr.DeviceEntryDisabler.INTEGRATION, dr.DeviceEntryDisabler.INTEGRATION), + (dr.DeviceEntryDisabler.USER, dr.DeviceEntryDisabler.USER), + (UNDEFINED, None), + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_device_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + device_disabled_by: dr.DeviceEntryDisabler | UndefinedType | None, + expected_disabled_by: dr.DeviceEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring a device.""" + entry_id = mock_config_entry.entry_id + update_events = async_capture_events(hass, dr.EVENT_DEVICE_REGISTRY_UPDATED) + entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_orig.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=None, + entry_type=dr.DeviceEntryType.SERVICE, + hw_version="hw_version_orig", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_orig", + model="model_orig", + model_id="model_id_orig", + name="name_orig", + serial_number="serial_no_orig", + suggested_area="suggested_area_orig", + sw_version="version_orig", + via_device="via_device_id_orig", + ) + + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + device_registry.async_remove_device(entry.id) + + assert len(device_registry.devices) == 0 + assert len(device_registry.deleted_devices) == 1 + + deleted_entry = device_registry.deleted_devices[entry.id] + device_registry.deleted_devices[entry.id] = attr.evolve( + deleted_entry, disabled_by=UNDEFINED + ) + + # This will restore the original device, user customizations of + # area_id, disabled_by, labels and name_by_user will be restored + entry3 = device_registry.async_get_or_create( + config_entry_id=entry_id, + config_subentry_id=None, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + disabled_by=device_disabled_by, + entry_type=None, + hw_version="hw_version_new", + identifiers={("bridgeid", "0123")}, + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + name="name_new", + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + via_device="via_device_id_new", + ) + assert entry3 == dr.DeviceEntry( + area_id="suggested_area_orig", + config_entries={entry_id}, + config_entries_subentries={entry_id: {None}}, + configuration_url="http://config_url_new.bla", + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:ab:cd:ef")}, + created_at=utcnow(), + disabled_by=expected_disabled_by, + entry_type=None, + hw_version="hw_version_new", + id=entry.id, + identifiers={("bridgeid", "0123")}, + labels=set(), + manufacturer="manufacturer_new", + model="model_new", + model_id="model_id_new", + modified_at=utcnow(), + name_by_user=None, + name="name_new", + primary_config_entry=entry_id, + serial_number="serial_no_new", + suggested_area="suggested_area_new", + sw_version="version_new", + ) + + assert entry.id == entry3.id + assert len(device_registry.devices) == 1 + assert len(device_registry.deleted_devices) == 0 + + assert isinstance(entry3.config_entries, set) + assert isinstance(entry3.connections, set) + assert isinstance(entry3.identifiers, set) + + await hass.async_block_till_done() + + assert len(update_events) == 3 + assert update_events[0].data == { + "action": "create", + "device_id": entry.id, + } + assert update_events[1].data == { + "action": "remove", + "device_id": entry.id, + "device": entry.dict_repr, + } + assert update_events[2].data == { + "action": "create", + "device_id": entry3.id, + } + + @pytest.mark.parametrize( ( "config_entry_disabled_by", diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index acbcb02a5ded8a..421f52bca73769 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -20,6 +20,7 @@ from homeassistant.exceptions import MaxLengthExceeded from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.event import async_track_entity_registry_updated_event +from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.dt import utc_from_timestamp, utcnow from tests.common import ( @@ -610,14 +611,17 @@ async def test_load_bad_data( "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "disabled_by": None, + "disabled_by_undefined": False, "entity_id": "test.test3", "hidden_by": None, + "hidden_by_undefined": False, "icon": None, "id": "00003", "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, "options": None, + "options_undefined": False, "orphaned_timestamp": None, "platform": "super_platform", "unique_id": 234, # Should not load @@ -631,14 +635,17 @@ async def test_load_bad_data( "created_at": "2024-02-14T12:00:00.900075+00:00", "device_class": None, "disabled_by": None, + "disabled_by_undefined": False, "entity_id": "test.test4", "hidden_by": None, + "hidden_by_undefined": False, "icon": None, "id": "00004", "labels": [], "modified_at": "2024-02-14T12:00:00.900075+00:00", "name": None, "options": None, + "options_undefined": False, "orphaned_timestamp": None, "platform": "super_platform", "unique_id": ["also", "not", "valid"], # Should not load @@ -962,9 +969,10 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) assert entry.device_class is None assert entry.original_device_class == "best_class" - # Check we store migrated data + # Check migrated data await flush_store(registry._store) - assert hass_storage[er.STORAGE_KEY] == { + migrated_data = hass_storage[er.STORAGE_KEY] + assert migrated_data == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, "key": er.STORAGE_KEY, @@ -1007,6 +1015,11 @@ async def test_migration_1_1(hass: HomeAssistant, hass_storage: dict[str, Any]) }, } + # Serialize the migrated data again + registry.async_schedule_save() + await flush_store(registry._store) + assert hass_storage[er.STORAGE_KEY] == migrated_data + @pytest.mark.parametrize("load_registries", [False]) async def test_migration_1_7(hass: HomeAssistant, hass_storage: dict[str, Any]) -> None: @@ -1142,9 +1155,181 @@ async def test_migration_1_11( assert entry.device_class is None assert entry.original_device_class == "best_class" + deleted_entry = registry.deleted_entities[ + ("test", "super_duper_platform", "very_very_unique") + ] + assert deleted_entry.disabled_by is UNDEFINED + assert deleted_entry.hidden_by is UNDEFINED + assert deleted_entry.options is UNDEFINED + + # Check migrated data + await flush_store(registry._store) + migrated_data = hass_storage[er.STORAGE_KEY] + assert migrated_data == { + "version": er.STORAGE_VERSION_MAJOR, + "minor_version": er.STORAGE_VERSION_MINOR, + "key": er.STORAGE_KEY, + "data": { + "entities": [ + { + "aliases": [], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": None, + "disabled_by": None, + "entity_category": None, + "entity_id": "test.entity", + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "id": ANY, + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "original_device_class": "best_class", + "original_icon": None, + "original_name": None, + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unique_id": "very_unique", + "unit_of_measurement": None, + "device_class": None, + } + ], + "deleted_entities": [ + { + "aliases": [], + "area_id": None, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_class": None, + "disabled_by": None, + "disabled_by_undefined": True, + "entity_id": "test.deleted_entity", + "hidden_by": None, + "hidden_by_undefined": True, + "icon": None, + "id": "23456", + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "options_undefined": True, + "orphaned_timestamp": None, + "platform": "super_duper_platform", + "unique_id": "very_very_unique", + } + ], + }, + } + + # Serialize the migrated data again + registry.async_schedule_save() + await flush_store(registry._store) + assert hass_storage[er.STORAGE_KEY] == migrated_data + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_1_18( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test migration from version 1.18. + + This version has a flawed migration. + """ + hass_storage[er.STORAGE_KEY] = { + "version": 1, + "minor_version": 18, + "data": { + "entities": [ + { + "aliases": [], + "area_id": None, + "capabilities": {}, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_id": None, + "disabled_by": None, + "entity_category": None, + "entity_id": "test.entity", + "has_entity_name": False, + "hidden_by": None, + "icon": None, + "id": "12345", + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "original_device_class": "best_class", + "original_icon": None, + "original_name": None, + "platform": "super_platform", + "previous_unique_id": None, + "suggested_object_id": None, + "supported_features": 0, + "translation_key": None, + "unique_id": "very_unique", + "unit_of_measurement": None, + "device_class": None, + } + ], + "deleted_entities": [ + { + "aliases": [], + "area_id": None, + "categories": {}, + "config_entry_id": None, + "config_subentry_id": None, + "created_at": "1970-01-01T00:00:00+00:00", + "device_class": None, + "disabled_by": None, + "entity_id": "test.deleted_entity", + "hidden_by": None, + "icon": None, + "id": "23456", + "labels": [], + "modified_at": "1970-01-01T00:00:00+00:00", + "name": None, + "options": {}, + "orphaned_timestamp": None, + "platform": "super_duper_platform", + "unique_id": "very_very_unique", + } + ], + }, + } + + await er.async_load(hass) + registry = er.async_get(hass) + + entry = registry.async_get_or_create("test", "super_platform", "very_unique") + + assert entry.device_class is None + assert entry.original_device_class == "best_class" + + deleted_entry = registry.deleted_entities[ + ("test", "super_duper_platform", "very_very_unique") + ] + assert deleted_entry.disabled_by is None + assert deleted_entry.hidden_by is None + assert deleted_entry.options == {} + # Check migrated data await flush_store(registry._store) - assert hass_storage[er.STORAGE_KEY] == { + migrated_data = hass_storage[er.STORAGE_KEY] + assert migrated_data == { "version": er.STORAGE_VERSION_MAJOR, "minor_version": er.STORAGE_VERSION_MINOR, "key": er.STORAGE_KEY, @@ -1193,14 +1378,17 @@ async def test_migration_1_11( "created_at": "1970-01-01T00:00:00+00:00", "device_class": None, "disabled_by": None, + "disabled_by_undefined": False, "entity_id": "test.deleted_entity", "hidden_by": None, + "hidden_by_undefined": False, "icon": None, "id": "23456", "labels": [], "modified_at": "1970-01-01T00:00:00+00:00", "name": None, "options": {}, + "options_undefined": False, "orphaned_timestamp": None, "platform": "super_duper_platform", "unique_id": "very_very_unique", @@ -1209,6 +1397,11 @@ async def test_migration_1_11( }, } + # Serialize the migrated data again + registry.async_schedule_save() + await flush_store(registry._store) + assert hass_storage[er.STORAGE_KEY] == migrated_data + async def test_update_entity_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry @@ -3150,6 +3343,366 @@ async def test_restore_entity( assert update_events[16].data == {"action": "create", "entity_id": "light.hue_1234"} +@pytest.mark.parametrize( + ("entity_disabled_by"), + [ + None, + er.RegistryEntryDisabler.CONFIG_ENTRY, + er.RegistryEntryDisabler.DEVICE, + er.RegistryEntryDisabler.HASS, + er.RegistryEntryDisabler.INTEGRATION, + er.RegistryEntryDisabler.USER, + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_entity_disabled_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_disabled_by: er.RegistryEntryDisabler | None, +) -> None: + """Check how the disabled_by flag is treated when restoring an entity.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] + entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( + deleted_entry, disabled_by=UNDEFINED + ) + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=None, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=entity_disabled_by, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + +@pytest.mark.parametrize( + ("entity_hidden_by"), + [ + None, + er.RegistryEntryHider.INTEGRATION, + er.RegistryEntryHider.USER, + ], +) +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_entity_hidden_by( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_hidden_by: er.RegistryEntryHider | None, +) -> None: + """Check how the hidden_by flag is treated when restoring an entity.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] + entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( + deleted_entry, hidden_by=UNDEFINED + ) + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=entity_hidden_by, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=entity_hidden_by, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key1": "value1"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + +@pytest.mark.usefixtures("freezer") +async def test_restore_migrated_entity_initial_options( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Check how the initial options is treated when restoring an entity.""" + update_events = async_capture_events(hass, er.EVENT_ENTITY_REGISTRY_UPDATED) + config_entry = MockConfigEntry(domain="light") + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key1": "value1"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.DIAGNOSTIC, + get_initial_options=lambda: {"test_domain": {"key1": "value1"}}, + has_entity_name=True, + hidden_by=er.RegistryEntryHider.INTEGRATION, + original_device_class="device_class_1", + original_icon="original_icon_1", + original_name="original_name_1", + suggested_object_id="hue_5678", + supported_features=1, + translation_key="translation_key_1", + unit_of_measurement="unit_1", + ) + + entity_registry.async_remove(entry.entity_id) + assert len(entity_registry.entities) == 0 + assert len(entity_registry.deleted_entities) == 1 + + deleted_entry = entity_registry.deleted_entities[("light", "hue", "1234")] + entity_registry.deleted_entities[("light", "hue", "1234")] = attr.evolve( + deleted_entry, options=UNDEFINED + ) + + # Re-add entity, integration has changed + entry_restored = entity_registry.async_get_or_create( + "light", + "hue", + "1234", + capabilities={"key2": "value2"}, + config_entry=config_entry, + config_subentry_id=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + get_initial_options=lambda: {"test_domain": {"key2": "value2"}}, + has_entity_name=False, + hidden_by=None, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + assert len(entity_registry.entities) == 1 + assert len(entity_registry.deleted_entities) == 0 + assert entry != entry_restored + # entity_id and user customizations are restored. new integration options are + # respected. + assert entry_restored == er.RegistryEntry( + entity_id="light.hue_5678", + unique_id="1234", + platform="hue", + aliases=set(), + area_id=None, + categories={}, + capabilities={"key2": "value2"}, + config_entry_id=config_entry.entry_id, + config_subentry_id=None, + created_at=utcnow(), + device_class=None, + device_id=device_entry.id, + disabled_by=None, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + hidden_by=er.RegistryEntryHider.INTEGRATION, + icon=None, + id=entry.id, + labels=set(), + modified_at=utcnow(), + name=None, + options={"test_domain": {"key2": "value2"}}, + original_device_class="device_class_2", + original_icon="original_icon_2", + original_name="original_name_2", + suggested_object_id="suggested_2", + supported_features=2, + translation_key="translation_key_2", + unit_of_measurement="unit_2", + ) + + # Check the events + await hass.async_block_till_done() + assert len(update_events) == 3 + assert update_events[0].data == {"action": "create", "entity_id": "light.hue_5678"} + assert update_events[1].data == {"action": "remove", "entity_id": "light.hue_5678"} + assert update_events[2].data == {"action": "create", "entity_id": "light.hue_5678"} + + @pytest.mark.parametrize( ( "config_entry_disabled_by",