From e7f832a82f20d23c19ec31f34cf7e72af713bfcf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 28 Sep 2020 22:04:27 +0200 Subject: [PATCH] Add MQTT tag scanner (#40709) --- homeassistant/components/mqtt/__init__.py | 3 +- homeassistant/components/mqtt/discovery.py | 7 + homeassistant/components/mqtt/tag.py | 224 +++++++ tests/components/mqtt/test_tag.py | 744 +++++++++++++++++++++ 4 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/mqtt/tag.py create mode 100644 tests/components/mqtt/test_tag.py diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index bafbead96d6..1d924a40963 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1229,7 +1229,7 @@ async def cleanup_device_registry(hass, device_id): """Remove device registry entry if there are no remaining entities or triggers.""" # Local import to avoid circular dependencies # pylint: disable=import-outside-toplevel - from . import device_trigger + from . import device_trigger, tag device_registry = await hass.helpers.device_registry.async_get_registry() entity_registry = await hass.helpers.entity_registry.async_get_registry() @@ -1239,6 +1239,7 @@ async def cleanup_device_registry(hass, device_id): entity_registry, device_id ) and not await device_trigger.async_get_triggers(hass, device_id) + and not tag.async_has_tags(hass, device_id) ): device_registry.async_remove_device(device_id) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index a7d5236148b..6c4cbfd212f 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -32,6 +32,7 @@ SUPPORTED_COMPONENTS = [ "lock", "sensor", "switch", + "tag", "vacuum", ] @@ -154,6 +155,12 @@ async def async_start( from . import device_automation await device_automation.async_setup_entry(hass, config_entry) + elif component == "tag": + # Local import to avoid circular dependencies + # pylint: disable=import-outside-toplevel + from . import tag + + await tag.async_setup_entry(hass, config_entry) else: await hass.config_entries.async_forward_entry_setup( config_entry, component diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py new file mode 100644 index 00000000000..94356ccf778 --- /dev/null +++ b/homeassistant/components/mqtt/tag.py @@ -0,0 +1,224 @@ +"""Provides tag scanning for MQTT.""" +import logging + +import voluptuous as vol + +from homeassistant.components import mqtt +from homeassistant.const import CONF_PLATFORM, CONF_VALUE_TEMPLATE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import ( + ATTR_DISCOVERY_HASH, + ATTR_DISCOVERY_TOPIC, + CONF_CONNECTIONS, + CONF_DEVICE, + CONF_IDENTIFIERS, + CONF_QOS, + CONF_TOPIC, + DOMAIN, + cleanup_device_registry, + subscription, +) +from .discovery import MQTT_DISCOVERY_NEW, MQTT_DISCOVERY_UPDATED, clear_discovery_hash +from .util import valid_subscribe_topic + +_LOGGER = logging.getLogger(__name__) + +TAG = "tag" +TAGS = "mqtt_tags" + +PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_PLATFORM): "mqtt", + vol.Required(CONF_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + }, + mqtt.validate_device_has_at_least_one_identifier, +) + + +async def async_setup_entry(hass, config_entry): + """Set up MQTT tag scan dynamically through MQTT discovery.""" + + async def async_discover(discovery_payload): + """Discover and add MQTT tag scan.""" + discovery_data = discovery_payload.discovery_data + try: + config = PLATFORM_SCHEMA(discovery_payload) + await async_setup_tag(hass, config, config_entry, discovery_data) + except Exception: + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) + raise + + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format("tag", "mqtt"), async_discover + ) + + +async def async_setup_tag(hass, config, config_entry, discovery_data): + """Set up the MQTT tag scanner.""" + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + discovery_id = discovery_hash[1] + + device_id = None + if CONF_DEVICE in config: + await _update_device(hass, config_entry, config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get_device( + {(DOMAIN, id_) for id_ in config[CONF_DEVICE][CONF_IDENTIFIERS]}, + {tuple(x) for x in config[CONF_DEVICE][CONF_CONNECTIONS]}, + ) + + if device is None: + return + device_id = device.id + + if TAGS not in hass.data: + hass.data[TAGS] = {} + if device_id not in hass.data[TAGS]: + hass.data[TAGS][device_id] = {} + + tag_scanner = MQTTTagScanner( + hass, + config, + device_id, + discovery_data, + config_entry, + ) + + await tag_scanner.setup() + + if device_id: + hass.data[TAGS][device_id][discovery_id] = tag_scanner + + +def async_has_tags(hass, device_id): + """Device has tag scanners.""" + if TAGS not in hass.data or device_id not in hass.data[TAGS]: + return False + return hass.data[TAGS][device_id] != {} + + +class MQTTTagScanner: + """MQTT Tag scanner.""" + + def __init__(self, hass, config, device_id, discovery_data, config_entry): + """Initialize.""" + self._config = config + self._config_entry = config_entry + self.device_id = device_id + self.discovery_data = discovery_data + self.hass = hass + self._remove_discovery = None + self._remove_device_updated = None + self._sub_state = None + self._value_template = None + + self._setup_from_config(config) + + async def discovery_update(self, payload): + """Handle discovery update.""" + discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] + _LOGGER.info( + "Got update for tag scanner with hash: %s '%s'", discovery_hash, payload + ) + if not payload: + # Empty payload: Remove tag scanner + _LOGGER.info("Removing tag scanner: %s", discovery_hash) + await self.tear_down() + if self.device_id: + await cleanup_device_registry(self.hass, self.device_id) + else: + # Non-empty payload: Update tag scanner + _LOGGER.info("Updating tag scanner: %s", discovery_hash) + config = PLATFORM_SCHEMA(payload) + self._config = config + if self.device_id: + await _update_device(self.hass, self._config_entry, config) + self._setup_from_config(config) + await self.subscribe_topics() + + def _setup_from_config(self, config): + self._value_template = lambda value, error_value: value + if CONF_VALUE_TEMPLATE in config: + value_template = config.get(CONF_VALUE_TEMPLATE) + value_template.hass = self.hass + + self._value_template = value_template.async_render_with_possible_json_value + + async def setup(self): + """Set up the MQTT tag scanner.""" + discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] + await self.subscribe_topics() + if self.device_id: + self._remove_device_updated = self.hass.bus.async_listen( + EVENT_DEVICE_REGISTRY_UPDATED, self.device_removed + ) + self._remove_discovery = async_dispatcher_connect( + self.hass, + MQTT_DISCOVERY_UPDATED.format(discovery_hash), + self.discovery_update, + ) + + async def subscribe_topics(self): + """Subscribe to MQTT topics.""" + + async def tag_scanned(msg): + tag_id = self._value_template(msg.payload, error_value="").strip() + if not tag_id: # No output from template, ignore + return + + await self.hass.components.tag.async_scan_tag(tag_id, self.device_id) + + self._sub_state = await subscription.async_subscribe_topics( + self.hass, + self._sub_state, + { + "state_topic": { + "topic": self._config[CONF_TOPIC], + "msg_callback": tag_scanned, + "qos": self._config[CONF_QOS], + } + }, + ) + + async def device_removed(self, event): + """Handle the removal of a device.""" + device_id = event.data["device_id"] + if event.data["action"] != "remove" or device_id != self.device_id: + return + + await self.tear_down() + + async def tear_down(self): + """Cleanup tag scanner.""" + discovery_hash = self.discovery_data[ATTR_DISCOVERY_HASH] + discovery_id = discovery_hash[1] + discovery_topic = self.discovery_data[ATTR_DISCOVERY_TOPIC] + + clear_discovery_hash(self.hass, discovery_hash) + if self.device_id: + self._remove_device_updated() + self._remove_discovery() + + mqtt.publish(self.hass, discovery_topic, "", retain=True) + self._sub_state = await subscription.async_unsubscribe_topics( + self.hass, self._sub_state + ) + if self.device_id: + self.hass.data[TAGS][self.device_id].pop(discovery_id) + + +async def _update_device(hass, config_entry, config): + """Update device registry.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + config_entry_id = config_entry.entry_id + device_info = mqtt.device_info_from_config(config[CONF_DEVICE]) + + if config_entry_id is not None and device_info is not None: + device_info["config_entry_id"] = config_entry_id + device_registry.async_get_or_create(**device_info) diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py new file mode 100644 index 00000000000..fa041fb58ad --- /dev/null +++ b/tests/components/mqtt/test_tag.py @@ -0,0 +1,744 @@ +"""The tests for MQTT tag scanner.""" +import copy +import json + +import pytest + +from homeassistant.components.mqtt import DOMAIN +from homeassistant.components.mqtt.discovery import async_start + +from tests.async_mock import ANY, patch +from tests.common import ( + async_fire_mqtt_message, + async_get_device_automations, + mock_device_registry, + mock_registry, +) + +DEFAULT_CONFIG_DEVICE = { + "device": {"identifiers": ["0AFFD2"]}, + "topic": "foobar/tag_scanned", +} + +DEFAULT_CONFIG = { + "topic": "foobar/tag_scanned", +} + +DEFAULT_CONFIG_JSON = { + "device": {"identifiers": ["0AFFD2"]}, + "topic": "foobar/tag_scanned", + "value_template": "{{ value_json.PN532.UID }}", +} + +DEFAULT_TAG_ID = "E9F35959" + +DEFAULT_TAG_SCAN = "E9F35959" + +DEFAULT_TAG_SCAN_JSON = ( + '{"Time":"2020-09-28T17:02:10","PN532":{"UID":"E9F35959", "DATA":"ILOVETASMOTA"}}' +) + + +@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) + + +@pytest.fixture +def tag_mock(): + """Fixture to mock tag.""" + with patch("homeassistant.components.tag.async_scan_tag") as mock_tag: + yield mock_tag + + +@pytest.mark.no_fail_on_log_exception +async def test_discover_bad_tag(hass, device_reg, entity_reg, mqtt_mock, tag_mock): + """Test bad discovery message.""" + config1 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + # Test sending bad data + data0 = '{ "device":{"identifiers":["0AFFD2"]}, "topics": "foobar/tag_scanned" }' + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data0) + await hass.async_block_till_done() + assert device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) is None + + # Test sending correct data + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", json.dumps(config1)) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + +async def test_if_fires_on_mqtt_message_with_device( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning, with device.""" + config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + +async def test_if_fires_on_mqtt_message_without_device( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning, without device.""" + config = copy.deepcopy(DEFAULT_CONFIG) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, None) + + +async def test_if_fires_on_mqtt_message_with_template( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning, with device.""" + config = copy.deepcopy(DEFAULT_CONFIG_JSON) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN_JSON) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + +async def test_strip_tag_id(hass, device_reg, mqtt_mock, tag_mock): + """Test strip whitespace from tag_id.""" + config = copy.deepcopy(DEFAULT_CONFIG) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", "123456 ") + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, "123456", None) + + +async def test_if_fires_on_mqtt_message_after_update_with_device( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning after update.""" + config1 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) + config2 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) + config2["topic"] = "foobar/tag_scanned2" + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config1)) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + # Update the tag scanner with different topic + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config2)) + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + async_fire_mqtt_message(hass, "foobar/tag_scanned2", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + # Update the tag scanner with same topic + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config2)) + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + async_fire_mqtt_message(hass, "foobar/tag_scanned2", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + +async def test_if_fires_on_mqtt_message_after_update_without_device( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning after update.""" + config1 = copy.deepcopy(DEFAULT_CONFIG) + config2 = copy.deepcopy(DEFAULT_CONFIG) + config2["topic"] = "foobar/tag_scanned2" + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config1)) + await hass.async_block_till_done() + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, None) + + # Update the tag scanner with different topic + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config2)) + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + async_fire_mqtt_message(hass, "foobar/tag_scanned2", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, None) + + # Update the tag scanner with same topic + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config2)) + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + async_fire_mqtt_message(hass, "foobar/tag_scanned2", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, None) + + +async def test_if_fires_on_mqtt_message_after_update_with_template( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning after update.""" + config1 = copy.deepcopy(DEFAULT_CONFIG_JSON) + config2 = copy.deepcopy(DEFAULT_CONFIG_JSON) + config2["value_template"] = "{{ value_json.RDM6300.UID }}" + tag_scan_2 = '{"Time":"2020-09-28T17:02:10","RDM6300":{"UID":"E9F35959", "DATA":"ILOVETASMOTA"}}' + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config1)) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN_JSON) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + # Update the tag scanner with different template + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config2)) + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN_JSON) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", tag_scan_2) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + # Update the tag scanner with same template + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config2)) + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN_JSON) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", tag_scan_2) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + +async def test_no_resubscribe_same_topic(hass, device_reg, mqtt_mock): + """Test subscription to topics without change.""" + config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + assert device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + call_count = mqtt_mock.async_subscribe.call_count + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + assert mqtt_mock.async_subscribe.call_count == call_count + + +async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning after removal.""" + config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + # Remove the tag scanner + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + # Rediscover the tag scanner + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + +async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_without_device( + hass, device_reg, mqtt_mock, tag_mock +): + """Test tag scanning not firing after removal.""" + config = copy.deepcopy(DEFAULT_CONFIG) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, None) + + # Remove the tag scanner + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + # Rediscover the tag scanner + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, None) + + +async def test_not_fires_on_mqtt_message_after_remove_from_registry( + hass, + device_reg, + mqtt_mock, + tag_mock, +): + """Test tag scanning after removal.""" + config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + # Fake tag scan. + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) + + # Remove the device + device_reg.async_remove_device(device_entry.id) + await hass.async_block_till_done() + tag_mock.reset_mock() + + async_fire_mqtt_message(hass, "foobar/tag_scanned", DEFAULT_TAG_SCAN) + await hass.async_block_till_done() + tag_mock.assert_not_called() + + +async def test_entity_device_info_with_connection(hass, mqtt_mock): + """Test MQTT device registry integration.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps( + { + "topic": "test-topic", + "device": { + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + } + ) + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device(set(), {("mac", "02:5b:26:a8:dc:12")}) + assert device is not None + assert device.connections == {("mac", "02:5b:26:a8:dc:12")} + assert device.manufacturer == "Whatever" + assert device.name == "Beer" + assert device.model == "Glass" + assert device.sw_version == "0.1-beta" + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT device registry integration.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps( + { + "topic": "test-topic", + "device": { + "identifiers": ["helloworld"], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + } + ) + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.identifiers == {("mqtt", "helloworld")} + assert device.manufacturer == "Whatever" + assert device.name == "Beer" + assert device.model == "Glass" + assert device.sw_version == "0.1-beta" + + +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + "topic": "test-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Beer" + + config["device"]["name"] = "Milk" + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Milk" + + +async def test_cleanup_tag(hass, device_reg, entity_reg, mqtt_mock): + """Test tag discovery topic is cleaned when device is removed from registry.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + config = { + "topic": "test-topic", + "device": {"identifiers": ["helloworld"]}, + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) + await hass.async_block_till_done() + + # Verify device registry entry is created + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + device_reg.async_remove_device(device_entry.id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify device registry entry is cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is None + + # Verify retained discovery topic has been cleared + mqtt_mock.async_publish.assert_called_once_with( + "homeassistant/tag/bla/config", "", 0, True + ) + + +async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): + """Test removal from device registry when tag is removed.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + config = { + "topic": "test-topic", + "device": {"identifiers": ["helloworld"]}, + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", data) + await hass.async_block_till_done() + + # Verify device registry entry is created + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + async_fire_mqtt_message(hass, "homeassistant/tag/bla/config", "") + await hass.async_block_till_done() + + # Verify device registry entry is cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is None + + +async def test_cleanup_device_several_tags( + hass, device_reg, entity_reg, mqtt_mock, tag_mock +): + """Test removal from device registry when the last tag is removed.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + config1 = { + "topic": "test-topic1", + "device": {"identifiers": ["helloworld"]}, + } + + config2 = { + "topic": "test-topic2", + "device": {"identifiers": ["helloworld"]}, + } + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config1)) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/tag/bla2/config", json.dumps(config2)) + await hass.async_block_till_done() + + # Verify device registry entry is created + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") + await hass.async_block_till_done() + + # Verify device registry entry is not cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + # Fake tag scan. + async_fire_mqtt_message(hass, "test-topic1", "12345") + async_fire_mqtt_message(hass, "test-topic2", "23456") + await hass.async_block_till_done() + tag_mock.assert_called_once_with(ANY, "23456", device_entry.id) + + async_fire_mqtt_message(hass, "homeassistant/tag/bla2/config", "") + await hass.async_block_till_done() + + # Verify device registry entry is cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is None + + +async def test_cleanup_device_with_entity_and_trigger_1( + hass, device_reg, entity_reg, mqtt_mock +): + """Test removal from device registry for device with tag, entity and trigger. + + Tag removed first, then trigger and entity. + """ + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + config1 = { + "topic": "test-topic", + "device": {"identifiers": ["helloworld"]}, + } + + config2 = { + "automation_type": "trigger", + "topic": "test-topic", + "type": "foo", + "subtype": "bar", + "device": {"identifiers": ["helloworld"]}, + } + + config3 = { + "name": "test_binary_sensor", + "state_topic": "test-topic", + "device": {"identifiers": ["helloworld"]}, + "unique_id": "veryunique", + } + + data1 = json.dumps(config1) + data2 = json.dumps(config2) + data3 = json.dumps(config3) + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla3/config", data3) + await hass.async_block_till_done() + + # Verify device registry entry is created + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") + await hass.async_block_till_done() + + # Verify device registry entry is not cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", "") + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla3/config", "") + await hass.async_block_till_done() + + # Verify device registry entry is cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is None + + +async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mock): + """Test removal from device registry for device with tag, entity and trigger. + + Trigger and entity removed first, then tag. + """ + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + await async_start(hass, "homeassistant", config_entry) + + config1 = { + "topic": "test-topic", + "device": {"identifiers": ["helloworld"]}, + } + + config2 = { + "automation_type": "trigger", + "topic": "test-topic", + "type": "foo", + "subtype": "bar", + "device": {"identifiers": ["helloworld"]}, + } + + config3 = { + "name": "test_binary_sensor", + "state_topic": "test-topic", + "device": {"identifiers": ["helloworld"]}, + "unique_id": "veryunique", + } + + data1 = json.dumps(config1) + data2 = json.dumps(config2) + data3 = json.dumps(config3) + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", data2) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla3/config", data3) + await hass.async_block_till_done() + + # Verify device registry entry is created + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert len(triggers) == 3 # 2 binary_sensor triggers + device trigger + + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla2/config", "") + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla3/config", "") + await hass.async_block_till_done() + + # Verify device registry entry is not cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", "") + await hass.async_block_till_done() + + # Verify device registry entry is cleared + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is None