Add YAML and discovery info export feature for MQTT device subentries (#141896)

Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
Jan Bouwhuis 2025-07-17 23:06:47 +02:00 committed by GitHub
parent 3b6eb045c6
commit 29afa891ec
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 608 additions and 8 deletions

View File

@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from enum import IntEnum from enum import IntEnum
import json
import logging import logging
import queue import queue
from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError
@ -24,6 +25,7 @@ from cryptography.hazmat.primitives.serialization import (
) )
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
import voluptuous as vol import voluptuous as vol
import yaml
from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.button import ButtonDeviceClass from homeassistant.components.button import ButtonDeviceClass
@ -78,6 +80,7 @@ from homeassistant.const import (
CONF_PORT, CONF_PORT,
CONF_PROTOCOL, CONF_PROTOCOL,
CONF_STATE_TEMPLATE, CONF_STATE_TEMPLATE,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME, CONF_USERNAME,
CONF_VALUE_TEMPLATE, CONF_VALUE_TEMPLATE,
@ -321,6 +324,10 @@ SET_CLIENT_CERT = "set_client_cert"
BOOLEAN_SELECTOR = BooleanSelector() BOOLEAN_SELECTOR = BooleanSelector()
TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
TEXT_SELECTOR_READ_ONLY = TextSelector(
TextSelectorConfig(type=TextSelectorType.TEXT, read_only=True)
)
URL_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.URL))
PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT)) PUBLISH_TOPIC_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
PORT_SELECTOR = vol.All( PORT_SELECTOR = vol.All(
NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)), NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)),
@ -400,6 +407,7 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
) )
) )
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig()) TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True))
SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema( SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
{ {
@ -556,6 +564,8 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
) )
) )
EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY}
@callback @callback
def validate_cover_platform_config( def validate_cover_platform_config(
@ -3102,8 +3112,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
menu_options.append("delete_entity") menu_options.append("delete_entity")
menu_options.extend(["device", "availability"]) menu_options.extend(["device", "availability"])
self._async_update_component_data_defaults() self._async_update_component_data_defaults()
if self._subentry_data != self._get_reconfigure_subentry().data: menu_options.append(
menu_options.append("save_changes") "save_changes"
if self._subentry_data != self._get_reconfigure_subentry().data
else "export"
)
return self.async_show_menu( return self.async_show_menu(
step_id="summary_menu", step_id="summary_menu",
menu_options=menu_options, menu_options=menu_options,
@ -3145,6 +3158,117 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
title=self._subentry_data[CONF_DEVICE][CONF_NAME], title=self._subentry_data[CONF_DEVICE][CONF_NAME],
) )
async def async_step_export(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Export the MQTT device config as YAML or discovery payload."""
return self.async_show_menu(
step_id="export",
menu_options=["export_yaml", "export_discovery"],
)
async def async_step_export_yaml(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Export the MQTT device config as YAML."""
if user_input is not None:
return await self.async_step_summary_menu()
subentry = self._get_reconfigure_subentry()
mqtt_yaml_config_base: dict[str, list[dict[str, dict[str, Any]]]] = {DOMAIN: []}
mqtt_yaml_config = mqtt_yaml_config_base[DOMAIN]
for component_id, component_data in self._subentry_data["components"].items():
component_config: dict[str, Any] = component_data.copy()
component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}"
component_config[CONF_DEVICE] = {
key: value
for key, value in self._subentry_data["device"].items()
if key != "mqtt_settings"
} | {"identifiers": [subentry.subentry_id]}
platform = component_config.pop(CONF_PLATFORM)
component_config.update(self._subentry_data.get("availability", {}))
component_config.update(
self._subentry_data["device"].get("mqtt_settings", {}).copy()
)
for field in EXCLUDE_FROM_CONFIG_IF_NONE:
if field in component_config and component_config[field] is None:
component_config.pop(field)
mqtt_yaml_config.append({platform: component_config})
yaml_config = yaml.dump(mqtt_yaml_config_base)
data_schema = vol.Schema(
{
vol.Optional("yaml"): TEMPLATE_SELECTOR_READ_ONLY,
}
)
data_schema = self.add_suggested_values_to_schema(
data_schema=data_schema,
suggested_values={"yaml": yaml_config},
)
return self.async_show_form(
step_id="export_yaml",
last_step=False,
data_schema=data_schema,
description_placeholders={
"url": "https://www.home-assistant.io/integrations/mqtt/"
},
)
async def async_step_export_discovery(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Export the MQTT device config dor MQTT discovery."""
if user_input is not None:
return await self.async_step_summary_menu()
subentry = self._get_reconfigure_subentry()
discovery_topic = f"homeassistant/device/{subentry.subentry_id}/config"
discovery_payload: dict[str, Any] = {}
discovery_payload.update(self._subentry_data.get("availability", {}))
discovery_payload["dev"] = {
key: value
for key, value in self._subentry_data["device"].items()
if key != "mqtt_settings"
} | {"identifiers": [subentry.subentry_id]}
discovery_payload["o"] = {"name": "MQTT subentry export"}
discovery_payload["cmps"] = {}
for component_id, component_data in self._subentry_data["components"].items():
component_config: dict[str, Any] = component_data.copy()
component_config[CONF_UNIQUE_ID] = f"{subentry.subentry_id}_{component_id}"
component_config.update(self._subentry_data.get("availability", {}))
component_config.update(
self._subentry_data["device"].get("mqtt_settings", {}).copy()
)
for field in EXCLUDE_FROM_CONFIG_IF_NONE:
if field in component_config and component_config[field] is None:
component_config.pop(field)
discovery_payload["cmps"][component_id] = component_config
data_schema = vol.Schema(
{
vol.Optional("discovery_topic"): TEXT_SELECTOR_READ_ONLY,
vol.Optional("discovery_payload"): TEMPLATE_SELECTOR_READ_ONLY,
}
)
data_schema = self.add_suggested_values_to_schema(
data_schema=data_schema,
suggested_values={
"discovery_topic": discovery_topic,
"discovery_payload": json.dumps(discovery_payload, indent=2),
},
)
return self.async_show_form(
step_id="export_discovery",
last_step=False,
data_schema=data_schema,
description_placeholders={
"url": "https://www.home-assistant.io/integrations/mqtt/"
},
)
@callback @callback
def async_is_pem_data(data: bytes) -> bool: def async_is_pem_data(data: bytes) -> bool:

View File

@ -247,6 +247,58 @@ def async_setup_entity_entry_helper(
"""Set up entity creation dynamically through MQTT discovery.""" """Set up entity creation dynamically through MQTT discovery."""
mqtt_data = hass.data[DATA_MQTT] mqtt_data = hass.data[DATA_MQTT]
@callback
def _async_migrate_subentry(
config: dict[str, Any], raw_config: dict[str, Any], migration_type: str
) -> bool:
"""Start a repair flow to allow migration of MQTT device subentries.
If a YAML config or discovery is detected using the ID
of an existing mqtt subentry, and exported configuration is detected,
and a repair flow is offered to migrate the subentry.
"""
if (
CONF_DEVICE in config
and CONF_IDENTIFIERS in config[CONF_DEVICE]
and config[CONF_DEVICE][CONF_IDENTIFIERS]
and (subentry_id := config[CONF_DEVICE][CONF_IDENTIFIERS][0])
in entry.subentries
):
name: str = config[CONF_DEVICE].get(CONF_NAME, "-")
if migration_type == "subentry_migration_yaml":
_LOGGER.info(
"Starting migration repair flow for MQTT subentry %s "
"for migration to YAML config: %s",
subentry_id,
raw_config,
)
elif migration_type == "subentry_migration_discovery":
_LOGGER.info(
"Starting migration repair flow for MQTT subentry %s "
"for migration to configuration via MQTT discovery: %s",
subentry_id,
raw_config,
)
async_create_issue(
hass,
DOMAIN,
subentry_id,
issue_domain=DOMAIN,
is_fixable=True,
severity=IssueSeverity.WARNING,
learn_more_url=learn_more_url(domain),
data={
"entry_id": entry.entry_id,
"subentry_id": subentry_id,
"name": name,
},
translation_placeholders={"name": name},
translation_key=migration_type,
)
return True
return False
@callback @callback
def _async_setup_entity_entry_from_discovery( def _async_setup_entity_entry_from_discovery(
discovery_payload: MQTTDiscoveryPayload, discovery_payload: MQTTDiscoveryPayload,
@ -263,9 +315,22 @@ def async_setup_entity_entry_helper(
entity_class = schema_class_mapping[config[CONF_SCHEMA]] entity_class = schema_class_mapping[config[CONF_SCHEMA]]
if TYPE_CHECKING: if TYPE_CHECKING:
assert entity_class is not None assert entity_class is not None
async_add_entities( if _async_migrate_subentry(
[entity_class(hass, config, entry, discovery_payload.discovery_data)] config, discovery_payload, "subentry_migration_discovery"
) ):
_handle_discovery_failure(hass, discovery_payload)
_LOGGER.debug(
"MQTT discovery skipped, as device exists in subentry, "
"and repair flow must be completed first"
)
else:
async_add_entities(
[
entity_class(
hass, config, entry, discovery_payload.discovery_data
)
]
)
except vol.Invalid as err: except vol.Invalid as err:
_handle_discovery_failure(hass, discovery_payload) _handle_discovery_failure(hass, discovery_payload)
async_handle_schema_error(discovery_payload, err) async_handle_schema_error(discovery_payload, err)
@ -346,6 +411,11 @@ def async_setup_entity_entry_helper(
entity_class = schema_class_mapping[config[CONF_SCHEMA]] entity_class = schema_class_mapping[config[CONF_SCHEMA]]
if TYPE_CHECKING: if TYPE_CHECKING:
assert entity_class is not None assert entity_class is not None
if _async_migrate_subentry(
config, yaml_config, "subentry_migration_yaml"
):
continue
entities.append(entity_class(hass, config, entry, None)) entities.append(entity_class(hass, config, entry, None))
except vol.Invalid as exc: except vol.Invalid as exc:
error = str(exc) error = str(exc)

View File

@ -0,0 +1,74 @@
"""Repairs for MQTT."""
from __future__ import annotations
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant import data_entry_flow
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN
class MQTTDeviceEntryMigration(RepairsFlow):
"""Handler to remove subentry for migrated MQTT device."""
def __init__(self, entry_id: str, subentry_id: str, name: str) -> None:
"""Initialize the flow."""
self.entry_id = entry_id
self.subentry_id = subentry_id
self.name = name
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()
async def async_step_confirm(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
device_registry = dr.async_get(self.hass)
subentry_device = device_registry.async_get_device(
identifiers={(DOMAIN, self.subentry_id)}
)
entry = self.hass.config_entries.async_get_entry(self.entry_id)
if TYPE_CHECKING:
assert entry is not None
assert subentry_device is not None
self.hass.config_entries.async_remove_subentry(entry, self.subentry_id)
return self.async_create_entry(data={})
return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({}),
description_placeholders={"name": self.name},
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if TYPE_CHECKING:
assert data is not None
entry_id = data["entry_id"]
subentry_id = data["subentry_id"]
name = data["name"]
if TYPE_CHECKING:
assert isinstance(entry_id, str)
assert isinstance(subentry_id, str)
assert isinstance(name, str)
return MQTTDeviceEntryMigration(
entry_id=entry_id,
subentry_id=subentry_id,
name=name,
)

View File

@ -3,6 +3,28 @@
"invalid_platform_config": { "invalid_platform_config": {
"title": "Invalid config found for MQTT {domain} item", "title": "Invalid config found for MQTT {domain} item",
"description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue."
},
"subentry_migration_discovery": {
"title": "MQTT device \"{name}\" subentry migration to MQTT discovery",
"fix_flow": {
"step": {
"confirm": {
"title": "[%key:component::mqtt::issues::subentry_migration_discovery::title%]",
"description": "Exported MQTT device \"{name}\" identified via MQTT discovery. Select **Submit** to confirm that the MQTT device is to be migrated to the main MQTT configuration, and to remove the existing MQTT device subentry. Make sure that the discovery is retained at the MQTT broker, or is resent after the subentry is removed, so that the MQTT device will be set up correctly. As an alternative you can change the device identifiers and entity unique ID-s in your MQTT discovery configuration payload, and cancel this repair if you want to keep the MQTT device subentry."
}
}
}
},
"subentry_migration_yaml": {
"title": "MQTT device \"{name}\" subentry migration to YAML",
"fix_flow": {
"step": {
"confirm": {
"title": "[%key:component::mqtt::issues::subentry_migration_yaml::title%]",
"description": "Exported MQTT device \"{name}\" identified in YAML configuration. Select **Submit** to confirm that the MQTT device is to be migrated to main MQTT config entry, and to remove the existing MQTT device subentry. As an alternative you can change the device identifiers and entity unique ID-s in your configuration.yaml file, and cancel this repair if you want to keep the MQTT device subentry."
}
}
}
} }
}, },
"config": { "config": {
@ -107,10 +129,10 @@
"config_subentries": { "config_subentries": {
"device": { "device": {
"initiate_flow": { "initiate_flow": {
"user": "Add MQTT Device", "user": "Add MQTT device",
"reconfigure": "Reconfigure MQTT Device" "reconfigure": "Reconfigure MQTT device"
}, },
"entry_type": "MQTT Device", "entry_type": "MQTT device",
"step": { "step": {
"availability": { "availability": {
"title": "Availability options", "title": "Availability options",
@ -175,6 +197,7 @@
"delete_entity": "Delete an entity", "delete_entity": "Delete an entity",
"availability": "Configure availability", "availability": "Configure availability",
"device": "Update device properties", "device": "Update device properties",
"export": "Export MQTT device configuration",
"save_changes": "Save changes" "save_changes": "Save changes"
} }
}, },
@ -627,6 +650,36 @@
} }
} }
} }
},
"export": {
"title": "Export MQTT device config",
"description": "An export allows you to migrate the MQTT device configuration to YAML-based configuration or MQTT discovery. The configuration export can also be helpful for troubleshooting.",
"menu_options": {
"export_discovery": "Export MQTT discovery information",
"export_yaml": "Export to YAML configuration"
}
},
"export_yaml": {
"title": "[%key:component::mqtt::config_subentries::device::step::export::title%]",
"description": "You can copy the configuration below and place it your configuration.yaml file. Home Assistant will detect if the setup of the MQTT device was tried via YAML instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.",
"data": {
"yaml": "Copy the YAML configuration below:"
},
"data_description": {
"yaml": "Place YAML configuration in your [configuration.yaml]({url}#yaml-configuration-listed-per-item)."
}
},
"export_discovery": {
"title": "[%key:component::mqtt::config_subentries::device::step::export::title%]",
"description": "To allow setup via MQTT [discovery]({url}#device-discovery-payload), the discovery payload needs to be published to the discovery topic. Copy the information from the fields below. Home Assistant will detect if the setup of the MQTT device was tried via MQTT discovery instead, and will offer a repair flow to clean up the redundant subentry. You can also choose to change the identifiers if you do not want to remove the subentry.",
"data": {
"discovery_topic": "Discovery topic",
"discovery_payload": "Discovery payload:"
},
"data_description": {
"discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload, used to trigger MQTT discovery. An empty payload published to this topic will remove the device and discovered entities.",
"discovery_payload": "The JSON [discovery payload]({url}#discovery-discovery-payload) that contains information about the MQTT device."
}
} }
}, },
"abort": { "abort": {

View File

@ -3344,6 +3344,7 @@ async def test_subentry_reconfigure_remove_entity(
"delete_entity", "delete_entity",
"device", "device",
"availability", "availability",
"export",
] ]
# assert we can delete an entity # assert we can delete an entity
@ -3465,6 +3466,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
"delete_entity", "delete_entity",
"device", "device",
"availability", "availability",
"export",
] ]
# assert we can update an entity # assert we can update an entity
@ -3683,6 +3685,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity(
"update_entity", "update_entity",
"device", "device",
"availability", "availability",
"export",
] ]
# assert we can update the entity, there is no select step # assert we can update the entity, there is no select step
@ -3823,6 +3826,7 @@ async def test_subentry_reconfigure_edit_entity_reset_fields(
"update_entity", "update_entity",
"device", "device",
"availability", "availability",
"export",
] ]
# assert we can update the entity, there is no select step # assert we can update the entity, there is no select step
@ -3953,6 +3957,7 @@ async def test_subentry_reconfigure_add_entity(
"update_entity", "update_entity",
"device", "device",
"availability", "availability",
"export",
] ]
# assert we can update the entity, there is no select step # assert we can update the entity, there is no select step
@ -4058,6 +4063,7 @@ async def test_subentry_reconfigure_update_device_properties(
"delete_entity", "delete_entity",
"device", "device",
"availability", "availability",
"export",
] ]
# assert we can update the device properties # assert we can update the device properties
@ -4214,6 +4220,100 @@ async def test_subentry_reconfigure_availablity(
} }
@pytest.mark.parametrize(
"mqtt_config_subentries_data",
[
(
ConfigSubentryData(
data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI,
subentry_type="device",
title="Mock subentry",
),
)
],
)
@pytest.mark.parametrize(
("flow_step", "field_suggestions"),
[
("export_yaml", {"yaml": "identifiers:\n - {}\n"}),
(
"export_discovery",
{
"discovery_topic": "homeassistant/device/{}/config",
"discovery_payload": '"identifiers": [\n "{}"\n',
},
),
],
)
async def test_subentry_reconfigure_export_settings(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
device_registry: dr.DeviceRegistry,
flow_step: str,
field_suggestions: dict[str, str],
) -> None:
"""Test the subentry ConfigFlow reconfigure export feature."""
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, 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 entity for all subentry components
components = deepcopy(dict(subentry.data))["components"]
assert len(components) == 2
# assert menu options, we have the option to export
assert result["menu_options"] == [
"entity",
"update_entity",
"delete_entity",
"device",
"availability",
"export",
]
# Open export menu
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": "export"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "export"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": flow_step},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == flow_step
assert result["description_placeholders"] == {
"url": "https://www.home-assistant.io/integrations/mqtt/"
}
# Assert the export is correct
for field in result["data_schema"].schema:
assert (
field_suggestions[field].format(subentry_id)
in field.description["suggested_value"]
)
# Back to summary menu
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
user_input={},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "summary_menu"
async def test_subentry_configflow_section_feature( async def test_subentry_configflow_section_feature(
hass: HomeAssistant, hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_mock_entry: MqttMockHAClientGenerator,

View File

@ -0,0 +1,179 @@
"""Test repairs for MQTT."""
from collections.abc import Coroutine
from copy import deepcopy
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components import mqtt
from homeassistant.config_entries import ConfigSubentry, ConfigSubentryData
from homeassistant.const import SERVICE_RELOAD
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.util.yaml import parse_yaml
from .common import MOCK_NOTIFY_SUBENTRY_DATA_MULTI, async_fire_mqtt_message
from tests.common import MockConfigEntry, async_capture_events
from tests.components.repairs import (
async_process_repairs_platforms,
process_repair_fix_flow,
start_repair_fix_flow,
)
from tests.conftest import ClientSessionGenerator
from tests.typing import MqttMockHAClientGenerator
async def help_setup_yaml(hass: HomeAssistant, config: dict[str, str]) -> None:
"""Help to set up an exported MQTT device via YAML."""
with patch(
"homeassistant.config.load_yaml_config_file",
return_value=parse_yaml(config["yaml"]),
):
await hass.services.async_call(
mqtt.DOMAIN,
SERVICE_RELOAD,
{},
blocking=True,
)
await hass.async_block_till_done()
async def help_setup_discovery(hass: HomeAssistant, config: dict[str, str]) -> None:
"""Help to set up an exported MQTT device via YAML."""
async_fire_mqtt_message(
hass, config["discovery_topic"], config["discovery_payload"]
)
await hass.async_block_till_done(wait_background_tasks=True)
@pytest.mark.parametrize(
"mqtt_config_subentries_data",
[
(
ConfigSubentryData(
data=MOCK_NOTIFY_SUBENTRY_DATA_MULTI,
subentry_type="device",
title="Mock subentry",
),
)
],
)
@pytest.mark.parametrize(
("flow_step", "setup_helper", "translation_key"),
[
("export_yaml", help_setup_yaml, "subentry_migration_yaml"),
("export_discovery", help_setup_discovery, "subentry_migration_discovery"),
],
)
async def test_subentry_reconfigure_export_settings(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
device_registry: dr.DeviceRegistry,
hass_client: ClientSessionGenerator,
flow_step: str,
setup_helper: Coroutine[Any, Any, None],
translation_key: str,
) -> None:
"""Test the subentry ConfigFlow YAML export with migration to YAML."""
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, 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.config_entries_subentries[config_entry.entry_id] == {subentry_id}
assert device is not None
# assert we entity for all subentry components
components = deepcopy(dict(subentry.data))["components"]
assert len(components) == 2
# assert menu options, we have the option to export
assert result["menu_options"] == [
"entity",
"update_entity",
"delete_entity",
"device",
"availability",
"export",
]
# Open export menu
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": "export"},
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "export"
result = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{"next_step_id": flow_step},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == flow_step
assert result["description_placeholders"] == {
"url": "https://www.home-assistant.io/integrations/mqtt/"
}
# Copy the exported config suggested values for an export
suggested_values_from_schema = {
field: field.description["suggested_value"]
for field in result["data_schema"].schema
}
# Try to set up the exported config with a changed device name
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await setup_helper(hass, suggested_values_from_schema)
# Assert the subentry device was not effected by the exported configs
device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)})
assert device.config_entries_subentries[config_entry.entry_id] == {subentry_id}
assert device is not None
# Assert a repair flow was created
# This happens when the exported device identifier was detected
# The subentry ID is used as device identifier
assert len(events) == 1
issue_id = events[0].data["issue_id"]
issue_registry = ir.async_get(hass)
repair_issue = issue_registry.async_get_issue(mqtt.DOMAIN, issue_id)
assert repair_issue.translation_key == translation_key
await async_process_repairs_platforms(hass)
client = await hass_client()
data = await start_repair_fix_flow(client, mqtt.DOMAIN, issue_id)
flow_id = data["flow_id"]
assert data["description_placeholders"] == {"name": "Milk notifier"}
assert data["step_id"] == "confirm"
data = await process_repair_fix_flow(client, flow_id)
assert data["type"] == "create_entry"
# Assert the subentry is removed and no other entity has linked the device
device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)})
assert device is None
await hass.async_block_till_done(wait_background_tasks=True)
assert len(config_entry.subentries) == 0
# Try to set up the exported config again
events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED)
await setup_helper(hass, suggested_values_from_schema)
assert len(events) == 0
# The MQTT device was now set up from the new source
await hass.async_block_till_done(wait_background_tasks=True)
device = device_registry.async_get_device(identifiers={(mqtt.DOMAIN, subentry_id)})
assert device.config_entries_subentries[config_entry.entry_id] == {None}
assert device is not None