Add discovery for MQTT device tracker (#42327)

This commit is contained in:
PeteBa 2020-12-07 12:16:56 +00:00 committed by GitHub
parent 1d0b4290fe
commit 727b1d37b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 602 additions and 7 deletions

View file

@ -0,0 +1,7 @@
"""Support for tracking MQTT enabled devices."""
from .schema_discovery import async_setup_entry_from_discovery
from .schema_yaml import PLATFORM_SCHEMA_YAML, async_setup_scanner_from_yaml
PLATFORM_SCHEMA = PLATFORM_SCHEMA_YAML
async_setup_scanner = async_setup_scanner_from_yaml
async_setup_entry = async_setup_entry_from_discovery

View file

@ -0,0 +1,229 @@
"""Support for tracking MQTT enabled devices identified through discovery."""
import logging
import voluptuous as vol
from homeassistant.components import device_tracker, mqtt
from homeassistant.components.device_tracker import SOURCE_TYPES
from homeassistant.components.device_tracker.config_entry import TrackerEntity
from homeassistant.const import (
ATTR_GPS_ACCURACY,
ATTR_LATITUDE,
ATTR_LONGITUDE,
CONF_DEVICE,
CONF_ICON,
CONF_NAME,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
STATE_HOME,
STATE_NOT_HOME,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .. import (
MqttAttributes,
MqttAvailability,
MqttDiscoveryUpdate,
MqttEntityDeviceInfo,
subscription,
)
from ..const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC
from ..debug_info import log_messages
from ..discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
_LOGGER = logging.getLogger(__name__)
CONF_PAYLOAD_HOME = "payload_home"
CONF_PAYLOAD_NOT_HOME = "payload_not_home"
CONF_SOURCE_TYPE = "source_type"
PLATFORM_SCHEMA_DISCOVERY = (
mqtt.MQTT_RO_PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
vol.Optional(CONF_ICON): cv.icon,
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string,
vol.Optional(CONF_PAYLOAD_NOT_HOME, default=STATE_NOT_HOME): cv.string,
vol.Optional(CONF_SOURCE_TYPE): vol.In(SOURCE_TYPES),
vol.Optional(CONF_UNIQUE_ID): cv.string,
}
)
.extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema)
.extend(mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
)
async def async_setup_entry_from_discovery(hass, config_entry, async_add_entities):
"""Set up MQTT device tracker dynamically through MQTT discovery."""
async def async_discover(discovery_payload):
"""Discover and add an MQTT device tracker."""
discovery_data = discovery_payload.discovery_data
try:
config = PLATFORM_SCHEMA_DISCOVERY(discovery_payload)
await _async_setup_entity(
hass, config, async_add_entities, config_entry, discovery_data
)
except Exception:
clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH])
raise
async_dispatcher_connect(
hass, MQTT_DISCOVERY_NEW.format(device_tracker.DOMAIN, "mqtt"), async_discover
)
async def _async_setup_entity(
hass, config, async_add_entities, config_entry=None, discovery_data=None
):
"""Set up the MQTT Device Tracker entity."""
async_add_entities([MqttDeviceTracker(hass, config, config_entry, discovery_data)])
class MqttDeviceTracker(
MqttAttributes,
MqttAvailability,
MqttDiscoveryUpdate,
MqttEntityDeviceInfo,
TrackerEntity,
):
"""Representation of a device tracker using MQTT."""
def __init__(self, hass, config, config_entry, discovery_data):
"""Initialize the tracker."""
self.hass = hass
self._location_name = None
self._sub_state = None
self._unique_id = config.get(CONF_UNIQUE_ID)
# Load config
self._setup_from_config(config)
device_config = config.get(CONF_DEVICE)
MqttAttributes.__init__(self, config)
MqttAvailability.__init__(self, config)
MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update)
MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
async def async_added_to_hass(self):
"""Subscribe to MQTT events."""
await super().async_added_to_hass()
await self._subscribe_topics()
async def discovery_update(self, discovery_payload):
"""Handle updated discovery message."""
config = PLATFORM_SCHEMA_DISCOVERY(discovery_payload)
self._setup_from_config(config)
await self.attributes_discovery_update(config)
await self.availability_discovery_update(config)
await self.device_info_discovery_update(config)
await self._subscribe_topics()
self.async_write_ha_state()
def _setup_from_config(self, config):
"""(Re)Setup the entity."""
self._config = config
value_template = self._config.get(CONF_VALUE_TEMPLATE)
if value_template is not None:
value_template.hass = self.hass
async def _subscribe_topics(self):
"""(Re)Subscribe to topics."""
@callback
@log_messages(self.hass, self.entity_id)
def message_received(msg):
"""Handle new MQTT messages."""
payload = msg.payload
value_template = self._config.get(CONF_VALUE_TEMPLATE)
if value_template is not None:
payload = value_template.async_render_with_possible_json_value(payload)
if payload == self._config[CONF_PAYLOAD_HOME]:
self._location_name = STATE_HOME
elif payload == self._config[CONF_PAYLOAD_NOT_HOME]:
self._location_name = STATE_NOT_HOME
else:
self._location_name = msg.payload
self.async_write_ha_state()
self._sub_state = await subscription.async_subscribe_topics(
self.hass,
self._sub_state,
{
"state_topic": {
"topic": self._config[CONF_STATE_TOPIC],
"msg_callback": message_received,
"qos": self._config[CONF_QOS],
}
},
)
async def async_will_remove_from_hass(self):
"""Unsubscribe when removed."""
self._sub_state = await subscription.async_unsubscribe_topics(
self.hass, self._sub_state
)
await MqttAttributes.async_will_remove_from_hass(self)
await MqttAvailability.async_will_remove_from_hass(self)
await MqttDiscoveryUpdate.async_will_remove_from_hass(self)
@property
def icon(self):
"""Return the icon of the device."""
return self._config.get(CONF_ICON)
@property
def latitude(self):
"""Return latitude if provided in device_state_attributes or None."""
if (
self.device_state_attributes is not None
and ATTR_LATITUDE in self.device_state_attributes
):
return self.device_state_attributes[ATTR_LATITUDE]
return None
@property
def location_accuracy(self):
"""Return location accuracy if provided in device_state_attributes or None."""
if (
self.device_state_attributes is not None
and ATTR_GPS_ACCURACY in self.device_state_attributes
):
return self.device_state_attributes[ATTR_GPS_ACCURACY]
return None
@property
def longitude(self):
"""Return longitude if provided in device_state_attributes or None."""
if (
self.device_state_attributes is not None
and ATTR_LONGITUDE in self.device_state_attributes
):
return self.device_state_attributes[ATTR_LONGITUDE]
return None
@property
def location_name(self):
"""Return a location name for the current location of the device."""
return self._location_name
@property
def name(self):
"""Return the name of the device tracker."""
return self._config.get(CONF_NAME)
@property
def unique_id(self):
"""Return a unique ID."""
return self._unique_id
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
return self._config.get(CONF_SOURCE_TYPE)

View file

@ -1,5 +1,4 @@
"""Support for tracking MQTT enabled devices."""
import logging
"""Support for tracking MQTT enabled devices defined in YAML."""
import voluptuous as vol
@ -9,15 +8,13 @@ from homeassistant.const import CONF_DEVICES, STATE_HOME, STATE_NOT_HOME
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from . import CONF_QOS
_LOGGER = logging.getLogger(__name__)
from ..const import CONF_QOS
CONF_PAYLOAD_HOME = "payload_home"
CONF_PAYLOAD_NOT_HOME = "payload_not_home"
CONF_SOURCE_TYPE = "source_type"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend(
PLATFORM_SCHEMA_YAML = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend(
{
vol.Required(CONF_DEVICES): {cv.string: mqtt.valid_subscribe_topic},
vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string,
@ -27,7 +24,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend(
)
async def async_setup_scanner(hass, config, async_see, discovery_info=None):
async def async_setup_scanner_from_yaml(hass, config, async_see, discovery_info=None):
"""Set up the MQTT tracker."""
devices = config[CONF_DEVICES]
qos = config[CONF_QOS]

View file

@ -34,6 +34,7 @@ SUPPORTED_COMPONENTS = [
"climate",
"cover",
"device_automation",
"device_tracker",
"fan",
"light",
"lock",

View file

@ -0,0 +1,361 @@
"""The tests for the MQTT device_tracker discovery platform."""
import pytest
from homeassistant.components.mqtt.discovery import ALREADY_DISCOVERED
from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN
from tests.common import async_fire_mqtt_message, mock_device_registry, mock_registry
@pytest.fixture
def device_reg(hass):
"""Return an empty, loaded, registry."""
return mock_device_registry(hass)
@pytest.fixture
def entity_reg(hass):
"""Return an empty, loaded, registry."""
return mock_registry(hass)
async def test_discover_device_tracker(hass, mqtt_mock, caplog):
"""Test discovering an MQTT device tracker component."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "test", "state_topic": "test_topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.test")
assert state is not None
assert state.name == "test"
assert ("device_tracker", "bla") in hass.data[ALREADY_DISCOVERED]
@pytest.mark.no_fail_on_log_exception
async def test_discovery_broken(hass, mqtt_mock, caplog):
"""Test handling of bad discovery message."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is None
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "required-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is not None
assert state.name == "Beer"
async def test_non_duplicate_device_tracker_discovery(hass, mqtt_mock, caplog):
"""Test for a non duplicate component."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "test-topic" }',
)
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
state_duplicate = hass.states.get("device_tracker.beer1")
assert state is not None
assert state.name == "Beer"
assert state_duplicate is None
assert "Component has already been discovered: device_tracker bla" in caplog.text
async def test_device_tracker_removal(hass, mqtt_mock, caplog):
"""Test removal of component through empty discovery message."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is not None
async_fire_mqtt_message(hass, "homeassistant/device_tracker/bla/config", "")
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is None
async def test_device_tracker_rediscover(hass, mqtt_mock, caplog):
"""Test rediscover of removed component."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is not None
async_fire_mqtt_message(hass, "homeassistant/device_tracker/bla/config", "")
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is None
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is not None
async def test_duplicate_device_tracker_removal(hass, mqtt_mock, caplog):
"""Test for a non duplicate component."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "homeassistant/device_tracker/bla/config", "")
await hass.async_block_till_done()
assert "Component has already been discovered: device_tracker bla" in caplog.text
caplog.clear()
async_fire_mqtt_message(hass, "homeassistant/device_tracker/bla/config", "")
await hass.async_block_till_done()
assert (
"Component has already been discovered: device_tracker bla" not in caplog.text
)
async def test_device_tracker_discovery_update(hass, mqtt_mock, caplog):
"""Test for a discovery update event."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Beer", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is not None
assert state.name == "Beer"
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "Cider", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.beer")
assert state is not None
assert state.name == "Cider"
async def test_cleanup_device_tracker(hass, device_reg, entity_reg, mqtt_mock):
"""Test discvered device is cleaned up when removed from registry."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "device":{"identifiers":["0AFFD2"]},'
' "state_topic": "foobar/tracker",'
' "unique_id": "unique" }',
)
await hass.async_block_till_done()
# Verify device and registry entries are created
device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
assert device_entry is not None
entity_entry = entity_reg.async_get("device_tracker.mqtt_unique")
assert entity_entry is not None
state = hass.states.get("device_tracker.mqtt_unique")
assert state is not None
device_reg.async_remove_device(device_entry.id)
await hass.async_block_till_done()
# Verify device and registry entries are cleared
device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set())
assert device_entry is None
entity_entry = entity_reg.async_get("device_tracker.mqtt_unique")
assert entity_entry is None
# Verify state is removed
state = hass.states.get("device_tracker.mqtt_unique")
assert state is None
await hass.async_block_till_done()
# Verify retained discovery topic has been cleared
mqtt_mock.async_publish.assert_called_once_with(
"homeassistant/device_tracker/bla/config", "", 0, True
)
async def test_setting_device_tracker_value_via_mqtt_message(hass, mqtt_mock, caplog):
"""Test the setting of the value via MQTT."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "test", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.test")
assert state.state == STATE_UNKNOWN
async_fire_mqtt_message(hass, "test-topic", "home")
state = hass.states.get("device_tracker.test")
assert state.state == STATE_HOME
async_fire_mqtt_message(hass, "test-topic", "not_home")
state = hass.states.get("device_tracker.test")
assert state.state == STATE_NOT_HOME
async def test_setting_device_tracker_value_via_mqtt_message_and_template(
hass, mqtt_mock, caplog
):
"""Test the setting of the value via MQTT."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
"{"
'"name": "test", '
'"state_topic": "test-topic", '
'"value_template": "{% if value is equalto \\"proxy_for_home\\" %}home{% else %}not_home{% endif %}" '
"}",
)
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "test-topic", "proxy_for_home")
state = hass.states.get("device_tracker.test")
assert state.state == STATE_HOME
async_fire_mqtt_message(hass, "test-topic", "anything_for_not_home")
state = hass.states.get("device_tracker.test")
assert state.state == STATE_NOT_HOME
async def test_setting_device_tracker_value_via_mqtt_message_and_template2(
hass, mqtt_mock, caplog
):
"""Test the setting of the value via MQTT."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
"{"
'"name": "test", '
'"state_topic": "test-topic", '
'"value_template": "{{ value | lower }}" '
"}",
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.test")
assert state.state == STATE_UNKNOWN
async_fire_mqtt_message(hass, "test-topic", "HOME")
state = hass.states.get("device_Tracker.test")
assert state.state == STATE_HOME
async_fire_mqtt_message(hass, "test-topic", "NOT_HOME")
state = hass.states.get("device_tracker.test")
assert state.state == STATE_NOT_HOME
async def test_setting_device_tracker_location_via_mqtt_message(
hass, mqtt_mock, caplog
):
"""Test the setting of the location via MQTT."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
'{ "name": "test", "state_topic": "test-topic" }',
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.test")
assert state.state == STATE_UNKNOWN
async_fire_mqtt_message(hass, "test-topic", "test-location")
state = hass.states.get("device_tracker.test")
assert state.state == "test-location"
async def test_setting_device_tracker_location_via_lat_lon_message(
hass, mqtt_mock, caplog
):
"""Test the setting of the latitude and longitude via MQTT."""
async_fire_mqtt_message(
hass,
"homeassistant/device_tracker/bla/config",
"{ "
'"name": "test", '
'"state_topic": "test-topic", '
'"json_attributes_topic": "attributes-topic" '
"}",
)
await hass.async_block_till_done()
state = hass.states.get("device_tracker.test")
assert state.state == STATE_UNKNOWN
hass.config.latitude = 32.87336
hass.config.longitude = -117.22743
async_fire_mqtt_message(
hass,
"attributes-topic",
'{"latitude":32.87336,"longitude": -117.22743, "gps_accuracy":1.5}',
)
state = hass.states.get("device_tracker.test")
assert state.attributes["latitude"] == 32.87336
assert state.attributes["longitude"] == -117.22743
assert state.attributes["gps_accuracy"] == 1.5
assert state.state == STATE_HOME
async_fire_mqtt_message(
hass,
"attributes-topic",
'{"latitude":50.1,"longitude": -2.1, "gps_accuracy":1.5}',
)
state = hass.states.get("device_tracker.test")
assert state.attributes["latitude"] == 50.1
assert state.attributes["longitude"] == -2.1
assert state.attributes["gps_accuracy"] == 1.5
assert state.state == STATE_NOT_HOME
async_fire_mqtt_message(hass, "attributes-topic", '{"longitude": -117.22743}')
state = hass.states.get("device_tracker.test")
assert state.attributes["longitude"] == -117.22743
assert state.state == STATE_UNKNOWN
async_fire_mqtt_message(hass, "attributes-topic", '{"latitude":32.87336}')
state = hass.states.get("device_tracker.test")
assert state.attributes["latitude"] == 32.87336
assert state.state == STATE_NOT_HOME