Add entity category option to entities set up via an MQTT subentry

pull/146776/head
jbouwh 2025-06-13 21:27:34 +00:00
parent 91bc56b15c
commit fd7f693f3b
5 changed files with 129 additions and 9 deletions

View File

@ -66,6 +66,7 @@ from homeassistant.const import (
CONF_DEVICE_CLASS, CONF_DEVICE_CLASS,
CONF_DISCOVERY, CONF_DISCOVERY,
CONF_EFFECT, CONF_EFFECT,
CONF_ENTITY_CATEGORY,
CONF_HOST, CONF_HOST,
CONF_NAME, CONF_NAME,
CONF_OPTIMISTIC, CONF_OPTIMISTIC,
@ -84,6 +85,7 @@ from homeassistant.const import (
STATE_CLOSING, STATE_CLOSING,
STATE_OPEN, STATE_OPEN,
STATE_OPENING, STATE_OPENING,
EntityCategory,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section
@ -411,6 +413,14 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
): TEXT_SELECTOR, ): TEXT_SELECTOR,
} }
) )
ENTITY_CATEGORY_SELECTOR = SelectSelector(
SelectSelectorConfig(
options=[category.value for category in EntityCategory],
mode=SelectSelectorMode.DROPDOWN,
translation_key=CONF_ENTITY_CATEGORY,
sort=True,
)
)
# Sensor specific selectors # Sensor specific selectors
SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector(
@ -603,6 +613,18 @@ def validate_fan_platform_config(config: dict[str, Any]) -> dict[str, str]:
return errors return errors
@callback
def validate_binary_sensor_platform_config(
config: dict[str, Any],
) -> dict[str, str]:
"""Validate the binary sensor sensor entity_category."""
errors: dict[str, str] = {}
if config.get(CONF_ENTITY_CATEGORY) == EntityCategory.CONFIG:
errors[CONF_ENTITY_CATEGORY] = "sensor_entity_category_must_not_be_config"
return errors
@callback @callback
def validate_sensor_platform_config( def validate_sensor_platform_config(
config: dict[str, Any], config: dict[str, Any],
@ -648,6 +670,9 @@ def validate_sensor_platform_config(
): ):
errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom_for_state_class" errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom_for_state_class"
if config.get(CONF_ENTITY_CATEGORY) == EntityCategory.CONFIG:
errors[CONF_ENTITY_CATEGORY] = "sensor_entity_category_must_not_be_config"
return errors return errors
@ -730,6 +755,11 @@ COMMON_ENTITY_FIELDS: dict[str, PlatformField] = {
exclude_from_reconfig=True, exclude_from_reconfig=True,
default=None, default=None,
), ),
CONF_ENTITY_CATEGORY: PlatformField(
selector=ENTITY_CATEGORY_SELECTOR,
required=False,
default=None,
),
CONF_ENTITY_PICTURE: PlatformField( CONF_ENTITY_PICTURE: PlatformField(
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url" selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
), ),
@ -1855,7 +1885,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
str, str,
Callable[[dict[str, Any]], dict[str, str]] | None, Callable[[dict[str, Any]], dict[str, str]] | None,
] = { ] = {
Platform.BINARY_SENSOR.value: None, Platform.BINARY_SENSOR.value: validate_binary_sensor_platform_config,
Platform.BUTTON.value: None, Platform.BUTTON.value: None,
Platform.COVER.value: validate_cover_platform_config, Platform.COVER.value: validate_cover_platform_config,
Platform.FAN.value: validate_fan_platform_config, Platform.FAN.value: validate_fan_platform_config,
@ -1995,13 +2025,12 @@ def validate_user_input(
) )
if config_validator is not None: if config_validator is not None:
if TYPE_CHECKING:
assert component_data is not None
errors |= config_validator( errors |= config_validator(
calculate_merged_config( merged_user_input
if component_data is None
else calculate_merged_config(
merged_user_input, data_schema_fields, component_data merged_user_input, data_schema_fields, component_data
), )
) )
return merged_user_input, errors return merged_user_input, errors
@ -2751,8 +2780,16 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
entity_name_label = f" ({name})" if name is not None else "" entity_name_label = f" ({name})" if name is not None else ""
data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig) data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig)
if user_input is not None: if user_input is not None:
platform: str = (
user_input[CONF_PLATFORM]
if component_data is None
else component_data[CONF_PLATFORM]
)
merged_user_input, errors = validate_user_input( merged_user_input, errors = validate_user_input(
user_input, data_schema_fields, component_data=component_data user_input,
data_schema_fields,
component_data=component_data,
config_validator=ENTITY_CONFIG_VALIDATOR[platform],
) )
if not errors: if not errors:
if self._component_id is None: if self._component_id is None:

View File

@ -313,6 +313,11 @@ def async_setup_entity_entry_helper(
component_config.pop("platform") component_config.pop("platform")
component_config.update(availability_config) component_config.update(availability_config)
component_config.update(device_mqtt_options) component_config.update(device_mqtt_options)
if (
CONF_ENTITY_CATEGORY in component_config
and component_config[CONF_ENTITY_CATEGORY] is None
):
component_config.pop(CONF_ENTITY_CATEGORY)
try: try:
config = platform_schema_modern(component_config) config = platform_schema_modern(component_config)

View File

@ -181,11 +181,13 @@
"data": { "data": {
"platform": "Type of entity", "platform": "Type of entity",
"name": "Entity name", "name": "Entity name",
"entity_category": "Entity category",
"entity_picture": "Entity picture" "entity_picture": "Entity picture"
}, },
"data_description": { "data_description": {
"platform": "The type of the entity to configure.", "platform": "The type of the entity to configure.",
"name": "The name of the entity. Leave empty to set it to `None` to [mark it as main feature of the MQTT device](https://www.home-assistant.io/integrations/mqtt/#naming-of-mqtt-entities).", "name": "The name of the entity. Leave empty to set it to `None` to [mark it as main feature of the MQTT device](https://www.home-assistant.io/integrations/mqtt/#naming-of-mqtt-entities).",
"entity_category": "The category of the entity to configure. Leave empty to set it to `None` to [mark it as main feature of the MQTT device](https://developers.home-assistant.io/docs/core/entity/#registry-properties). An entity with a category will not be exposed to cloud, Alexa, or Google Assistant components, nor included in indirect service calls to devices or areas.",
"entity_picture": "An URL to a picture to be assigned." "entity_picture": "An URL to a picture to be assigned."
} }
}, },
@ -651,6 +653,7 @@
"options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used", "options_not_allowed_with_state_class_or_uom": "The 'Options' setting is not allowed when state class or unit of measurement are used",
"options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset", "options_device_class_enum": "The 'Options' setting must be used with the Enumeration device class. If you continue, the existing options will be reset",
"options_with_enum_device_class": "Configure options for the enumeration sensor", "options_with_enum_device_class": "Configure options for the enumeration sensor",
"sensor_entity_category_must_not_be_config": "Sensor entities can not be categorized as configurable",
"uom_required_for_device_class": "The selected device class requires a unit" "uom_required_for_device_class": "The selected device class requires a unit"
} }
} }
@ -887,6 +890,12 @@
"switch": "[%key:component::switch::title%]" "switch": "[%key:component::switch::title%]"
} }
}, },
"entity_category": {
"options": {
"config": "Config",
"diagnostic": "Diagnostic"
}
},
"light_schema": { "light_schema": {
"options": { "options": {
"basic": "Default schema", "basic": "Default schema",

View File

@ -71,6 +71,7 @@ MOCK_SUBENTRY_BINARY_SENSOR_COMPONENT = {
"platform": "binary_sensor", "platform": "binary_sensor",
"name": "Hatch", "name": "Hatch",
"device_class": "door", "device_class": "door",
"entity_category": None,
"state_topic": "test-topic", "state_topic": "test-topic",
"payload_on": "ON", "payload_on": "ON",
"payload_off": "OFF", "payload_off": "OFF",
@ -86,6 +87,7 @@ MOCK_SUBENTRY_BUTTON_COMPONENT = {
"name": "Restart", "name": "Restart",
"device_class": "restart", "device_class": "restart",
"command_topic": "test-topic", "command_topic": "test-topic",
"entity_category": None,
"payload_press": "PRESS", "payload_press": "PRESS",
"command_template": "{{ value }}", "command_template": "{{ value }}",
"retain": False, "retain": False,
@ -97,6 +99,7 @@ MOCK_SUBENTRY_COVER_COMPONENT = {
"platform": "cover", "platform": "cover",
"name": "Blind", "name": "Blind",
"device_class": "blind", "device_class": "blind",
"entity_category": None,
"command_topic": "test-topic", "command_topic": "test-topic",
"payload_stop": None, "payload_stop": None,
"payload_stop_tilt": "STOP", "payload_stop_tilt": "STOP",
@ -132,6 +135,7 @@ MOCK_SUBENTRY_FAN_COMPONENT = {
"platform": "fan", "platform": "fan",
"name": "Breezer", "name": "Breezer",
"command_topic": "test-topic", "command_topic": "test-topic",
"entity_category": None,
"state_topic": "test-topic", "state_topic": "test-topic",
"command_template": "{{ value }}", "command_template": "{{ value }}",
"value_template": "{{ value_json.value }}", "value_template": "{{ value_json.value }}",
@ -169,6 +173,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = {
"363a7ecad6be4a19b939a016ea93e994": { "363a7ecad6be4a19b939a016ea93e994": {
"platform": "notify", "platform": "notify",
"name": "Milkman alert", "name": "Milkman alert",
"entity_category": None,
"command_topic": "test-topic", "command_topic": "test-topic",
"command_template": "{{ value }}", "command_template": "{{ value }}",
"entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994",
@ -179,6 +184,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = {
"6494827dac294fa0827c54b02459d309": { "6494827dac294fa0827c54b02459d309": {
"platform": "notify", "platform": "notify",
"name": "The second notifier", "name": "The second notifier",
"entity_category": None,
"command_topic": "test-topic2", "command_topic": "test-topic2",
"entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309", "entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309",
}, },
@ -187,6 +193,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = {
"5269352dd9534c908d22812ea5d714cd": { "5269352dd9534c908d22812ea5d714cd": {
"platform": "notify", "platform": "notify",
"name": None, "name": None,
"entity_category": None,
"command_topic": "test-topic", "command_topic": "test-topic",
"command_template": "{{ value }}", "command_template": "{{ value }}",
"entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd",
@ -198,6 +205,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT = {
"e9261f6feed443e7b7d5f3fbe2a47412": { "e9261f6feed443e7b7d5f3fbe2a47412": {
"platform": "sensor", "platform": "sensor",
"name": "Energy", "name": "Energy",
"entity_category": None,
"device_class": "enum", "device_class": "enum",
"state_topic": "test-topic", "state_topic": "test-topic",
"options": ["low", "medium", "high"], "options": ["low", "medium", "high"],
@ -210,6 +218,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = {
"a0f85790a95d4889924602effff06b6e": { "a0f85790a95d4889924602effff06b6e": {
"platform": "sensor", "platform": "sensor",
"name": "Energy", "name": "Energy",
"entity_category": None,
"state_class": "measurement", "state_class": "measurement",
"state_topic": "test-topic", "state_topic": "test-topic",
"entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e",
@ -219,6 +228,7 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = {
"e9261f6feed443e7b7d5f3fbe2a47412": { "e9261f6feed443e7b7d5f3fbe2a47412": {
"platform": "sensor", "platform": "sensor",
"name": "Energy", "name": "Energy",
"entity_category": None,
"state_class": "total", "state_class": "total",
"last_reset_value_template": "{{ value_json.value }}", "last_reset_value_template": "{{ value_json.value }}",
"state_topic": "test-topic", "state_topic": "test-topic",
@ -229,6 +239,7 @@ MOCK_SUBENTRY_SWITCH_COMPONENT = {
"3faf1318016c46c5aea26707eeb6f12e": { "3faf1318016c46c5aea26707eeb6f12e": {
"platform": "switch", "platform": "switch",
"name": "Outlet", "name": "Outlet",
"entity_category": None,
"device_class": "outlet", "device_class": "outlet",
"command_topic": "test-topic", "command_topic": "test-topic",
"state_topic": "test-topic", "state_topic": "test-topic",
@ -250,6 +261,7 @@ MOCK_SUBENTRY_LIGHT_BASIC_KELVIN_COMPONENT = {
"payload_off": "OFF", "payload_off": "OFF",
"payload_on": "ON", "payload_on": "ON",
"command_topic": "test-topic", "command_topic": "test-topic",
"entity_category": None,
"schema": "basic", "schema": "basic",
"state_topic": "test-topic", "state_topic": "test-topic",
"color_temp_kelvin": True, "color_temp_kelvin": True,

View File

@ -2654,6 +2654,7 @@ async def test_migrate_of_incompatible_config_entry(
"config_subentries_data", "config_subentries_data",
"mock_device_user_input", "mock_device_user_input",
"mock_entity_user_input", "mock_entity_user_input",
"mock_entity_failed_user_input",
"mock_entity_details_user_input", "mock_entity_details_user_input",
"mock_entity_details_failed_user_input", "mock_entity_details_failed_user_input",
"mock_mqtt_user_input", "mock_mqtt_user_input",
@ -2665,6 +2666,16 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 2}},
{"name": "Hatch"}, {"name": "Hatch"},
(
(
(
{"entity_category": "config"},
{
"entity_category": "sensor_entity_category_must_not_be_config"
},
),
)
),
{"device_class": "door"}, {"device_class": "door"},
(), (),
{ {
@ -2684,6 +2695,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_BUTTON_SUBENTRY_DATA_SINGLE, MOCK_BUTTON_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 2}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 2}},
{"name": "Restart"}, {"name": "Restart"},
(),
{"device_class": "restart"}, {"device_class": "restart"},
(), (),
{ {
@ -2704,6 +2716,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_COVER_SUBENTRY_DATA_SINGLE, MOCK_COVER_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Blind"}, {"name": "Blind"},
(),
{"device_class": "blind"}, {"device_class": "blind"},
(), (),
{ {
@ -2790,6 +2803,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_FAN_SUBENTRY_DATA_SINGLE, MOCK_FAN_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Breezer"}, {"name": "Breezer"},
(),
{ {
"fan_feature_speed": True, "fan_feature_speed": True,
"fan_feature_preset_modes": True, "fan_feature_preset_modes": True,
@ -2941,6 +2955,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 1}},
{"name": "Milkman alert"}, {"name": "Milkman alert"},
(),
None, None,
None, None,
{ {
@ -2960,6 +2975,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{}, {},
(),
None, None,
None, None,
{ {
@ -2979,6 +2995,16 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_SENSOR_SUBENTRY_DATA_SINGLE, MOCK_SENSOR_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Energy"}, {"name": "Energy"},
(
(
(
{"entity_category": "config"},
{
"entity_category": "sensor_entity_category_must_not_be_config"
},
),
)
),
{"device_class": "enum", "options": ["low", "medium", "high"]}, {"device_class": "enum", "options": ["low", "medium", "high"]},
( (
( (
@ -3035,6 +3061,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Energy"}, {"name": "Energy"},
(),
{ {
"state_class": "measurement", "state_class": "measurement",
}, },
@ -3057,6 +3084,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS, MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Outlet"}, {"name": "Outlet"},
(),
{"device_class": "outlet"}, {"device_class": "outlet"},
(), (),
{ {
@ -3085,6 +3113,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE, MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 1}}, {"name": "Milk notifier", "mqtt_settings": {"qos": 1}},
{"name": "Basic light"}, {"name": "Basic light"},
(),
{}, {},
{}, {},
{ {
@ -3146,6 +3175,7 @@ async def test_subentry_configflow(
config_subentries_data: dict[str, Any], config_subentries_data: dict[str, Any],
mock_device_user_input: dict[str, Any], mock_device_user_input: dict[str, Any],
mock_entity_user_input: dict[str, Any], mock_entity_user_input: dict[str, Any],
mock_entity_failed_user_input: tuple[tuple[dict[str, Any], dict[str, str]],],
mock_entity_details_user_input: dict[str, Any], mock_entity_details_user_input: dict[str, Any],
mock_entity_details_failed_user_input: tuple[ mock_entity_details_failed_user_input: tuple[
tuple[dict[str, Any], dict[str, str]], tuple[dict[str, Any], dict[str, str]],
@ -3202,6 +3232,16 @@ async def test_subentry_configflow(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "entity" assert result["step_id"] == "entity"
# First test platform validators if set of test
for failed_user_input, failed_errors in mock_entity_failed_user_input:
# Test an invalid entity details user input case
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={"platform": component["platform"]} | failed_user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == failed_errors
# Try again with valid data # Try again with valid data
result = await hass.config_entries.subentries.async_configure( result = await hass.config_entries.subentries.async_configure(
result["flow_id"], result["flow_id"],
@ -3906,9 +3946,26 @@ async def test_subentry_reconfigure_edit_entity_reset_fields(
{ {
"command_topic": "test-topic2", "command_topic": "test-topic2",
}, },
) ),
(
(
ConfigSubentryData(
data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
subentry_type="device",
title="Mock subentry",
),
),
{
"platform": "notify",
"name": "The second notifier",
"entity_category": "config",
},
{
"command_topic": "test-topic2",
},
),
], ],
ids=["notify_notify"], ids=["notify_notify_no_entity_category", "notify_notify_entity_category"],
) )
async def test_subentry_reconfigure_add_entity( async def test_subentry_reconfigure_add_entity(
hass: HomeAssistant, hass: HomeAssistant,