Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow MQTT device based auto discovery #118757

Open
wants to merge 7 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions homeassistant/components/mqtt/abbreviations.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"cmd_on_tpl": "command_on_template",
"cmd_t": "command_topic",
"cmd_tpl": "command_template",
"cmp": "components",
"cod_arm_req": "code_arm_required",
"cod_dis_req": "code_disarm_required",
"cod_form": "code_format",
Expand Down Expand Up @@ -95,6 +96,7 @@
"min_mirs": "min_mireds",
"max_temp": "max_temp",
"min_temp": "min_temp",
"migr_discvry": "migrate_discovery",
"mode": "mode",
"mode_cmd_tpl": "mode_command_template",
"mode_cmd_t": "mode_command_topic",
Expand Down
4 changes: 4 additions & 0 deletions homeassistant/components/mqtt/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
CONF_CERTIFICATE = "certificate"
CONF_CLIENT_KEY = "client_key"
CONF_CLIENT_CERT = "client_cert"
CONF_COMPONENTS = "components"
CONF_TLS_INSECURE = "tls_insecure"

# Device and integration info options
Expand All @@ -104,6 +105,8 @@
CONF_OBJECT_ID = "object_id"
CONF_SUPPORT_URL = "support_url"

CONF_MIGRATE_DISCOVERY = "migrate_discovery"

DEFAULT_PREFIX = "homeassistant"
DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status"
DEFAULT_DISCOVERY = True
Expand Down Expand Up @@ -195,6 +198,7 @@
"camera",
"climate",
"cover",
"device",
jbouwh marked this conversation as resolved.
Show resolved Hide resolved
"device_automation",
"device_tracker",
"event",
Expand Down
7 changes: 6 additions & 1 deletion homeassistant/components/mqtt/device_automation.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@

from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType

from . import device_trigger
from .config import MQTT_BASE_SCHEMA
from .const import CONF_MIGRATE_DISCOVERY
from .mixins import async_setup_non_entity_entry_helper

AUTOMATION_TYPE_TRIGGER = "trigger"
Expand All @@ -20,7 +22,10 @@
CONF_AUTOMATION_TYPE = "automation_type"

DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend(
{vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA},
{
vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA,
vol.Optional(CONF_MIGRATE_DISCOVERY): cv.boolean,
},
extra=vol.ALLOW_EXTRA,
)

Expand Down
200 changes: 167 additions & 33 deletions homeassistant/components/mqtt/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import time
from typing import TYPE_CHECKING, Any

import voluptuous as vol

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DEVICE, CONF_PLATFORM
from homeassistant.core import HassJobType, HomeAssistant, callback
Expand All @@ -20,7 +22,7 @@
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
from homeassistant.helpers.service_info.mqtt import MqttServiceInfo, ReceivePayloadType
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.loader import async_get_mqtt
from homeassistant.util.json import json_loads_object
Expand All @@ -33,13 +35,14 @@
ATTR_DISCOVERY_PAYLOAD,
ATTR_DISCOVERY_TOPIC,
CONF_AVAILABILITY,
CONF_COMPONENTS,
CONF_ORIGIN,
CONF_TOPIC,
DOMAIN,
SUPPORTED_COMPONENTS,
)
from .models import DATA_MQTT, MqttOriginInfo, ReceiveMessage
from .schemas import MQTT_ORIGIN_INFO_SCHEMA
from .models import DATA_MQTT, MqttComponentConfig, MqttOriginInfo, ReceiveMessage
from .schemas import DEVICE_DISCOVERY_SCHEMA, MQTT_ORIGIN_INFO_SCHEMA, SHARED_OPTIONS
from .util import async_forward_entry_setup_and_setup_discovery

ABBREVIATIONS_SET = set(ABBREVIATIONS)
Expand Down Expand Up @@ -69,6 +72,7 @@
class MQTTDiscoveryPayload(dict[str, Any]):
"""Class to hold and MQTT discovery payload and discovery data."""

device_discovery: bool = False
discovery_data: DiscoveryInfoType


Expand All @@ -87,9 +91,13 @@ def async_log_discovery_origin_info(
message: str, discovery_payload: MQTTDiscoveryPayload, level: int = logging.INFO
) -> None:
"""Log information about the discovery and origin."""
# We only log origin info once per device discovery
if not _LOGGER.isEnabledFor(level):
# bail early if logging is disabled
return
if discovery_payload.device_discovery:
_LOGGER.log(level, message)
return
if CONF_ORIGIN not in discovery_payload:
_LOGGER.log(level, message)
return
Expand Down Expand Up @@ -169,6 +177,65 @@ def _replace_topic_base(discovery_payload: dict[str, Any]) -> None:
availability_conf[CONF_TOPIC] = f"{topic[:-1]}{base}"


@callback
def _generate_device_cleanup_config(
hass: HomeAssistant, object_id: str, node_id: str | None
) -> dict[str, Any]:
"""Generate a cleanup message on device cleanup."""
mqtt_data = hass.data[DATA_MQTT]
device_node_id: str = f"{node_id} {object_id}" if node_id else object_id
config: dict[str, Any] = {CONF_DEVICE: {}, CONF_COMPONENTS: {}}
comp_config = config[CONF_COMPONENTS]
for platform, discover_id in mqtt_data.discovery_already_discovered:
ids = discover_id.split(" ")
component_node_id = ids.pop(0)
component_object_id = " ".join(ids)
if not ids:
continue
if device_node_id == component_node_id:
comp_config[component_object_id] = {CONF_PLATFORM: platform}

return config if comp_config else {}


@callback
def _parse_device_payload(
hass: HomeAssistant,
payload: ReceivePayloadType,
object_id: str,
node_id: str | None,
) -> dict[str, Any]:
"""Parse a device discovery payload."""
device_payload: dict[str, Any] = {}
if payload == "":
if not (
device_payload := _generate_device_cleanup_config(hass, object_id, node_id)
):
_LOGGER.warning(
"No device components to cleanup for %s, node_id '%s'",
object_id,
node_id,
)
return device_payload
try:
device_payload = MQTTDiscoveryPayload(json_loads_object(payload))
except ValueError:
_LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload)
return {}
_replace_all_abbreviations(device_payload)
try:
DEVICE_DISCOVERY_SCHEMA(device_payload)
except vol.Invalid as exc:
_LOGGER.warning(
"Invalid MQTT device discovery payload for %s, %s: '%s'",
object_id,
exc,
payload,
)
return {}
return device_payload


@callback
def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool:
"""Parse and validate origin info from a single component discovery payload."""
Expand All @@ -186,6 +253,16 @@ def _valid_origin_info(discovery_payload: MQTTDiscoveryPayload) -> bool:
return True


@callback
def _merge_common_options(
component_config: MQTTDiscoveryPayload, device_config: dict[str, Any]
) -> None:
"""Merge common options with the component config options."""
for option in SHARED_OPTIONS:
if option in device_config and option not in component_config:
component_config[option] = device_config.get(option)


async def async_start( # noqa: C901
hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry
) -> None:
Expand Down Expand Up @@ -229,8 +306,7 @@ def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901
_LOGGER.warning(
(
"Received message on illegal discovery topic '%s'. The topic"
" contains "
"not allowed characters. For more information see "
" contains not allowed characters. For more information see "
"https://www.home-assistant.io/integrations/mqtt/#discovery-topic"
),
topic,
Expand All @@ -239,59 +315,117 @@ def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901

component, node_id, object_id = match.groups()

if payload:
discovered_components: list[MqttComponentConfig] = []
if component == CONF_DEVICE:
# Process device based discovery message
# and regenate cleanup config.
device_discovery_payload = _parse_device_payload(
hass, payload, object_id, node_id
)
if not device_discovery_payload:
return
device_config: dict[str, Any]
origin_config: dict[str, Any] | None
component_configs: dict[str, dict[str, Any]]
device_config = device_discovery_payload[CONF_DEVICE]
origin_config = device_discovery_payload.get(CONF_ORIGIN)
component_configs = device_discovery_payload[CONF_COMPONENTS]
for component_id, config in component_configs.items():
component = config.pop(CONF_PLATFORM)
# The object_id in the device discovery topic is the unique identifier.
# It is used as node_id for the components it contains.
component_node_id = object_id
# The component_id in the discovery playload is used as object_id
# If we have an additional node_id in the discovery topic,
# we extend the component_id with it.
component_object_id = (
f"{node_id} {component_id}" if node_id else component_id
)
_replace_all_abbreviations(config)
# We add wrapper to the discovery payload with the discovery data.
# If the dict is empty after removing the platform, the payload is
# assumed to remove the existing config and we do not want to add
# device or orig or shared availability attributes.
if discovery_payload := MQTTDiscoveryPayload(config):
discovery_payload.device_discovery = True
discovery_payload[CONF_DEVICE] = device_config
discovery_payload[CONF_ORIGIN] = origin_config
# Only assign shared config options
# when they are not set at entity level
_merge_common_options(discovery_payload, device_discovery_payload)
discovered_components.append(
MqttComponentConfig(
component,
component_object_id,
component_node_id,
discovery_payload,
)
)
_LOGGER.debug(
"Process device discovery payload %s", device_discovery_payload
)
device_discovery_id = f"{node_id} {object_id}" if node_id else object_id
message = f"Processing device discovery for '{device_discovery_id}'"
async_log_discovery_origin_info(
message, MQTTDiscoveryPayload(device_discovery_payload)
)

else:
# Process component based discovery message
try:
discovery_payload = MQTTDiscoveryPayload(json_loads_object(payload))
discovery_payload = MQTTDiscoveryPayload(
json_loads_object(payload) if payload else {}
)
except ValueError:
_LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload)
return
_replace_all_abbreviations(discovery_payload)
if not _valid_origin_info(discovery_payload):
return
discovered_components.append(
MqttComponentConfig(component, object_id, node_id, discovery_payload)
)

discovery_pending_discovered = mqtt_data.discovery_pending_discovered
for component_config in discovered_components:
component = component_config.component
node_id = component_config.node_id
object_id = component_config.object_id
discovery_payload = component_config.discovery_payload

if TOPIC_BASE in discovery_payload:
_replace_topic_base(discovery_payload)
else:
discovery_payload = MQTTDiscoveryPayload({})

# If present, the node_id will be included in the discovered object id
discovery_id = f"{node_id} {object_id}" if node_id else object_id
discovery_hash = (component, discovery_id)
# If present, the node_id will be included in the discovery_id.
discovery_id = f"{node_id} {object_id}" if node_id else object_id
discovery_hash = (component, discovery_id)

if discovery_payload:
# Attach MQTT topic to the payload, used for debug prints
setattr(
discovery_payload,
"__configuration_source__",
f"MQTT (topic: '{topic}')",
)
discovery_data = {
discovery_payload.discovery_data = {
ATTR_DISCOVERY_HASH: discovery_hash,
ATTR_DISCOVERY_PAYLOAD: discovery_payload,
ATTR_DISCOVERY_TOPIC: topic,
}
setattr(discovery_payload, "discovery_data", discovery_data)

discovery_payload[CONF_PLATFORM] = "mqtt"

if discovery_hash in mqtt_data.discovery_pending_discovered:
pending = mqtt_data.discovery_pending_discovered[discovery_hash]["pending"]
pending.appendleft(discovery_payload)
_LOGGER.debug(
"Component has already been discovered: %s %s, queuing update",
component,
discovery_id,
)
return
if discovery_hash in discovery_pending_discovered:
pending = discovery_pending_discovered[discovery_hash]["pending"]
pending.appendleft(discovery_payload)
_LOGGER.debug(
"Component has already been discovered: %s %s, queuing update",
component,
discovery_id,
)
return

async_process_discovery_payload(component, discovery_id, discovery_payload)
async_process_discovery_payload(component, discovery_id, discovery_payload)

@callback
def async_process_discovery_payload(
component: str, discovery_id: str, payload: MQTTDiscoveryPayload
) -> None:
"""Process the payload of a new discovery."""

_LOGGER.debug("Process discovery payload %s", payload)
_LOGGER.debug("Process component discovery payload %s", payload)
discovery_hash = (component, discovery_id)

already_discovered = discovery_hash in mqtt_data.discovery_already_discovered
Expand Down
Loading
Loading