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
4 changes: 2 additions & 2 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
10 changes: 5 additions & 5 deletions build.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 18 additions & 0 deletions homeassistant/components/tuya/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions homeassistant/components/tuya/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,9 @@
"total_energy": {
"name": "Total energy"
},
"total_power": {
"name": "Total power"
},
"total_production": {
"name": "Total production"
},
Expand Down
51 changes: 39 additions & 12 deletions homeassistant/helpers/device_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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"],
Expand All @@ -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"],
Expand Down
101 changes: 75 additions & 26 deletions homeassistant/helpers/entity_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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"],
Expand Down
Loading
Loading