mirror of
https://github.com/home-assistant/core.git
synced 2025-07-29 08:07:45 +00:00
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:
parent
3b6eb045c6
commit
29afa891ec
@ -8,6 +8,7 @@ from collections.abc import Callable, Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from enum import IntEnum
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
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
|
||||
import voluptuous as vol
|
||||
import yaml
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.button import ButtonDeviceClass
|
||||
@ -78,6 +80,7 @@ from homeassistant.const import (
|
||||
CONF_PORT,
|
||||
CONF_PROTOCOL,
|
||||
CONF_STATE_TEMPLATE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USERNAME,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
@ -321,6 +324,10 @@ SET_CLIENT_CERT = "set_client_cert"
|
||||
|
||||
BOOLEAN_SELECTOR = BooleanSelector()
|
||||
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))
|
||||
PORT_SELECTOR = vol.All(
|
||||
NumberSelector(NumberSelectorConfig(mode=NumberSelectorMode.BOX, min=1, max=65535)),
|
||||
@ -400,6 +407,7 @@ SUBENTRY_PLATFORM_SELECTOR = SelectSelector(
|
||||
)
|
||||
)
|
||||
TEMPLATE_SELECTOR = TemplateSelector(TemplateSelectorConfig())
|
||||
TEMPLATE_SELECTOR_READ_ONLY = TemplateSelector(TemplateSelectorConfig(read_only=True))
|
||||
|
||||
SUBENTRY_AVAILABILITY_SCHEMA = vol.Schema(
|
||||
{
|
||||
@ -556,6 +564,8 @@ SUPPORTED_COLOR_MODES_SELECTOR = SelectSelector(
|
||||
)
|
||||
)
|
||||
|
||||
EXCLUDE_FROM_CONFIG_IF_NONE = {CONF_ENTITY_CATEGORY}
|
||||
|
||||
|
||||
@callback
|
||||
def validate_cover_platform_config(
|
||||
@ -3102,8 +3112,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
menu_options.append("delete_entity")
|
||||
menu_options.extend(["device", "availability"])
|
||||
self._async_update_component_data_defaults()
|
||||
if self._subentry_data != self._get_reconfigure_subentry().data:
|
||||
menu_options.append("save_changes")
|
||||
menu_options.append(
|
||||
"save_changes"
|
||||
if self._subentry_data != self._get_reconfigure_subentry().data
|
||||
else "export"
|
||||
)
|
||||
return self.async_show_menu(
|
||||
step_id="summary_menu",
|
||||
menu_options=menu_options,
|
||||
@ -3145,6 +3158,117 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
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
|
||||
def async_is_pem_data(data: bytes) -> bool:
|
||||
|
@ -247,6 +247,58 @@ def async_setup_entity_entry_helper(
|
||||
"""Set up entity creation dynamically through MQTT discovery."""
|
||||
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
|
||||
def _async_setup_entity_entry_from_discovery(
|
||||
discovery_payload: MQTTDiscoveryPayload,
|
||||
@ -263,9 +315,22 @@ def async_setup_entity_entry_helper(
|
||||
entity_class = schema_class_mapping[config[CONF_SCHEMA]]
|
||||
if TYPE_CHECKING:
|
||||
assert entity_class is not None
|
||||
async_add_entities(
|
||||
[entity_class(hass, config, entry, discovery_payload.discovery_data)]
|
||||
)
|
||||
if _async_migrate_subentry(
|
||||
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:
|
||||
_handle_discovery_failure(hass, discovery_payload)
|
||||
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]]
|
||||
if TYPE_CHECKING:
|
||||
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))
|
||||
except vol.Invalid as exc:
|
||||
error = str(exc)
|
||||
|
74
homeassistant/components/mqtt/repairs.py
Normal file
74
homeassistant/components/mqtt/repairs.py
Normal 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,
|
||||
)
|
@ -3,6 +3,28 @@
|
||||
"invalid_platform_config": {
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
@ -107,10 +129,10 @@
|
||||
"config_subentries": {
|
||||
"device": {
|
||||
"initiate_flow": {
|
||||
"user": "Add MQTT Device",
|
||||
"reconfigure": "Reconfigure MQTT Device"
|
||||
"user": "Add MQTT device",
|
||||
"reconfigure": "Reconfigure MQTT device"
|
||||
},
|
||||
"entry_type": "MQTT Device",
|
||||
"entry_type": "MQTT device",
|
||||
"step": {
|
||||
"availability": {
|
||||
"title": "Availability options",
|
||||
@ -175,6 +197,7 @@
|
||||
"delete_entity": "Delete an entity",
|
||||
"availability": "Configure availability",
|
||||
"device": "Update device properties",
|
||||
"export": "Export MQTT device configuration",
|
||||
"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": {
|
||||
|
@ -3344,6 +3344,7 @@ async def test_subentry_reconfigure_remove_entity(
|
||||
"delete_entity",
|
||||
"device",
|
||||
"availability",
|
||||
"export",
|
||||
]
|
||||
|
||||
# assert we can delete an entity
|
||||
@ -3465,6 +3466,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
|
||||
"delete_entity",
|
||||
"device",
|
||||
"availability",
|
||||
"export",
|
||||
]
|
||||
|
||||
# assert we can update an entity
|
||||
@ -3683,6 +3685,7 @@ async def test_subentry_reconfigure_edit_entity_single_entity(
|
||||
"update_entity",
|
||||
"device",
|
||||
"availability",
|
||||
"export",
|
||||
]
|
||||
|
||||
# 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",
|
||||
"device",
|
||||
"availability",
|
||||
"export",
|
||||
]
|
||||
|
||||
# assert we can update the entity, there is no select step
|
||||
@ -3953,6 +3957,7 @@ async def test_subentry_reconfigure_add_entity(
|
||||
"update_entity",
|
||||
"device",
|
||||
"availability",
|
||||
"export",
|
||||
]
|
||||
|
||||
# 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",
|
||||
"device",
|
||||
"availability",
|
||||
"export",
|
||||
]
|
||||
|
||||
# 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(
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock_entry: MqttMockHAClientGenerator,
|
||||
|
179
tests/components/mqtt/test_repairs.py
Normal file
179
tests/components/mqtt/test_repairs.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user