Move QoS setting to shared device properties in MQTT device subentries configuration (#141369)

* Move QoS setting to shared device properties in MQTT device subentries configuration

* Use kwargs for validate_user_input helper
This commit is contained in:
Jan Bouwhuis 2025-03-26 15:20:08 +01:00 committed by GitHub
parent 63d4efda2e
commit 0de3549e6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 124 additions and 60 deletions

View File

@ -134,6 +134,7 @@ from .const import (
DEFAULT_PORT,
DEFAULT_PREFIX,
DEFAULT_PROTOCOL,
DEFAULT_QOS,
DEFAULT_TRANSPORT,
DEFAULT_WILL,
DEFAULT_WS_PATH,
@ -368,10 +369,6 @@ COMMON_ENTITY_FIELDS = {
CONF_ENTITY_PICTURE: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"),
}
COMMON_MQTT_FIELDS = {
CONF_QOS: PlatformField(QOS_SELECTOR, False, valid_qos_schema, default=0),
}
PLATFORM_ENTITY_FIELDS = {
Platform.NOTIFY.value: {},
Platform.SENSOR.value: {
@ -431,16 +428,17 @@ ENTITY_CONFIG_VALIDATOR: dict[
Platform.SENSOR.value: validate_sensor_platform_config,
}
MQTT_DEVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_NAME): TEXT_SELECTOR,
vol.Optional(ATTR_SW_VERSION): TEXT_SELECTOR,
vol.Optional(ATTR_HW_VERSION): TEXT_SELECTOR,
vol.Optional(ATTR_MODEL): TEXT_SELECTOR,
vol.Optional(ATTR_MODEL_ID): TEXT_SELECTOR,
vol.Optional(ATTR_CONFIGURATION_URL): TEXT_SELECTOR,
}
)
MQTT_DEVICE_PLATFORM_FIELDS = {
ATTR_NAME: PlatformField(TEXT_SELECTOR, False, str),
ATTR_SW_VERSION: PlatformField(TEXT_SELECTOR, False, str),
ATTR_HW_VERSION: PlatformField(TEXT_SELECTOR, False, str),
ATTR_MODEL: PlatformField(TEXT_SELECTOR, False, str),
ATTR_MODEL_ID: PlatformField(TEXT_SELECTOR, False, str),
ATTR_CONFIGURATION_URL: PlatformField(TEXT_SELECTOR, False, cv.url, "invalid_url"),
CONF_QOS: PlatformField(
QOS_SELECTOR, False, int, default=DEFAULT_QOS, section="mqtt_settings"
),
}
REAUTH_SCHEMA = vol.Schema(
{
@ -527,7 +525,8 @@ def calculate_merged_config(
def validate_user_input(
user_input: dict[str, Any],
data_schema_fields: dict[str, PlatformField],
component_data: dict[str, Any] | None,
*,
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."""
@ -566,11 +565,21 @@ def data_schema_from_fields(
reconfig: bool,
component_data: dict[str, Any] | None = None,
user_input: dict[str, Any] | None = None,
device_data: MqttDeviceData | None = None,
) -> vol.Schema:
"""Generate custom data schema from platform fields."""
component_data_with_user_input = deepcopy(component_data)
"""Generate custom data schema from platform fields or device data."""
if device_data is not None:
component_data_with_user_input: dict[str, Any] | None = dict(device_data)
if TYPE_CHECKING:
assert component_data_with_user_input is not None
component_data_with_user_input.update(
component_data_with_user_input.pop("mqtt_settings", {})
)
else:
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()
}
@ -1221,17 +1230,26 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a new MQTT device."""
errors: dict[str, str] = {}
validate_field("configuration_url", cv.url, user_input, errors, "invalid_url")
if not errors and user_input is not None:
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input)
if self.source == SOURCE_RECONFIGURE:
return await self.async_step_summary_menu()
return await self.async_step_entity()
errors: dict[str, Any] = {}
device_data = self._subentry_data[CONF_DEVICE]
data_schema = data_schema_from_fields(
MQTT_DEVICE_PLATFORM_FIELDS,
device_data=device_data,
reconfig=True,
)
if user_input is not None:
merged_user_input, errors = validate_user_input(
user_input, MQTT_DEVICE_PLATFORM_FIELDS
)
if not errors:
self._subentry_data[CONF_DEVICE] = cast(
MqttDeviceData, merged_user_input
)
if self.source == SOURCE_RECONFIGURE:
return await self.async_step_summary_menu()
return await self.async_step_entity()
data_schema = self.add_suggested_values_to_schema(
MQTT_DEVICE_SCHEMA,
self._subentry_data[CONF_DEVICE] if user_input is None else user_input,
data_schema, device_data if user_input is None else user_input
)
return self.async_show_form(
step_id=CONF_DEVICE,
@ -1257,7 +1275,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
data_schema = data_schema_from_fields(data_schema_fields, reconfig=reconfig)
if user_input is not None:
merged_user_input, errors = validate_user_input(
user_input, data_schema_fields, component_data
user_input, data_schema_fields, component_data=component_data
)
if not errors:
if self._component_id is None:
@ -1357,8 +1375,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
merged_user_input, errors = validate_user_input(
user_input,
data_schema_fields,
component_data,
ENTITY_CONFIG_VALIDATOR[platform],
component_data=component_data,
config_validator=ENTITY_CONFIG_VALIDATOR[platform],
)
if not errors:
self.update_component_fields(data_schema_fields, merged_user_input)
@ -1395,7 +1413,7 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
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_MQTT_FIELDS[platform] | COMMON_MQTT_FIELDS
data_schema_fields = PLATFORM_MQTT_FIELDS[platform]
data_schema = data_schema_from_fields(
data_schema_fields,
reconfig=bool(
@ -1408,8 +1426,8 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
merged_user_input, errors = validate_user_input(
user_input,
data_schema_fields,
component_data,
ENTITY_CONFIG_VALIDATOR[platform],
component_data=component_data,
config_validator=ENTITY_CONFIG_VALIDATOR[platform],
)
if not errors:
self.update_component_fields(data_schema_fields, merged_user_input)

View File

@ -300,6 +300,7 @@ def async_setup_entity_entry_helper(
availability_config = subentry_data.get("availability", {})
subentry_entities: list[Entity] = []
device_config = subentry_data["device"].copy()
device_mqtt_options = device_config.pop("mqtt_settings", {})
device_config["identifiers"] = config_subentry_id
for component_id, component_data in subentry_data["components"].items():
if component_data["platform"] != domain:
@ -311,6 +312,7 @@ def async_setup_entity_entry_helper(
component_config[CONF_DEVICE] = device_config
component_config.pop("platform")
component_config.update(availability_config)
component_config.update(device_mqtt_options)
try:
config = platform_schema_modern(component_config)

View File

@ -420,6 +420,12 @@ class MqttComponentConfig:
discovery_payload: MQTTDiscoveryPayload
class DeviceMqttOptions(TypedDict, total=False):
"""Hold the shared MQTT specific options for an MQTT device."""
qos: int
class MqttDeviceData(TypedDict, total=False):
"""Hold the data for an MQTT device."""
@ -430,6 +436,7 @@ class MqttDeviceData(TypedDict, total=False):
hw_version: str
model: str
model_id: str
mqtt_settings: DeviceMqttOptions
class MqttAvailabilityData(TypedDict, total=False):

View File

@ -150,6 +150,17 @@
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
"model": "E.g. 'Cleanmaster Pro'.",
"model_id": "E.g. '123NK2PRO'."
},
"sections": {
"mqtt_settings": {
"name": "MQTT Settings",
"data": {
"qos": "QoS"
},
"data_description": {
"qos": "The QoS value the device's entities should use."
}
}
}
},
"summary_menu": {
@ -235,8 +246,7 @@
"value_template": "Value template",
"last_reset_value_template": "Last reset value template",
"force_update": "Force update",
"retain": "Retain",
"qos": "QoS"
"retain": "Retain"
},
"data_description": {
"command_topic": "The publishing topic that will be used to control the {platform} entity. [Learn more.]({url}#command_topic)",
@ -245,8 +255,7 @@
"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."
"retain": "Select if values published by the {platform} entity should be retained at the MQTT broker."
},
"sections": {
"advanced_settings": {

View File

@ -70,7 +70,6 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT1 = {
"363a7ecad6be4a19b939a016ea93e994": {
"platform": "notify",
"name": "Milkman alert",
"qos": 0,
"command_topic": "test-topic",
"command_template": "{{ value }}",
"entity_picture": "https://example.com/363a7ecad6be4a19b939a016ea93e994",
@ -81,7 +80,6 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = {
"6494827dac294fa0827c54b02459d309": {
"platform": "notify",
"name": "The second notifier",
"qos": 0,
"command_topic": "test-topic2",
"entity_picture": "https://example.com/6494827dac294fa0827c54b02459d309",
},
@ -89,7 +87,6 @@ MOCK_SUBENTRY_NOTIFY_COMPONENT2 = {
MOCK_SUBENTRY_NOTIFY_COMPONENT_NO_NAME = {
"5269352dd9534c908d22812ea5d714cd": {
"platform": "notify",
"qos": 0,
"command_topic": "test-topic",
"command_template": "{{ value }}",
"entity_picture": "https://example.com/5269352dd9534c908d22812ea5d714cd",
@ -102,7 +99,6 @@ MOCK_SUBENTRY_SENSOR_COMPONENT = {
"platform": "sensor",
"name": "Energy",
"device_class": "enum",
"qos": 1,
"state_topic": "test-topic",
"options": ["low", "medium", "high"],
"expire_after": 30,
@ -117,7 +113,6 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_STATE_CLASS = {
"state_class": "measurement",
"state_topic": "test-topic",
"entity_picture": "https://example.com/a0f85790a95d4889924602effff06b6e",
"qos": 0,
},
}
MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = {
@ -128,7 +123,6 @@ MOCK_SUBENTRY_SENSOR_COMPONENT_LAST_RESET = {
"last_reset_value_template": "{{ value_json.value }}",
"state_topic": "test-topic",
"entity_picture": "https://example.com/e9261f6feed443e7b7d5f3fbe2a47412",
"qos": 0,
},
}
@ -139,7 +133,6 @@ MOCK_SUBENTRY_LIGHT_COMPONENT = {
"8131babc5e8d4f44b82e0761d39091a2": {
"platform": "light",
"name": "Test light",
"qos": 1,
"command_topic": "test-topic4",
"schema": "basic",
"entity_picture": "https://example.com/8131babc5e8d4f44b82e0761d39091a2",
@ -149,7 +142,6 @@ MOCK_SUBENTRY_NOTIFY_BAD_SCHEMA = {
"b10b531e15244425a74bb0abb1e9d2c6": {
"platform": "notify",
"name": "Test",
"qos": 1,
"command_topic": "bad#topic",
},
}
@ -183,6 +175,7 @@ MOCK_NOTIFY_SUBENTRY_DATA_SINGLE = {
"model": "Model XL",
"model_id": "mn002",
"configuration_url": "https://example.com",
"mqtt_settings": {"qos": 1},
},
"components": MOCK_SUBENTRY_NOTIFY_COMPONENT1,
}

View File

@ -2616,6 +2616,7 @@ async def test_migrate_of_incompatible_config_entry(
@pytest.mark.parametrize(
(
"config_subentries_data",
"mock_device_user_input",
"mock_entity_user_input",
"mock_entity_details_user_input",
"mock_entity_details_failed_user_input",
@ -2626,13 +2627,13 @@ async def test_migrate_of_incompatible_config_entry(
[
(
MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
{"name": "Milk notifier", "mqtt_settings": {"qos": 1}},
{"name": "Milkman alert"},
None,
None,
{
"command_topic": "test-topic",
"command_template": "{{ value }}",
"qos": 0,
"retain": False,
},
(
@ -2645,13 +2646,13 @@ async def test_migrate_of_incompatible_config_entry(
),
(
MOCK_NOTIFY_SUBENTRY_DATA_NO_NAME,
{"name": "Milk notifier", "mqtt_settings": {"qos": 0}},
{},
None,
None,
{
"command_topic": "test-topic",
"command_template": "{{ value }}",
"qos": 0,
"retain": False,
},
(
@ -2664,6 +2665,7 @@ async def test_migrate_of_incompatible_config_entry(
),
(
MOCK_SENSOR_SUBENTRY_DATA_SINGLE,
{"name": "Test sensor", "mqtt_settings": {"qos": 0}},
{"name": "Energy"},
{"device_class": "enum", "options": ["low", "medium", "high"]},
(
@ -2708,7 +2710,6 @@ async def test_migrate_of_incompatible_config_entry(
"state_topic": "test-topic",
"value_template": "{{ value_json.value }}",
"advanced_settings": {"expire_after": 30},
"qos": 1,
},
(
(
@ -2720,6 +2721,7 @@ async def test_migrate_of_incompatible_config_entry(
),
(
MOCK_SENSOR_SUBENTRY_DATA_SINGLE_STATE_CLASS,
{"name": "Test sensor", "mqtt_settings": {"qos": 0}},
{"name": "Energy"},
{
"state_class": "measurement",
@ -2743,6 +2745,7 @@ async def test_subentry_configflow(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
config_subentries_data: dict[str, Any],
mock_device_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[
@ -2753,7 +2756,7 @@ async def test_subentry_configflow(
entity_name: str,
) -> None:
"""Test the subentry ConfigFlow."""
device_name = config_subentries_data["device"]["name"]
device_name = mock_device_user_input["name"]
component = next(iter(config_subentries_data["components"].values()))
await mqtt_mock_entry()
@ -2780,14 +2783,7 @@ async def test_subentry_configflow(
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={
"name": device_name,
"sw_version": "1.0",
"hw_version": "2.1 rev a",
"model": "Model XL",
"model_id": "mn002",
"configuration_url": "https://example.com",
},
user_input=mock_device_user_input,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "entity"
@ -3471,7 +3467,6 @@ async def test_subentry_reconfigure_edit_entity_reset_fields(
},
{
"command_topic": "test-topic2",
"qos": 0,
},
)
],

View File

@ -1,7 +1,7 @@
"""The tests for shared code of the MQTT platform."""
from typing import Any
from unittest.mock import patch
from unittest.mock import call, patch
import pytest
@ -21,7 +21,11 @@ from homeassistant.helpers import (
)
from homeassistant.util import slugify
from .common import MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA, MOCK_SUBENTRY_DATA_SET_MIX
from .common import (
MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
MOCK_SUBENTRY_DATA_BAD_COMPONENT_SCHEMA,
MOCK_SUBENTRY_DATA_SET_MIX,
)
from tests.common import MockConfigEntry, async_capture_events, async_fire_mqtt_message
from tests.typing import MqttMockHAClientGenerator
@ -547,3 +551,39 @@ async def test_loading_subentry_with_bad_component_schema(
"Schema violation occurred when trying to set up entity from subentry"
in caplog.text
)
@pytest.mark.parametrize(
"mqtt_config_subentries_data",
[
(
ConfigSubentryData(
data=MOCK_NOTIFY_SUBENTRY_DATA_SINGLE,
subentry_type="device",
title="Mock subentry",
),
)
],
)
async def test_qos_on_mqt_device_from_subentry(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
mqtt_config_subentries_data: tuple[dict[str, Any]],
device_registry: dr.DeviceRegistry,
) -> None:
"""Test QoS is set correctly on entities from MQTT device."""
mqtt_mock = await mqtt_mock_entry()
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
subentry_id = next(iter(entry.subentries))
# Each subentry has one device
device = device_registry.async_get_device({("mqtt", subentry_id)})
assert device is not None
assert hass.states.get("notify.milk_notifier_milkman_alert") is not None
await hass.services.async_call(
"notify",
"send_message",
{"entity_id": "notify.milk_notifier_milkman_alert", "message": "Test message"},
)
await hass.async_block_till_done()
assert len(mqtt_mock.async_publish.mock_calls) == 1
mqtt_mock.async_publish.mock_calls[0] = call("test-topic", "Test message", 1, False)