From 77bf977d63ec49510e6326b8d69b60fc81429399 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 26 Mar 2025 13:34:24 +0100 Subject: [PATCH] Add sensor as entity platform on MQTT subentries (#139899) * Add sensor as entity platform on MQTT subentries * Fix typo * Improve device class data description * Tweak * Rework reconfig calculation * Filter out last_reset_value_template if state class is not total * Collapse expire after as advanced setting * Update suggested_display_precision translation strings * Make options and last_reset_template conditional, use sections for advanced settings * Ensure options are removed properly * Improve sensor options label, ensure UOM is set when device class has units * Use helper to apply suggested values from component config * Rename to `Add option` * Fix schema builder not hiding empty sections and removing fields excluded from reconfig * Do not hide advanced settings if values are available or are defaults * Improve spelling and Learn more links * Improve unit of measurement validation * Fix UOM selector and translation strings * Address comments from code review * Remove stale comment * Rename selector constant, split validator * Simplify config validator * Return tuple with config and errors for config validation --- homeassistant/components/mqtt/config_flow.py | 455 +++++++++++++++++-- homeassistant/components/mqtt/const.py | 3 + homeassistant/components/mqtt/entity.py | 7 +- homeassistant/components/mqtt/sensor.py | 14 +- homeassistant/components/mqtt/strings.json | 125 ++++- homeassistant/components/mqtt/util.py | 6 + tests/components/mqtt/common.py | 74 ++- tests/components/mqtt/test_config_flow.py | 406 +++++++++++++++-- 8 files changed, 993 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index cc98315c218..acdc225a59a 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -27,6 +27,12 @@ import voluptuous as vol from homeassistant.components.file_upload import process_uploaded_file from homeassistant.components.hassio import AddonError, AddonManager, AddonState +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASS_UNITS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -45,6 +51,7 @@ from homeassistant.const import ( ATTR_SW_VERSION, CONF_CLIENT_ID, CONF_DEVICE, + CONF_DEVICE_CLASS, CONF_DISCOVERY, CONF_HOST, CONF_NAME, @@ -53,10 +60,12 @@ from homeassistant.const import ( CONF_PLATFORM, CONF_PORT, CONF_PROTOCOL, + CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, + CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, SectionConfig, section from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.hassio import is_hassio from homeassistant.helpers.json import json_dumps @@ -99,11 +108,16 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_DISCOVERY_PREFIX, CONF_ENTITY_PICTURE, + CONF_EXPIRE_AFTER, CONF_KEEPALIVE, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_OPTIONS, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, + CONF_STATE_TOPIC, + CONF_SUGGESTED_DISPLAY_PRECISION, CONF_TLS_INSECURE, CONF_TRANSPORT, CONF_WILL_MESSAGE, @@ -133,6 +147,7 @@ from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData from .util import ( async_create_certificate_temp_files, get_file_path, + learn_more_url, valid_birth_will, valid_publish_topic, valid_qos_schema, @@ -217,7 +232,7 @@ KEY_UPLOAD_SELECTOR = FileSelector( ) # Subentry selectors -SUBENTRY_PLATFORMS = [Platform.NOTIFY] +SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR] SUBENTRY_PLATFORM_SELECTOR = SelectSelector( SelectSelectorConfig( options=[platform.value for platform in SUBENTRY_PLATFORMS], @@ -225,7 +240,6 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector( translation_key=CONF_PLATFORM, ) ) - TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( @@ -241,17 +255,109 @@ SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( } ) +# Sensor specific selectors +SENSOR_DEVICE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key="device_class_sensor", + sort=True, + ) +) +SENSOR_STATE_CLASS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[device_class.value for device_class in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_STATE_CLASS, + ) +) +OPTIONS_SELECTOR = SelectSelector( + SelectSelectorConfig( + options=[], + custom_value=True, + multiple=True, + ) +) +SUGGESTED_DISPLAY_PRECISION_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0, max=9) +) +EXPIRE_AFTER_SELECTOR = NumberSelector( + NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=0) +) + + +@callback +def validate_sensor_platform_config( + config: dict[str, Any], +) -> dict[str, str]: + """Validate the sensor options, state and device class config.""" + errors: dict[str, str] = {} + # Only allow `options` to be set for `enum` sensors + # to limit the possible sensor values + if config.get(CONF_OPTIONS) is not None: + if config.get(CONF_STATE_CLASS) or config.get(CONF_UNIT_OF_MEASUREMENT): + errors[CONF_OPTIONS] = "options_not_allowed_with_state_class_or_uom" + + if (device_class := config.get(CONF_DEVICE_CLASS)) != SensorDeviceClass.ENUM: + errors[CONF_DEVICE_CLASS] = "options_device_class_enum" + + if ( + (device_class := config.get(CONF_DEVICE_CLASS)) == SensorDeviceClass.ENUM + and errors is not None + and CONF_OPTIONS not in config + ): + errors[CONF_OPTIONS] = "options_with_enum_device_class" + + if ( + device_class in DEVICE_CLASS_UNITS + and (unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)) is None + and errors is not None + ): + # Do not allow an empty unit of measurement in a subentry data flow + errors[CONF_UNIT_OF_MEASUREMENT] = "uom_required_for_device_class" + return errors + + if ( + device_class is not None + and device_class in DEVICE_CLASS_UNITS + and unit_of_measurement not in DEVICE_CLASS_UNITS[device_class] + ): + errors[CONF_UNIT_OF_MEASUREMENT] = "invalid_uom" + + return errors + @dataclass(frozen=True) class PlatformField: """Stores a platform config field schema, required flag and validator.""" - selector: Selector + selector: Selector[Any] | Callable[..., Selector[Any]] required: bool validator: Callable[..., Any] error: str | None = None default: str | int | vol.Undefined = vol.UNDEFINED exclude_from_reconfig: bool = False + conditions: tuple[dict[str, Any], ...] | None = None + custom_filtering: bool = False + section: str | None = None + + +@callback +def unit_of_measurement_selector(user_data: dict[str, Any | None]) -> Selector: + """Return a context based unit of measurement selector.""" + if ( + user_data is None + or (device_class := user_data.get(CONF_DEVICE_CLASS)) is None + or device_class not in DEVICE_CLASS_UNITS + ): + return TEXT_SELECTOR + return SelectSelector( + SelectSelectorConfig( + options=[str(uom) for uom in DEVICE_CLASS_UNITS[device_class]], + sort=True, + custom_value=True, + ) + ) COMMON_ENTITY_FIELDS = { @@ -264,7 +370,29 @@ COMMON_ENTITY_FIELDS = { COMMON_MQTT_FIELDS = { CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0), - CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), +} + +PLATFORM_ENTITY_FIELDS = { + Platform.NOTIFY.value: {}, + Platform.SENSOR.value: { + CONF_DEVICE_CLASS: PlatformField(SENSOR_DEVICE_CLASS_SELECTOR, False, str), + CONF_STATE_CLASS: PlatformField(SENSOR_STATE_CLASS_SELECTOR, False, str), + CONF_UNIT_OF_MEASUREMENT: PlatformField( + unit_of_measurement_selector, False, str, custom_filtering=True + ), + CONF_SUGGESTED_DISPLAY_PRECISION: PlatformField( + SUGGESTED_DISPLAY_PRECISION_SELECTOR, + False, + cv.positive_int, + section="advanced_settings", + ), + CONF_OPTIONS: PlatformField( + OPTIONS_SELECTOR, + False, + cv.ensure_list, + conditions=({"device_class": "enum"},), + ), + }, } PLATFORM_MQTT_FIELDS = { Platform.NOTIFY.value: { @@ -274,7 +402,33 @@ PLATFORM_MQTT_FIELDS = { CONF_COMMAND_TEMPLATE: PlatformField( TEMPLATE_SELECTOR, False, cv.template, "invalid_template" ), + CONF_RETAIN: PlatformField(BOOLEAN_SELECTOR, False, bool), }, + Platform.SENSOR.value: { + CONF_STATE_TOPIC: PlatformField( + TEXT_SELECTOR, True, valid_subscribe_topic, "invalid_subscribe_topic" + ), + CONF_VALUE_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, False, cv.template, "invalid_template" + ), + CONF_LAST_RESET_VALUE_TEMPLATE: PlatformField( + TEMPLATE_SELECTOR, + False, + cv.template, + "invalid_template", + conditions=({CONF_STATE_CLASS: "total"},), + ), + CONF_EXPIRE_AFTER: PlatformField( + EXPIRE_AFTER_SELECTOR, False, cv.positive_int, section="advanced_settings" + ), + }, +} +ENTITY_CONFIG_VALIDATOR: dict[ + str, + Callable[[dict[str, Any]], dict[str, str]] | None, +] = { + Platform.NOTIFY.value: None, + Platform.SENSOR.value: validate_sensor_platform_config, } MQTT_DEVICE_SCHEMA = vol.Schema( @@ -337,38 +491,140 @@ def validate_field( errors[field] = error +@callback +def _check_conditions( + platform_field: PlatformField, component_data: dict[str, Any] | None = None +) -> bool: + """Only include field if one of conditions match, or no conditions are set.""" + if platform_field.conditions is None or component_data is None: + return True + return any( + all(component_data.get(key) == value for key, value in condition.items()) + for condition in platform_field.conditions + ) + + +@callback +def calculate_merged_config( + merged_user_input: dict[str, Any], + data_schema_fields: dict[str, PlatformField], + component_data: dict[str, Any], +) -> dict[str, Any]: + """Calculate merged config.""" + base_schema_fields = { + key + for key, platform_field in data_schema_fields.items() + if _check_conditions(platform_field, component_data) + } - set(merged_user_input) + return { + key: value + for key, value in component_data.items() + if key not in base_schema_fields + } | merged_user_input + + @callback def validate_user_input( user_input: dict[str, Any], data_schema_fields: dict[str, PlatformField], - errors: dict[str, str], -) -> None: + component_data: dict[str, Any] | None, + config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None, +) -> tuple[dict[str, Any], dict[str, str]]: """Validate user input.""" - for field, value in user_input.items(): + errors: dict[str, str] = {} + # Merge sections + merged_user_input: dict[str, Any] = {} + for key, value in user_input.items(): + if isinstance(value, dict): + merged_user_input.update(value) + else: + merged_user_input[key] = value + + for field, value in merged_user_input.items(): validator = data_schema_fields[field].validator try: validator(value) except (ValueError, vol.Invalid): errors[field] = data_schema_fields[field].error or "invalid_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, data_schema_fields, component_data + ), + ) + + return merged_user_input, errors + @callback def data_schema_from_fields( data_schema_fields: dict[str, PlatformField], reconfig: bool, + component_data: dict[str, Any] | None = None, + user_input: dict[str, Any] | None = None, ) -> vol.Schema: - """Generate data schema from platform fields.""" - return vol.Schema( - { + """Generate custom data schema from platform fields.""" + component_data_with_user_input = deepcopy(component_data) + if component_data_with_user_input is not None and user_input is not None: + component_data_with_user_input |= user_input + sections: dict[str | None, None] = { + field_details.section: None for field_details in data_schema_fields.values() + } + data_schema: dict[Any, Any] = {} + all_data_element_options: set[Any] = set() + no_reconfig_options: set[Any] = set() + for schema_section in sections: + data_schema_element = { vol.Required(field_name, default=field_details.default) if field_details.required else vol.Optional( field_name, default=field_details.default - ): field_details.selector + ): field_details.selector(component_data_with_user_input) # type: ignore[operator] + if field_details.custom_filtering + else field_details.selector for field_name, field_details in data_schema_fields.items() - if not field_details.exclude_from_reconfig or not reconfig + if field_details.section == schema_section + and (not field_details.exclude_from_reconfig or not reconfig) + and _check_conditions(field_details, component_data_with_user_input) } - ) + data_element_options = set(data_schema_element) + all_data_element_options |= data_element_options + no_reconfig_options |= { + field_name + for field_name, field_details in data_schema_fields.items() + if field_details.section == schema_section + and field_details.exclude_from_reconfig + } + if schema_section is None: + data_schema.update(data_schema_element) + continue + collapsed = ( + not any( + (default := data_schema_fields[str(option)].default) is vol.UNDEFINED + or component_data_with_user_input[str(option)] != default + for option in data_element_options + if option in component_data_with_user_input + ) + if component_data_with_user_input is not None + else True + ) + data_schema[vol.Optional(schema_section)] = section( + vol.Schema(data_schema_element), SectionConfig({"collapsed": collapsed}) + ) + + # Reset all fields from the component_data not in the schema + if component_data: + filtered_fields = ( + set(data_schema_fields) - all_data_element_options - no_reconfig_options + ) + for field in filtered_fields: + if field in component_data: + del component_data[field] + return vol.Schema(data_schema) class FlowHandler(ConfigFlow, domain=DOMAIN): @@ -893,20 +1149,56 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): @callback def update_component_fields( - self, data_schema: vol.Schema, user_input: dict[str, Any] + self, + data_schema_fields: dict[str, PlatformField], + merged_user_input: dict[str, Any], ) -> None: """Update the componment fields.""" if TYPE_CHECKING: assert self._component_id is not None component_data = self._subentry_data["components"][self._component_id] - # Remove the fields from the component data if they are not in the user input - for field in [ - form_field - for form_field in data_schema.schema - if form_field in component_data and form_field not in user_input - ]: + # Remove the fields from the component data + # if they are not in the schema and not in the user input + config = calculate_merged_config( + merged_user_input, data_schema_fields, component_data + ) + for field in ( + field + for field, platform_field in data_schema_fields.items() + if field in (set(component_data) - set(config)) + and not platform_field.exclude_from_reconfig + ): component_data.pop(field) - component_data.update(user_input) + component_data.update(merged_user_input) + + @callback + def generate_names(self) -> tuple[str, str]: + """Generate the device and full entity name.""" + if TYPE_CHECKING: + assert self._component_id is not None + device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] + if entity_name := self._subentry_data["components"][self._component_id].get( + CONF_NAME + ): + full_entity_name: str = f"{device_name} {entity_name}" + else: + full_entity_name = device_name + return device_name, full_entity_name + + @callback + def get_suggested_values_from_component( + self, data_schema: vol.Schema + ) -> dict[str, Any]: + """Get suggestions from component data based on the data schema.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + return { + field_key: self.get_suggested_values_from_component(value.schema) + if isinstance(value, section) + else component_data.get(field_key) + for field_key, value in data_schema.schema.items() + } async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -956,25 +1248,28 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): data_schema_fields = COMMON_ENTITY_FIELDS entity_name_label: str = "" platform_label: str = "" + component_data: dict[str, Any] | None = None if reconfig := (self._component_id is not None): - name: str | None = self._subentry_data["components"][ - self._component_id - ].get(CONF_NAME) + component_data = self._subentry_data["components"][self._component_id] + name: str | None = component_data.get(CONF_NAME) platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} " 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: - validate_user_input(user_input, data_schema_fields, errors) + merged_user_input, errors = validate_user_input( + user_input, data_schema_fields, component_data + ) if not errors: if self._component_id is None: self._component_id = uuid4().hex self._subentry_data["components"].setdefault(self._component_id, {}) - self.update_component_fields(data_schema, user_input) - return await self.async_step_mqtt_platform_config() + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_entity_platform_config() data_schema = self.add_suggested_values_to_schema(data_schema, user_input) elif self.source == SOURCE_RECONFIGURE and self._component_id is not None: data_schema = self.add_suggested_values_to_schema( - data_schema, self._subentry_data["components"][self._component_id] + data_schema, + self.get_suggested_values_from_component(data_schema), ) device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] return self.async_show_form( @@ -994,9 +1289,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] entities = [ SelectOptionDict( - value=key, label=f"{device_name} {component.get(CONF_NAME, '-')}" + value=key, + label=f"{device_name} {component_data.get(CONF_NAME, '-')}" + f" ({component_data[CONF_PLATFORM]})", ) - for key, component in self._subentry_data["components"].items() + for key, component_data in self._subentry_data["components"].items() ] data_schema = vol.Schema( { @@ -1034,6 +1331,61 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): return await self.async_step_summary_menu() return self._show_update_or_delete_form("delete_entity") + async def async_step_entity_platform_config( + self, user_input: dict[str, Any] | None = None + ) -> SubentryFlowResult: + """Configure platform entity details.""" + if TYPE_CHECKING: + assert self._component_id is not None + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] + data_schema_fields = PLATFORM_ENTITY_FIELDS[platform] + errors: dict[str, str] = {} + + data_schema = data_schema_from_fields( + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, + user_input=user_input, + ) + if not data_schema.schema: + return await self.async_step_mqtt_platform_config() + if user_input is not None: + # Test entity fields against the validator + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data, + ENTITY_CONFIG_VALIDATOR[platform], + ) + if not errors: + self.update_component_fields(data_schema_fields, merged_user_input) + return await self.async_step_mqtt_platform_config() + + data_schema = self.add_suggested_values_to_schema(data_schema, user_input) + else: + data_schema = self.add_suggested_values_to_schema( + data_schema, + self.get_suggested_values_from_component(data_schema), + ) + + device_name, full_entity_name = self.generate_names() + return self.async_show_form( + step_id="entity_platform_config", + data_schema=data_schema, + description_placeholders={ + "mqtt_device": device_name, + CONF_PLATFORM: platform, + "entity": full_entity_name, + "url": learn_more_url(platform), + } + | (user_input or {}), + errors=errors, + last_step=False, + ) + async def async_step_mqtt_platform_config( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: @@ -1041,16 +1393,26 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): errors: dict[str, str] = {} if TYPE_CHECKING: assert self._component_id is not None - platform = self._subentry_data["components"][self._component_id][CONF_PLATFORM] + component_data = self._subentry_data["components"][self._component_id] + platform = component_data[CONF_PLATFORM] data_schema_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS data_schema = data_schema_from_fields( - data_schema_fields, reconfig=self._component_id is not None + data_schema_fields, + reconfig=bool( + {field for field in data_schema_fields if field in component_data} + ), + component_data=component_data, ) if user_input is not None: # Test entity fields against the validator - validate_user_input(user_input, data_schema_fields, errors) + merged_user_input, errors = validate_user_input( + user_input, + data_schema_fields, + component_data, + ENTITY_CONFIG_VALIDATOR[platform], + ) if not errors: - self.update_component_fields(data_schema, user_input) + self.update_component_fields(data_schema_fields, merged_user_input) self._component_id = None if self.source == SOURCE_RECONFIGURE: return await self.async_step_summary_menu() @@ -1059,16 +1421,10 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): data_schema = self.add_suggested_values_to_schema(data_schema, user_input) else: data_schema = self.add_suggested_values_to_schema( - data_schema, self._subentry_data["components"][self._component_id] + data_schema, + self.get_suggested_values_from_component(data_schema), ) - device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] - entity_name: str | None - if entity_name := self._subentry_data["components"][self._component_id].get( - CONF_NAME - ): - full_entity_name: str = f"{device_name} {entity_name}" - else: - full_entity_name = device_name + device_name, full_entity_name = self.generate_names() return self.async_show_form( step_id="mqtt_platform_config", data_schema=data_schema, @@ -1076,6 +1432,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): "mqtt_device": device_name, CONF_PLATFORM: platform, "entity": full_entity_name, + "url": learn_more_url(platform), }, errors=errors, last_step=False, @@ -1087,12 +1444,12 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): ) -> SubentryFlowResult: """Create a subentry for a new MQTT device.""" device_name = self._subentry_data[CONF_DEVICE][CONF_NAME] - component: dict[str, Any] = next( + component_data: dict[str, Any] = next( iter(self._subentry_data["components"].values()) ) - platform = component[CONF_PLATFORM] + platform = component_data[CONF_PLATFORM] entity_name: str | None - if entity_name := component.get(CONF_NAME): + if entity_name := component_data.get(CONF_NAME): full_entity_name: str = f"{device_name} {entity_name}" else: full_entity_name = device_name @@ -1151,8 +1508,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow): self._component_id = None mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME] mqtt_items = ", ".join( - f"{mqtt_device} {component.get(CONF_NAME, '-')}" - for component in self._subentry_data["components"].values() + f"{mqtt_device} {component_data.get(CONF_NAME, '-')} ({component_data[CONF_PLATFORM]})" + for component_data in self._subentry_data["components"].values() ) menu_options = [ "entity", diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index c050a1c32da..b2fcd492435 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -86,6 +86,7 @@ CONF_EFFECT_STATE_TOPIC = "effect_state_topic" CONF_EFFECT_TEMPLATE = "effect_template" CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template" CONF_ENTITY_PICTURE = "entity_picture" +CONF_EXPIRE_AFTER = "expire_after" CONF_FLASH_TIME_LONG = "flash_time_long" CONF_FLASH_TIME_SHORT = "flash_time_short" CONF_GREEN_TEMPLATE = "green_template" @@ -93,6 +94,7 @@ CONF_HS_COMMAND_TEMPLATE = "hs_command_template" CONF_HS_COMMAND_TOPIC = "hs_command_topic" CONF_HS_STATE_TOPIC = "hs_state_topic" CONF_HS_VALUE_TEMPLATE = "hs_value_template" +CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" CONF_MAX_KELVIN = "max_kelvin" CONF_MAX_MIREDS = "max_mireds" CONF_MIN_KELVIN = "min_kelvin" @@ -128,6 +130,7 @@ CONF_STATE_CLOSED = "state_closed" CONF_STATE_CLOSING = "state_closing" CONF_STATE_OPEN = "state_open" CONF_STATE_OPENING = "state_opening" +CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" CONF_SUPPORTED_COLOR_MODES = "supported_color_modes" CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template" CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic" diff --git a/homeassistant/components/mqtt/entity.py b/homeassistant/components/mqtt/entity.py index 0b4f65fab47..5fdcbea2e70 100644 --- a/homeassistant/components/mqtt/entity.py +++ b/homeassistant/components/mqtt/entity.py @@ -123,7 +123,7 @@ from .subscription import ( async_subscribe_topics_internal, async_unsubscribe_topics, ) -from .util import mqtt_config_entry_enabled +from .util import learn_more_url, mqtt_config_entry_enabled _LOGGER = logging.getLogger(__name__) @@ -346,9 +346,6 @@ def async_setup_entity_entry_helper( line = getattr(yaml_config, "__line__", "?") issue_id = hex(hash(frozenset(yaml_config))) yaml_config_str = yaml_dump(yaml_config) - learn_more_url = ( - f"https://www.home-assistant.io/integrations/{domain}.mqtt/" - ) async_create_issue( hass, DOMAIN, @@ -356,7 +353,7 @@ def async_setup_entity_entry_helper( issue_domain=domain, is_fixable=False, severity=IssueSeverity.ERROR, - learn_more_url=learn_more_url, + learn_more_url=learn_more_url(domain), translation_placeholders={ "domain": domain, "config_file": config_file, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 4d67b0d56e6..b27ef68368a 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -41,7 +41,15 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_OPTIONS, CONF_STATE_TOPIC, DOMAIN, PAYLOAD_NONE +from .const import ( + CONF_EXPIRE_AFTER, + CONF_LAST_RESET_VALUE_TEMPLATE, + CONF_OPTIONS, + CONF_STATE_TOPIC, + CONF_SUGGESTED_DISPLAY_PRECISION, + DOMAIN, + PAYLOAD_NONE, +) from .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -51,10 +59,6 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 -CONF_EXPIRE_AFTER = "expire_after" -CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template" -CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision" - MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset( { sensor.ATTR_LAST_RESET, diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index f0112097f4e..9aa1522915f 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -198,20 +198,66 @@ "component": "Select the entity you want to update." } }, + "entity_platform_config": { + "title": "Configure MQTT device \"{mqtt_device}\"", + "description": "Please configure specific details for {platform} entity \"{entity}\":", + "data": { + "device_class": "Device class", + "state_class": "State class", + "unit_of_measurement": "Unit of measurement", + "options": "Add option" + }, + "data_description": { + "device_class": "The device class of the {platform} entity. [Learn more.]({url}#device_class)", + "state_class": "The [state_class](https://developers.home-assistant.io/docs/core/entity/sensor/#available-state-classes) of the sensor. [Learn more.]({url}#state_class)", + "unit_of_measurement": "Defines the unit of measurement of the sensor, if any.", + "options": "Options for allowed sensor state values. The sensor’s device_class must be set to Enumeration. The options option cannot be used together with State Class or Unit of measurement." + }, + "sections": { + "advanced_settings": { + "name": "Advanced options", + "data": { + "suggested_display_precision": "Suggested display precision" + }, + "data_description": { + "suggested_display_precision": "The number of decimals which should be used in the {platform} entity state after rounding. [Learn more.]({url}#suggested_display_precision)" + } + } + } + }, "mqtt_platform_config": { "title": "Configure MQTT device \"{mqtt_device}\"", "description": "Please configure MQTT specific details for {platform} entity \"{entity}\":", "data": { "command_topic": "Command topic", "command_template": "Command template", + "state_topic": "State topic", + "value_template": "Value template", + "last_reset_value_template": "Last reset value template", + "force_update": "Force update", "retain": "Retain", "qos": "QoS" }, "data_description": { - "command_topic": "The publishing topic that will be used to control the {platform} entity.", + "command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)", "command_template": "A [template](https://www.home-assistant.io/docs/configuration/templating/#using-command-templates-with-mqtt) to render the payload to be published at the command topic.", + "state_topic": "The MQTT topic subscribed to receive {platform} state values. [Learn more.]({url}#state_topic)", + "value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the {platform} entity value.", + "last_reset_value_template": "Defines a [template](https://www.home-assistant.io/docs/configuration/templating/#using-value-templates-with-mqtt) to extract the last reset. When Last reset template is set, the State class option must be Total. [Learn more.]({url}#last_reset_value_template)", + "force_update": "Sends update events even if the value hasn’t changed. Useful if you want to have meaningful value graphs in history. [Learn more.]({url}#force_update)", "retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.", "qos": "The QoS value {platform} entity should use." + }, + "sections": { + "advanced_settings": { + "name": "Advanced settings", + "data": { + "expire_after": "Expire after" + }, + "data_description": { + "expire_after": "If set, it defines the number of seconds after the sensor’s state expires, if it’s not updated. After expiry, the sensor’s state becomes unavailable. If not set, the sensor's state never expires. [Learn more.]({url}#expire_after)" + } + } } } }, @@ -225,7 +271,12 @@ "invalid_input": "Invalid value", "invalid_subscribe_topic": "Invalid subscribe topic", "invalid_template": "Invalid template", - "invalid_url": "Invalid URL" + "invalid_uom": "The unit of measurement \"{unit_of_measurement}\" is not supported by the selected device class, please either remove the device class, select a device class which supports \"{unit_of_measurement}\", or pick a supported unit of measurement from the list", + "invalid_url": "Invalid URL", + "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", + "uom_required_for_device_class": "The selected device device class requires a unit" } } }, @@ -342,9 +393,70 @@ } }, "selector": { + "device_class_sensor": { + "options": { + "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", + "area": "[%key:component::sensor::entity_component::area::name%]", + "aqi": "[%key:component::sensor::entity_component::aqi::name%]", + "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", + "battery": "[%key:component::sensor::entity_component::battery::name%]", + "blood_glucose_concentration": "[%key:component::sensor::entity_component::blood_glucose_concentration::name%]", + "carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]", + "carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "conductivity": "[%key:component::sensor::entity_component::conductivity::name%]", + "current": "[%key:component::sensor::entity_component::current::name%]", + "data_rate": "[%key:component::sensor::entity_component::data_rate::name%]", + "data_size": "[%key:component::sensor::entity_component::data_size::name%]", + "date": "[%key:component::sensor::entity_component::date::name%]", + "distance": "[%key:component::sensor::entity_component::distance::name%]", + "duration": "[%key:component::sensor::entity_component::duration::name%]", + "energy": "[%key:component::sensor::entity_component::energy::name%]", + "energy_distance": "[%key:component::sensor::entity_component::energy_distance::name%]", + "energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]", + "enum": "Enumeration", + "frequency": "[%key:component::sensor::entity_component::frequency::name%]", + "gas": "[%key:component::sensor::entity_component::gas::name%]", + "humidity": "[%key:component::sensor::entity_component::humidity::name%]", + "illuminance": "[%key:component::sensor::entity_component::illuminance::name%]", + "irradiance": "[%key:component::sensor::entity_component::irradiance::name%]", + "moisture": "[%key:component::sensor::entity_component::moisture::name%]", + "monetary": "[%key:component::sensor::entity_component::monetary::name%]", + "nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]", + "nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]", + "ozone": "[%key:component::sensor::entity_component::ozone::name%]", + "ph": "[%key:component::sensor::entity_component::ph::name%]", + "pm1": "[%key:component::sensor::entity_component::pm1::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "power": "[%key:component::sensor::entity_component::power::name%]", + "power_factor": "[%key:component::sensor::entity_component::power_factor::name%]", + "precipitation": "[%key:component::sensor::entity_component::precipitation::name%]", + "precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]", + "pressure": "[%key:component::sensor::entity_component::pressure::name%]", + "reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]", + "signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]", + "sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]", + "speed": "[%key:component::sensor::entity_component::speed::name%]", + "sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "temperature": "[%key:component::sensor::entity_component::temperature::name%]", + "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", + "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", + "voltage": "[%key:component::sensor::entity_component::voltage::name%]", + "volume": "[%key:component::sensor::entity_component::volume::name%]", + "volume_flow_rate": "[%key:component::sensor::entity_component::volume_flow_rate::name%]", + "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", + "water": "[%key:component::sensor::entity_component::water::name%]", + "weight": "[%key:component::sensor::entity_component::weight::name%]", + "wind_direction": "[%key:component::sensor::entity_component::wind_direction::name%]", + "wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]" + } + }, "platform": { "options": { - "notify": "Notify" + "notify": "Notify", + "sensor": "Sensor" } }, "set_ca_cert": { @@ -353,6 +465,13 @@ "auto": "Auto", "custom": "Custom" } + }, + "state_class": { + "options": { + "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", + "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", + "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" + } } }, "services": { diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 27bdb4f2a35..e3996c80a8a 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -411,3 +411,9 @@ def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None: return certificate_file.read() except OSError: return None + + +@callback +def learn_more_url(platform: str) -> str: + """Return the URL for the platform specific MQTT documentation.""" + return f"https://www.home-assistant.io/integrations/{platform}.mqtt/" diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index f000c4e0b9b..aad71fbc26e 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -72,7 +72,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = { "name": "Milkman alert", "qos": 0, "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994", "retain": False, }, @@ -91,12 +91,47 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = { "platform": "notify", "qos": 0, "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd", "retain": False, }, } +MOCK_SUBENTRY_SENSOR_COMPONENT = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "device_class": "enum", + "qos": 1, + "state_topic": "test-topic", + "options": ["low", "medium", "high"], + "expire_after": 30, + "value_template": "{{ value_json.value }}", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = { + "a0f85790a95d4889924602effff06b6e": { + "platform": "sensor", + "name": "Energy", + "state_class": "measurement", + "state_topic": "test-topic", + "entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e", + "qos": 0, + }, +} +MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = { + "e9261f6feed443e7b7d5f3fbe2a47412": { + "platform": "sensor", + "name": "Energy", + "state_class": "total", + "last_reset_value_template": "{{ value_json.value }}", + "state_topic": "test-topic", + "entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412", + "qos": 0, + }, +} + # Bogus light component just for code coverage # Note that light cannot be setup through the UI yet # The test is for code coverage @@ -151,7 +186,7 @@ MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = { }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT1, } -MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { +MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = { "device": { "name": "Milk notifier", "sw_version": "1.0", @@ -162,6 +197,39 @@ MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = { }, "components": MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME, } +MOCK_SENSOR_SUBENTRY_DATA_SINGLE = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS, +} +MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE = { + "device": { + "name": "Test sensor", + "sw_version": "1.0", + "hw_version": "2.1 rev a", + "model": "Model XL", + "model_id": "mn002", + "configuration_url": "https://example.com", + }, + "components": MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET, +} MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = { "device": { diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 354cb33ba39..266be761a91 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -18,6 +18,7 @@ from homeassistant import config_entries from homeassistant.components import mqtt from homeassistant.components.hassio import AddonError from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED +from homeassistant.components.mqtt.util import learn_more_url from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData from homeassistant.const import ( CONF_CLIENT_ID, @@ -33,8 +34,11 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo from .common import ( MOCK_NOTIFY_SUBENTRY_DATA_MULTI, + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, - MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, ) from tests.common import MockConfigEntry, MockMqttReasonCode @@ -2613,49 +2617,139 @@ async def test_migrate_of_incompatible_config_entry( ( "config_subentries_data", "mock_entity_user_input", + "mock_entity_details_user_input", + "mock_entity_details_failed_user_input", "mock_mqtt_user_input", "mock_failed_mqtt_user_input", - "mock_failed_mqtt_user_input_errors", "entity_name", ), [ ( MOCK_NOTIFY_SUBENTRY_DATA_SINGLE, {"name": "Milkman alert"}, + None, + None, { "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "qos": 0, "retain": False, }, - {"command_topic": "test-topic#invalid"}, - {"command_topic": "invalid_publish_topic"}, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), "Milk notifier Milkman alert", ), ( - MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME, + MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME, {}, + None, + None, { "command_topic": "test-topic", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "qos": 0, "retain": False, }, - {"command_topic": "test-topic#invalid"}, - {"command_topic": "invalid_publish_topic"}, + ( + ( + {"command_topic": "test-topic#invalid"}, + {"command_topic": "invalid_publish_topic"}, + ), + ), "Milk notifier", ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + {"name": "Energy"}, + {"device_class": "enum", "options": ["low", "medium", "high"]}, + ( + ( + { + "device_class": "energy", + "unit_of_measurement": "ppm", + }, + {"unit_of_measurement": "invalid_uom"}, + ), + # Trigger options to be shown on the form + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + # Test options are only allowed with device_class enum + ( + { + "device_class": "energy", + "options": ["less", "more"], + }, + { + "device_class": "options_device_class_enum", + "unit_of_measurement": "uom_required_for_device_class", + }, + ), + # Include options again to allow flow with valid data + ( + {"device_class": "enum"}, + {"options": "options_with_enum_device_class"}, + ), + ( + { + "device_class": "enum", + "state_class": "measurement", + "options": ["less", "more"], + }, + {"options": "options_not_allowed_with_state_class_or_uom"}, + ), + ), + { + "state_topic": "test-topic", + "value_template": "{{ value_json.value }}", + "advanced_settings": {"expire_after": 30}, + "qos": 1, + }, + ( + ( + {"state_topic": "test-topic#invalid"}, + {"state_topic": "invalid_subscribe_topic"}, + ), + ), + "Test sensor Energy", + ), + ( + MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS, + {"name": "Energy"}, + { + "state_class": "measurement", + }, + (), + { + "state_topic": "test-topic", + }, + (), + "Test sensor Energy", + ), + ], + ids=[ + "notify_with_entity_name", + "notify_no_entity_name", + "sensor_options", + "sensor_total", ], - ids=["notify_with_entity_name", "notify_no_entity_name"], ) async def test_subentry_configflow( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, config_subentries_data: dict[str, Any], mock_entity_user_input: dict[str, Any], + mock_entity_details_user_input: dict[str, Any], + mock_entity_details_failed_user_input: tuple[ + tuple[dict[str, Any], dict[str, str]], + ], mock_mqtt_user_input: dict[str, Any], - mock_failed_mqtt_user_input: dict[str, Any], - mock_failed_mqtt_user_input_errors: dict[str, Any], + mock_failed_mqtt_user_input: tuple[tuple[dict[str, Any], dict[str, str]],], entity_name: str, ) -> None: """Test the subentry ConfigFlow.""" @@ -2723,23 +2817,55 @@ async def test_subentry_configflow( | mock_entity_user_input, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" assert result["errors"] == {} assert result["description_placeholders"] == { - "mqtt_device": "Milk notifier", - "platform": "notify", + "mqtt_device": device_name, + "platform": component["platform"], "entity": entity_name, + "url": learn_more_url(component["platform"]), } - # Process entity platform config flow + # Process extra step if the platform supports it + if mock_entity_details_user_input is not None: + # Extra entity details flow step + assert result["step_id"] == "entity_platform_config" - # Test an invalid mqtt user_input case - result = await hass.config_entries.subentries.async_configure( - result["flow_id"], - user_input=mock_failed_mqtt_user_input, - ) - assert result["type"] is FlowResultType.FORM - assert result["errors"] == mock_failed_mqtt_user_input_errors + # First test validators if set of test + for failed_user_input, failed_errors in mock_entity_details_failed_user_input: + # Test an invalid entity details user input case + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors + + # Now try again with valid data + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=mock_entity_details_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + assert result["description_placeholders"] == { + "mqtt_device": device_name, + "platform": component["platform"], + "entity": entity_name, + "url": learn_more_url(component["platform"]), + } + else: + # No details form step + assert result["step_id"] == "mqtt_platform_config" + + # Process mqtt platform config flow + # Test an invalid mqtt user input case + for failed_user_input, failed_errors in mock_failed_mqtt_user_input: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=failed_user_input, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == failed_errors # Try again with a valid configuration result = await hass.config_entries.subentries.async_configure( @@ -2799,8 +2925,12 @@ async def test_subentry_reconfigure_remove_entity( assert len(components) == 2 object_list = list(components) component_list = list(components.values()) - entity_name_0 = f"{device.name} {component_list[0]['name']}" - entity_name_1 = f"{device.name} {component_list[1]['name']}" + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) for key, component in components.items(): unique_entity_id = f"{subentry_id}_{key}" @@ -2920,8 +3050,12 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( assert len(components) == 2 object_list = list(components) component_list = list(components.values()) - entity_name_0 = f"{device.name} {component_list[0]['name']}" - entity_name_1 = f"{device.name} {component_list[1]['name']}" + entity_name_0 = ( + f"{device.name} {component_list[0]['name']} ({component_list[0]['platform']})" + ) + entity_name_1 = ( + f"{device.name} {component_list[1]['name']} ({component_list[1]['platform']})" + ) for key in components: unique_entity_id = f"{subentry_id}_{key}" @@ -3000,7 +3134,13 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( @pytest.mark.parametrize( - ("mqtt_config_subentries_data", "user_input_mqtt"), + ( + "mqtt_config_subentries_data", + "user_input_platform_config_validation", + "user_input_platform_config", + "user_input_mqtt", + "removed_options", + ), [ ( ( @@ -3010,21 +3150,66 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites( title="Mock subentry", ), ), + (), + None, { "command_topic": "test-topic1-updated", - "command_template": "{{ value_json.value }}", + "command_template": "{{ value }}", "retain": True, }, - ) + {"entity_picture"}, + ), + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE, + subentry_type="device", + title="Mock subentry", + ), + ), + ( + ( + { + "device_class": "battery", + "options": [], + "state_class": "measurement", + "unit_of_measurement": "invalid", + }, + # Allow to accept options are being removed + { + "device_class": "options_device_class_enum", + "options": "options_not_allowed_with_state_class_or_uom", + "unit_of_measurement": "invalid_uom", + }, + ), + ), + { + "device_class": "battery", + "state_class": "measurement", + "unit_of_measurement": "%", + "advanced_settings": {"suggested_display_precision": 1}, + }, + { + "state_topic": "test-topic1-updated", + "value_template": "{{ value_json.value }}", + }, + {"options", "expire_after", "entity_picture"}, + ), ], - ids=["notify"], + ids=["notify", "sensor"], ) async def test_subentry_reconfigure_edit_entity_single_entity( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + user_input_platform_config_validation: tuple[ + tuple[dict[str, Any], dict[str, str] | None], ... + ] + | None, + user_input_platform_config: dict[str, Any] | None, user_input_mqtt: dict[str, Any], + removed_options: tuple[str, ...], ) -> None: """Test the subentry ConfigFlow reconfigure with single entity.""" await mqtt_mock_entry() @@ -3081,7 +3266,28 @@ async def test_subentry_reconfigure_edit_entity_single_entity( user_input={}, ) assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "mqtt_platform_config" + + if user_input_platform_config is None: + # Skip entity flow step + assert result["step_id"] == "mqtt_platform_config" + else: + # Additional entity flow step + assert result["step_id"] == "entity_platform_config" + for entity_validation_config, errors in user_input_platform_config_validation: + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=entity_validation_config, + ) + assert result["step_id"] == "entity_platform_config" + assert result.get("errors") == errors + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_platform_config, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" # submit the new platform specific entity data, result = await hass.config_entries.subentries.async_configure( @@ -3110,6 +3316,142 @@ async def test_subentry_reconfigure_edit_entity_single_entity( for key, value in user_input_mqtt.items(): assert new_components[component_id][key] == value + assert set(component) - set(new_components[component_id]) == removed_options + + +@pytest.mark.parametrize( + ( + "mqtt_config_subentries_data", + "user_input_entity_details", + "user_input_mqtt", + "filtered_out_fields", + ), + [ + ( + ( + ConfigSubentryData( + data=MOCK_SENSOR_SUBENTRY_DATA_SINGLE_LAST_RESET_TEMPLATE, + subentry_type="device", + title="Mock subentry", + ), + ), + { + "state_class": "measurement", + }, + { + "state_topic": "test-topic", + }, + ("last_reset_value_template",), + ), + ], + ids=["sensor_last_reset_template"], +) +async def test_subentry_reconfigure_edit_entity_reset_fields( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + user_input_entity_details: dict[str, Any], + user_input_mqtt: dict[str, Any], + filtered_out_fields: tuple[str, ...], +) -> None: + """Test the subentry ConfigFlow reconfigure resets filtered out fields.""" + await mqtt_mock_entry() + config_entry: MockConfigEntry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + subentry_id: str + subentry: ConfigSubentry + subentry_id, subentry = next(iter(config_entry.subentries.items())) + result = await config_entry.start_subentry_reconfigure_flow( + hass, "device", subentry_id + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # assert we have a device for the subentry + device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)}) + assert device is not None + + # assert we have an entity for the subentry component + components = deepcopy(dict(subentry.data))["components"] + assert len(components) == 1 + + component_id, component = next(iter(components.items())) + for field in filtered_out_fields: + assert field in component + + unique_entity_id = f"{subentry_id}_{component_id}" + entity_id = entity_registry.async_get_entity_id( + domain=component["platform"], platform=mqtt.DOMAIN, unique_id=unique_entity_id + ) + assert entity_id is not None + entity_entry = entity_registry.async_get(entity_id) + assert entity_entry is not None + assert entity_entry.config_subentry_id == subentry_id + + # assert menu options, we do not have the option to delete an entity + # we have no option to save and finish yet + assert result["menu_options"] == [ + "entity", + "update_entity", + "device", + "availability", + ] + + # assert we can update the entity, there is no select step + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "update_entity"}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity" + + # submit the new common entity data, reset entity_picture + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input={}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "entity_platform_config" + + # submit the new entity platform config + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_entity_details, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "mqtt_platform_config" + + # submit the new platform specific mqtt data, + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + user_input=user_input_mqtt, + ) + assert result["type"] is FlowResultType.MENU + assert result["step_id"] == "summary_menu" + + # finish reconfigure flow + result = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {"next_step_id": "save_changes"}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + + # Check we still have out components + new_components = deepcopy(dict(subentry.data))["components"] + assert len(new_components) == 1 + + # Check our update was successful + assert "entity_picture" not in new_components[component_id] + + # Check the second component was updated + for key, value in user_input_mqtt.items(): + assert new_components[component_id][key] == value + + # Check field are filtered out correctly + for field in filtered_out_fields: + assert field not in new_components[component_id] + @pytest.mark.parametrize( ("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"),