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
This commit is contained in:
Jan Bouwhuis 2025-03-26 13:34:24 +01:00 committed by GitHub
parent 3f68e327f3
commit 77bf977d63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 993 additions and 97 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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,

View File

@ -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,

View File

@ -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 sensors 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 hasnt 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 sensors state expires, if its not updated. After expiry, the sensors 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": {

View File

@ -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/"

View File

@ -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": {

View File

@ -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"),