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_DISCOVERY,
CONF_EFFECT,
CONF_ENTITY_CATEGORY,
CONF_HOST,
CONF_NAME,
CONF_OPTIMISTIC,
@ -84,6 +85,7 @@ from homeassistant.const import (
STATE_CLOSING,
STATE_OPEN,
STATE_OPENING,
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section
@ -411,6 +413,14 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
): 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_DEVICE_CLASS_SELECTOR = SelectSelector(
@ -603,6 +613,18 @@ def validate_fan_platform_config(config: dict[str, Any]) -> dict[str, str]:
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
def validate_sensor_platform_config(
config: dict[str, Any],
@ -648,6 +670,9 @@ def validate_sensor_platform_config(
):
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
@ -730,6 +755,11 @@ COMMON_ENTITY_FIELDS: dict[str, PlatformField] = {
exclude_from_reconfig=True,
default=None,
),
CONF_ENTITY_CATEGORY: PlatformField(
selector=ENTITY_CATEGORY_SELECTOR,
required=False,
default=None,
),
CONF_ENTITY_PICTURE: PlatformField(
selector=TEXT_SELECTOR, required=False, validator=cv.url, error="invalid_url"
),
@ -1855,7 +1885,7 @@ ENTITY_CONFIG_VALIDATOR: dict[
str,
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.COVER.value: validate_cover_platform_config,
Platform.FAN.value: validate_fan_platform_config,
@ -1995,13 +2025,12 @@ def validate_user_input(
)
if config_validator is not None:
if TYPE_CHECKING:
assert component_data is not None
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
),
)
)
return merged_user_input, errors
@ -2751,8 +2780,16 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
entity_name_label = f" ({name})" if name is not None else ""
data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig)
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(
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 self._component_id is None:

View File

@ -313,6 +313,11 @@ def async_setup_entity_entry_helper(
component_config.pop("platform")
component_config.update(availability_config)
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:
config = platform_schema_modern(component_config)

View File

@ -181,11 +181,13 @@
"data": {
"platform": "Type of entity",
"name": "Entity name",
"entity_category": "Entity category",
"entity_picture": "Entity picture"
},
"data_description": {
"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).",
"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."
}
},
@ -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_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",
"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"
}
}
@ -887,6 +890,12 @@
"switch": "[%key:component::switch::title%]"
}
},
"entity_category": {
"options": {
"config": "Config",
"diagnostic": "Diagnostic"
}
},
"light_schema": {
"options": {
"basic": "Default schema",

View File

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

View File

@ -2654,6 +2654,7 @@ async def test_migrate_of_incompatible_config_entry(
"config_subentries_data",
"mock_device_user_input",
"mock_entity_user_input",
"mock_entity_failed_user_input",
"mock_entity_details_user_input",
"mock_entity_details_failed_user_input",
"mock_mqtt_user_input",
@ -2665,6 +2666,16 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_BINARY_SENSOR_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 2}},
{"name": "Hatch"},
(
(
(
{"entity_category": "config"},
{
"entity_category": "sensor_entity_category_must_not_be_config"
},
),
)
),
{"device_class": "door"},
(),
{
@ -2684,6 +2695,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_BUTTON_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 2}},
{"name": "Restart"},
(),
{"device_class": "restart"},
(),
{
@ -2704,6 +2716,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_COVER_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Blind"},
(),
{"device_class": "blind"},
(),
{
@ -2790,6 +2803,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_FAN_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Breezer"},
(),
{
"fan_feature_speed": True,
"fan_feature_preset_modes": True,
@ -2941,6 +2955,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 1}},
{"name": "Milkman alert"},
(),
None,
None,
{
@ -2960,6 +2975,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{},
(),
None,
None,
{
@ -2979,6 +2995,16 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_SENSOR_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Energy"},
(
(
(
{"entity_category": "config"},
{
"entity_category": "sensor_entity_category_must_not_be_config"
},
),
)
),
{"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,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Energy"},
(),
{
"state_class": "measurement",
},
@ -3057,6 +3084,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_SWITCH_SUBENTRY_DATA_SINGLE_STATE_CLASS,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{"name": "Outlet"},
(),
{"device_class": "outlet"},
(),
{
@ -3085,6 +3113,7 @@ async def test_migrate_of_incompatible_config_entry(
MOCK_LIGHT_BASIC_KELVIN_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 1}},
{"name": "Basic light"},
(),
{},
{},
{
@ -3146,6 +3175,7 @@ async def test_subentry_configflow(
config_subentries_data: dict[str, Any],
mock_device_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_failed_user_input: tuple[
tuple[dict[str, Any], dict[str, str]],
@ -3202,6 +3232,16 @@ async def test_subentry_configflow(
assert result["type"] is FlowResultType.FORM
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
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
@ -3906,9 +3946,26 @@ async def test_subentry_reconfigure_edit_entity_reset_fields(
{
"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(
hass: HomeAssistant,