Compare commits

...

16 Commits

Author SHA1 Message Date
jbouwh
3796be6e6c Fix group check 2026-02-14 13:41:25 +00:00
jbouwh
bd1d6b747f Allow updates of group members 2026-02-14 13:31:16 +00:00
jbouwh
60829089b5 Refactor to new code base and Fix tests 2026-02-14 12:57:48 +00:00
jbouwh
fd32b138f2 Revert unneeded changes 2026-02-14 12:57:48 +00:00
jbouwh
cdf2b61e89 Set member unique ID's during class init 2026-02-14 12:57:47 +00:00
jbouwh
03792e88f8 Remove integration domain 2026-02-14 12:57:47 +00:00
jbouwh
564f59b127 Remove invalid import 2026-02-14 12:57:47 +00:00
jbouwh
e864cb23e3 Rework with mixin - Light only 2026-02-14 12:57:47 +00:00
jbouwh
1a50cb3852 Automatically update the entity propery when a member created, updated or deleted 2026-02-14 12:57:47 +00:00
jbouwh
d5dc4d93fe Apply light group icon to all MQTT light schemas 2026-02-14 12:57:47 +00:00
jbouwh
29ca5adb7c Allow an MQTT entity to show as a group 2026-02-14 12:57:47 +00:00
Artur Pragacz
4579949ee1 Tests 2026-02-14 12:57:47 +00:00
Artur Pragacz
be8dcd49ce Use name mangling 2026-02-14 12:57:47 +00:00
Artur Pragacz
5575cd2ddc Add deprecation 2026-02-14 12:57:47 +00:00
Artur Pragacz
597d70fa6f More caching 2026-02-14 12:57:47 +00:00
Artur Pragacz
477bd7d32c Improve group entities 2026-02-14 12:57:47 +00:00
11 changed files with 785 additions and 50 deletions

View File

@@ -20,9 +20,6 @@ from homeassistant.const import (
CONF_ENTITIES,
CONF_NAME,
CONF_UNIQUE_ID,
SERVICE_LOCK,
SERVICE_OPEN,
SERVICE_UNLOCK,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
@@ -32,6 +29,7 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.group import GenericGroup
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .entity import GroupEntity
@@ -117,47 +115,13 @@ class LockGroup(GroupEntity, LockEntity):
) -> None:
"""Initialize a lock group."""
self._entity_ids = entity_ids
self.group = GenericGroup(self, entity_ids)
self._attr_supported_features = LockEntityFeature.OPEN
self._attr_name = name
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_ids}
self._attr_unique_id = unique_id
async def async_lock(self, **kwargs: Any) -> None:
"""Forward the lock command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
_LOGGER.debug("Forwarded lock command: %s", data)
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_LOCK,
data,
blocking=True,
context=self._context,
)
async def async_unlock(self, **kwargs: Any) -> None:
"""Forward the unlock command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_UNLOCK,
data,
blocking=True,
context=self._context,
)
async def async_open(self, **kwargs: Any) -> None:
"""Forward the open command to all locks in the group."""
data = {ATTR_ENTITY_ID: self._entity_ids}
await self.hass.services.async_call(
LOCK_DOMAIN,
SERVICE_OPEN,
data,
blocking=True,
context=self._context,
)
@callback
def async_update_group_state(self) -> None:
"""Query all members and determine the lock group state."""

View File

@@ -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",

View File

@@ -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)

View File

@@ -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"

View File

@@ -49,6 +49,7 @@ from homeassistant.helpers.event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
)
from homeassistant.helpers.group import IntegrationSpecificGroup
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import (
@@ -79,6 +80,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 +138,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class",
"device_info",
"entity_category",
"entity_id",
"entity_picture",
"entity_registry_enabled_default",
"extra_state_attributes",
@@ -463,7 +466,7 @@ def async_setup_entity_entry_helper(
class MqttAttributesMixin(Entity):
"""Mixin used for platforms that support JSON attributes."""
"""Mixin used for platforms that support JSON attributes and group entities."""
_attributes_extra_blocked: frozenset[str] = frozenset()
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
@@ -471,10 +474,13 @@ class MqttAttributesMixin(Entity):
[MessageCallbackType, set[str] | None, ReceiveMessage], None
]
_process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
group: IntegrationSpecificGroup
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin."""
"""Initialize the JSON attributes and handle group entities."""
self._attributes_sub_state: dict[str, EntitySubscription] = {}
if CONF_GROUP in config:
self.group = IntegrationSpecificGroup(self, config[CONF_GROUP])
self._attributes_config = config
async def async_added_to_hass(self) -> None:
@@ -485,6 +491,8 @@ class MqttAttributesMixin(Entity):
def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None:
"""Handle updated discovery message."""
if hasattr(self, "group") and CONF_GROUP in config:
self.group.included_unique_ids = config[CONF_GROUP]
self._attributes_config = config
self._attributes_prepare_subscribe_topics()
@@ -546,7 +554,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

View File

@@ -22,6 +22,7 @@ from homeassistant.core import State, callback
from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE, DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.group import IntegrationSpecificGroup
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
@@ -51,6 +52,18 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
meta = self.entity_data.entity.info_object
self._attr_unique_id = meta.unique_id
if self.entity_data.is_group_entity:
group_proxy = self.entity_data.group_proxy
assert group_proxy is not None
platform = self.entity_data.entity.PLATFORM
unique_ids = [
entity.info_object.unique_id
for member in group_proxy.group.members
for entity in member.device.platform_entities.values()
if platform == entity.PLATFORM
]
self.group = IntegrationSpecificGroup(self, unique_ids)
if meta.entity_category is not None:
self._attr_entity_category = EntityCategory(meta.entity_category)

View File

@@ -332,6 +332,9 @@ ATTR_NAME: Final = "name"
# Contains one string or a list of strings, each being an entity id
ATTR_ENTITY_ID: Final = "entity_id"
# Contains a list of entity ids that are members of a group
ATTR_GROUP_ENTITIES: Final = "group_entities"
# Contains one string, the config entry ID
ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id"

View File

@@ -27,6 +27,7 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME,
ATTR_GROUP_ENTITIES,
ATTR_ICON,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
@@ -54,13 +55,15 @@ from homeassistant.loader import async_suggest_report_issue, bind_hass
from homeassistant.util import ensure_unique_string, slugify
from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed
from . import device_registry as dr, entity_registry as er, singleton
from . import device_registry as dr, entity_registry as er
from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData
from .event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
)
from .frame import report_non_thread_safe_operation
from .frame import report_non_thread_safe_operation, report_usage
from .group import Group
from .singleton import singleton
from .typing import UNDEFINED, StateType, UndefinedType
timer = time.time
@@ -90,7 +93,7 @@ def async_setup(hass: HomeAssistant) -> None:
@callback
@bind_hass
@singleton.singleton(DATA_ENTITY_SOURCE)
@singleton(DATA_ENTITY_SOURCE)
def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]:
"""Get the entity sources.
@@ -457,6 +460,10 @@ class Entity(
# Only handled internally, never to be used by integrations.
internal_integration_suggested_object_id: str | None
# A group information in case the entity represents a group
group: Group | None
__group: Group | None = None
# If we reported if this entity was slow
_slow_reported = False
@@ -1064,6 +1071,12 @@ class Entity(
entry = self.registry_entry
capability_attr = self.capability_attributes
if self.__group is not None:
capability_attr = capability_attr.copy() if capability_attr else {}
capability_attr[ATTR_GROUP_ENTITIES] = (
self.__group.included_entity_ids.copy()
)
attr = capability_attr.copy() if capability_attr else {}
available = self.available # only call self.available once per update cycle
@@ -1503,6 +1516,17 @@ class Entity(
)
self._async_subscribe_device_updates()
if hasattr(self, "group") and self.group is not None:
if not isinstance(self.group, Group):
report_usage( # type: ignore[unreachable]
f"sets a `group` attribute on entity {self.entity_id} which is "
"not a `Group` instance",
breaks_in_ha_version="2027.2",
)
else:
self.__group = self.group
self.__group.async_added_to_hass()
async def async_internal_will_remove_from_hass(self) -> None:
"""Run when entity will be removed from hass.
@@ -1513,6 +1537,9 @@ class Entity(
if self.platform:
del entity_sources(self.hass)[self.entity_id]
if self.__group is not None:
self.__group.async_will_remove_from_hass()
@callback
def _async_registry_updated(
self, event: Event[er.EventEntityRegistryUpdatedData]

View File

@@ -3,19 +3,156 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from typing import TYPE_CHECKING, Any
from propcache.api import cached_property
from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE
from homeassistant.core import HomeAssistant
from homeassistant.core import Event, HomeAssistant, callback
from . import entity_registry as er
from .singleton import singleton
if TYPE_CHECKING:
from .entity import Entity
DATA_GROUP_ENTITIES = "group_entities"
ENTITY_PREFIX = "group."
class Group:
"""A group base class."""
_entity: Entity
def __init__(self, entity: Entity) -> None:
"""Initialize the group."""
self._entity = entity
@property
def included_entity_ids(self) -> list[str]:
"""Return the list of entity IDs."""
raise NotImplementedError
@callback
def async_added_to_hass(self) -> None:
"""Handle when the entity is added to hass."""
entity = self._entity
get_group_entities(entity.hass)[entity.entity_id] = entity
@callback
def async_will_remove_from_hass(self) -> None:
"""Handle when the entity will be removed from hass."""
entity = self._entity
del get_group_entities(entity.hass)[entity.entity_id]
class GenericGroup(Group):
"""A generic group."""
def __init__(self, entity: Entity, included_entity_ids: list[str]) -> None:
"""Initialize the group."""
super().__init__(entity)
self._included_entity_ids = included_entity_ids
@cached_property
def included_entity_ids(self) -> list[str]:
"""Return the list of entity IDs."""
return self._included_entity_ids
class IntegrationSpecificGroup(Group):
"""An integration-specific group."""
_included_entity_ids: list[str] | None = None
_included_unique_ids: list[str]
def __init__(self, entity: Entity, included_unique_ids: list[str]) -> None:
"""Initialize the group."""
super().__init__(entity)
self._included_unique_ids = included_unique_ids
@cached_property
def included_entity_ids(self) -> list[str]:
"""Return the list of entity IDs."""
entity_registry = er.async_get(self._entity.hass)
self._included_entity_ids = [
entity_id
for unique_id in self.included_unique_ids
if (
entity_id := entity_registry.async_get_entity_id(
self._entity.platform.domain,
self._entity.platform.platform_name,
unique_id,
)
)
is not None
]
return self._included_entity_ids
@property
def included_unique_ids(self) -> list[str]:
"""Return the list of unique IDs."""
return self._included_unique_ids
@included_unique_ids.setter
def included_unique_ids(self, value: list[str]) -> None:
"""Set the list of unique IDs."""
self._included_unique_ids = value
if self._included_entity_ids is not None:
self._included_entity_ids = None
del self.included_entity_ids
@callback
def async_added_to_hass(self) -> None:
"""Handle when the entity is added to hass."""
super().async_added_to_hass()
entity = self._entity
entity_registry = er.async_get(entity.hass)
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_entity_ids is not None
and event.data["entity_id"] in self._included_entity_ids
):
if self._included_entity_ids is not None:
self._included_entity_ids = None
del self.included_entity_ids
entity.async_write_ha_state()
entity.async_on_remove(
entity.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
_handle_entity_registry_updated,
)
)
@callback
@singleton(DATA_GROUP_ENTITIES)
def get_group_entities(hass: HomeAssistant) -> dict[str, Entity]:
"""Get the group entities.
Items are added to this dict by Group.async_added_to_hass and
removed by Group.async_will_remove_from_hass.
"""
return {}
def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[str]:
"""Return entity_ids with group entity ids replaced by their members.
Async friendly.
"""
group_entities = get_group_entities(hass)
found_ids: list[str] = []
for entity_id in entity_ids:
if not isinstance(entity_id, str) or entity_id in (
@@ -25,8 +162,22 @@ def expand_entity_ids(hass: HomeAssistant, entity_ids: Iterable[Any]) -> list[st
continue
entity_id = entity_id.lower()
# If entity_id points at a group, expand it
if entity_id.startswith(ENTITY_PREFIX):
if (entity := group_entities.get(entity_id)) is not None and isinstance(
entity.group, GenericGroup
):
child_entities = entity.group.included_entity_ids
if entity_id in child_entities:
child_entities = list(child_entities)
child_entities.remove(entity_id)
found_ids.extend(
ent_id
for ent_id in expand_entity_ids(hass, child_entities)
if ent_id not in found_ids
)
# If entity_id points at an old-style group, expand it
elif entity_id.startswith(ENTITY_PREFIX):
child_entities = get_entity_ids(hass, entity_id)
if entity_id in child_entities:
child_entities = list(child_entities)

View File

@@ -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,60 @@ COLOR_MODES_CONFIG = {
}
}
GROUP_MEMBER_1_TOPIC = "homeassistant/light/member_1/config"
GROUP_MEMBER_2_TOPIC = "homeassistant/light/member_2/config"
GROUP_MEMBER_3_TOPIC = "homeassistant/light/member_3/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_MEMBER_3_CONFIG = json.dumps(
{
"schema": "json",
"command_topic": "test-command-topic-member3",
"unique_id": "very_unique_member3",
"name": "member3",
"default_entity_id": "light.member3",
}
)
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"],
}
)
GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_EXPANDED = 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", "very_unique_member3"],
}
)
class JsonValidator:
"""Helper to compare JSON."""
@@ -1859,6 +1915,114 @@ 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("group_entities") == [
"light.member1",
"light.member2",
]
# Now create and discover a new member
async_fire_mqtt_message(hass, GROUP_MEMBER_3_TOPIC, GROUP_DISCOVERY_MEMBER_3_CONFIG)
await hass.async_block_till_done()
# Update the group discovery
async_fire_mqtt_message(
hass, GROUP_TOPIC, GROUP_DISCOVERY_LIGHT_GROUP_CONFIG_EXPANDED
)
await hass.async_block_till_done()
assert hass.states.get("light.member1") is not None
assert hass.states.get("light.member2") is not None
assert hass.states.get("light.member3") is not None
group_state = hass.states.get("light.group")
assert group_state is not None
assert group_state.attributes.get("group_entities") == [
"light.member1",
"light.member2",
"light.member3",
]
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("group_entities") == [
"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("group_entities") == ["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("group_entities") == ["light.member2_updated"]
@pytest.mark.parametrize(
"hass_config",
[
@@ -2040,7 +2204,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 +2213,54 @@ 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("group_entities") == [
"light.member_1",
"light.member_2",
]
async def test_setting_blocked_attribute_via_mqtt_json_message(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None:

View File

@@ -1,8 +1,15 @@
"""Test the group helper."""
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.const import ATTR_ENTITY_ID, ATTR_GROUP_ENTITIES, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import group
from homeassistant.helpers import entity_registry as er, group
from homeassistant.helpers.group import (
GenericGroup,
IntegrationSpecificGroup,
get_group_entities,
)
from tests.common import MockEntity, MockEntityPlatform
async def test_expand_entity_ids(hass: HomeAssistant) -> None:
@@ -104,3 +111,349 @@ async def test_get_entity_ids_with_non_existing_group_name(hass: HomeAssistant)
async def test_get_entity_ids_with_non_group_state(hass: HomeAssistant) -> None:
"""Test get_entity_ids with a non group state."""
assert group.get_entity_ids(hass, "switch.AC") == []
async def test_get_group_entities(hass: HomeAssistant) -> None:
"""Test get_group_entities returns registered group entities."""
assert get_group_entities(hass) == {}
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.test_group", unique_id="test_group_1")
ent.group = GenericGroup(ent, ["light.bulb1", "light.bulb2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
group_entities = get_group_entities(hass)
assert "light.test_group" in group_entities
assert group_entities["light.test_group"] is ent
async def test_group_entity_removed_from_registry(hass: HomeAssistant) -> None:
"""Test group entity is removed from get_group_entities on removal."""
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.test_group", unique_id="test_group_2")
ent.group = GenericGroup(ent, ["light.bulb1", "light.bulb2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert "light.test_group" in get_group_entities(hass)
await platform.async_remove_entity(ent.entity_id)
await hass.async_block_till_done()
assert "light.test_group" not in get_group_entities(hass)
async def test_multiple_group_entities(hass: HomeAssistant) -> None:
"""Test multiple group entities can be registered and work independently."""
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent1 = MockEntity(entity_id="light.group1", unique_id="multi_1")
ent1.group = GenericGroup(ent1, ["light.a", "light.b"])
ent2 = MockEntity(entity_id="light.group2", unique_id="multi_2")
ent2.group = GenericGroup(ent2, ["light.c", "light.d"])
await platform.async_add_entities([ent1, ent2])
await hass.async_block_till_done()
group_entities = get_group_entities(hass)
assert "light.group1" in group_entities
assert "light.group2" in group_entities
expanded1 = group.expand_entity_ids(hass, ["light.group1"])
expanded2 = group.expand_entity_ids(hass, ["light.group2"])
assert sorted(expanded1) == ["light.a", "light.b"]
assert sorted(expanded2) == ["light.c", "light.d"]
async def test_generic_group_included_entity_ids(hass: HomeAssistant) -> None:
"""Test GenericGroup included_entity_ids property."""
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.test_group")
ent.group = GenericGroup(ent, ["light.bulb1", "light.bulb2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert ent.group.included_entity_ids == ["light.bulb1", "light.bulb2"]
async def test_expand_entity_ids_with_generic_group(hass: HomeAssistant) -> None:
"""Test expand_entity_ids with GenericGroup entities."""
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.living_room_group", unique_id="living_room")
ent.group = GenericGroup(ent, ["light.lamp1", "light.lamp2", "light.lamp3"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
hass.states.async_set("light.lamp1", STATE_ON)
hass.states.async_set("light.lamp2", STATE_OFF)
hass.states.async_set("light.lamp3", STATE_ON)
expanded = group.expand_entity_ids(hass, ["light.living_room_group"])
assert sorted(expanded) == ["light.lamp1", "light.lamp2", "light.lamp3"]
async def test_expand_entity_ids_with_generic_group_recursive(
hass: HomeAssistant,
) -> None:
"""Test expand_entity_ids with nested GenericGroup entities."""
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
inner_group = MockEntity(entity_id="light.inner_group", unique_id="inner")
inner_group.group = GenericGroup(inner_group, ["light.lamp1", "light.lamp2"])
outer_group = MockEntity(entity_id="light.outer_group", unique_id="outer")
outer_group.group = GenericGroup(outer_group, ["light.inner_group", "light.lamp3"])
await platform.async_add_entities([inner_group, outer_group])
await hass.async_block_till_done()
expanded = group.expand_entity_ids(hass, ["light.outer_group"])
assert sorted(expanded) == ["light.lamp1", "light.lamp2", "light.lamp3"]
async def test_expand_entity_ids_with_generic_group_self_reference(
hass: HomeAssistant,
) -> None:
"""Test expand_entity_ids handles GenericGroup with self-reference."""
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.self_ref_group", unique_id="self_ref")
ent.group = GenericGroup(
ent, ["light.self_ref_group", "light.bulb1", "light.bulb2"]
)
await platform.async_add_entities([ent])
await hass.async_block_till_done()
expanded = group.expand_entity_ids(hass, ["light.self_ref_group"])
assert sorted(expanded) == ["light.bulb1", "light.bulb2"]
async def test_entity_group_attribute_in_state(hass: HomeAssistant) -> None:
"""Test ATTR_GROUP_ENTITIES is included in entity state attributes."""
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.group_with_attrs", unique_id="attrs_test")
ent.group = GenericGroup(ent, ["light.lamp1", "light.lamp2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
state = hass.states.get("light.group_with_attrs")
assert state is not None
assert ATTR_GROUP_ENTITIES in state.attributes
assert state.attributes[ATTR_GROUP_ENTITIES] == ["light.lamp1", "light.lamp2"]
async def test_integration_specific_group_included_entity_ids(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test IntegrationSpecificGroup resolves entity IDs from unique IDs."""
entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="member1"
)
entity_registry.async_get_or_create(
"light", "test", "unique_2", suggested_object_id="member2"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.integration_group", unique_id="int_group")
ent.group = IntegrationSpecificGroup(ent, ["unique_1", "unique_2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert sorted(ent.group.included_entity_ids) == ["light.member1", "light.member2"]
async def test_integration_specific_group_missing_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test IntegrationSpecificGroup handles missing entities."""
entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="member1"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.partial_group", unique_id="partial")
ent.group = IntegrationSpecificGroup(
ent, ["unique_1", "unique_2", "unique_missing"]
)
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert ent.group.included_entity_ids == ["light.member1"]
async def test_integration_specific_group_included_unique_ids_setter(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test IntegrationSpecificGroup included_unique_ids setter clears cache."""
entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="member1"
)
entity_registry.async_get_or_create(
"light", "test", "unique_2", suggested_object_id="member2"
)
entity_registry.async_get_or_create(
"light", "test", "unique_3", suggested_object_id="member3"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.dynamic_group", unique_id="dynamic")
ent.group = IntegrationSpecificGroup(ent, ["unique_1"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert ent.group.included_entity_ids == ["light.member1"]
ent.group.included_unique_ids = ["unique_2", "unique_3"]
assert sorted(ent.group.included_entity_ids) == ["light.member2", "light.member3"]
async def test_integration_specific_group_member_added(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test IntegrationSpecificGroup updates when member is added to registry."""
entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="member1"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.registry_group", unique_id="reg_group")
ent.group = IntegrationSpecificGroup(ent, ["unique_1", "unique_2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert ent.group.included_entity_ids == ["light.member1"]
entity_registry.async_get_or_create(
"light", "test", "unique_2", suggested_object_id="member2"
)
await hass.async_block_till_done()
assert sorted(ent.group.included_entity_ids) == ["light.member1", "light.member2"]
async def test_integration_specific_group_member_removed(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test IntegrationSpecificGroup updates when member is removed from registry."""
entry1 = entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="member1"
)
entity_registry.async_get_or_create(
"light", "test", "unique_2", suggested_object_id="member2"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.remove_group", unique_id="rem_group")
ent.group = IntegrationSpecificGroup(ent, ["unique_1", "unique_2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert sorted(ent.group.included_entity_ids) == ["light.member1", "light.member2"]
entity_registry.async_remove(entry1.entity_id)
await hass.async_block_till_done()
assert ent.group.included_entity_ids == ["light.member2"]
async def test_integration_specific_group_member_renamed(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test IntegrationSpecificGroup updates when member entity_id is renamed."""
entry = entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="original_name"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.group", unique_id="grp")
ent.group = IntegrationSpecificGroup(ent, ["unique_1"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
assert ent.group.included_entity_ids == ["light.original_name"]
entity_registry.async_update_entity(entry.entity_id, new_entity_id="light.new_name")
await hass.async_block_till_done()
assert ent.group.included_entity_ids == ["light.new_name"]
async def test_integration_specific_group_attribute_in_state(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test ATTR_GROUP_ENTITIES is included in IntegrationSpecificGroup state."""
entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="member1"
)
entity_registry.async_get_or_create(
"light", "test", "unique_2", suggested_object_id="member2"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.int_group_attrs", unique_id="int_attrs")
ent.group = IntegrationSpecificGroup(ent, ["unique_1", "unique_2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
state = hass.states.get("light.int_group_attrs")
assert state is not None
assert ATTR_GROUP_ENTITIES in state.attributes
assert sorted(state.attributes[ATTR_GROUP_ENTITIES]) == [
"light.member1",
"light.member2",
]
async def test_expand_entity_ids_integration_specific_group_not_expanded(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test expand_entity_ids doesn't expand IntegrationSpecificGroup."""
entity_registry.async_get_or_create(
"light", "test", "unique_1", suggested_object_id="member1"
)
entity_registry.async_get_or_create(
"light", "test", "unique_2", suggested_object_id="member2"
)
platform = MockEntityPlatform(hass, domain="light", platform_name="test")
ent = MockEntity(entity_id="light.int_specific_group", unique_id="int_spec")
ent.group = IntegrationSpecificGroup(ent, ["unique_1", "unique_2"])
await platform.async_add_entities([ent])
await hass.async_block_till_done()
expanded = group.expand_entity_ids(hass, ["light.int_specific_group"])
assert expanded == ["light.int_specific_group"]