mirror of
https://github.com/home-assistant/core.git
synced 2025-09-25 21:09:38 +00:00
Compare commits
2 Commits
2025.10.0b
...
mqtt-json-
Author | SHA1 | Date | |
---|---|---|---|
![]() |
e2e907963a | ||
![]() |
104ff0f1e1 |
@@ -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",
|
||||
|
@@ -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"
|
||||
|
@@ -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)
|
||||
|
@@ -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."""
|
||||
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user