diff --git a/homeassistant/components/mqtt/abbreviations.py b/homeassistant/components/mqtt/abbreviations.py index cc0f37ea145..43f14eba1c5 100644 --- a/homeassistant/components/mqtt/abbreviations.py +++ b/homeassistant/components/mqtt/abbreviations.py @@ -111,6 +111,7 @@ ABBREVIATIONS = { "mode_stat_tpl": "mode_state_template", "modes": "modes", "name": "name", + "o": "origin", "obj_id": "object_id", "off_dly": "off_delay", "on_cmd_type": "on_command_type", @@ -275,3 +276,9 @@ DEVICE_ABBREVIATIONS = { "sw": "sw_version", "sa": "suggested_area", } + +ORIGIN_ABBREVIATIONS = { + "name": "name", + "sw": "sw_version", + "url": "support_url", +} diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 97d2e1473f5..c0589f60cbe 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -17,6 +17,7 @@ CONF_COMMAND_TOPIC = "command_topic" CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" CONF_KEEPALIVE = "keepalive" +CONF_ORIGIN = "origin" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN CONF_SCHEMA = "schema" @@ -57,6 +58,19 @@ CONF_CLIENT_KEY = "client_key" CONF_CLIENT_CERT = "client_cert" CONF_TLS_INSECURE = "tls_insecure" +# Device and integration info options +CONF_IDENTIFIERS = "identifiers" +CONF_CONNECTIONS = "connections" +CONF_MANUFACTURER = "manufacturer" +CONF_HW_VERSION = "hw_version" +CONF_SW_VERSION = "sw_version" +CONF_VIA_DEVICE = "via_device" +CONF_DEPRECATED_VIA_HUB = "via_hub" +CONF_SUGGESTED_AREA = "suggested_area" +CONF_CONFIGURATION_URL = "configuration_url" +CONF_OBJECT_ID = "object_id" +CONF_SUPPORT_URL = "support_url" + DATA_MQTT = "mqtt" DATA_MQTT_AVAILABLE = "mqtt_client_available" diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 8e563a48cdd..e701937a048 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -9,8 +9,10 @@ import re import time from typing import Any +import voluptuous as vol + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, CONF_PLATFORM +from homeassistant.const import CONF_DEVICE, CONF_NAME, CONF_PLATFORM from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv @@ -24,16 +26,19 @@ from homeassistant.loader import async_get_mqtt from homeassistant.util.json import json_loads_object from .. import mqtt -from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS +from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS, ORIGIN_ABBREVIATIONS from .const import ( ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, + CONF_ORIGIN, + CONF_SUPPORT_URL, + CONF_SW_VERSION, CONF_TOPIC, DOMAIN, ) -from .models import ReceiveMessage +from .models import MqttOriginInfo, ReceiveMessage from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -77,6 +82,16 @@ MQTT_DISCOVERY_DONE = "mqtt_discovery_done_{}" TOPIC_BASE = "~" +MQTT_ORIGIN_INFO_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_SW_VERSION): cv.string, + vol.Optional(CONF_SUPPORT_URL): cv.configuration_url, + } + ), +) + class MQTTDiscoveryPayload(dict[str, Any]): """Class to hold and MQTT discovery payload and discovery data.""" @@ -94,6 +109,30 @@ def set_discovery_hash(hass: HomeAssistant, discovery_hash: tuple[str, str]) -> get_mqtt_data(hass).discovery_already_discovered.add(discovery_hash) +@callback +def async_log_discovery_origin_info( + message: str, discovery_payload: MQTTDiscoveryPayload +) -> None: + """Log information about the discovery and origin.""" + if CONF_ORIGIN not in discovery_payload: + _LOGGER.info(message) + return + origin_info: MqttOriginInfo = discovery_payload[CONF_ORIGIN] + sw_version_log = "" + if sw_version := origin_info.get("sw_version"): + sw_version_log = f", version: {sw_version}" + support_url_log = "" + if support_url := origin_info.get("support_url"): + support_url_log = f", support URL: {support_url}" + _LOGGER.info( + "%s from external application %s%s%s", + message, + origin_info["name"], + sw_version_log, + support_url_log, + ) + + async def async_start( # noqa: C901 hass: HomeAssistant, discovery_topic: str, config_entry: ConfigEntry ) -> None: @@ -149,6 +188,22 @@ async def async_start( # noqa: C901 key = DEVICE_ABBREVIATIONS.get(key, key) device[key] = device.pop(abbreviated_key) + if CONF_ORIGIN in discovery_payload: + origin_info: dict[str, Any] = discovery_payload[CONF_ORIGIN] + try: + for key in list(origin_info): + abbreviated_key = key + key = ORIGIN_ABBREVIATIONS.get(key, key) + origin_info[key] = origin_info.pop(abbreviated_key) + MQTT_ORIGIN_INFO_SCHEMA(discovery_payload[CONF_ORIGIN]) + except Exception: # pylint: disable=broad-except + _LOGGER.warning( + "Unable to parse origin information " + "from discovery message, got %s", + discovery_payload[CONF_ORIGIN], + ) + return + if CONF_AVAILABILITY in discovery_payload: for availability_conf in cv.ensure_list( discovery_payload[CONF_AVAILABILITY] @@ -246,17 +301,15 @@ async def async_start( # noqa: C901 if discovery_hash in mqtt_data.discovery_already_discovered: # Dispatch update - _LOGGER.info( - "Component has already been discovered: %s %s, sending update", - component, - discovery_id, - ) + message = f"Component has already been discovered: {component} {discovery_id}, sending update" + async_log_discovery_origin_info(message, payload) async_dispatcher_send( hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload ) elif payload: # Add component - _LOGGER.info("Found new component: %s %s", component, discovery_id) + message = f"Found new component: {component} {discovery_id}" + async_log_discovery_origin_info(message, payload) mqtt_data.discovery_already_discovered.add(discovery_hash) async_dispatcher_send( hass, MQTT_DISCOVERY_NEW.format(component, "mqtt"), payload diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 97ba96f0207..3b28bc8804f 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -69,9 +69,20 @@ from .const import ( ATTR_DISCOVERY_PAYLOAD, ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, + CONF_CONFIGURATION_URL, + CONF_CONNECTIONS, + CONF_DEPRECATED_VIA_HUB, CONF_ENCODING, + CONF_HW_VERSION, + CONF_IDENTIFIERS, + CONF_MANUFACTURER, + CONF_OBJECT_ID, + CONF_ORIGIN, CONF_QOS, + CONF_SUGGESTED_AREA, + CONF_SW_VERSION, CONF_TOPIC, + CONF_VIA_DEVICE, DEFAULT_ENCODING, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, @@ -84,6 +95,7 @@ from .discovery import ( MQTT_DISCOVERY_DONE, MQTT_DISCOVERY_NEW, MQTT_DISCOVERY_UPDATED, + MQTT_ORIGIN_INFO_SCHEMA, MQTTDiscoveryPayload, clear_discovery_hash, set_discovery_hash, @@ -119,17 +131,6 @@ CONF_PAYLOAD_NOT_AVAILABLE = "payload_not_available" CONF_JSON_ATTRS_TOPIC = "json_attributes_topic" CONF_JSON_ATTRS_TEMPLATE = "json_attributes_template" -CONF_IDENTIFIERS = "identifiers" -CONF_CONNECTIONS = "connections" -CONF_MANUFACTURER = "manufacturer" -CONF_HW_VERSION = "hw_version" -CONF_SW_VERSION = "sw_version" -CONF_VIA_DEVICE = "via_device" -CONF_DEPRECATED_VIA_HUB = "via_hub" -CONF_SUGGESTED_AREA = "suggested_area" -CONF_CONFIGURATION_URL = "configuration_url" -CONF_OBJECT_ID = "object_id" - MQTT_ATTRIBUTES_BLOCKED = { "assumed_state", "available", @@ -228,6 +229,7 @@ MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All( MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_ORIGIN): MQTT_ORIGIN_INFO_SCHEMA, vol.Optional(CONF_ENABLED_BY_DEFAULT, default=True): cv.boolean, vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, vol.Optional(CONF_ICON): cv.icon, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 99267d9572a..d553274ab3e 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -99,6 +99,16 @@ class PendingDiscovered(TypedDict): unsub: CALLBACK_TYPE +class MqttOriginInfo(TypedDict, total=False): + """Integration info of discovered entity.""" + + name: str + manufacturer: str + sw_version: str + hw_version: str + support_url: str + + class MqttCommandTemplate: """Class for rendering MQTT payload with command templates.""" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index f51d469bde7..c528687623b 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -168,6 +168,83 @@ async def test_correct_config_discovery( assert ("binary_sensor", "bla") in hass.data["mqtt"].discovery_already_discovered +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_discovery_integration_info( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test logging discovery of new and updated items.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Beer", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.0" } }', + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.beer") + + assert state is not None + assert state.name == "Beer" + + assert ( + "Found new component: binary_sensor bla from external application bla2mqtt, version: 1.0" + in caplog.text + ) + caplog.clear() + + # Send an update and add support url + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Milk", "state_topic": "test-topic", "o": {"name": "bla2mqtt", "sw": "1.1", "url": "https://bla2mqtt.example.com/support" } }', + ) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + + assert state is not None + assert state.name == "Milk" + + assert ( + "Component has already been discovered: binary_sensor bla, sending update from external application bla2mqtt, version: 1.1, support URL: https://bla2mqtt.example.com/support" + in caplog.text + ) + + +@pytest.mark.parametrize( + "config_message", + [ + '{ "name": "Beer", "state_topic": "test-topic", "o": "bla2mqtt" }', + '{ "name": "Beer", "state_topic": "test-topic", "o": 2.0 }', + '{ "name": "Beer", "state_topic": "test-topic", "o": null }', + '{ "name": "Beer", "state_topic": "test-topic", "o": {"sw": "bla2mqtt"} }', + ], +) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_discovery_with_invalid_integration_info( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, + config_message: str, +) -> None: + """Test sending in correct JSON.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + config_message, + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.beer") + + assert state is None + assert ( + "Unable to parse origin information from discovery message, got" in caplog.text + ) + + @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]) async def test_discover_fan( hass: HomeAssistant, @@ -1266,6 +1343,8 @@ ABBREVIATIONS_WHITE_LIST = [ "CONF_WILL_MESSAGE", "CONF_WS_PATH", "CONF_WS_HEADERS", + # Integration info + "CONF_SUPPORT_URL", # Undocumented device configuration "CONF_DEPRECATED_VIA_HUB", "CONF_VIA_DEVICE",