1
mirror of https://github.com/home-assistant/core synced 2024-10-01 05:30:36 +02:00

MQTT discovery (#5724)

* Change implementation

* Re-write

* Remove unused consts

* Update discovery.py

* Add tests

* fix other tests

* Fix check_config script test

* Lint

* Lint
This commit is contained in:
Fabian Affolter 2017-02-07 18:13:24 +01:00 committed by Paulus Schoutsen
parent 45507cd9d1
commit c7fd28c10f
7 changed files with 213 additions and 11 deletions

View File

@ -36,7 +36,10 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({
# pylint: disable=unused-argument
def setup_platform(hass, config, add_devices, discovery_info=None):
"""Setup the MQTT binary sensor."""
"""Set up the MQTT binary sensor."""
if discovery_info is not None:
config = PLATFORM_SCHEMA(discovery_info)
value_template = config.get(CONF_VALUE_TEMPLATE)
if value_template is not None:
value_template.hass = hass

View File

@ -4,7 +4,6 @@ Support for MQTT message handling.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/mqtt/
"""
import asyncio
import logging
import os
import socket
@ -12,6 +11,7 @@ import time
import voluptuous as vol
from homeassistant.core import callback
from homeassistant.bootstrap import prepare_setup_platform
from homeassistant.config import load_yaml_config_file
from homeassistant.exceptions import HomeAssistantError
@ -36,6 +36,8 @@ REQUIREMENTS = ['paho-mqtt==1.2']
CONF_EMBEDDED = 'embedded'
CONF_BROKER = 'broker'
CONF_CLIENT_ID = 'client_id'
CONF_DISCOVERY = 'discovery'
CONF_DISCOVERY_PREFIX = 'discovery_prefix'
CONF_KEEPALIVE = 'keepalive'
CONF_CERTIFICATE = 'certificate'
CONF_CLIENT_KEY = 'client_key'
@ -58,6 +60,8 @@ DEFAULT_KEEPALIVE = 60
DEFAULT_QOS = 0
DEFAULT_RETAIN = False
DEFAULT_PROTOCOL = PROTOCOL_311
DEFAULT_DISCOVERY = False
DEFAULT_DISCOVERY_PREFIX = 'homeassistant'
ATTR_TOPIC = 'topic'
ATTR_PAYLOAD = 'payload'
@ -70,7 +74,8 @@ MAX_RECONNECT_WAIT = 300 # seconds
def valid_subscribe_topic(value, invalid_chars='\0'):
"""Validate that we can subscribe using this MQTT topic."""
if isinstance(value, str) and all(c not in value for c in invalid_chars):
value = cv.string(value)
if all(c not in value for c in invalid_chars):
return vol.Length(min=1, max=65535)(value)
raise vol.Invalid('Invalid MQTT topic name')
@ -80,6 +85,11 @@ def valid_publish_topic(value):
return valid_subscribe_topic(value, invalid_chars='#+\0')
def valid_discovery_topic(value):
"""Validate a discovery topic."""
return valid_subscribe_topic(value, invalid_chars='#+\0/')
_VALID_QOS_SCHEMA = vol.All(vol.Coerce(int), vol.In([0, 1, 2]))
CLIENT_KEY_AUTH_MSG = 'client_key and client_cert must both be present in ' \
@ -111,7 +121,10 @@ CONFIG_SCHEMA = vol.Schema({
vol.All(cv.string, vol.In([PROTOCOL_31, PROTOCOL_311])),
vol.Optional(CONF_EMBEDDED): HBMQTT_CONFIG_SCHEMA,
vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA
vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
vol.Optional(CONF_DISCOVERY_PREFIX,
default=DEFAULT_DISCOVERY_PREFIX): valid_discovery_topic,
}),
}, extra=vol.ALLOW_EXTRA)
@ -170,15 +183,16 @@ def publish_template(hass, topic, payload_template, qos=None, retain=None):
hass.services.call(DOMAIN, SERVICE_PUBLISH, data)
def async_subscribe(hass, topic, callback, qos=DEFAULT_QOS):
@callback
def async_subscribe(hass, topic, msg_callback, qos=DEFAULT_QOS):
"""Subscribe to an MQTT topic."""
@asyncio.coroutine
@callback
def mqtt_topic_subscriber(event):
"""Match subscribed MQTT topic."""
if not _match_topic(topic, event.data[ATTR_TOPIC]):
return
hass.async_run_job(callback, event.data[ATTR_TOPIC],
hass.async_run_job(msg_callback, event.data[ATTR_TOPIC],
event.data[ATTR_PAYLOAD], event.data[ATTR_QOS])
async_remove = hass.bus.async_listen(EVENT_MQTT_MESSAGE_RECEIVED,
@ -213,6 +227,21 @@ def _setup_server(hass, config):
return success and broker_config
def _setup_discovery(hass, config):
"""Try to start the discovery of MQTT devices."""
conf = config.get(DOMAIN, {})
discovery = prepare_setup_platform(hass, config, DOMAIN, 'discovery')
if discovery is None:
_LOGGER.error("Unable to load MQTT discovery")
return None
success = discovery.start(hass, conf[CONF_DISCOVERY_PREFIX], config)
return success
def setup(hass, config):
"""Start the MQTT protocol service."""
conf = config.get(DOMAIN, {})
@ -301,6 +330,9 @@ def setup(hass, config):
descriptions.get(SERVICE_PUBLISH),
schema=MQTT_PUBLISH_SCHEMA)
if conf.get(CONF_DISCOVERY):
_setup_discovery(hass, config)
return True

View File

@ -0,0 +1,62 @@
"""
Support for MQTT discovery.
For more details about this component, please refer to the documentation at
https://home-assistant.io/components/mqtt/#discovery
"""
import asyncio
import json
import logging
import re
from homeassistant.core import callback
import homeassistant.components.mqtt as mqtt
from homeassistant.components.mqtt import DOMAIN
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.const import CONF_PLATFORM
from homeassistant.components.mqtt import CONF_STATE_TOPIC
_LOGGER = logging.getLogger(__name__)
TOPIC_MATCHER = re.compile(
r'homeassistant/(?P<component>\w+)/(?P<object_id>\w+)/config')
SUPPORTED_COMPONENTS = ['binary_sensor']
@callback
def async_start(hass, discovery_topic, hass_config):
"""Initialization of MQTT Discovery."""
@asyncio.coroutine
def async_device_message_received(topic, payload, qos):
"""Process the received message."""
match = TOPIC_MATCHER.match(topic)
if not match:
return
component, object_id = match.groups()
try:
payload = json.loads(payload)
except ValueError:
_LOGGER.warning(
"Unable to parse JSON %s: %s", object_id, payload)
return
if component not in SUPPORTED_COMPONENTS:
_LOGGER.warning("Component %s is not supported", component)
return
payload = dict(payload)
payload[CONF_PLATFORM] = 'mqtt'
if CONF_STATE_TOPIC not in payload:
payload[CONF_STATE_TOPIC] = '{}/{}/{}/state'.format(
discovery_topic, component, object_id)
yield from async_load_platform(
hass, component, DOMAIN, payload, hass_config)
mqtt.async_subscribe(hass, discovery_topic + '/#',
async_device_message_received, 0)
return True

View File

@ -26,6 +26,7 @@ from homeassistant.components import sun, mqtt
from homeassistant.components.http.auth import auth_middleware
from homeassistant.components.http.const import (
KEY_USE_X_FORWARDED_FOR, KEY_BANS_ENABLED, KEY_TRUSTED_NETWORKS)
from homeassistant.util.async import run_callback_threadsafe
_TEST_INSTANCE_PORT = SERVER_PORT
_LOGGER = logging.getLogger(__name__)
@ -147,15 +148,22 @@ def mock_service(hass, domain, service):
return calls
def fire_mqtt_message(hass, topic, payload, qos=0):
@ha.callback
def async_fire_mqtt_message(hass, topic, payload, qos=0):
"""Fire the MQTT message."""
hass.bus.fire(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, {
hass.bus.async_fire(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, {
mqtt.ATTR_TOPIC: topic,
mqtt.ATTR_PAYLOAD: payload,
mqtt.ATTR_QOS: qos,
})
def fire_mqtt_message(hass, topic, payload, qos=0):
"""Fire the MQTT message."""
run_callback_threadsafe(
hass.loop, async_fire_mqtt_message, hass, topic, payload, qos).result()
def fire_time_changed(hass, time):
"""Fire a time changes event."""
hass.bus.fire(EVENT_TIME_CHANGED, {'now': time})

View File

@ -0,0 +1,74 @@
"""The tests for the MQTT component."""
import asyncio
from unittest.mock import patch
from homeassistant.components.mqtt.discovery import async_start
from tests.common import async_fire_mqtt_message, mock_coro
@asyncio.coroutine
def test_subscribing_config_topic(hass, mqtt_mock):
"""Test setting up discovery."""
hass_config = {}
discovery_topic = 'homeassistant'
async_start(hass, discovery_topic, hass_config)
assert mqtt_mock.subscribe.called
call_args = mqtt_mock.subscribe.mock_calls[0][1]
assert call_args[0] == discovery_topic + '/#'
assert call_args[1] == 0
@asyncio.coroutine
@patch('homeassistant.components.mqtt.discovery.async_load_platform')
def test_invalid_topic(mock_load_platform, hass, mqtt_mock):
"""Test sending in invalid JSON."""
mock_load_platform.return_value = mock_coro()
async_start(hass, 'homeassistant', {})
async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/not_config',
'{}')
yield from hass.async_block_till_done()
assert not mock_load_platform.called
@asyncio.coroutine
@patch('homeassistant.components.mqtt.discovery.async_load_platform')
def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog):
"""Test sending in invalid JSON."""
mock_load_platform.return_value = mock_coro()
async_start(hass, 'homeassistant', {})
async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
'not json')
yield from hass.async_block_till_done()
assert 'Unable to parse JSON' in caplog.text
assert not mock_load_platform.called
@asyncio.coroutine
@patch('homeassistant.components.mqtt.discovery.async_load_platform')
def test_only_valid_components(mock_load_platform, hass, mqtt_mock, caplog):
"""Test sending in invalid JSON."""
mock_load_platform.return_value = mock_coro()
async_start(hass, 'homeassistant', {})
async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', '{}')
yield from hass.async_block_till_done()
assert 'Component climate is not supported' in caplog.text
assert not mock_load_platform.called
@asyncio.coroutine
def test_correct_config_discovery(hass, mqtt_mock, caplog):
"""Test sending in invalid JSON."""
async_start(hass, 'homeassistant', {})
async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
'{ "name": "Beer" }')
yield from hass.async_block_till_done()
state = hass.states.get('binary_sensor.beer')
assert state is not None
assert state.name == 'Beer'

View File

@ -1,12 +1,14 @@
"""Setup some common test helper things."""
import functools
import logging
from unittest.mock import patch
import pytest
import requests_mock as _requests_mock
from homeassistant import util
from homeassistant import util, bootstrap
from homeassistant.util import location
from homeassistant.components import mqtt
from .common import async_test_home_assistant
from .test_util.aiohttp import mock_aiohttp_client
@ -58,3 +60,18 @@ def aioclient_mock():
"""Fixture to mock aioclient calls."""
with mock_aiohttp_client() as mock_session:
yield mock_session
@pytest.fixture
def mqtt_mock(loop, hass):
"""Fixture to mock MQTT."""
with patch('homeassistant.components.mqtt.MQTT') as mock_mqtt:
loop.run_until_complete(bootstrap.async_setup_component(
hass, mqtt.DOMAIN, {
mqtt.DOMAIN: {
mqtt.CONF_BROKER: 'mock-broker',
}
}))
client = mock_mqtt()
client.reset_mock()
return client

View File

@ -101,7 +101,13 @@ class TestCheckConfig(unittest.TestCase):
res = check_config.check(get_test_config_dir('platform.yaml'))
change_yaml_files(res)
self.assertDictEqual(
{'mqtt': {'keepalive': 60, 'port': 1883, 'protocol': '3.1.1'},
{'mqtt': {
'keepalive': 60,
'port': 1883,
'protocol': '3.1.1',
'discovery': False,
'discovery_prefix': 'homeassistant',
},
'light': []},
res['components']
)