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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion homeassistant/components/ecovacs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.11", "deebot-client==13.6.0"]
"requirements": ["py-sucks==0.9.11", "deebot-client==13.7.0"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/frontend/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250827.0"]
"requirements": ["home-assistant-frontend==20250828.0"]
}
2 changes: 1 addition & 1 deletion homeassistant/components/nexia/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"documentation": "https://www.home-assistant.io/integrations/nexia",
"iot_class": "cloud_polling",
"loggers": ["nexia"],
"requirements": ["nexia==2.10.0"]
"requirements": ["nexia==2.11.0"]
}
20 changes: 14 additions & 6 deletions homeassistant/components/zwave_js/binary_sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription):
# - Replace water filter
# - Sump pump failure


# This set can be removed once all notification sensors have been migrated
# to use the new discovery schema and we've removed the old discovery code.
MIGRATED_NOTIFICATION_TYPES = {
NotificationType.SMOKE_ALARM,
}

NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = (
NotificationZWaveJSEntityDescription(
# NotificationType 2: Carbon Monoxide - State Id's 1 and 2
Expand Down Expand Up @@ -402,6 +409,12 @@ def async_add_binary_sensor(
# ensure the notification CC Value is valid as binary sensor
if not is_valid_notification_binary_sensor(info):
return
if (
notification_type := info.primary_value.metadata.cc_specific[
CC_SPECIFIC_NOTIFICATION_TYPE
]
) in MIGRATED_NOTIFICATION_TYPES:
return
# Get all sensors from Notification CC states
for state_key in info.primary_value.metadata.states:
if TYPE_CHECKING:
Expand All @@ -414,12 +427,7 @@ def async_add_binary_sensor(
NotificationZWaveJSEntityDescription | None
) = None
for description in NOTIFICATION_SENSOR_MAPPINGS:
if (
int(description.key)
== info.primary_value.metadata.cc_specific[
CC_SPECIFIC_NOTIFICATION_TYPE
]
) and (
if (int(description.key) == notification_type) and (
not description.states or int(state_key) in description.states
):
notification_description = description
Expand Down
1 change: 1 addition & 0 deletions homeassistant/helpers/device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,7 @@ def async_get_or_create(
connections,
identifiers,
)
disabled_by = UNDEFINED

self.devices[device.id] = device
# If creating a new device, default to the config entry name
Expand Down
97 changes: 68 additions & 29 deletions homeassistant/helpers/entity_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from enum import StrEnum
import logging
import time
from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict
from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, TypedDict

import attr
import voluptuous as vol
Expand Down Expand Up @@ -85,6 +85,8 @@
CLEANUP_INTERVAL = 3600 * 24
ORPHANED_ENTITY_KEEP_SECONDS = 3600 * 24 * 30

UNDEFINED_STR: Final = "UNDEFINED"

ENTITY_CATEGORY_VALUE_TO_INDEX: dict[EntityCategory | None, int] = {
val: idx for idx, val in enumerate(EntityCategory)
}
Expand Down Expand Up @@ -164,6 +166,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."""
Expand Down Expand Up @@ -414,15 +427,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)
Expand All @@ -445,15 +460,21 @@ 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 UNDEFINED_STR,
"entity_id": self.entity_id,
"hidden_by": self.hidden_by,
"hidden_by": self.hidden_by
if self.hidden_by is not UNDEFINED
else UNDEFINED_STR,
"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 UNDEFINED_STR,
"orphaned_timestamp": self.orphaned_timestamp,
"platform": self.platform,
"unique_id": self.unique_id,
Expand Down Expand Up @@ -584,12 +605,12 @@ async def _async_migrate_func( # noqa: C901
entity["area_id"] = None
entity["categories"] = {}
entity["device_class"] = None
entity["disabled_by"] = None
entity["hidden_by"] = None
entity["disabled_by"] = UNDEFINED_STR
entity["hidden_by"] = UNDEFINED_STR
entity["icon"] = None
entity["labels"] = []
entity["name"] = None
entity["options"] = {}
entity["options"] = UNDEFINED_STR

if old_major_version > 1:
raise NotImplementedError
Expand Down Expand Up @@ -958,25 +979,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
Expand Down Expand Up @@ -1529,6 +1555,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
) -> _EnumT | UndefinedType | None:
"""Convert string to the passed enum, UNDEFINED or None."""
if value is None:
return None
if value == UNDEFINED_STR:
return UNDEFINED
try:
return cls(value)
except ValueError:
return None

for entity in data["deleted_entities"]:
try:
domain = split_entity_id(entity["entity_id"])[0]
Expand All @@ -1546,6 +1586,7 @@ async def async_load(self) -> None:
entity["platform"],
entity["unique_id"],
)

deleted_entities[key] = DeletedRegistryEntry(
aliases=set(entity["aliases"]),
area_id=entity["area_id"],
Expand All @@ -1554,23 +1595,21 @@ 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_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"]
),
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 entity["options"] is not UNDEFINED_STR
else UNDEFINED,
orphaned_timestamp=entity["orphaned_timestamp"],
platform=entity["platform"],
unique_id=entity["unique_id"],
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/package_constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ habluetooth==5.1.0
hass-nabucasa==1.0.0
hassil==3.2.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20250827.0
home-assistant-frontend==20250828.0
home-assistant-intents==2025.8.27
httpx==0.28.1
ifaddr==0.2.0
Expand Down
6 changes: 3 additions & 3 deletions requirements_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions requirements_test_all.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions tests/components/zwave_js/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Generator
import copy
import io
import logging
from typing import Any, cast
from unittest.mock import DEFAULT, AsyncMock, MagicMock, patch

Expand Down Expand Up @@ -925,6 +926,7 @@ async def integration_fixture(
hass: HomeAssistant,
client: MagicMock,
platforms: list[Platform],
caplog: pytest.LogCaptureFixture,
) -> MockConfigEntry:
"""Set up the zwave_js integration."""
entry = MockConfigEntry(
Expand All @@ -939,6 +941,11 @@ async def integration_fixture(

client.async_send_command.reset_mock()

# Make sure no errors logged during setup.
# Eg. unique id collisions are only logged as errors and not raised,
# and may not cause tests to fail otherwise.
assert not any(record.levelno == logging.ERROR for record in caplog.records)

return entry


Expand Down
1 change: 1 addition & 0 deletions tests/helpers/test_device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -3937,6 +3937,7 @@ async def test_restore_disabled_by(
config_subentry_id=None,
configuration_url="http://config_url_new.bla",
connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
disabled_by=None,
entry_type=None,
hw_version="hw_version_new",
identifiers={("bridgeid", "0123")},
Expand Down
Loading
Loading