mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 09:47:13 +00:00
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:
parent
3f68e327f3
commit
77bf977d63
@ -27,6 +27,12 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.components.file_upload import process_uploaded_file
|
from homeassistant.components.file_upload import process_uploaded_file
|
||||||
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
|
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 (
|
from homeassistant.config_entries import (
|
||||||
SOURCE_RECONFIGURE,
|
SOURCE_RECONFIGURE,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
@ -45,6 +51,7 @@ from homeassistant.const import (
|
|||||||
ATTR_SW_VERSION,
|
ATTR_SW_VERSION,
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
CONF_DEVICE,
|
CONF_DEVICE,
|
||||||
|
CONF_DEVICE_CLASS,
|
||||||
CONF_DISCOVERY,
|
CONF_DISCOVERY,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_NAME,
|
CONF_NAME,
|
||||||
@ -53,10 +60,12 @@ from homeassistant.const import (
|
|||||||
CONF_PLATFORM,
|
CONF_PLATFORM,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_PROTOCOL,
|
CONF_PROTOCOL,
|
||||||
|
CONF_UNIT_OF_MEASUREMENT,
|
||||||
CONF_USERNAME,
|
CONF_USERNAME,
|
||||||
|
CONF_VALUE_TEMPLATE,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback
|
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 import config_validation as cv, entity_registry as er
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
from homeassistant.helpers.json import json_dumps
|
from homeassistant.helpers.json import json_dumps
|
||||||
@ -99,11 +108,16 @@ from .const import (
|
|||||||
CONF_COMMAND_TOPIC,
|
CONF_COMMAND_TOPIC,
|
||||||
CONF_DISCOVERY_PREFIX,
|
CONF_DISCOVERY_PREFIX,
|
||||||
CONF_ENTITY_PICTURE,
|
CONF_ENTITY_PICTURE,
|
||||||
|
CONF_EXPIRE_AFTER,
|
||||||
CONF_KEEPALIVE,
|
CONF_KEEPALIVE,
|
||||||
|
CONF_LAST_RESET_VALUE_TEMPLATE,
|
||||||
|
CONF_OPTIONS,
|
||||||
CONF_PAYLOAD_AVAILABLE,
|
CONF_PAYLOAD_AVAILABLE,
|
||||||
CONF_PAYLOAD_NOT_AVAILABLE,
|
CONF_PAYLOAD_NOT_AVAILABLE,
|
||||||
CONF_QOS,
|
CONF_QOS,
|
||||||
CONF_RETAIN,
|
CONF_RETAIN,
|
||||||
|
CONF_STATE_TOPIC,
|
||||||
|
CONF_SUGGESTED_DISPLAY_PRECISION,
|
||||||
CONF_TLS_INSECURE,
|
CONF_TLS_INSECURE,
|
||||||
CONF_TRANSPORT,
|
CONF_TRANSPORT,
|
||||||
CONF_WILL_MESSAGE,
|
CONF_WILL_MESSAGE,
|
||||||
@ -133,6 +147,7 @@ from .models import MqttAvailabilityData, MqttDeviceData, MqttSubentryData
|
|||||||
from .util import (
|
from .util import (
|
||||||
async_create_certificate_temp_files,
|
async_create_certificate_temp_files,
|
||||||
get_file_path,
|
get_file_path,
|
||||||
|
learn_more_url,
|
||||||
valid_birth_will,
|
valid_birth_will,
|
||||||
valid_publish_topic,
|
valid_publish_topic,
|
||||||
valid_qos_schema,
|
valid_qos_schema,
|
||||||
@ -217,7 +232,7 @@ KEY_UPLOAD_SELECTOR = FileSelector(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Subentry selectors
|
# Subentry selectors
|
||||||
SUBENTRY_PLATFORMS = [Platform.NOTIFY]
|
SUBENTRY_PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
|
||||||
SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
|
SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
|
||||||
SelectSelectorConfig(
|
SelectSelectorConfig(
|
||||||
options=[platform.value for platform in SUBENTRY_PLATFORMS],
|
options=[platform.value for platform in SUBENTRY_PLATFORMS],
|
||||||
@ -225,7 +240,6 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
|
|||||||
translation_key=CONF_PLATFORM,
|
translation_key=CONF_PLATFORM,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
|
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
|
||||||
|
|
||||||
SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
|
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)
|
@dataclass(frozen=True)
|
||||||
class PlatformField:
|
class PlatformField:
|
||||||
"""Stores a platform config field schema, required flag and validator."""
|
"""Stores a platform config field schema, required flag and validator."""
|
||||||
|
|
||||||
selector: Selector
|
selector: Selector[Any] | Callable[..., Selector[Any]]
|
||||||
required: bool
|
required: bool
|
||||||
validator: Callable[..., Any]
|
validator: Callable[..., Any]
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
default: str | int | vol.Undefined = vol.UNDEFINED
|
default: str | int | vol.Undefined = vol.UNDEFINED
|
||||||
exclude_from_reconfig: bool = False
|
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 = {
|
COMMON_ENTITY_FIELDS = {
|
||||||
@ -264,7 +370,29 @@ COMMON_ENTITY_FIELDS = {
|
|||||||
|
|
||||||
COMMON_MQTT_FIELDS = {
|
COMMON_MQTT_FIELDS = {
|
||||||
CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0),
|
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_MQTT_FIELDS = {
|
||||||
Platform.NOTIFY.value: {
|
Platform.NOTIFY.value: {
|
||||||
@ -274,7 +402,33 @@ PLATFORM_MQTT_FIELDS = {
|
|||||||
CONF_COMMAND_TEMPLATE: PlatformField(
|
CONF_COMMAND_TEMPLATE: PlatformField(
|
||||||
TEMPLATE_SELECTOR, False, cv.template, "invalid_template"
|
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(
|
MQTT_DEVICE_SCHEMA = vol.Schema(
|
||||||
@ -337,38 +491,140 @@ def validate_field(
|
|||||||
errors[field] = error
|
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
|
@callback
|
||||||
def validate_user_input(
|
def validate_user_input(
|
||||||
user_input: dict[str, Any],
|
user_input: dict[str, Any],
|
||||||
data_schema_fields: dict[str, PlatformField],
|
data_schema_fields: dict[str, PlatformField],
|
||||||
errors: dict[str, str],
|
component_data: dict[str, Any] | None,
|
||||||
) -> None:
|
config_validator: Callable[[dict[str, Any]], dict[str, str]] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], dict[str, str]]:
|
||||||
"""Validate user input."""
|
"""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
|
validator = data_schema_fields[field].validator
|
||||||
try:
|
try:
|
||||||
validator(value)
|
validator(value)
|
||||||
except (ValueError, vol.Invalid):
|
except (ValueError, vol.Invalid):
|
||||||
errors[field] = data_schema_fields[field].error or "invalid_input"
|
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
|
@callback
|
||||||
def data_schema_from_fields(
|
def data_schema_from_fields(
|
||||||
data_schema_fields: dict[str, PlatformField],
|
data_schema_fields: dict[str, PlatformField],
|
||||||
reconfig: bool,
|
reconfig: bool,
|
||||||
|
component_data: dict[str, Any] | None = None,
|
||||||
|
user_input: dict[str, Any] | None = None,
|
||||||
) -> vol.Schema:
|
) -> vol.Schema:
|
||||||
"""Generate data schema from platform fields."""
|
"""Generate custom data schema from platform fields."""
|
||||||
return vol.Schema(
|
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)
|
vol.Required(field_name, default=field_details.default)
|
||||||
if field_details.required
|
if field_details.required
|
||||||
else vol.Optional(
|
else vol.Optional(
|
||||||
field_name, default=field_details.default
|
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()
|
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):
|
class FlowHandler(ConfigFlow, domain=DOMAIN):
|
||||||
@ -893,20 +1149,56 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def update_component_fields(
|
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:
|
) -> None:
|
||||||
"""Update the componment fields."""
|
"""Update the componment fields."""
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert self._component_id is not None
|
assert self._component_id is not None
|
||||||
component_data = self._subentry_data["components"][self._component_id]
|
component_data = self._subentry_data["components"][self._component_id]
|
||||||
# Remove the fields from the component data if they are not in the user input
|
# Remove the fields from the component data
|
||||||
for field in [
|
# if they are not in the schema and not in the user input
|
||||||
form_field
|
config = calculate_merged_config(
|
||||||
for form_field in data_schema.schema
|
merged_user_input, data_schema_fields, component_data
|
||||||
if form_field in component_data and form_field not in user_input
|
)
|
||||||
]:
|
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.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(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@ -956,25 +1248,28 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
data_schema_fields = COMMON_ENTITY_FIELDS
|
data_schema_fields = COMMON_ENTITY_FIELDS
|
||||||
entity_name_label: str = ""
|
entity_name_label: str = ""
|
||||||
platform_label: str = ""
|
platform_label: str = ""
|
||||||
|
component_data: dict[str, Any] | None = None
|
||||||
if reconfig := (self._component_id is not None):
|
if reconfig := (self._component_id is not None):
|
||||||
name: str | None = self._subentry_data["components"][
|
component_data = self._subentry_data["components"][self._component_id]
|
||||||
self._component_id
|
name: str | None = component_data.get(CONF_NAME)
|
||||||
].get(CONF_NAME)
|
|
||||||
platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} "
|
platform_label = f"{self._subentry_data['components'][self._component_id][CONF_PLATFORM]} "
|
||||||
entity_name_label = f" ({name})" if name is not None else ""
|
entity_name_label = f" ({name})" if name is not None else ""
|
||||||
data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig)
|
data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig)
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
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 not errors:
|
||||||
if self._component_id is None:
|
if self._component_id is None:
|
||||||
self._component_id = uuid4().hex
|
self._component_id = uuid4().hex
|
||||||
self._subentry_data["components"].setdefault(self._component_id, {})
|
self._subentry_data["components"].setdefault(self._component_id, {})
|
||||||
self.update_component_fields(data_schema, user_input)
|
self.update_component_fields(data_schema_fields, merged_user_input)
|
||||||
return await self.async_step_mqtt_platform_config()
|
return await self.async_step_entity_platform_config()
|
||||||
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
|
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
|
||||||
elif self.source == SOURCE_RECONFIGURE and self._component_id is not None:
|
elif self.source == SOURCE_RECONFIGURE and self._component_id is not None:
|
||||||
data_schema = self.add_suggested_values_to_schema(
|
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]
|
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
@ -994,9 +1289,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||||
entities = [
|
entities = [
|
||||||
SelectOptionDict(
|
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(
|
data_schema = vol.Schema(
|
||||||
{
|
{
|
||||||
@ -1034,6 +1331,61 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
return await self.async_step_summary_menu()
|
return await self.async_step_summary_menu()
|
||||||
return self._show_update_or_delete_form("delete_entity")
|
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(
|
async def async_step_mqtt_platform_config(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> SubentryFlowResult:
|
) -> SubentryFlowResult:
|
||||||
@ -1041,16 +1393,26 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert self._component_id is not None
|
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_fields = PLATFORM_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS
|
||||||
data_schema = data_schema_from_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:
|
if user_input is not None:
|
||||||
# Test entity fields against the validator
|
# 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:
|
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
|
self._component_id = None
|
||||||
if self.source == SOURCE_RECONFIGURE:
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
return await self.async_step_summary_menu()
|
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)
|
data_schema = self.add_suggested_values_to_schema(data_schema, user_input)
|
||||||
else:
|
else:
|
||||||
data_schema = self.add_suggested_values_to_schema(
|
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]
|
device_name, full_entity_name = self.generate_names()
|
||||||
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
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="mqtt_platform_config",
|
step_id="mqtt_platform_config",
|
||||||
data_schema=data_schema,
|
data_schema=data_schema,
|
||||||
@ -1076,6 +1432,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
"mqtt_device": device_name,
|
"mqtt_device": device_name,
|
||||||
CONF_PLATFORM: platform,
|
CONF_PLATFORM: platform,
|
||||||
"entity": full_entity_name,
|
"entity": full_entity_name,
|
||||||
|
"url": learn_more_url(platform),
|
||||||
},
|
},
|
||||||
errors=errors,
|
errors=errors,
|
||||||
last_step=False,
|
last_step=False,
|
||||||
@ -1087,12 +1444,12 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
) -> SubentryFlowResult:
|
) -> SubentryFlowResult:
|
||||||
"""Create a subentry for a new MQTT device."""
|
"""Create a subentry for a new MQTT device."""
|
||||||
device_name = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
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())
|
iter(self._subentry_data["components"].values())
|
||||||
)
|
)
|
||||||
platform = component[CONF_PLATFORM]
|
platform = component_data[CONF_PLATFORM]
|
||||||
entity_name: str | None
|
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}"
|
full_entity_name: str = f"{device_name} {entity_name}"
|
||||||
else:
|
else:
|
||||||
full_entity_name = device_name
|
full_entity_name = device_name
|
||||||
@ -1151,8 +1508,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
self._component_id = None
|
self._component_id = None
|
||||||
mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
mqtt_device = self._subentry_data[CONF_DEVICE][CONF_NAME]
|
||||||
mqtt_items = ", ".join(
|
mqtt_items = ", ".join(
|
||||||
f"{mqtt_device} {component.get(CONF_NAME, '-')}"
|
f"{mqtt_device} {component_data.get(CONF_NAME, '-')} ({component_data[CONF_PLATFORM]})"
|
||||||
for component in self._subentry_data["components"].values()
|
for component_data in self._subentry_data["components"].values()
|
||||||
)
|
)
|
||||||
menu_options = [
|
menu_options = [
|
||||||
"entity",
|
"entity",
|
||||||
|
@ -86,6 +86,7 @@ CONF_EFFECT_STATE_TOPIC = "effect_state_topic"
|
|||||||
CONF_EFFECT_TEMPLATE = "effect_template"
|
CONF_EFFECT_TEMPLATE = "effect_template"
|
||||||
CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
|
CONF_EFFECT_VALUE_TEMPLATE = "effect_value_template"
|
||||||
CONF_ENTITY_PICTURE = "entity_picture"
|
CONF_ENTITY_PICTURE = "entity_picture"
|
||||||
|
CONF_EXPIRE_AFTER = "expire_after"
|
||||||
CONF_FLASH_TIME_LONG = "flash_time_long"
|
CONF_FLASH_TIME_LONG = "flash_time_long"
|
||||||
CONF_FLASH_TIME_SHORT = "flash_time_short"
|
CONF_FLASH_TIME_SHORT = "flash_time_short"
|
||||||
CONF_GREEN_TEMPLATE = "green_template"
|
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_COMMAND_TOPIC = "hs_command_topic"
|
||||||
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
||||||
CONF_HS_VALUE_TEMPLATE = "hs_value_template"
|
CONF_HS_VALUE_TEMPLATE = "hs_value_template"
|
||||||
|
CONF_LAST_RESET_VALUE_TEMPLATE = "last_reset_value_template"
|
||||||
CONF_MAX_KELVIN = "max_kelvin"
|
CONF_MAX_KELVIN = "max_kelvin"
|
||||||
CONF_MAX_MIREDS = "max_mireds"
|
CONF_MAX_MIREDS = "max_mireds"
|
||||||
CONF_MIN_KELVIN = "min_kelvin"
|
CONF_MIN_KELVIN = "min_kelvin"
|
||||||
@ -128,6 +130,7 @@ CONF_STATE_CLOSED = "state_closed"
|
|||||||
CONF_STATE_CLOSING = "state_closing"
|
CONF_STATE_CLOSING = "state_closing"
|
||||||
CONF_STATE_OPEN = "state_open"
|
CONF_STATE_OPEN = "state_open"
|
||||||
CONF_STATE_OPENING = "state_opening"
|
CONF_STATE_OPENING = "state_opening"
|
||||||
|
CONF_SUGGESTED_DISPLAY_PRECISION = "suggested_display_precision"
|
||||||
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
|
CONF_SUPPORTED_COLOR_MODES = "supported_color_modes"
|
||||||
CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template"
|
CONF_TEMP_COMMAND_TEMPLATE = "temperature_command_template"
|
||||||
CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic"
|
CONF_TEMP_COMMAND_TOPIC = "temperature_command_topic"
|
||||||
|
@ -123,7 +123,7 @@ from .subscription import (
|
|||||||
async_subscribe_topics_internal,
|
async_subscribe_topics_internal,
|
||||||
async_unsubscribe_topics,
|
async_unsubscribe_topics,
|
||||||
)
|
)
|
||||||
from .util import mqtt_config_entry_enabled
|
from .util import learn_more_url, mqtt_config_entry_enabled
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -346,9 +346,6 @@ def async_setup_entity_entry_helper(
|
|||||||
line = getattr(yaml_config, "__line__", "?")
|
line = getattr(yaml_config, "__line__", "?")
|
||||||
issue_id = hex(hash(frozenset(yaml_config)))
|
issue_id = hex(hash(frozenset(yaml_config)))
|
||||||
yaml_config_str = yaml_dump(yaml_config)
|
yaml_config_str = yaml_dump(yaml_config)
|
||||||
learn_more_url = (
|
|
||||||
f"https://www.home-assistant.io/integrations/{domain}.mqtt/"
|
|
||||||
)
|
|
||||||
async_create_issue(
|
async_create_issue(
|
||||||
hass,
|
hass,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -356,7 +353,7 @@ def async_setup_entity_entry_helper(
|
|||||||
issue_domain=domain,
|
issue_domain=domain,
|
||||||
is_fixable=False,
|
is_fixable=False,
|
||||||
severity=IssueSeverity.ERROR,
|
severity=IssueSeverity.ERROR,
|
||||||
learn_more_url=learn_more_url,
|
learn_more_url=learn_more_url(domain),
|
||||||
translation_placeholders={
|
translation_placeholders={
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"config_file": config_file,
|
"config_file": config_file,
|
||||||
|
@ -41,7 +41,15 @@ from homeassistant.util import dt as dt_util
|
|||||||
|
|
||||||
from . import subscription
|
from . import subscription
|
||||||
from .config import MQTT_RO_SCHEMA
|
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 .entity import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper
|
||||||
from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
|
from .models import MqttValueTemplate, PayloadSentinel, ReceiveMessage
|
||||||
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
from .schemas import MQTT_ENTITY_COMMON_SCHEMA
|
||||||
@ -51,10 +59,6 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
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(
|
MQTT_SENSOR_ATTRIBUTES_BLOCKED = frozenset(
|
||||||
{
|
{
|
||||||
sensor.ATTR_LAST_RESET,
|
sensor.ATTR_LAST_RESET,
|
||||||
|
@ -198,20 +198,66 @@
|
|||||||
"component": "Select the entity you want to update."
|
"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": {
|
"mqtt_platform_config": {
|
||||||
"title": "Configure MQTT device \"{mqtt_device}\"",
|
"title": "Configure MQTT device \"{mqtt_device}\"",
|
||||||
"description": "Please configure MQTT specific details for {platform} entity \"{entity}\":",
|
"description": "Please configure MQTT specific details for {platform} entity \"{entity}\":",
|
||||||
"data": {
|
"data": {
|
||||||
"command_topic": "Command topic",
|
"command_topic": "Command topic",
|
||||||
"command_template": "Command template",
|
"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",
|
"retain": "Retain",
|
||||||
"qos": "QoS"
|
"qos": "QoS"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"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.",
|
"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.",
|
"retain": "Select if values published by the {platform} entity should be retained at the MQTT broker.",
|
||||||
"qos": "The QoS value {platform} entity should use."
|
"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_input": "Invalid value",
|
||||||
"invalid_subscribe_topic": "Invalid subscribe topic",
|
"invalid_subscribe_topic": "Invalid subscribe topic",
|
||||||
"invalid_template": "Invalid template",
|
"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": {
|
"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": {
|
"platform": {
|
||||||
"options": {
|
"options": {
|
||||||
"notify": "Notify"
|
"notify": "Notify",
|
||||||
|
"sensor": "Sensor"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"set_ca_cert": {
|
"set_ca_cert": {
|
||||||
@ -353,6 +465,13 @@
|
|||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"custom": "Custom"
|
"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": {
|
"services": {
|
||||||
|
@ -411,3 +411,9 @@ def migrate_certificate_file_to_content(file_name_or_auto: str) -> str | None:
|
|||||||
return certificate_file.read()
|
return certificate_file.read()
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
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/"
|
||||||
|
@ -72,7 +72,7 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = {
|
|||||||
"name": "Milkman alert",
|
"name": "Milkman alert",
|
||||||
"qos": 0,
|
"qos": 0,
|
||||||
"command_topic": "test-topic",
|
"command_topic": "test-topic",
|
||||||
"command_template": "{{ value_json.value }}",
|
"command_template": "{{ value }}",
|
||||||
"entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994",
|
"entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994",
|
||||||
"retain": False,
|
"retain": False,
|
||||||
},
|
},
|
||||||
@ -91,12 +91,47 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = {
|
|||||||
"platform": "notify",
|
"platform": "notify",
|
||||||
"qos": 0,
|
"qos": 0,
|
||||||
"command_topic": "test-topic",
|
"command_topic": "test-topic",
|
||||||
"command_template": "{{ value_json.value }}",
|
"command_template": "{{ value }}",
|
||||||
"entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd",
|
"entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd",
|
||||||
"retain": False,
|
"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
|
# Bogus light component just for code coverage
|
||||||
# Note that light cannot be setup through the UI yet
|
# Note that light cannot be setup through the UI yet
|
||||||
# The test is for code coverage
|
# The test is for code coverage
|
||||||
@ -151,7 +186,7 @@ MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = {
|
|||||||
},
|
},
|
||||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1,
|
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1,
|
||||||
}
|
}
|
||||||
MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = {
|
MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME = {
|
||||||
"device": {
|
"device": {
|
||||||
"name": "Milk notifier",
|
"name": "Milk notifier",
|
||||||
"sw_version": "1.0",
|
"sw_version": "1.0",
|
||||||
@ -162,6 +197,39 @@ MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME = {
|
|||||||
},
|
},
|
||||||
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT_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 = {
|
MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA = {
|
||||||
"device": {
|
"device": {
|
||||||
|
@ -18,6 +18,7 @@ from homeassistant import config_entries
|
|||||||
from homeassistant.components import mqtt
|
from homeassistant.components import mqtt
|
||||||
from homeassistant.components.hassio import AddonError
|
from homeassistant.components.hassio import AddonError
|
||||||
from homeassistant.components.mqtt.config_flow import PWD_NOT_CHANGED
|
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.config_entries import ConfigSubentry, ConfigSubentryData
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
CONF_CLIENT_ID,
|
CONF_CLIENT_ID,
|
||||||
@ -33,8 +34,11 @@ from homeassistant.helpers.service_info.hassio import HassioServiceInfo
|
|||||||
|
|
||||||
from .common import (
|
from .common import (
|
||||||
MOCK_NOTIFY_SUBENTRY_DATA_MULTI,
|
MOCK_NOTIFY_SUBENTRY_DATA_MULTI,
|
||||||
|
MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME,
|
||||||
MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
|
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
|
from tests.common import MockConfigEntry, MockMqttReasonCode
|
||||||
@ -2613,49 +2617,139 @@ async def test_migrate_of_incompatible_config_entry(
|
|||||||
(
|
(
|
||||||
"config_subentries_data",
|
"config_subentries_data",
|
||||||
"mock_entity_user_input",
|
"mock_entity_user_input",
|
||||||
|
"mock_entity_details_user_input",
|
||||||
|
"mock_entity_details_failed_user_input",
|
||||||
"mock_mqtt_user_input",
|
"mock_mqtt_user_input",
|
||||||
"mock_failed_mqtt_user_input",
|
"mock_failed_mqtt_user_input",
|
||||||
"mock_failed_mqtt_user_input_errors",
|
|
||||||
"entity_name",
|
"entity_name",
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
|
MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
|
||||||
{"name": "Milkman alert"},
|
{"name": "Milkman alert"},
|
||||||
|
None,
|
||||||
|
None,
|
||||||
{
|
{
|
||||||
"command_topic": "test-topic",
|
"command_topic": "test-topic",
|
||||||
"command_template": "{{ value_json.value }}",
|
"command_template": "{{ value }}",
|
||||||
"qos": 0,
|
"qos": 0,
|
||||||
"retain": False,
|
"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",
|
"Milk notifier Milkman alert",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
MOCK_SUBENTRY_DATA_NOTIFY_NO_NAME,
|
MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME,
|
||||||
{},
|
{},
|
||||||
|
None,
|
||||||
|
None,
|
||||||
{
|
{
|
||||||
"command_topic": "test-topic",
|
"command_topic": "test-topic",
|
||||||
"command_template": "{{ value_json.value }}",
|
"command_template": "{{ value }}",
|
||||||
"qos": 0,
|
"qos": 0,
|
||||||
"retain": False,
|
"retain": False,
|
||||||
},
|
},
|
||||||
{"command_topic": "test-topic#invalid"},
|
(
|
||||||
{"command_topic": "invalid_publish_topic"},
|
(
|
||||||
|
{"command_topic": "test-topic#invalid"},
|
||||||
|
{"command_topic": "invalid_publish_topic"},
|
||||||
|
),
|
||||||
|
),
|
||||||
"Milk notifier",
|
"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(
|
async def test_subentry_configflow(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||||
config_subentries_data: dict[str, Any],
|
config_subentries_data: dict[str, Any],
|
||||||
mock_entity_user_input: 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_mqtt_user_input: dict[str, Any],
|
||||||
mock_failed_mqtt_user_input: dict[str, Any],
|
mock_failed_mqtt_user_input: tuple[tuple[dict[str, Any], dict[str, str]],],
|
||||||
mock_failed_mqtt_user_input_errors: dict[str, Any],
|
|
||||||
entity_name: str,
|
entity_name: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the subentry ConfigFlow."""
|
"""Test the subentry ConfigFlow."""
|
||||||
@ -2723,23 +2817,55 @@ async def test_subentry_configflow(
|
|||||||
| mock_entity_user_input,
|
| mock_entity_user_input,
|
||||||
)
|
)
|
||||||
assert result["type"] is FlowResultType.FORM
|
assert result["type"] is FlowResultType.FORM
|
||||||
assert result["step_id"] == "mqtt_platform_config"
|
|
||||||
assert result["errors"] == {}
|
assert result["errors"] == {}
|
||||||
assert result["description_placeholders"] == {
|
assert result["description_placeholders"] == {
|
||||||
"mqtt_device": "Milk notifier",
|
"mqtt_device": device_name,
|
||||||
"platform": "notify",
|
"platform": component["platform"],
|
||||||
"entity": entity_name,
|
"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
|
# First test validators if set of test
|
||||||
result = await hass.config_entries.subentries.async_configure(
|
for failed_user_input, failed_errors in mock_entity_details_failed_user_input:
|
||||||
result["flow_id"],
|
# Test an invalid entity details user input case
|
||||||
user_input=mock_failed_mqtt_user_input,
|
result = await hass.config_entries.subentries.async_configure(
|
||||||
)
|
result["flow_id"],
|
||||||
assert result["type"] is FlowResultType.FORM
|
user_input=failed_user_input,
|
||||||
assert result["errors"] == mock_failed_mqtt_user_input_errors
|
)
|
||||||
|
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
|
# Try again with a valid configuration
|
||||||
result = await hass.config_entries.subentries.async_configure(
|
result = await hass.config_entries.subentries.async_configure(
|
||||||
@ -2799,8 +2925,12 @@ async def test_subentry_reconfigure_remove_entity(
|
|||||||
assert len(components) == 2
|
assert len(components) == 2
|
||||||
object_list = list(components)
|
object_list = list(components)
|
||||||
component_list = list(components.values())
|
component_list = list(components.values())
|
||||||
entity_name_0 = f"{device.name} {component_list[0]['name']}"
|
entity_name_0 = (
|
||||||
entity_name_1 = f"{device.name} {component_list[1]['name']}"
|
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():
|
for key, component in components.items():
|
||||||
unique_entity_id = f"{subentry_id}_{key}"
|
unique_entity_id = f"{subentry_id}_{key}"
|
||||||
@ -2920,8 +3050,12 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
|
|||||||
assert len(components) == 2
|
assert len(components) == 2
|
||||||
object_list = list(components)
|
object_list = list(components)
|
||||||
component_list = list(components.values())
|
component_list = list(components.values())
|
||||||
entity_name_0 = f"{device.name} {component_list[0]['name']}"
|
entity_name_0 = (
|
||||||
entity_name_1 = f"{device.name} {component_list[1]['name']}"
|
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:
|
for key in components:
|
||||||
unique_entity_id = f"{subentry_id}_{key}"
|
unique_entity_id = f"{subentry_id}_{key}"
|
||||||
@ -3000,7 +3134,13 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@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",
|
title="Mock subentry",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(),
|
||||||
|
None,
|
||||||
{
|
{
|
||||||
"command_topic": "test-topic1-updated",
|
"command_topic": "test-topic1-updated",
|
||||||
"command_template": "{{ value_json.value }}",
|
"command_template": "{{ value }}",
|
||||||
"retain": True,
|
"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(
|
async def test_subentry_reconfigure_edit_entity_single_entity(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||||
device_registry: dr.DeviceRegistry,
|
device_registry: dr.DeviceRegistry,
|
||||||
entity_registry: er.EntityRegistry,
|
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],
|
user_input_mqtt: dict[str, Any],
|
||||||
|
removed_options: tuple[str, ...],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test the subentry ConfigFlow reconfigure with single entity."""
|
"""Test the subentry ConfigFlow reconfigure with single entity."""
|
||||||
await mqtt_mock_entry()
|
await mqtt_mock_entry()
|
||||||
@ -3081,7 +3266,28 @@ async def test_subentry_reconfigure_edit_entity_single_entity(
|
|||||||
user_input={},
|
user_input={},
|
||||||
)
|
)
|
||||||
assert result["type"] is FlowResultType.FORM
|
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,
|
# submit the new platform specific entity data,
|
||||||
result = await hass.config_entries.subentries.async_configure(
|
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():
|
for key, value in user_input_mqtt.items():
|
||||||
assert new_components[component_id][key] == value
|
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(
|
@pytest.mark.parametrize(
|
||||||
("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"),
|
("mqtt_config_subentries_data", "user_input_entity", "user_input_mqtt"),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user