From 06e9489fc746406e457a9ff1f4fe37cb37ad67ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 6 Oct 2020 14:51:58 +0200 Subject: [PATCH] Add Tasmota integration (#39624) * Add Tasmota integration * Refactor * Add tests, small improvements * isort * Attempt to fix tests failing with Python 3.8 * Revert "Attempt to fix tests failing with Python 3.8" This reverts commit 11454f8a00136f068ea27204183fa3e62f3cd263. * Fix tests failing with Python 3.8 * Cleanup tests * Address review comments * Address review comments * Address review comments * Use MAC address for device identification * Bump hatasmota * Bump hatasmota * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Fix indentation * Remove preparation for device remove WS API * Address review comments * Remove useless try-except * Tweak * Improve tests * Tweak * Address review comments * Correct test * Update manifest.json Co-authored-by: Martin Hjelmare --- CODEOWNERS | 1 + homeassistant/components/mqtt/__init__.py | 30 ++ homeassistant/components/tasmota/__init__.py | 104 +++++ .../components/tasmota/config_flow.py | 56 +++ homeassistant/components/tasmota/const.py | 6 + homeassistant/components/tasmota/discovery.py | 121 ++++++ .../components/tasmota/manifest.json | 9 + homeassistant/components/tasmota/mixins.py | 111 ++++++ homeassistant/components/tasmota/strings.json | 19 + homeassistant/components/tasmota/switch.py | 116 ++++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tasmota/__init__.py | 1 + tests/components/tasmota/conftest.py | 48 +++ tests/components/tasmota/test_common.py | 328 +++++++++++++++ tests/components/tasmota/test_config_flow.py | 61 +++ tests/components/tasmota/test_discovery.py | 372 ++++++++++++++++++ tests/components/tasmota/test_switch.py | 177 +++++++++ tests/conftest.py | 2 + 20 files changed, 1569 insertions(+) create mode 100644 homeassistant/components/tasmota/__init__.py create mode 100644 homeassistant/components/tasmota/config_flow.py create mode 100644 homeassistant/components/tasmota/const.py create mode 100644 homeassistant/components/tasmota/discovery.py create mode 100644 homeassistant/components/tasmota/manifest.json create mode 100644 homeassistant/components/tasmota/mixins.py create mode 100644 homeassistant/components/tasmota/strings.json create mode 100644 homeassistant/components/tasmota/switch.py create mode 100644 tests/components/tasmota/__init__.py create mode 100644 tests/components/tasmota/conftest.py create mode 100644 tests/components/tasmota/test_common.py create mode 100644 tests/components/tasmota/test_config_flow.py create mode 100644 tests/components/tasmota/test_discovery.py create mode 100644 tests/components/tasmota/test_switch.py diff --git a/CODEOWNERS b/CODEOWNERS index f24b9f538f5..a335dc0bb19 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -437,6 +437,7 @@ homeassistant/components/tado/* @michaelarnauts @bdraco homeassistant/components/tag/* @balloob @dmulcahey homeassistant/components/tahoma/* @philklei homeassistant/components/tankerkoenig/* @guillempages +homeassistant/components/tasmota/* @emontnemery homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue @tetienne diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 74a664532df..b42032c6a1c 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1465,3 +1465,33 @@ async def websocket_subscribe(hass, connection, msg): ) connection.send_message(websocket_api.result_message(msg["id"])) + + +@callback +def async_subscribe_connection_status(hass, connection_status_callback): + """Subscribe to MQTT connection changes.""" + + @callback + def connected(): + hass.async_add_job(connection_status_callback, True) + + @callback + def disconnected(): + _LOGGER.error("Calling connection_status_callback, False") + hass.async_add_job(connection_status_callback, False) + + subscriptions = { + "connect": async_dispatcher_connect(hass, MQTT_CONNECTED, connected), + "disconnect": async_dispatcher_connect(hass, MQTT_DISCONNECTED, disconnected), + } + + def unsubscribe(): + subscriptions["connect"]() + subscriptions["disconnect"]() + + return unsubscribe + + +def is_connected(hass): + """Return if MQTT client is connected.""" + return hass.data[DATA_MQTT].connected diff --git a/homeassistant/components/tasmota/__init__.py b/homeassistant/components/tasmota/__init__.py new file mode 100644 index 00000000000..1e11aff448e --- /dev/null +++ b/homeassistant/components/tasmota/__init__.py @@ -0,0 +1,104 @@ +"""The Tasmota integration.""" +import logging + +from hatasmota.const import ( + CONF_MAC, + CONF_MANUFACTURER, + CONF_MODEL, + CONF_NAME, + CONF_SW_VERSION, +) +from hatasmota.discovery import clear_discovery_topic +from hatasmota.mqtt import TasmotaMQTTClient + +from homeassistant.components import mqtt +from homeassistant.components.mqtt.subscription import ( + async_subscribe_topics, + async_unsubscribe_topics, +) +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType + +from . import discovery +from .const import CONF_DISCOVERY_PREFIX +from .discovery import TASMOTA_DISCOVERY_DEVICE + +_LOGGER = logging.getLogger(__name__) + +DEVICE_MACS = "tasmota_devices" + + +async def async_setup(hass: HomeAssistantType, config: dict): + """Set up the Tasmota component.""" + return True + + +async def async_setup_entry(hass, entry): + """Set up Tasmota from a config entry.""" + hass.data[DEVICE_MACS] = {} + + def _publish(*args, **kwds): + mqtt.async_publish(hass, *args, **kwds) + + async def _subscribe_topics(sub_state, topics): + # Optionally mark message handlers as callback + for topic in topics.values(): + if "msg_callback" in topic and "event_loop_safe" in topic: + topic["msg_callback"] = callback(topic["msg_callback"]) + return await async_subscribe_topics(hass, sub_state, topics) + + async def _unsubscribe_topics(sub_state): + return await async_unsubscribe_topics(hass, sub_state) + + tasmota_mqtt = TasmotaMQTTClient(_publish, _subscribe_topics, _unsubscribe_topics) + + discovery_prefix = entry.data[CONF_DISCOVERY_PREFIX] + await discovery.async_start(hass, discovery_prefix, entry, tasmota_mqtt) + + async def async_discover_device(config, mac): + """Discover and add a Tasmota device.""" + await async_setup_device(hass, mac, config, entry, tasmota_mqtt) + + async_dispatcher_connect(hass, TASMOTA_DISCOVERY_DEVICE, async_discover_device) + + return True + + +async def _remove_device(hass, config_entry, mac, tasmota_mqtt): + """Remove device from device registry.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get_device(set(), {(CONNECTION_NETWORK_MAC, mac)}) + + if device is None: + return + + _LOGGER.debug("Removing tasmota device %s", mac) + device_registry.async_remove_device(device.id) + clear_discovery_topic(mac, config_entry.data[CONF_DISCOVERY_PREFIX], tasmota_mqtt) + + +async def _update_device(hass, config_entry, config): + """Add or update device registry.""" + device_registry = await hass.helpers.device_registry.async_get_registry() + config_entry_id = config_entry.entry_id + device_info = { + "connections": {(CONNECTION_NETWORK_MAC, config[CONF_MAC])}, + "manufacturer": config[CONF_MANUFACTURER], + "model": config[CONF_MODEL], + "name": config[CONF_NAME], + "sw_version": config[CONF_SW_VERSION], + "config_entry_id": config_entry_id, + } + _LOGGER.debug("Adding or updating tasmota device %s", config[CONF_MAC]) + device = device_registry.async_get_or_create(**device_info) + hass.data[DEVICE_MACS][device.id] = config[CONF_MAC] + + +async def async_setup_device(hass, mac, config, config_entry, tasmota_mqtt): + """Set up the Tasmota device.""" + if not config: + await _remove_device(hass, config_entry, mac, tasmota_mqtt) + else: + await _update_device(hass, config_entry, config) diff --git a/homeassistant/components/tasmota/config_flow.py b/homeassistant/components/tasmota/config_flow.py new file mode 100644 index 00000000000..fbac4bd7dd2 --- /dev/null +++ b/homeassistant/components/tasmota/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for Tasmota.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.mqtt import valid_subscribe_topic + +from .const import ( # pylint:disable=unused-import + CONF_DISCOVERY_PREFIX, + DEFAULT_PREFIX, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return await self.async_step_config() + + async def async_step_config(self, user_input=None): + """Confirm the setup.""" + errors = {} + data = {CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX} + + if user_input is not None: + bad_prefix = False + if self.show_advanced_options: + prefix = user_input[CONF_DISCOVERY_PREFIX] + try: + valid_subscribe_topic(f"{prefix}/#") + except vol.Invalid: + errors["base"] = "invalid_discovery_topic" + bad_prefix = True + else: + data = user_input + if not bad_prefix: + return self.async_create_entry(title="Tasmota", data=data) + + fields = {} + if self.show_advanced_options: + fields[vol.Optional(CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX)] = str + + return self.async_show_form( + step_id="config", data_schema=vol.Schema(fields), errors=errors + ) diff --git a/homeassistant/components/tasmota/const.py b/homeassistant/components/tasmota/const.py new file mode 100644 index 00000000000..62504b4a86f --- /dev/null +++ b/homeassistant/components/tasmota/const.py @@ -0,0 +1,6 @@ +"""Constants used by multiple Tasmota modules.""" +CONF_DISCOVERY_PREFIX = "discovery_prefix" + +DEFAULT_PREFIX = "tasmota/discovery" + +DOMAIN = "tasmota" diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py new file mode 100644 index 00000000000..bb64aa62396 --- /dev/null +++ b/homeassistant/components/tasmota/discovery.py @@ -0,0 +1,121 @@ +"""Support for MQTT discovery.""" +import asyncio +import logging + +from hatasmota.const import CONF_RELAY +from hatasmota.discovery import ( + TasmotaDiscovery, + get_device_config as tasmota_get_device_config, + get_entities_for_platform as tasmota_get_entities_for_platform, + get_entity as tasmota_get_entity, + has_entities_with_platform as tasmota_has_entities_with_platform, +) + +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SUPPORTED_COMPONENTS = { + "switch": CONF_RELAY, +} + +ALREADY_DISCOVERED = "tasmota_discovered_components" +CONFIG_ENTRY_IS_SETUP = "tasmota_config_entry_is_setup" +DATA_CONFIG_ENTRY_LOCK = "tasmota_config_entry_lock" +TASMOTA_DISCOVERY_DEVICE = "tasmota_discovery_device" +TASMOTA_DISCOVERY_ENTITY_NEW = "tasmota_discovery_entity_new_{}" +TASMOTA_DISCOVERY_ENTITY_UPDATED = "tasmota_discovery_entity_updated_{}_{}_{}" + + +def clear_discovery_hash(hass, discovery_hash): + """Clear entry in ALREADY_DISCOVERED list.""" + del hass.data[ALREADY_DISCOVERED][discovery_hash] + + +def set_discovery_hash(hass, discovery_hash): + """Set entry in ALREADY_DISCOVERED list.""" + hass.data[ALREADY_DISCOVERED][discovery_hash] = {} + + +async def async_start( + hass: HomeAssistantType, discovery_topic, config_entry, tasmota_mqtt +) -> bool: + """Start MQTT Discovery.""" + + async def async_device_discovered(payload, mac): + """Process the received message.""" + + if ALREADY_DISCOVERED not in hass.data: + hass.data[ALREADY_DISCOVERED] = {} + + _LOGGER.debug("Received discovery data for tasmota device: %s", mac) + tasmota_device_config = tasmota_get_device_config(payload) + async_dispatcher_send( + hass, TASMOTA_DISCOVERY_DEVICE, tasmota_device_config, mac + ) + + async with hass.data[DATA_CONFIG_ENTRY_LOCK]: + for component, component_key in SUPPORTED_COMPONENTS.items(): + if not tasmota_has_entities_with_platform(payload, component_key): + continue + config_entries_key = f"{component}.tasmota" + if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: + await hass.config_entries.async_forward_entry_setup( + config_entry, component + ) + hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) + + for component, component_key in SUPPORTED_COMPONENTS.items(): + tasmota_entities = tasmota_get_entities_for_platform(payload, component_key) + for (idx, tasmota_entity_config) in enumerate(tasmota_entities): + discovery_hash = (mac, component, idx) + if not tasmota_entity_config: + # Entity disabled, clean up entity registry + entity_registry = ( + await hass.helpers.entity_registry.async_get_registry() + ) + unique_id = "{}_{}_{}".format(*discovery_hash) + entity_id = entity_registry.async_get_entity_id( + component, DOMAIN, unique_id + ) + if entity_id: + _LOGGER.debug( + "Removing entity: %s %s", component, discovery_hash + ) + entity_registry.async_remove(entity_id) + continue + + if discovery_hash in hass.data[ALREADY_DISCOVERED]: + _LOGGER.debug( + "Entity already added, sending update: %s %s", + component, + discovery_hash, + ) + async_dispatcher_send( + hass, + TASMOTA_DISCOVERY_ENTITY_UPDATED.format(*discovery_hash), + tasmota_entity_config, + ) + else: + _LOGGER.debug("Adding new entity: %s %s", component, discovery_hash) + hass.data[ALREADY_DISCOVERED][discovery_hash] = None + + tasmota_entity = tasmota_get_entity( + tasmota_entity_config, component_key, tasmota_mqtt + ) + + async_dispatcher_send( + hass, + TASMOTA_DISCOVERY_ENTITY_NEW.format(component), + tasmota_entity, + discovery_hash, + ) + + hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() + hass.data[CONFIG_ENTRY_IS_SETUP] = set() + + tasmota_discovery = TasmotaDiscovery(discovery_topic, tasmota_mqtt) + await tasmota_discovery.start_discovery(async_device_discovered) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json new file mode 100644 index 00000000000..5dd3ad1fbb6 --- /dev/null +++ b/homeassistant/components/tasmota/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "tasmota", + "name": "Tasmota (beta)", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tasmota", + "requirements": ["hatasmota==0.0.9"], + "dependencies": ["mqtt"], + "codeowners": ["@emontnemery"] +} diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py new file mode 100644 index 00000000000..c8099cfbb85 --- /dev/null +++ b/homeassistant/components/tasmota/mixins.py @@ -0,0 +1,111 @@ +"""Tasnmota entity mixins.""" +import logging + +from homeassistant.components.mqtt import ( + async_subscribe_connection_status, + is_connected as mqtt_connected, +) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .discovery import ( + TASMOTA_DISCOVERY_ENTITY_UPDATED, + clear_discovery_hash, + set_discovery_hash, +) + +DATA_MQTT = "mqtt" + +_LOGGER = logging.getLogger(__name__) + + +class TasmotaEntity(Entity): + """Base class for Tasmota entities.""" + + def __init__(self, tasmota_entity) -> None: + """Initialize.""" + self._tasmota_entity = tasmota_entity + + +class TasmotaAvailability(TasmotaEntity): + """Mixin used for platforms that report availability.""" + + def __init__(self, **kwds) -> None: + """Initialize the availability mixin.""" + self._available = False + super().__init__(**kwds) + + async def async_added_to_hass(self) -> None: + """Subscribe MQTT events.""" + await super().async_added_to_hass() + self._tasmota_entity.set_on_availability_callback(self.availability_updated) + self.async_on_remove( + async_subscribe_connection_status(self.hass, self.async_mqtt_connected) + ) + + @callback + def availability_updated(self, available: bool) -> None: + """Handle updated availability.""" + self._available = available + self.async_write_ha_state() + + @callback + def async_mqtt_connected(self, _): + """Update state on connection/disconnection to MQTT broker.""" + if not self.hass.is_stopping: + self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return if the device is available.""" + if not mqtt_connected(self.hass) and not self.hass.is_stopping: + return False + return self._available + + +class TasmotaDiscoveryUpdate(TasmotaEntity): + """Mixin used to handle updated discovery message.""" + + def __init__(self, discovery_hash, discovery_update, **kwds) -> None: + """Initialize the discovery update mixin.""" + self._discovery_hash = discovery_hash + self._discovery_update = discovery_update + self._removed_from_hass = False + super().__init__(**kwds) + + async def async_added_to_hass(self) -> None: + """Subscribe to discovery updates.""" + await super().async_added_to_hass() + self._removed_from_hass = False + + async def discovery_callback(config): + """Handle discovery update.""" + _LOGGER.debug( + "Got update for entity with hash: %s '%s'", + self._discovery_hash, + config, + ) + if not self._tasmota_entity.config_same(config): + # Changed payload: Notify component + _LOGGER.debug("Updating component: %s", self.entity_id) + await self._discovery_update(config) + else: + # Unchanged payload: Ignore to avoid changing states + _LOGGER.debug("Ignoring unchanged update for: %s", self.entity_id) + + # Set in case the entity has been removed and is re-added, for example when changing entity_id + set_discovery_hash(self.hass, self._discovery_hash) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + TASMOTA_DISCOVERY_ENTITY_UPDATED.format(*self._discovery_hash), + discovery_callback, + ) + ) + + async def async_will_remove_from_hass(self) -> None: + """Stop listening to signal and cleanup discovery data..""" + if not self._removed_from_hass: + clear_discovery_hash(self.hass, self._discovery_hash) + self._removed_from_hass = True diff --git a/homeassistant/components/tasmota/strings.json b/homeassistant/components/tasmota/strings.json new file mode 100644 index 00000000000..d19bb093263 --- /dev/null +++ b/homeassistant/components/tasmota/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "config": { + "title": "Tasmota", + "description": "Please enter the Tasmota configuration.", + "data": { + "discovery_prefix": "Discovery topic prefix" + } + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "error": { + "invalid_discovery_topic": "Invalid discovery topic prefix." + } + } +} diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py new file mode 100644 index 00000000000..f087a30958c --- /dev/null +++ b/homeassistant/components/tasmota/switch.py @@ -0,0 +1,116 @@ +"""Support for Tasmota switches.""" +import logging + +from homeassistant.components import switch +from homeassistant.components.switch import SwitchEntity +from homeassistant.core import callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DOMAIN as TASMOTA_DOMAIN +from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW +from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Tasmota switch dynamically through discovery.""" + + @callback + def async_discover(tasmota_entity, discovery_hash): + """Discover and add a Tasmota switch.""" + async_add_entities( + [ + TasmotaSwitch( + tasmota_entity=tasmota_entity, discovery_hash=discovery_hash + ) + ] + ) + + async_dispatcher_connect( + hass, + TASMOTA_DISCOVERY_ENTITY_NEW.format(switch.DOMAIN, TASMOTA_DOMAIN), + async_discover, + ) + + +class TasmotaSwitch( + TasmotaAvailability, + TasmotaDiscoveryUpdate, + SwitchEntity, +): + """Representation of a Tasmota switch.""" + + def __init__(self, tasmota_entity, **kwds): + """Initialize the Tasmota switch.""" + self._state = False + self._sub_state = None + + self._unique_id = tasmota_entity.unique_id + + super().__init__( + discovery_update=self.discovery_update, + tasmota_entity=tasmota_entity, + **kwds, + ) + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + await super().async_added_to_hass() + self._tasmota_entity.set_on_state_callback(self.state_updated) + await self._subscribe_topics() + + async def discovery_update(self, update): + """Handle updated discovery message.""" + self._tasmota_entity.config_update(update) + await self._subscribe_topics() + self.async_write_ha_state() + + @callback + def state_updated(self, state): + """Handle new MQTT state messages.""" + self._state = state + self.async_write_ha_state() + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + await self._tasmota_entity.subscribe_topics() + + async def async_will_remove_from_hass(self): + """Unsubscribe when removed.""" + await self._tasmota_entity.unsubscribe_topics() + await super().async_will_remove_from_hass() + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the switch.""" + return self._tasmota_entity.name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def unique_id(self): + """Return a unique ID.""" + return self._tasmota_entity.unique_id + + @property + def device_info(self): + """Return a device description for device registry.""" + return {"connections": {(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)}} + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + self._tasmota_entity.set_state(True) + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + self._tasmota_entity.set_state(False) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index af788cf67dc..c7a29e549f7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -186,6 +186,7 @@ FLOWS = [ "syncthru", "synology_dsm", "tado", + "tasmota", "tellduslive", "tesla", "tibber", diff --git a/requirements_all.txt b/requirements_all.txt index 4b13fa12582..217c6f2c446 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -728,6 +728,9 @@ hass-nabucasa==0.37.0 # homeassistant.components.splunk hass_splunk==0.1.1 +# homeassistant.components.tasmota +hatasmota==0.0.9 + # homeassistant.components.jewish_calendar hdate==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62c501522bb..875cb1004ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -360,6 +360,9 @@ hangups==0.4.11 # homeassistant.components.cloud hass-nabucasa==0.37.0 +# homeassistant.components.tasmota +hatasmota==0.0.9 + # homeassistant.components.jewish_calendar hdate==0.9.5 diff --git a/tests/components/tasmota/__init__.py b/tests/components/tasmota/__init__.py new file mode 100644 index 00000000000..201e9c3df18 --- /dev/null +++ b/tests/components/tasmota/__init__.py @@ -0,0 +1 @@ +"""Tests for Tasmota component.""" diff --git a/tests/components/tasmota/conftest.py b/tests/components/tasmota/conftest.py new file mode 100644 index 00000000000..9a1907ecf3f --- /dev/null +++ b/tests/components/tasmota/conftest.py @@ -0,0 +1,48 @@ +"""Test fixtures for Tasmota component.""" + +import pytest + +from homeassistant import config_entries +from homeassistant.components.tasmota.const import ( + CONF_DISCOVERY_PREFIX, + DEFAULT_PREFIX, + DOMAIN, +) + +from tests.common import MockConfigEntry, 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 setup_tasmota_helper(hass): + """Set up Tasmota.""" + hass.config.components.add("tasmota") + + entry = MockConfigEntry( + connection_class=config_entries.CONN_CLASS_LOCAL_PUSH, + data={CONF_DISCOVERY_PREFIX: DEFAULT_PREFIX}, + domain=DOMAIN, + title="Tasmota", + ) + + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + + assert "tasmota" in hass.config.components + + +@pytest.fixture +async def setup_tasmota(hass): + """Set up Tasmota.""" + await setup_tasmota_helper(hass) diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py new file mode 100644 index 00000000000..fb729b98834 --- /dev/null +++ b/tests/components/tasmota/test_common.py @@ -0,0 +1,328 @@ +"""Common test objects.""" +import copy +import json + +from hatasmota.const import ( + CONF_MAC, + CONF_OFFLINE, + CONF_ONLINE, + CONF_PREFIX, + PREFIX_CMND, + PREFIX_TELE, +) +from hatasmota.utils import ( + get_state_offline, + get_state_online, + get_topic_tele_state, + get_topic_tele_will, +) + +from homeassistant.components.tasmota.const import DEFAULT_PREFIX +from homeassistant.const import STATE_UNAVAILABLE + +from tests.async_mock import ANY +from tests.common import async_fire_mqtt_message + + +async def help_test_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, domain, config +): + """Test availability after MQTT disconnection.""" + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + get_state_online(config), + ) + + state = hass.states.get(f"{domain}.test") + assert state.state != STATE_UNAVAILABLE + + mqtt_mock.connected = False + await hass.async_add_executor_job(mqtt_client_mock.on_disconnect, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + mqtt_mock.connected = True + await hass.async_add_executor_job(mqtt_client_mock.on_connect, None, None, None, 0) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + state = hass.states.get(f"{domain}.test") + assert state.state != STATE_UNAVAILABLE + + +async def help_test_availability( + hass, + mqtt_mock, + domain, + config, +): + """Test availability. + + This is a test helper for the TasmotaAvailability mixin. + """ + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + get_state_online(config), + ) + + state = hass.states.get(f"{domain}.test") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message( + hass, + get_topic_tele_will(config), + get_state_offline(config), + ) + + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + +async def help_test_availability_discovery_update( + hass, + mqtt_mock, + domain, + config, +): + """Test update of discovered TasmotaAvailability. + + This is a test helper for the TasmotaAvailability mixin. + """ + # customize availability topic + config1 = copy.deepcopy(config) + config1[CONF_PREFIX][PREFIX_TELE] = "tele1" + config1[CONF_OFFLINE] = "offline1" + config1[CONF_ONLINE] = "online1" + config2 = copy.deepcopy(config) + config2[CONF_PREFIX][PREFIX_TELE] = "tele2" + config2[CONF_OFFLINE] = "offline2" + config2[CONF_ONLINE] = "online2" + data1 = json.dumps(config1) + data2 = json.dumps(config2) + + availability_topic1 = get_topic_tele_will(config1) + availability_topic2 = get_topic_tele_will(config2) + assert availability_topic1 != availability_topic2 + offline1 = get_state_offline(config1) + offline2 = get_state_offline(config2) + assert offline1 != offline2 + online1 = get_state_online(config1) + online2 = get_state_online(config2) + assert online1 != online2 + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config1[CONF_MAC]}/config", data1) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, availability_topic1, online1) + state = hass.states.get(f"{domain}.test") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, availability_topic1, offline1) + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + # Change availability settings + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config2[CONF_MAC]}/config", data2) + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic or payload + async_fire_mqtt_message(hass, availability_topic1, online1) + async_fire_mqtt_message(hass, availability_topic1, online2) + async_fire_mqtt_message(hass, availability_topic2, online1) + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, availability_topic2, online2) + state = hass.states.get(f"{domain}.test") + assert state.state != STATE_UNAVAILABLE + + +async def help_test_discovery_removal( + hass, mqtt_mock, caplog, domain, config1, config2 +): + """Test removal of discovered entity.""" + device_reg = await hass.helpers.device_registry.async_get_registry() + entity_reg = await hass.helpers.entity_registry.async_get_registry() + + data1 = json.dumps(config1) + data2 = json.dumps(config2) + assert config1[CONF_MAC] == config2[CONF_MAC] + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config1[CONF_MAC]}/config", data1) + await hass.async_block_till_done() + + # Verify device and entity registry entries are created + device_entry = device_reg.async_get_device(set(), {("mac", config1[CONF_MAC])}) + assert device_entry is not None + entity_entry = entity_reg.async_get(f"{domain}.test") + assert entity_entry is not None + + # Verify state is added + state = hass.states.get(f"{domain}.test") + assert state is not None + assert state.name == "Test" + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config2[CONF_MAC]}/config", data2) + await hass.async_block_till_done() + + # Verify entity registry entries are cleared + device_entry = device_reg.async_get_device(set(), {("mac", config2[CONF_MAC])}) + assert device_entry is not None + entity_entry = entity_reg.async_get(f"{domain}.test") + assert entity_entry is None + + # Verify state is removed + state = hass.states.get(f"{domain}.test") + assert state is None + + +async def help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, domain, config, discovery_update +): + """Test update of discovered component without changes. + + This is a test helper for the MqttDiscoveryUpdate mixin. + """ + config1 = copy.deepcopy(config) + config2 = copy.deepcopy(config) + config2[CONF_PREFIX][PREFIX_CMND] = "cmnd2" + data1 = json.dumps(config1) + data2 = json.dumps(config2) + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data1) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.test") + assert state is not None + assert state.name == "Test" + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data1) + await hass.async_block_till_done() + + assert not discovery_update.called + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data2) + await hass.async_block_till_done() + + assert discovery_update.called + + +async def help_test_discovery_device_remove(hass, mqtt_mock, domain, config): + """Test domain entity is removed when device is removed.""" + device_reg = await hass.helpers.device_registry.async_get_registry() + entity_reg = await hass.helpers.entity_registry.async_get_registry() + + config = copy.deepcopy(config) + unique_id = f"{config[CONF_MAC]}_{domain}_0" + + data = json.dumps(config) + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data) + await hass.async_block_till_done() + + device = device_reg.async_get_device(set(), {("mac", config[CONF_MAC])}) + assert device is not None + assert entity_reg.async_get_entity_id(domain, "tasmota", unique_id) + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", "") + await hass.async_block_till_done() + + device = device_reg.async_get_device(set(), {("mac", config[CONF_MAC])}) + assert device is None + assert not entity_reg.async_get_entity_id(domain, "tasmota", unique_id) + + +async def help_test_entity_id_update_subscriptions( + hass, mqtt_mock, domain, config, topics=None +): + """Test MQTT subscriptions are managed when entity_id is updated.""" + entity_reg = await hass.helpers.entity_registry.async_get_registry() + + config = copy.deepcopy(config) + data = json.dumps(config) + + mqtt_mock.async_subscribe.reset_mock() + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data) + await hass.async_block_till_done() + + topics = [get_topic_tele_state(config), get_topic_tele_will(config)] + assert len(topics) > 0 + + state = hass.states.get(f"{domain}.test") + assert state is not None + assert mqtt_mock.async_subscribe.call_count == len(topics) + for topic in topics: + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + mqtt_mock.async_subscribe.reset_mock() + + entity_reg.async_update_entity(f"{domain}.test", new_entity_id=f"{domain}.milk") + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.test") + assert state is None + + state = hass.states.get(f"{domain}.milk") + assert state is not None + for topic in topics: + mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + + +async def help_test_entity_id_update_discovery_update(hass, mqtt_mock, domain, config): + """Test MQTT discovery update after entity_id is updated.""" + entity_reg = await hass.helpers.entity_registry.async_get_registry() + + config = copy.deepcopy(config) + data = json.dumps(config) + + topic = get_topic_tele_will(config) + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, get_state_online(config)) + state = hass.states.get(f"{domain}.test") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, topic, get_state_offline(config)) + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + entity_reg.async_update_entity(f"{domain}.test", new_entity_id=f"{domain}.milk") + await hass.async_block_till_done() + + assert config[CONF_PREFIX][PREFIX_TELE] != "tele2" + config[CONF_PREFIX][PREFIX_TELE] = "tele2" + data = json.dumps(config) + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(domain)) == 1 + + topic = get_topic_tele_will(config) + async_fire_mqtt_message(hass, topic, get_state_online(config)) + state = hass.states.get(f"{domain}.milk") + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/tasmota/test_config_flow.py b/tests/components/tasmota/test_config_flow.py new file mode 100644 index 00000000000..ee66b486a5f --- /dev/null +++ b/tests/components/tasmota/test_config_flow.py @@ -0,0 +1,61 @@ +"""Test config flow.""" + +from tests.common import MockConfigEntry + + +async def test_user_setup(hass, mqtt_mock): + """Test we can finish a config flow.""" + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": "user"} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] == "create_entry" + assert result["result"].data == { + "discovery_prefix": "tasmota/discovery", + } + + +async def test_user_setup_advanced(hass, mqtt_mock): + """Test we can finish a config flow.""" + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": "user", "show_advanced_options": True} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"discovery_prefix": "test_tasmota/discovery"} + ) + + assert result["type"] == "create_entry" + assert result["result"].data == { + "discovery_prefix": "test_tasmota/discovery", + } + + +async def test_user_setup_invalid_topic_prefix(hass, mqtt_mock): + """Test if connection cannot be made.""" + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": "user", "show_advanced_options": True} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"discovery_prefix": "tasmota/config/#"} + ) + + assert result["type"] == "form" + assert result["errors"]["base"] == "invalid_discovery_topic" + + +async def test_user_single_instance(hass, mqtt_mock): + """Test we only allow a single config flow.""" + MockConfigEntry(domain="tasmota").add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + "tasmota", context={"source": "user"} + ) + assert result["type"] == "abort" + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py new file mode 100644 index 00000000000..6fdbe0a15da --- /dev/null +++ b/tests/components/tasmota/test_discovery.py @@ -0,0 +1,372 @@ +"""The tests for the MQTT discovery.""" +import copy +import json + +from hatasmota.const import CONF_ONLINE +import pytest + +from homeassistant.components.tasmota.const import DEFAULT_PREFIX +from homeassistant.components.tasmota.discovery import ALREADY_DISCOVERED + +from tests.async_mock import patch +from tests.common import async_fire_mqtt_message +from tests.components.tasmota.conftest import setup_tasmota_helper + +DEFAULT_CONFIG = { + "dn": "My Device", + "fn": ["Beer", "Milk", "Three", "Four", "Five"], + "hn": "tasmota_49A3BC", + "mac": "00000049A3BC", + "md": "Sonoff 123", + "ofl": "offline", + CONF_ONLINE: "online", + "state": ["OFF", "ON", "TOGGLE", "HOLD"], + "sw": "2.3.3.4", + "t": "tasmota_49A3BC", + "t_f": "%topic%/%prefix%/", + "t_p": ["cmnd", "stat", "tele"], + "li": [0, 0, 0, 0, 0, 0, 0, 0], + "rl": [0, 0, 0, 0, 0, 0, 0, 0], + "se": [], + "ver": 1, +} + + +async def test_subscribing_config_topic(hass, mqtt_mock, setup_tasmota): + """Test setting up discovery.""" + discovery_topic = DEFAULT_PREFIX + + assert mqtt_mock.async_subscribe.called + call_args = mqtt_mock.async_subscribe.mock_calls[0][1] + assert call_args[0] == discovery_topic + "/#" + assert call_args[2] == 0 + + +async def test_valid_discovery_message(hass, mqtt_mock, caplog): + """Test discovery callback called.""" + config = copy.deepcopy(DEFAULT_CONFIG) + + with patch( + "homeassistant.components.tasmota.discovery.tasmota_has_entities_with_platform" + ) as mock_tasmota_has_entities: + await setup_tasmota_helper(hass) + + async_fire_mqtt_message( + hass, f"{DEFAULT_PREFIX}/00000049A3BC/config", json.dumps(config) + ) + await hass.async_block_till_done() + assert mock_tasmota_has_entities.called + + +async def test_invalid_topic(hass, mqtt_mock): + """Test receiving discovery message on wrong topic.""" + with patch( + "homeassistant.components.tasmota.discovery.tasmota_has_entities_with_platform" + ) as mock_tasmota_has_entities: + await setup_tasmota_helper(hass) + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/123456/configuration", "{}") + await hass.async_block_till_done() + assert not mock_tasmota_has_entities.called + + +async def test_invalid_message(hass, mqtt_mock, caplog): + """Test receiving an invalid message.""" + with patch( + "homeassistant.components.tasmota.discovery.tasmota_has_entities_with_platform" + ) as mock_tasmota_has_entities: + await setup_tasmota_helper(hass) + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/123456/config", "asd") + await hass.async_block_till_done() + assert "Invalid discovery message" in caplog.text + assert not mock_tasmota_has_entities.called + + +async def test_invalid_mac(hass, mqtt_mock, caplog): + """Test topic is not matching device MAC.""" + config = copy.deepcopy(DEFAULT_CONFIG) + + with patch( + "homeassistant.components.tasmota.discovery.tasmota_has_entities_with_platform" + ) as mock_tasmota_has_entities: + await setup_tasmota_helper(hass) + + async_fire_mqtt_message( + hass, f"{DEFAULT_PREFIX}/00000049A3BA/config", json.dumps(config) + ) + await hass.async_block_till_done() + assert "MAC mismatch" in caplog.text + assert not mock_tasmota_has_entities.called + + +async def test_correct_config_discovery( + hass, mqtt_mock, caplog, device_reg, entity_reg, setup_tasmota +): + """Test receiving valid discovery message.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + # Verify device and registry entries are created + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + entity_entry = entity_reg.async_get("switch.beer") + assert entity_entry is not None + + state = hass.states.get("switch.beer") + assert state is not None + assert state.name == "Beer" + + assert (mac, "switch", 0) in hass.data[ALREADY_DISCOVERED] + + +async def test_device_discover( + hass, mqtt_mock, caplog, device_reg, entity_reg, setup_tasmota +): + """Test setting up a device.""" + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + # Verify device and registry entries are created + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + assert device_entry.manufacturer == "Tasmota" + assert device_entry.model == config["md"] + assert device_entry.name == config["dn"] + assert device_entry.sw_version == config["sw"] + + +async def test_device_update( + hass, mqtt_mock, caplog, device_reg, entity_reg, setup_tasmota +): + """Test updating a device.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["md"] = "Model 1" + config["dn"] = "Name 1" + config["sw"] = "v1.2.3.4" + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + # Verify device entry is created + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + + # Update device parameters + config["md"] = "Another model" + config["dn"] = "Another name" + config["sw"] = "v6.6.6" + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + # Verify device entry is updated + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + assert device_entry.model == "Another model" + assert device_entry.name == "Another name" + assert device_entry.sw_version == "v6.6.6" + + +@pytest.mark.no_fail_on_log_exception +async def test_discovery_broken(hass, mqtt_mock, caplog, device_reg, setup_tasmota): + """Test handling of exception when creating discovered device.""" + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + data = json.dumps(config) + + # Trigger an exception when the entity is added + with patch( + "hatasmota.discovery.get_device_config_helper", + return_value=object(), + ): + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", data) + await hass.async_block_till_done() + + # Verify device entry is not created + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is None + assert ( + "Exception in async_discover_device when dispatching 'tasmota_discovery_device'" + in caplog.text + ) + + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", data) + await hass.async_block_till_done() + + # Verify device entry is created + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + + +async def test_device_remove( + hass, mqtt_mock, caplog, device_reg, entity_reg, setup_tasmota +): + """Test removing a discovered device.""" + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + # Verify device entry is created + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + "", + ) + await hass.async_block_till_done() + + # Verify device entry is removed + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is None + + +async def test_device_remove_stale(hass, mqtt_mock, caplog, device_reg, setup_tasmota): + """Test removing a stale (undiscovered) device does not throw.""" + mac = "00000049A3BC" + + config_entry = hass.config_entries.async_entries("tasmota")[0] + + # Create a device + device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={("mac", mac)}, + ) + + # Verify device entry was created + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + + # Remove the device + device_reg.async_remove_device(device_entry.id) + + # Verify device entry is removed + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is None + + +async def test_device_rediscover( + hass, mqtt_mock, caplog, device_reg, entity_reg, setup_tasmota +): + """Test removing a device.""" + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + # Verify device entry is created + device_entry1 = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry1 is not None + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + "", + ) + await hass.async_block_till_done() + + # Verify device entry is removed + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is None + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + # Verify device entry is created, and id is reused + device_entry = device_reg.async_get_device(set(), {("mac", mac)}) + assert device_entry is not None + assert device_entry1.id == device_entry.id + + +async def test_entity_duplicate_discovery(hass, mqtt_mock, caplog, setup_tasmota): + """Test entities are not duplicated.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.beer") + state_duplicate = hass.states.get("binary_sensor.beer1") + + assert state is not None + assert state.name == "Beer" + assert state_duplicate is None + assert ( + f"Entity already added, sending update: switch ('{mac}', 'switch', 0)" + in caplog.text + ) + + +async def test_entity_duplicate_removal(hass, mqtt_mock, caplog, setup_tasmota): + """Test removing entity twice.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + config["rl"][0] = 0 + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", json.dumps(config)) + await hass.async_block_till_done() + assert f"Removing entity: switch ('{mac}', 'switch', 0)" in caplog.text + + caplog.clear() + async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{mac}/config", json.dumps(config)) + await hass.async_block_till_done() + assert f"Removing entity: switch ('{mac}', 'switch', 0)" not in caplog.text diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py new file mode 100644 index 00000000000..7eff464d837 --- /dev/null +++ b/tests/components/tasmota/test_switch.py @@ -0,0 +1,177 @@ +"""The tests for the MQTT switch platform.""" +import copy +import json + +from hatasmota.const import CONF_ONLINE + +from homeassistant.components import switch +from homeassistant.components.tasmota.const import DEFAULT_PREFIX +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON + +from .test_common import ( + help_test_availability, + help_test_availability_discovery_update, + help_test_availability_when_connection_lost, + help_test_discovery_device_remove, + help_test_discovery_removal, + help_test_discovery_update_unchanged, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, +) + +from tests.async_mock import patch +from tests.common import async_fire_mqtt_message +from tests.components.switch import common + +DEFAULT_CONFIG = { + "dn": "My Device", + "fn": ["Test", "Beer", "Milk", "Four", "Five"], + "hn": "tasmota_49A3BC", + "mac": "00000049A3BC", + "md": "Sonoff 123", + "ofl": "offline", + CONF_ONLINE: "online", + "state": ["OFF", "ON", "TOGGLE", "HOLD"], + "sw": "2.3.3.4", + "t": "tasmota_49A3BC", + "t_f": "%topic%/%prefix%/", + "t_p": ["cmnd", "stat", "tele"], + "li": [0, 0, 0, 0, 0, 0, 0, 0], + "rl": [1, 0, 0, 0, 0, 0, 0, 0], + "se": [], + "ver": 1, +} + + +async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): + """Test state update via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.test") + assert state.state == "unavailable" + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "online") + state = hass.states.get("switch.test") + assert state.state == STATE_OFF + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') + + state = hass.states.get("switch.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') + + state = hass.states.get("switch.test") + assert state.state == STATE_OFF + + +async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): + """Test the sending MQTT commands.""" + config = copy.deepcopy(DEFAULT_CONFIG) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "online") + state = hass.states.get("switch.test") + assert state.state == STATE_OFF + + # Turn the switch on and verify MQTT message is sent + await common.async_turn_on(hass, "switch.test") + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/POWER1", "ON", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Tasmota is not optimistic, the state should still be off + state = hass.states.get("switch.test") + assert state.state == STATE_OFF + + # Turn the switch off and verify MQTT message is sent + await common.async_turn_off(hass, "switch.test") + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/POWER1", "OFF", 0, False + ) + + state = hass.states.get("switch.test") + assert state.state == STATE_OFF + + +async def test_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, setup_tasmota +): + """Test availability after MQTT disconnection.""" + await help_test_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_availability(hass, mqtt_mock, setup_tasmota): + """Test availability.""" + await help_test_availability(hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG) + + +async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): + """Test availability discovery update.""" + await help_test_availability_discovery_update( + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_discovery_removal_switch(hass, mqtt_mock, caplog, setup_tasmota): + """Test removal of discovered switch.""" + config1 = copy.deepcopy(DEFAULT_CONFIG) + config2 = copy.deepcopy(DEFAULT_CONFIG) + config2["rl"][0] = 0 + + await help_test_discovery_removal( + hass, mqtt_mock, caplog, switch.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_switch( + hass, mqtt_mock, caplog, setup_tasmota +): + """Test update of discovered switch.""" + with patch( + "homeassistant.components.tasmota.switch.TasmotaSwitch.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG, discovery_update + ) + + +async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): + """Test device registry remove.""" + await help_test_discovery_device_remove( + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): + """Test MQTT subscriptions are managed when entity_id is updated.""" + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/conftest.py b/tests/conftest.py index d6da979bb5e..25572a2269b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -357,10 +357,12 @@ def mqtt_client_mock(hass): return FakeInfo(mid) def _subscribe(topic, qos=0): + mid = get_mid() mock_client.on_subscribe(0, 0, mid) return (0, mid) def _unsubscribe(topic): + mid = get_mid() mock_client.on_unsubscribe(0, 0, mid) return (0, mid)