diff --git a/homeassistant/components/mqtt/device_tracker/__init__.py b/homeassistant/components/mqtt/device_tracker/__init__.py new file mode 100644 index 00000000000..03574e6554b --- /dev/null +++ b/homeassistant/components/mqtt/device_tracker/__init__.py @@ -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 diff --git a/homeassistant/components/mqtt/device_tracker/schema_discovery.py b/homeassistant/components/mqtt/device_tracker/schema_discovery.py new file mode 100644 index 00000000000..aa45f8f92b2 --- /dev/null +++ b/homeassistant/components/mqtt/device_tracker/schema_discovery.py @@ -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) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker/schema_yaml.py similarity index 86% rename from homeassistant/components/mqtt/device_tracker.py rename to homeassistant/components/mqtt/device_tracker/schema_yaml.py index bcc969f0354..520bced2385 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker/schema_yaml.py @@ -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] diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d1e64d44bbc..ca576f83d2a 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -34,6 +34,7 @@ SUPPORTED_COMPONENTS = [ "climate", "cover", "device_automation", + "device_tracker", "fan", "light", "lock", diff --git a/tests/components/mqtt/test_device_tracker_discovery.py b/tests/components/mqtt/test_device_tracker_discovery.py new file mode 100644 index 00000000000..4ee6986e599 --- /dev/null +++ b/tests/components/mqtt/test_device_tracker_discovery.py @@ -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