Allow to migrate and MQTT subentry to YAML or discovery using a repair flow

This commit is contained in:
jbouwh 2025-07-06 18:31:45 +00:00
parent 11b0b2bd3d
commit 02d911b7c7
3 changed files with 204 additions and 6 deletions

View File

@ -247,6 +247,59 @@ 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,
"migration_type": migration_type,
},
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 +316,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 +412,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)

View File

@ -0,0 +1,105 @@
"""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 migrate device from subentry to main entry and reload."""
def __init__(
self, entry_id: str, subentry_id: str, name: str, migration_type: str
) -> None:
"""Initialize the flow."""
self.entry_id = entry_id
self.subentry_id = subentry_id
self.name = name
self.migration_type = migration_type
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 and self.migration_type == "subentry_migration_yaml":
# Via YAML the device was already registered and bound to the entry,
# so it is safe to remove the subentry from here.
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={})
if (
user_input is not None
and self.migration_type == "subentry_migration_discovery"
):
# The device offered via discovery was already set up through the subentry,
# so we need to update the device before removing the subentry and reload.
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
device_registry.async_update_device(
subentry_device.id,
remove_config_entry_id=self.entry_id,
remove_config_subentry_id=self.subentry_id,
add_config_entry_id=self.entry_id,
)
self.hass.config_entries.async_remove_subentry(entry, self.subentry_id)
self.hass.config_entries.async_schedule_reload(self.entry_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"]
migration_type = data["migration_type"]
if TYPE_CHECKING:
assert isinstance(entry_id, str)
assert isinstance(subentry_id, str)
assert isinstance(name, str)
assert isinstance(migration_type, str)
return MQTTDeviceEntryMigration(
entry_id=entry_id,
subentry_id=subentry_id,
name=name,
migration_type=migration_type,
)

View File

@ -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. Confirm that the MQTT device is migrated to the main MQTT configuration, the replaced subentry of the MQTT device is to be removed, and MQTT is to be reloaded. Make sure that the discovery is retained at the MQTT broker, or is resent after the reload is completed, so that the MQTT will be set up correctly."
}
}
}
},
"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. Confirm to migrate the MQTT device to main MQTT config entry, and to remove the replaced MQTT device subentry."
}
}
}
}
},
"config": {
@ -631,7 +653,7 @@
},
"export": {
"title": "Export MQTT device config",
"description": "An export allows you set up the MQTT device configuration via YAML or MQTT discovery. The configuration export can also be helpful for troubleshooting.",
"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"
@ -639,7 +661,7 @@
},
"export_yaml": {
"title": "[%key:component::mqtt::config_subentries::device::step::export::title%]",
"description": "The export includes the unique ID's used in the subentry configuration. You must change these ID's or remove the MQTT device subentry to avoid conflicts. Note that removing the MQTT device subentry will also remove the device from the device registry.",
"description": "You can copy the configuration below and place it your configuration.yaml file. Home Assistant will detect if the MQTT device was tried to set up via YAML instead, and will offer a repair flow to clean up the redundant subentry.",
"data": {
"yaml": "Copy the YAML configuration below:"
},
@ -649,7 +671,7 @@
},
"export_discovery": {
"title": "[%key:component::mqtt::config_subentries::device::step::export::title%]",
"description": "The export includes the unique ID's used in the subentry configuration. You must change these ID's or remove the MQTT device subentry to avoid conflicts. Note that removing the MQTT device subentry will also remove the device from the device registry.\n\nTo allow [discovery]({url}#device-discovery-payload), the discovery payload needs to be published to the discovery topic. Copy the needed information from the fields below.",
"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 MQTT device was tried set up via MQTT discovery instead, and will offer a repair flow to clean up the redundant subentry.",
"data": {
"discovery_topic": "Discovery topic",
"discovery_payload": "Discovery payload:"