Compare commits

...

3 Commits

Author SHA1 Message Date
jbouwh
be5dfcd06d Automatically update the entity propery when a member created, updated or deleted 2025-09-15 17:49:40 +00:00
jbouwh
29bda601cf Apply light group icon to all MQTT light schemas 2025-09-15 17:49:10 +00:00
jbouwh
3f31402d0e Allow an MQTT entity to show as a group 2025-09-15 17:49:10 +00:00
8 changed files with 239 additions and 3 deletions

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

@@ -106,6 +106,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

@@ -13,6 +13,7 @@ import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_CONFIGURATION_URL,
ATTR_ENTITY_ID,
ATTR_HW_VERSION,
ATTR_MANUFACTURER,
ATTR_MODEL,
@@ -32,7 +33,13 @@ from homeassistant.const import (
CONF_URL,
CONF_VALUE_TEMPLATE,
)
from homeassistant.core import Event, HassJobType, HomeAssistant, callback
from homeassistant.core import (
CALLBACK_TYPE,
Event,
HassJobType,
HomeAssistant,
callback,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import (
DeviceEntry,
@@ -79,6 +86,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 +144,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class",
"device_info",
"entity_category",
"entity_id",
"entity_picture",
"entity_registry_enabled_default",
"extra_state_attributes",
@@ -467,19 +476,74 @@ class MqttAttributesMixin(Entity):
_attributes_extra_blocked: frozenset[str] = frozenset()
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
_default_group_icon: str | None = None
_group_entity_ids: list[str] | None = None
_message_callback: Callable[
[MessageCallbackType, set[str] | None, ReceiveMessage], None
]
_process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
_monitor_member_updates_callback: CALLBACK_TYPE
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin."""
self._attributes_sub_state: dict[str, EntitySubscription] = {}
self._attributes_config = config
def _monitor_member_updates(self) -> None:
"""Update the group members if the entity registry is updated."""
entity_registry = er.async_get(self.hass)
async def _handle_entity_registry_updated(event: Event[Any]) -> None:
"""Handle registry 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._attributes_config[CONF_GROUP]
) or (
event.data["action"] == "remove"
and self._group_entity_ids is not None
and event.data["entity_id"] in self._group_entity_ids
):
self._update_group_entity_ids()
self._attr_extra_state_attributes[ATTR_ENTITY_ID] = (
self._group_entity_ids
)
self.async_write_ha_state()
self.async_on_remove(
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
_handle_entity_registry_updated,
)
)
def _update_group_entity_ids(self) -> None:
"""Set the entity_id property if the entity represents a group of entities.
Setting entity_id in the extra state attributes will show the discovered entity
as a group and will show the member entities in the UI.
"""
if CONF_GROUP not in self._attributes_config:
self._default_entity_icon = None
return
self._attr_icon = self._attr_icon or self._default_group_icon
entity_registry = er.async_get(self.hass)
self._group_entity_ids = []
for resource_id in self._attributes_config[CONF_GROUP]:
if entity_id := entity_registry.async_get_entity_id(
self.entity_id.split(".")[0], DOMAIN, resource_id
):
self._group_entity_ids.append(entity_id)
async def async_added_to_hass(self) -> None:
"""Subscribe MQTT events."""
await super().async_added_to_hass()
self._update_group_entity_ids()
if self._group_entity_ids is not None:
self._monitor_member_updates()
self._attr_extra_state_attributes = {ATTR_ENTITY_ID: self._group_entity_ids}
self._attributes_prepare_subscribe_topics()
self._attributes_subscribe_topics()
@@ -546,12 +610,14 @@ 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
and k not in self._attributes_extra_blocked
}
if self._group_entity_ids is not None:
filtered_dict[ATTR_ENTITY_ID] = self._group_entity_ids
if hasattr(self, "_process_update_extra_state_attributes"):
self._process_update_extra_state_attributes(filtered_dict)
else:

View File

@@ -239,6 +239,7 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity):
"""Representation of a MQTT light."""
_default_name = DEFAULT_NAME
_default_group_icon = "mdi:lightbulb-group"
_entity_id_format = ENTITY_ID_FORMAT
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
_topic: dict[str, str | None]

View File

@@ -164,6 +164,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
"""Representation of a MQTT JSON light."""
_default_name = DEFAULT_NAME
_default_group_icon = "mdi:lightbulb-group"
_entity_id_format = ENTITY_ID_FORMAT
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED

View File

@@ -121,6 +121,7 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity):
"""Representation of a MQTT Template light."""
_default_name = DEFAULT_NAME
_default_group_icon = "mdi:lightbulb-group"
_entity_id_format = ENTITY_ID_FORMAT
_attributes_extra_blocked = MQTT_LIGHT_ATTRIBUTES_BLOCKED
_optimistic: bool

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,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,88 @@ 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"]
assert group_state.attributes.get("icon") == "mdi:lightbulb-group"
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"]
assert group_state.attributes.get("icon") == "mdi:lightbulb-group"
# 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 +2157,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 +2166,52 @@ 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"]
assert state.attributes.get("icon") == "mdi:lightbulb-group"
async def test_setting_blocked_attribute_via_mqtt_json_message(
hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator
) -> None: