Compare commits

...

2 Commits

Author SHA1 Message Date
jbouwh
e2e907963a Improve doc string 2025-09-13 12:16:10 +00:00
jbouwh
104ff0f1e1 Add support for MQTT JSON light groups 2025-09-11 22:16:31 +00:00
5 changed files with 211 additions and 6 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

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

@@ -546,7 +546,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
@@ -1373,6 +1373,7 @@ class MqttEntity(
_attr_force_update = False
_attr_has_entity_name = True
_attr_should_poll = False
_default_entity: str | None = None
_default_name: str | None
_entity_id_format: str
_update_registry_entity_id: str | None = None
@@ -1609,7 +1610,7 @@ class MqttEntity(
self._attr_entity_registry_enabled_default = bool(
config.get(CONF_ENABLED_BY_DEFAULT)
)
self._attr_icon = config.get(CONF_ICON)
self._attr_icon = config.get(CONF_ICON, self._default_entity)
self._attr_entity_picture = config.get(CONF_ENTITY_PICTURE)
# Set the entity name if needed
self._set_entity_name(config)

View File

@@ -23,6 +23,7 @@ from homeassistant.components.light import (
ATTR_XY_COLOR,
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
DOMAIN as LIGHT_DOMAIN,
ENTITY_ID_FORMAT,
FLASH_LONG,
FLASH_SHORT,
@@ -34,6 +35,7 @@ from homeassistant.components.light import (
valid_supported_color_modes,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_BRIGHTNESS,
CONF_COLOR_TEMP,
CONF_EFFECT,
@@ -45,7 +47,7 @@ from homeassistant.const import (
STATE_ON,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.json import json_dumps
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, VolSchemaType
@@ -62,6 +64,7 @@ from ..const import (
CONF_FLASH,
CONF_FLASH_TIME_LONG,
CONF_FLASH_TIME_SHORT,
CONF_GROUP,
CONF_MAX_KELVIN,
CONF_MAX_MIREDS,
CONF_MIN_KELVIN,
@@ -77,6 +80,7 @@ from ..const import (
DEFAULT_FLASH_TIME_LONG,
DEFAULT_FLASH_TIME_SHORT,
DEFAULT_WHITE_SCALE,
DOMAIN,
)
from ..entity import MqttEntity
from ..models import ReceiveMessage
@@ -91,8 +95,6 @@ from .schema_basic import (
_LOGGER = logging.getLogger(__name__)
DOMAIN = "mqtt_json"
DEFAULT_NAME = "MQTT JSON Light"
DEFAULT_FLASH = True
@@ -115,6 +117,7 @@ _PLATFORM_SCHEMA_BASE = (
vol.Optional(
CONF_FLASH_TIME_SHORT, default=DEFAULT_FLASH_TIME_SHORT
): cv.positive_int,
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
vol.Optional(CONF_MAX_MIREDS): cv.positive_int,
vol.Optional(CONF_MIN_MIREDS): cv.positive_int,
vol.Optional(CONF_MAX_KELVIN): cv.positive_int,
@@ -171,16 +174,20 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
_fixed_color_mode: ColorMode | str | None = None
_flash_times: dict[str, int | None]
_group_member_entity_ids_resolved: bool
_topic: dict[str, str | None]
_optimistic: bool
_extra_state_attributes: dict[str, Any] | None = None
@staticmethod
def config_schema() -> VolSchemaType:
"""Return the config schema."""
return DISCOVERY_SCHEMA_JSON
@callback
def _setup_from_config(self, config: ConfigType) -> None:
"""(Re)Setup the entity."""
self._group_member_entity_ids_resolved = False
self._color_temp_kelvin = config[CONF_COLOR_TEMP_KELVIN]
self._attr_min_color_temp_kelvin = (
color_util.color_temperature_mired_to_kelvin(max_mireds)
@@ -226,6 +233,43 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
else:
self._attr_supported_color_modes = {ColorMode.ONOFF}
self._update_extra_state_and_group_info()
@callback
def _update_extra_state_and_group_info(self) -> None:
"""Set the entity_id property if the light represents a group of lights.
Setting entity_id in the extra state attributes will show the discover the light
as a group and allow to control the member light manually.
"""
if CONF_GROUP not in self._config:
self._attr_extra_state_attributes = self._extra_state_attributes or {}
self._default_entity = None
return
self._default_entity = "mdi:lightbulb-group"
entity_registry = er.async_get(self.hass)
_group_entity_ids: list[str] = []
self._group_member_entity_ids_resolved = True
for resource_id in self._config[CONF_GROUP]:
if entity_id := entity_registry.async_get_entity_id(
LIGHT_DOMAIN, DOMAIN, resource_id
):
_group_entity_ids.append(entity_id)
else:
# The ID is not (yet) resolved, so we retry at the next state update.
# This can only happen the first time the member entities
# are discovered, and added to the entity registry.
self._group_member_entity_ids_resolved = False
entity_attribute: dict[str, Any] = {ATTR_ENTITY_ID: _group_entity_ids}
if self._extra_state_attributes is None:
self._attr_extra_state_attributes = entity_attribute
return
self._attr_extra_state_attributes = (
self._extra_state_attributes | entity_attribute
)
def _update_color(self, values: dict[str, Any]) -> None:
color_mode: str = values["color_mode"]
if not self._supports_color_mode(color_mode):
@@ -327,6 +371,21 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity):
with suppress(KeyError):
self._attr_effect = cast(str, values["effect"])
# We update the group info on a received state up, as member
if not self._group_member_entity_ids_resolved:
self._update_extra_state_and_group_info()
@callback
def _process_update_extra_state_attributes(
self, extra_state_attributes: dict[str, Any]
) -> None:
"""Process an the extra state attributes update.
Add extracted group members if the light represents a group.
"""
self._extra_state_attributes = extra_state_attributes
self._update_extra_state_and_group_info()
@callback
def _prepare_subscribe_topics(self) -> None:
"""(Re)Subscribe to topics."""

View File

@@ -82,6 +82,7 @@ light:
"""
import copy
import json
from typing import Any
from unittest.mock import call, patch
@@ -169,6 +170,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 +1893,69 @@ 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
) -> 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.
"""
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
# Members are not added yet, we need a group state update first
# to trigger a state update
assert not group_state.attributes.get("entity_id")
async_fire_mqtt_message(hass, "test-state-topic-group", '{"state": "ON"}')
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.member1", "light.member2"]
assert group_state.attributes.get("icon") == "mdi:lightbulb-group"
@pytest.mark.parametrize(
"hass_config",
[
@@ -2040,7 +2137,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 +2146,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: