mirror of
https://github.com/home-assistant/core.git
synced 2025-11-05 00:49:37 +00:00
Compare commits
18 Commits
2025.11.0b
...
mqtt-entit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bb2c0b213 | ||
|
|
0307f0c781 | ||
|
|
81c4d8582a | ||
|
|
f66a03d6cb | ||
|
|
ff37570035 | ||
|
|
4f7e82ba76 | ||
|
|
87204bbfca | ||
|
|
69785a5361 | ||
|
|
c566950fb1 | ||
|
|
fd8e366a2f | ||
|
|
ab658e05a6 | ||
|
|
49542e8302 | ||
|
|
bc8d7fc02e | ||
|
|
20a494e4f8 | ||
|
|
64ad83b1cd | ||
|
|
254a4de025 | ||
|
|
ec43e01d51 | ||
|
|
4f25518671 |
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Final, final
|
||||
from typing import Any, Final, final
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
|
||||
@@ -133,9 +133,9 @@ class AirQualityEntity(Entity):
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, str | int | float]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
data: dict[str, str | int | float] = {}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
for prop, attr in PROP_TO_ATTR.items():
|
||||
if (value := getattr(self, prop)) is not None:
|
||||
|
||||
@@ -301,11 +301,12 @@ class AlarmControlPanelEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_A
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_CODE_FORMAT: self.code_format,
|
||||
ATTR_CHANGED_BY: self.changed_by,
|
||||
ATTR_CODE_ARM_REQUIRED: self.code_arm_required,
|
||||
}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
data[ATTR_CODE_FORMAT] = self.code_format
|
||||
data[ATTR_CHANGED_BY] = self.changed_by
|
||||
data[ATTR_CODE_ARM_REQUIRED] = self.code_arm_required
|
||||
return data
|
||||
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the alarm control panel entity is added to hass."""
|
||||
|
||||
@@ -525,17 +525,18 @@ class CalendarEntity(Entity):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the entity state attributes."""
|
||||
if (event := self.event) is None:
|
||||
return None
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
return {
|
||||
"message": event.summary,
|
||||
"all_day": event.all_day,
|
||||
"start_time": event.start_datetime_local.strftime(DATE_STR_FORMAT),
|
||||
"end_time": event.end_datetime_local.strftime(DATE_STR_FORMAT),
|
||||
"location": event.location if event.location else "",
|
||||
"description": event.description if event.description else "",
|
||||
}
|
||||
if (event := self.event) is None:
|
||||
return data or None
|
||||
|
||||
data["message"] = event.summary
|
||||
data["all_day"] = event.all_day
|
||||
data["start_time"] = event.start_datetime_local.strftime(DATE_STR_FORMAT)
|
||||
data["end_time"] = event.end_datetime_local.strftime(DATE_STR_FORMAT)
|
||||
data["location"] = event.location if event.location else ""
|
||||
data["description"] = event.description if event.description else ""
|
||||
return data
|
||||
|
||||
@final
|
||||
@property
|
||||
|
||||
@@ -664,7 +664,9 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, str | None]:
|
||||
"""Return the camera state attributes."""
|
||||
attrs = {"access_token": self.access_tokens[-1]}
|
||||
attrs: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
attrs["access_token"] = self.access_tokens[-1]
|
||||
|
||||
if model := self.model:
|
||||
attrs["model_name"] = model
|
||||
|
||||
@@ -341,16 +341,16 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the optional state attributes."""
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
supported_features = self.supported_features
|
||||
temperature_unit = self.temperature_unit
|
||||
precision = self.precision
|
||||
hass = self.hass
|
||||
|
||||
data: dict[str, str | float | None] = {
|
||||
ATTR_CURRENT_TEMPERATURE: show_temp(
|
||||
hass, self.current_temperature, temperature_unit, precision
|
||||
),
|
||||
}
|
||||
data[ATTR_CURRENT_TEMPERATURE] = show_temp(
|
||||
hass, self.current_temperature, temperature_unit, precision
|
||||
)
|
||||
|
||||
if ClimateEntityFeature.TARGET_TEMPERATURE in supported_features:
|
||||
data[ATTR_TEMPERATURE] = show_temp(
|
||||
|
||||
@@ -267,7 +267,7 @@ class CoverEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
data = {}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
if (current := self.current_cover_position) is not None:
|
||||
data[ATTR_CURRENT_POSITION] = current
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import final
|
||||
from typing import Any, final
|
||||
|
||||
from propcache.api import cached_property
|
||||
|
||||
@@ -28,7 +28,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.entity_platform import EntityPlatform
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -189,9 +188,11 @@ class BaseTrackerEntity(Entity):
|
||||
raise NotImplementedError
|
||||
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
|
||||
attr: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
attr[ATTR_SOURCE_TYPE] = self.source_type
|
||||
|
||||
if self.battery_level is not None:
|
||||
attr[ATTR_BATTERY_LEVEL] = self.battery_level
|
||||
@@ -278,9 +279,9 @@ class TrackerEntity(
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr: dict[str, StateType] = {}
|
||||
attr: dict[str, Any] = {}
|
||||
attr.update(super().state_attributes)
|
||||
|
||||
if self.latitude is not None and self.longitude is not None:
|
||||
@@ -431,9 +432,10 @@ class ScannerEntity(
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attr = super().state_attributes
|
||||
attr: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
attr.update(super().state_attributes)
|
||||
|
||||
if ip_address := self.ip_address:
|
||||
attr[ATTR_IP] = ip_address
|
||||
|
||||
@@ -48,7 +48,7 @@ from homeassistant.helpers.event import (
|
||||
async_track_utc_time_change,
|
||||
)
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType, GPSType, StateType
|
||||
from homeassistant.helpers.typing import ConfigType, GPSType
|
||||
from homeassistant.setup import (
|
||||
SetupPhases,
|
||||
async_notify_setup_error,
|
||||
@@ -842,9 +842,11 @@ class Device(RestoreEntity):
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the device state attributes."""
|
||||
attributes: dict[str, StateType] = {ATTR_SOURCE_TYPE: self.source_type}
|
||||
attributes: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
attributes[ATTR_SOURCE_TYPE] = self.source_type
|
||||
|
||||
if self.gps is not None:
|
||||
attributes[ATTR_LATITUDE] = self.gps[0]
|
||||
|
||||
@@ -180,7 +180,9 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
attributes = {ATTR_EVENT_TYPE: self.__last_event_type}
|
||||
attributes: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
attributes[ATTR_EVENT_TYPE] = self.__last_event_type
|
||||
if last_event_attributes := self.__last_event_attributes:
|
||||
attributes |= last_event_attributes
|
||||
return attributes
|
||||
|
||||
@@ -385,9 +385,10 @@ class FanEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, float | str | None]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return optional state attributes."""
|
||||
data: dict[str, float | str | None] = {}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
supported_features = self.supported_features
|
||||
|
||||
if FanEntityFeature.DIRECTION in supported_features:
|
||||
|
||||
@@ -101,7 +101,9 @@ class GeolocationEvent(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes of this external event."""
|
||||
data: dict[str, Any] = {ATTR_SOURCE: self.source}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
data[ATTR_SOURCE] = self.source
|
||||
if self.latitude is not None:
|
||||
data[ATTR_LATITUDE] = round(self.latitude, 5)
|
||||
if self.longitude is not None:
|
||||
|
||||
@@ -188,7 +188,7 @@ class HumidifierEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_AT
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the optional state attributes."""
|
||||
data: dict[str, Any] = {}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
if self.action is not None:
|
||||
data[ATTR_ACTION] = self.action if self.is_on else HumidifierAction.OFF
|
||||
|
||||
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta
|
||||
import logging
|
||||
import os
|
||||
from random import SystemRandom
|
||||
from typing import Final, final
|
||||
from typing import Any, Final, final
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
import httpx
|
||||
@@ -281,9 +281,12 @@ class ImageEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, str | None]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
return {"access_token": self.access_tokens[-1]}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
data["access_token"] = self.access_tokens[-1]
|
||||
return data
|
||||
|
||||
@callback
|
||||
def async_update_token(self) -> None:
|
||||
|
||||
@@ -15,6 +15,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_TOGGLE,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
@@ -1261,7 +1262,8 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return state attributes."""
|
||||
data: dict[str, Any] = {}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
supported_features = self.supported_features_compat
|
||||
supported_color_modes = self.supported_color_modes
|
||||
legacy_supported_color_modes = (
|
||||
@@ -1336,6 +1338,9 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
if color_mode:
|
||||
data.update(self._light_internal_convert_color(color_mode))
|
||||
|
||||
if included_entities := getattr(self, "included_entities", None):
|
||||
data[ATTR_ENTITY_ID] = included_entities
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
|
||||
@@ -25,7 +25,7 @@ from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.typing import ConfigType, StateType
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN, LockState
|
||||
@@ -244,9 +244,10 @@ class LockEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, StateType]:
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
state_attr = {}
|
||||
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
for prop, attr in PROP_TO_ATTR.items():
|
||||
if (value := getattr(self, prop)) is not None:
|
||||
state_attr[attr] = value
|
||||
|
||||
@@ -1123,7 +1123,7 @@ class MediaPlayerEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
state_attr: dict[str, Any] = {}
|
||||
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
if self.support_grouping:
|
||||
state_attr[ATTR_GROUP_MEMBERS] = self.group_members
|
||||
|
||||
@@ -73,6 +73,7 @@ ABBREVIATIONS = {
|
||||
"fan_mode_stat_t": "fan_mode_state_topic",
|
||||
"frc_upd": "force_update",
|
||||
"g_tpl": "green_template",
|
||||
"grp": "group",
|
||||
"hs_cmd_t": "hs_command_topic",
|
||||
"hs_cmd_tpl": "hs_command_template",
|
||||
"hs_stat_t": "hs_state_topic",
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from .const import (
|
||||
CONF_COMMAND_TOPIC,
|
||||
CONF_ENCODING,
|
||||
CONF_GROUP,
|
||||
CONF_QOS,
|
||||
CONF_RETAIN,
|
||||
CONF_STATE_TOPIC,
|
||||
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
|
||||
SCHEMA_BASE = {
|
||||
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
|
||||
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
|
||||
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
|
||||
}
|
||||
|
||||
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)
|
||||
|
||||
@@ -110,6 +110,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
|
||||
CONF_GET_POSITION_TEMPLATE = "position_template"
|
||||
CONF_GET_POSITION_TOPIC = "position_topic"
|
||||
CONF_GREEN_TEMPLATE = "green_template"
|
||||
CONF_GROUP = "group"
|
||||
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
|
||||
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
|
||||
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
||||
|
||||
@@ -79,6 +79,7 @@ from .const import (
|
||||
CONF_ENABLED_BY_DEFAULT,
|
||||
CONF_ENCODING,
|
||||
CONF_ENTITY_PICTURE,
|
||||
CONF_GROUP,
|
||||
CONF_HW_VERSION,
|
||||
CONF_IDENTIFIERS,
|
||||
CONF_JSON_ATTRS_TEMPLATE,
|
||||
@@ -136,6 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
||||
"device_class",
|
||||
"device_info",
|
||||
"entity_category",
|
||||
"entity_id",
|
||||
"entity_picture",
|
||||
"entity_registry_enabled_default",
|
||||
"extra_state_attributes",
|
||||
@@ -480,6 +482,9 @@ class MqttAttributesMixin(Entity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe MQTT events."""
|
||||
await super().async_added_to_hass()
|
||||
if CONF_GROUP in self._attributes_config:
|
||||
self.async_set_included_entities(self._attributes_config[CONF_GROUP])
|
||||
|
||||
self._attributes_prepare_subscribe_topics()
|
||||
self._attributes_subscribe_topics()
|
||||
|
||||
@@ -546,7 +551,7 @@ class MqttAttributesMixin(Entity):
|
||||
_LOGGER.warning("Erroneous JSON: %s", payload)
|
||||
else:
|
||||
if isinstance(json_dict, dict):
|
||||
filtered_dict = {
|
||||
filtered_dict: dict[str, Any] = {
|
||||
k: v
|
||||
for k, v in json_dict.items()
|
||||
if k not in MQTT_ATTRIBUTES_BLOCKED
|
||||
|
||||
@@ -184,13 +184,14 @@ class RemoteEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return optional state attributes."""
|
||||
if RemoteEntityFeature.ACTIVITY not in self.supported_features:
|
||||
return None
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
return {
|
||||
ATTR_ACTIVITY_LIST: self.activity_list,
|
||||
ATTR_CURRENT_ACTIVITY: self.current_activity,
|
||||
}
|
||||
if RemoteEntityFeature.ACTIVITY not in self.supported_features:
|
||||
return data or None
|
||||
|
||||
data[ATTR_ACTIVITY_LIST] = self.activity_list
|
||||
data[ATTR_CURRENT_ACTIVITY] = self.current_activity
|
||||
return data
|
||||
|
||||
def send_command(self, command: Iterable[str], **kwargs: Any) -> None:
|
||||
"""Send commands to a device."""
|
||||
|
||||
@@ -16,6 +16,7 @@ from propcache.api import cached_property
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ( # noqa: F401
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
EntityCategory,
|
||||
@@ -437,6 +438,8 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@override
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return state attributes."""
|
||||
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
if last_reset := self.last_reset:
|
||||
state_class = self.state_class
|
||||
if state_class != SensorStateClass.TOTAL:
|
||||
@@ -448,9 +451,9 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
)
|
||||
|
||||
if state_class == SensorStateClass.TOTAL:
|
||||
return {ATTR_LAST_RESET: last_reset.isoformat()}
|
||||
state_attr[ATTR_LAST_RESET] = last_reset.isoformat()
|
||||
|
||||
return None
|
||||
return state_attr or None
|
||||
|
||||
@cached_property
|
||||
def native_value(self) -> StateType | date | datetime | Decimal:
|
||||
|
||||
@@ -433,6 +433,8 @@ class UpdateEntity(
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return state attributes."""
|
||||
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
if (release_summary := self.release_summary) is not None:
|
||||
release_summary = release_summary[:255]
|
||||
|
||||
@@ -459,18 +461,17 @@ class UpdateEntity(
|
||||
skipped_version = None
|
||||
self.__skipped_version = None
|
||||
|
||||
return {
|
||||
ATTR_AUTO_UPDATE: self.auto_update,
|
||||
ATTR_DISPLAY_PRECISION: self.display_precision,
|
||||
ATTR_INSTALLED_VERSION: installed_version,
|
||||
ATTR_IN_PROGRESS: in_progress,
|
||||
ATTR_LATEST_VERSION: latest_version,
|
||||
ATTR_RELEASE_SUMMARY: release_summary,
|
||||
ATTR_RELEASE_URL: self.release_url,
|
||||
ATTR_SKIPPED_VERSION: skipped_version,
|
||||
ATTR_TITLE: self.title,
|
||||
ATTR_UPDATE_PERCENTAGE: update_percentage,
|
||||
}
|
||||
state_attr[ATTR_AUTO_UPDATE] = self.auto_update
|
||||
state_attr[ATTR_DISPLAY_PRECISION] = self.display_precision
|
||||
state_attr[ATTR_INSTALLED_VERSION] = installed_version
|
||||
state_attr[ATTR_IN_PROGRESS] = in_progress
|
||||
state_attr[ATTR_LATEST_VERSION] = latest_version
|
||||
state_attr[ATTR_RELEASE_SUMMARY] = release_summary
|
||||
state_attr[ATTR_RELEASE_URL] = self.release_url
|
||||
state_attr[ATTR_SKIPPED_VERSION] = skipped_version
|
||||
state_attr[ATTR_TITLE] = self.title
|
||||
state_attr[ATTR_UPDATE_PERCENTAGE] = update_percentage
|
||||
return state_attr
|
||||
|
||||
@final
|
||||
async def async_install_with_progress(
|
||||
|
||||
@@ -364,10 +364,12 @@ class StateVacuumEntity(
|
||||
"""Get the list of available fan speed steps of the vacuum cleaner."""
|
||||
return self._attr_fan_speed_list
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes of the vacuum cleaner."""
|
||||
data: dict[str, Any] = {}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
supported_features = self.supported_features
|
||||
|
||||
if VacuumEntityFeature.BATTERY in supported_features:
|
||||
|
||||
@@ -191,9 +191,11 @@ class ValveEntity(Entity):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes."""
|
||||
if not self.reports_position:
|
||||
return None
|
||||
return {ATTR_CURRENT_POSITION: self.current_valve_position}
|
||||
state_attr: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
if self.reports_position:
|
||||
state_attr[ATTR_CURRENT_POSITION] = self.current_valve_position
|
||||
return state_attr or None
|
||||
|
||||
@property
|
||||
def supported_features(self) -> ValveEntityFeature:
|
||||
|
||||
@@ -233,32 +233,36 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the optional state attributes."""
|
||||
data: dict[str, Any] = {
|
||||
ATTR_CURRENT_TEMPERATURE: show_temp(
|
||||
self.hass,
|
||||
self.current_temperature,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
ATTR_TEMPERATURE: show_temp(
|
||||
self.hass,
|
||||
self.target_temperature,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
ATTR_TARGET_TEMP_HIGH: show_temp(
|
||||
self.hass,
|
||||
self.target_temperature_high,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
ATTR_TARGET_TEMP_LOW: show_temp(
|
||||
self.hass,
|
||||
self.target_temperature_low,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
data.update(
|
||||
{
|
||||
ATTR_CURRENT_TEMPERATURE: show_temp(
|
||||
self.hass,
|
||||
self.current_temperature,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
ATTR_TEMPERATURE: show_temp(
|
||||
self.hass,
|
||||
self.target_temperature,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
ATTR_TARGET_TEMP_HIGH: show_temp(
|
||||
self.hass,
|
||||
self.target_temperature_high,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
ATTR_TARGET_TEMP_LOW: show_temp(
|
||||
self.hass,
|
||||
self.target_temperature_low,
|
||||
self.temperature_unit,
|
||||
self.precision,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
supported_features = self.supported_features
|
||||
|
||||
|
||||
@@ -562,7 +562,7 @@ class WeatherEntity(Entity, PostInit, cached_properties=CACHED_PROPERTIES_WITH_A
|
||||
|
||||
Attributes are configured from native units to user-configured units.
|
||||
"""
|
||||
data: dict[str, Any] = {}
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
|
||||
precision = self.precision
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.const import (
|
||||
ATTR_ASSUMED_STATE,
|
||||
ATTR_ATTRIBUTION,
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_ENTITY_PICTURE,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_ICON,
|
||||
@@ -526,6 +527,9 @@ class Entity(
|
||||
__capabilities_updated_at_reported: bool = False
|
||||
__remove_future: asyncio.Future[None] | None = None
|
||||
|
||||
# Remember we keep track of included entities
|
||||
__init_track_included_entities: bool = False
|
||||
|
||||
# Entity Properties
|
||||
_attr_assumed_state: bool = False
|
||||
_attr_attribution: str | None = None
|
||||
@@ -541,6 +545,8 @@ class Entity(
|
||||
_attr_extra_state_attributes: dict[str, Any]
|
||||
_attr_force_update: bool
|
||||
_attr_icon: str | None
|
||||
_attr_included_entities: list[str]
|
||||
_attr_included_unique_ids: list[str]
|
||||
_attr_name: str | None
|
||||
_attr_should_poll: bool = True
|
||||
_attr_state: StateType = STATE_UNKNOWN
|
||||
@@ -777,14 +783,27 @@ class Entity(
|
||||
"""
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the state attributes.
|
||||
|
||||
Implemented by component base class, should not be extended by integrations.
|
||||
An entity base class should use self.generate_entity_state_attributes()
|
||||
to generate the initial state attributes.
|
||||
Convention for attribute names is lowercase snake_case.
|
||||
"""
|
||||
return None
|
||||
return self.generate_entity_state_attributes() or None
|
||||
|
||||
def generate_entity_state_attributes(self) -> dict[str, Any]:
|
||||
"""Generate base state attributes dict for entity base components.
|
||||
|
||||
Base entity classes can extend the state attributes. This helper
|
||||
creates a new dict and sets the sharedcommon entity attributes
|
||||
"""
|
||||
state_attrs: dict[str, Any] = {}
|
||||
if included_entities := getattr(self, "included_entities", None):
|
||||
state_attrs[ATTR_ENTITY_ID] = included_entities
|
||||
return state_attrs
|
||||
|
||||
@cached_property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
@@ -1635,6 +1654,72 @@ class Entity(
|
||||
self.hass, integration_domain=platform_name, module=type(self).__module__
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_included_entities(self, unique_ids: list[str]) -> None:
|
||||
"""Set the list of included entities identified by their unique IDs.
|
||||
|
||||
Integrations need to initialize this in entity.async_async_added_to_hass,
|
||||
and when the list of included entities changes.
|
||||
The entity ids of included entities will will be looked up and they will be
|
||||
tracked for changes.
|
||||
None existing entities for the supplied unique IDs will be ignored.
|
||||
"""
|
||||
entity_registry = er.async_get(self.hass)
|
||||
self._attr_included_unique_ids = unique_ids
|
||||
assert self.entity_id is not None
|
||||
|
||||
def _update_group_entity_ids() -> None:
|
||||
self._attr_included_entities = []
|
||||
for included_id in self.included_unique_ids:
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
self.platform.domain, self.platform.platform_name, included_id
|
||||
):
|
||||
self._attr_included_entities.append(entity_id)
|
||||
|
||||
async def _handle_entity_registry_updated(event: Event[Any]) -> None:
|
||||
"""Handle registry create or update event."""
|
||||
if (
|
||||
event.data["action"] in {"create", "update"}
|
||||
and (entry := entity_registry.async_get(event.data["entity_id"]))
|
||||
and entry.unique_id in self.included_unique_ids
|
||||
) or (
|
||||
event.data["action"] == "remove"
|
||||
and self.included_entities is not None
|
||||
and event.data["entity_id"] in self.included_entities
|
||||
):
|
||||
_update_group_entity_ids()
|
||||
self.async_write_ha_state()
|
||||
|
||||
if not self.__init_track_included_entities:
|
||||
self.async_on_remove(
|
||||
self.hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
_handle_entity_registry_updated,
|
||||
)
|
||||
)
|
||||
self.__init_track_included_entities = True
|
||||
_update_group_entity_ids()
|
||||
|
||||
@property
|
||||
def included_unique_ids(self) -> list[str]:
|
||||
"""Return the list of unique IDs if the entity represents a group.
|
||||
|
||||
The corresponding entities will be shown as members in the UI.
|
||||
"""
|
||||
if hasattr(self, "_attr_included_unique_ids"):
|
||||
return self._attr_included_unique_ids
|
||||
return []
|
||||
|
||||
@property
|
||||
def included_entities(self) -> list[str] | None:
|
||||
"""Return a list of entity IDs if the entity represents a group.
|
||||
|
||||
Included entities will be shown as members in the UI.
|
||||
"""
|
||||
if hasattr(self, "_attr_included_entities"):
|
||||
return self._attr_included_entities
|
||||
return None
|
||||
|
||||
|
||||
class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""A class that describes toggle entities."""
|
||||
|
||||
@@ -82,6 +82,7 @@ light:
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from typing import Any
|
||||
from unittest.mock import call, patch
|
||||
|
||||
@@ -100,6 +101,7 @@ from homeassistant.const import (
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .common import (
|
||||
@@ -169,6 +171,39 @@ COLOR_MODES_CONFIG = {
|
||||
}
|
||||
}
|
||||
|
||||
GROUP_MEMBER_1_TOPIC = "homeassistant/light/member_1/config"
|
||||
GROUP_MEMBER_2_TOPIC = "homeassistant/light/member_2/config"
|
||||
GROUP_TOPIC = "homeassistant/light/group/config"
|
||||
GROUP_DISCOVERY_MEMBER_1_CONFIG = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-member1",
|
||||
"unique_id": "very_unique_member1",
|
||||
"name": "member1",
|
||||
"default_entity_id": "light.member1",
|
||||
}
|
||||
)
|
||||
GROUP_DISCOVERY_MEMBER_2_CONFIG = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-member2",
|
||||
"unique_id": "very_unique_member2",
|
||||
"name": "member2",
|
||||
"default_entity_id": "light.member2",
|
||||
}
|
||||
)
|
||||
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG = json.dumps(
|
||||
{
|
||||
"schema": "json",
|
||||
"command_topic": "test-command-topic-group",
|
||||
"state_topic": "test-state-topic-group",
|
||||
"unique_id": "very_unique_group",
|
||||
"name": "group",
|
||||
"default_entity_id": "light.group",
|
||||
"group": ["very_unique_member1", "very_unique_member2"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class JsonValidator:
|
||||
"""Helper to compare JSON."""
|
||||
@@ -1859,6 +1894,86 @@ async def test_white_scale(
|
||||
assert state.attributes.get("brightness") == 129
|
||||
|
||||
|
||||
async def test_light_group_discovery_members_before_group(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the discovery of a light group and linked entity IDs.
|
||||
|
||||
The members are discovered first, so they are known in the entity registry.
|
||||
"""
|
||||
await mqtt_mock_entry()
|
||||
# Discover light group members
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Discover group
|
||||
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is not None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("entity_id") == ["light.member1", "light.member2"]
|
||||
|
||||
|
||||
async def test_light_group_discovery_group_before_members(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test the discovery of a light group and linked entity IDs.
|
||||
|
||||
The group is discovered first, so the group members are
|
||||
not (all) known yet in the entity registry.
|
||||
The entity property should be updates as soon as member entities
|
||||
are discovered, updated or removed.
|
||||
"""
|
||||
await mqtt_mock_entry()
|
||||
|
||||
# Discover group
|
||||
async_fire_mqtt_message(hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Discover light group members
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, GROUP_DISCOVERY_MEMBER_1_CONFIG)
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_2_TOPIC, GROUP_DISCOVERY_MEMBER_2_CONFIG)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is not None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("entity_id") == ["light.member1", "light.member2"]
|
||||
|
||||
# Remove member 1
|
||||
async_fire_mqtt_message(hass, GROUP_MEMBER_1_TOPIC, "")
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get("light.member1") is None
|
||||
assert hass.states.get("light.member2") is not None
|
||||
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("entity_id") == ["light.member2"]
|
||||
|
||||
# Rename member 2
|
||||
entity_registry.async_update_entity(
|
||||
"light.member2", new_entity_id="light.member2_updated"
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
group_state = hass.states.get("light.group")
|
||||
assert group_state is not None
|
||||
assert group_state.attributes.get("entity_id") == ["light.member2_updated"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
@@ -2040,7 +2155,7 @@ async def test_custom_availability_payload(
|
||||
)
|
||||
|
||||
|
||||
async def test_setting_attribute_via_mqtt_json_message(
|
||||
async def test_setting_attribute_via_mqtt_json_message_single_light(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the setting of attribute via MQTT with JSON payload."""
|
||||
@@ -2049,6 +2164,51 @@ async def test_setting_attribute_via_mqtt_json_message(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hass_config",
|
||||
[
|
||||
help_custom_config(
|
||||
light.DOMAIN,
|
||||
DEFAULT_CONFIG,
|
||||
(
|
||||
{
|
||||
"unique_id": "very_unique_member_1",
|
||||
"name": "Part 1",
|
||||
"default_entity_id": "light.member_1",
|
||||
},
|
||||
{
|
||||
"unique_id": "very_unique_member_2",
|
||||
"name": "Part 2",
|
||||
"default_entity_id": "light.member_2",
|
||||
},
|
||||
{
|
||||
"unique_id": "very_unique_group",
|
||||
"name": "My group",
|
||||
"default_entity_id": "light.my_group",
|
||||
"json_attributes_topic": "attr-topic",
|
||||
"group": [
|
||||
"very_unique_member_1",
|
||||
"very_unique_member_2",
|
||||
"member_3_not_exists",
|
||||
],
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_setting_attribute_via_mqtt_json_message_light_group(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
"""Test the setting of attribute via MQTT with JSON payload."""
|
||||
await mqtt_mock_entry()
|
||||
|
||||
async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
|
||||
state = hass.states.get("light.my_group")
|
||||
|
||||
assert state and state.attributes.get("val") == "100"
|
||||
assert state.attributes.get("entity_id") == ["light.member_1", "light.member_2"]
|
||||
|
||||
|
||||
async def test_setting_blocked_attribute_via_mqtt_json_message(
|
||||
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
|
||||
) -> None:
|
||||
|
||||
@@ -6,7 +6,7 @@ import dataclasses
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any
|
||||
from typing import Any, final
|
||||
from unittest.mock import MagicMock, PropertyMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_ATTRIBUTION,
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
@@ -2896,3 +2897,104 @@ async def test_platform_state_write_from_init_unique_id(
|
||||
# The early attempt to write is interpreted as a unique ID collision
|
||||
assert "Platform test_platform does not generate unique IDs." in caplog.text
|
||||
assert "Entity id already exists - ignoring: test.test" not in caplog.text
|
||||
|
||||
|
||||
async def test_included_entities(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test included entities are exposed via the entity_id attribute."""
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
domain="hello",
|
||||
platform="test",
|
||||
unique_id="very_unique_oceans",
|
||||
suggested_object_id="oceans",
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
domain="hello",
|
||||
platform="test",
|
||||
unique_id="very_unique_continents",
|
||||
suggested_object_id="continents",
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
domain="hello",
|
||||
platform="test",
|
||||
unique_id="very_unique_moon",
|
||||
suggested_object_id="moon",
|
||||
)
|
||||
|
||||
class MockHelloBaseClass(entity.Entity):
|
||||
"""Domain base entity platform domain Hello."""
|
||||
|
||||
@final
|
||||
@property
|
||||
def state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes."""
|
||||
data: dict[str, Any] = self.generate_entity_state_attributes()
|
||||
data["extra"] = "beer"
|
||||
return data
|
||||
|
||||
class MockHelloIncludedEntitiesClass(MockHelloBaseClass, entity.Entity):
|
||||
"""Mock hello grouped entity class for a test integration."""
|
||||
|
||||
platform = MockEntityPlatform(hass, domain="hello", platform_name="test")
|
||||
mock_entity = MockHelloIncludedEntitiesClass()
|
||||
mock_entity.hass = hass
|
||||
mock_entity.entity_id = "hello.universe"
|
||||
mock_entity.unique_id = "very_unique_universe"
|
||||
await platform.async_add_entities([mock_entity])
|
||||
|
||||
# Initiate mock grouped entity for hello domain
|
||||
mock_entity.async_set_included_entities(
|
||||
["very_unique_continents", "very_unique_oceans"]
|
||||
)
|
||||
|
||||
mock_entity.async_schedule_update_ha_state(True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(mock_entity.entity_id)
|
||||
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.continents", "hello.oceans"]
|
||||
|
||||
# Add an entity to the group of included entities
|
||||
mock_entity.async_set_included_entities(
|
||||
["very_unique_continents", "very_unique_moon", "very_unique_oceans"]
|
||||
)
|
||||
|
||||
mock_entity.async_schedule_update_ha_state(True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(mock_entity.entity_id)
|
||||
assert state.attributes.get("extra") == "beer"
|
||||
assert state.attributes.get(ATTR_ENTITY_ID) == [
|
||||
"hello.continents",
|
||||
"hello.moon",
|
||||
"hello.oceans",
|
||||
]
|
||||
|
||||
# Remove an entity from the group of included entities
|
||||
mock_entity.async_set_included_entities(["very_unique_moon", "very_unique_oceans"])
|
||||
|
||||
mock_entity.async_schedule_update_ha_state(True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(mock_entity.entity_id)
|
||||
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon", "hello.oceans"]
|
||||
|
||||
# Rename an included entity via the registry entity
|
||||
entity_registry.async_update_entity(
|
||||
entity_id="hello.moon", new_entity_id="hello.moon_light"
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(mock_entity.entity_id)
|
||||
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon_light", "hello.oceans"]
|
||||
|
||||
# Remove an included entity from the registry entity
|
||||
entity_registry.async_remove(entity_id="hello.oceans")
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(mock_entity.entity_id)
|
||||
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon_light"]
|
||||
|
||||
Reference in New Issue
Block a user