Add YAML and discovery info export feature for MQTT device subentries

This commit is contained in:
jbouwh 2025-03-31 06:34:24 +00:00
parent 03366038ce
commit fb7cd59703
3 changed files with 227 additions and 2 deletions

View File

@ -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.file_upload import process_uploaded_file
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
@ -62,6 +64,7 @@ from homeassistant.const import (
CONF_PLATFORM,
CONF_PORT,
CONF_PROTOCOL,
CONF_UNIQUE_ID,
CONF_UNIT_OF_MEASUREMENT,
CONF_USERNAME,
CONF_VALUE_TEMPLATE,
@ -172,6 +175,7 @@ SET_CLIENT_CERT = "set_client_cert"
BOOLEAN_SELECTOR = BooleanSelector()
TEXT_SELECTOR = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
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)),
@ -1623,8 +1627,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
if len(self._subentry_data["components"]) > 1:
menu_options.append("delete_entity")
menu_options.extend(["device", "availability"])
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,
@ -1666,6 +1673,111 @@ 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()
)
mqtt_yaml_config.append({platform: component_config})
yaml_config = yaml.dump(mqtt_yaml_config_base)
data_schema = vol.Schema(
{
vol.Optional("yaml"): TEMPLATE_SELECTOR,
}
)
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()
)
discovery_payload["cmps"][component_id] = component_config
data_schema = vol.Schema(
{
vol.Optional("discovery_topic"): TEXT_SELECTOR,
vol.Optional("discovery_payload"): TEMPLATE_SELECTOR,
}
)
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:

View File

@ -172,6 +172,7 @@
"delete_entity": "Delete an entity",
"availability": "Configure availability",
"device": "Update device properties",
"export": "Export MQTT device configuration",
"save_changes": "Save changes"
}
},
@ -270,6 +271,36 @@
}
}
}
},
"export": {
"title": "Export MQTT device config",
"description": "An export allows you set up the MQTT Device configuration via YAMl or MQTT discovery. The configuration can also be helpfull for troubleshooting. The export includes the unique ID's used in the configuration. You cannot activate this configuration unless you remove the MQTT-device or change the unique id's. Removing the MQTT device subentry will also remove the device from the device registry. After removing the MQTT device subentry, it can be set up via YAML or discovery with the same unique ID's.",
"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": "Copy the YAML configuration below:",
"data": {
"yaml": "YAML"
},
"data_description": {
"yaml": "Place your YAML configuration [configuration.yaml]({url}#yaml-configuration-listed-per-item)."
}
},
"export_discovery": {
"title": "[%key:component::mqtt::config_subentries::device::step::export::title%]",
"description": "To allow [discovery]({url}#device-discovery-payload), the discovery payload needs to be published to the discovery topic",
"data": {
"discovery_topic": "Discovery topic",
"discovery_payload": "Discovery payload"
},
"data_description": {
"discovery_topic": "The [discovery topic]({url}#discovery-topic) to publish the discovery payload to, 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": {

View File

@ -2982,6 +2982,7 @@ async def test_subentry_reconfigure_remove_entity(
"delete_entity",
"device",
"availability",
"export",
]
# assert we can delete an entity
@ -3105,6 +3106,7 @@ async def test_subentry_reconfigure_edit_entity_multi_entitites(
"delete_entity",
"device",
"availability",
"export",
]
# assert we can update an entity
@ -3280,6 +3282,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
@ -3425,6 +3428,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
@ -3550,6 +3554,7 @@ async def test_subentry_reconfigure_add_entity(
"update_entity",
"device",
"availability",
"export",
]
# assert we can update the entity, there is no select step
@ -3649,6 +3654,7 @@ async def test_subentry_reconfigure_update_device_properties(
"delete_entity",
"device",
"availability",
"export",
]
# assert we can update the device properties
@ -3803,3 +3809,79 @@ async def test_subentry_reconfigure_availablity(
"payload_available": "1",
"payload_not_available": "0",
}
@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"), ["export_yaml", "export_discovery"])
async def test_subentry_reconfigure_export_settings(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
device_registry: dr.DeviceRegistry,
flow_step: str,
) -> None:
"""Test the subentry ConfigFlow reconfigure and update device properties."""
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 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/"
}
# 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"