From fd7f693f3bc967bf944d66a974e7ef60d375c41d Mon Sep 17 00:00:00 2001 From: jbouwh Date: Fri, 13 Jun 2025 21:27:34 +0000 Subject: [PATCH] Add entity category option to entities set up via an MQTT subentry --- homeassistant/components/mqtt/config_flow.py | 51 +++++++++++++--- homeassistant/components/mqtt/entity.py | 5 ++ homeassistant/components/mqtt/strings.json | 9 +++ tests/components/mqtt/common.py | 12 ++++ tests/components/mqtt/test_config_flow.py | 61 +++++++++++++++++++- 5 files changed, 129 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index b41e549093d..9ce10826250 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -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: diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 1202f04ed42..d493d3557c2 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -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) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9bc6df1b633..d1fcd869ede 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -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", diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index b985a8caffe..3e87925c1cd 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -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, diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index e30aa5d50d6..8233fde9353 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -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,