diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index bb64aa62396..e6bde93bee8 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -2,13 +2,13 @@ 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, + unique_id_from_hash, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -18,16 +18,16 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -SUPPORTED_COMPONENTS = { - "switch": CONF_RELAY, -} +SUPPORTED_PLATFORMS = [ + "switch", +] 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_{}_{}_{}" +TASMOTA_DISCOVERY_ENTITY_UPDATED = "tasmota_discovery_entity_updated_{}_{}_{}_{}" def clear_discovery_hash(hass, discovery_hash): @@ -45,6 +45,52 @@ async def async_start( ) -> bool: """Start MQTT Discovery.""" + async def _load_platform(platform): + """Load a Tasmota platform if not already done.""" + async with hass.data[DATA_CONFIG_ENTRY_LOCK]: + config_entries_key = f"{platform}.tasmota" + if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: + await hass.config_entries.async_forward_entry_setup( + config_entry, platform + ) + hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) + + async def _discover_entity(tasmota_entity_config, discovery_hash, platform): + """Handle adding or updating a discovered entity.""" + if not tasmota_entity_config: + # Entity disabled, clean up entity registry + entity_registry = await hass.helpers.entity_registry.async_get_registry() + unique_id = unique_id_from_hash(discovery_hash) + entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + if entity_id: + _LOGGER.debug("Removing entity: %s %s", platform, discovery_hash) + entity_registry.async_remove(entity_id) + return + + if discovery_hash in hass.data[ALREADY_DISCOVERED]: + _LOGGER.debug( + "Entity already added, sending update: %s %s", + platform, + discovery_hash, + ) + async_dispatcher_send( + hass, + TASMOTA_DISCOVERY_ENTITY_UPDATED.format(*discovery_hash), + tasmota_entity_config, + ) + else: + _LOGGER.debug("Adding new entity: %s %s", platform, discovery_hash) + tasmota_entity = tasmota_get_entity(tasmota_entity_config, tasmota_mqtt) + + hass.data[ALREADY_DISCOVERED][discovery_hash] = None + + async_dispatcher_send( + hass, + TASMOTA_DISCOVERY_ENTITY_NEW.format(platform), + tasmota_entity, + discovery_hash, + ) + async def async_device_discovered(payload, mac): """Process the received message.""" @@ -57,65 +103,21 @@ async def async_start( 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) + if not payload: + return - 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 + for platform in SUPPORTED_PLATFORMS: + if not tasmota_has_entities_with_platform(payload, platform): + continue + await _load_platform(platform) - 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, - ) + for platform in SUPPORTED_PLATFORMS: + tasmota_entities = tasmota_get_entities_for_platform(payload, platform) + for (tasmota_entity_config, discovery_hash) in tasmota_entities: + await _discover_entity(tasmota_entity_config, discovery_hash, platform) 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) + await tasmota_discovery.start_discovery(async_device_discovered, None) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 5dd3ad1fbb6..5540988edcc 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -3,7 +3,7 @@ "name": "Tasmota (beta)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tasmota", - "requirements": ["hatasmota==0.0.9"], + "requirements": ["hatasmota==0.0.10"], "dependencies": ["mqtt"], "codeowners": ["@emontnemery"] } diff --git a/requirements_all.txt b/requirements_all.txt index 217c6f2c446..0532e7be406 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -729,7 +729,7 @@ hass-nabucasa==0.37.0 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.0.9 +hatasmota==0.0.10 # homeassistant.components.jewish_calendar hdate==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 875cb1004ca..31ff656e261 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,7 +361,7 @@ hangups==0.4.11 hass-nabucasa==0.37.0 # homeassistant.components.tasmota -hatasmota==0.0.9 +hatasmota==0.0.10 # homeassistant.components.jewish_calendar hdate==0.9.5 diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index fb729b98834..73a09a4844d 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -11,8 +11,8 @@ from hatasmota.const import ( PREFIX_TELE, ) from hatasmota.utils import ( - get_state_offline, - get_state_online, + config_get_state_offline, + config_get_state_online, get_topic_tele_state, get_topic_tele_will, ) @@ -23,6 +23,38 @@ from homeassistant.const import STATE_UNAVAILABLE from tests.async_mock import ANY from tests.common import async_fire_mqtt_message +DEFAULT_CONFIG = { + "ip": "192.168.15.10", + "dn": "Tasmota", + "fn": ["Test", "Beer", "Milk", "Four", None], + "hn": "tasmota_49A3BC-0956", + "lk": 1, # RGB + white channels linked to a single light + "mac": "00000049A3BC", + "md": "Sonoff Basic", + "ofln": "Offline", + "onln": "Online", + "state": ["OFF", "ON", "TOGGLE", "HOLD"], + "sw": "8.4.0.2", + "t": "tasmota_49A3BC", + "ft": "%topic%/%prefix%/", + "tp": ["cmnd", "stat", "tele"], + "rl": [0, 0, 0, 0, 0, 0, 0, 0], + "swc": [-1, -1, -1, -1, -1, -1, -1, -1], + "btn": [0, 0, 0, 0], + "so": { + "11": 0, # Swap button single and double press functionality + "13": 0, # Allow immediate action on single button press + "17": 1, # Show Color string as hex or comma-separated + "20": 0, # Update of Dimmer/Color/CT without turning power on + "30": 0, # Enforce Home Assistant auto-discovery as light + "68": 0, # Multi-channel PWM instead of a single light + "73": 0, # Enable Buttons decoupling and send multi-press and hold MQTT messages + "80": 0, # Blinds and shutters support + }, + "lt_st": 0, + "ver": 1, +} + async def help_test_availability_when_connection_lost( hass, mqtt_client_mock, mqtt_mock, domain, config @@ -37,7 +69,7 @@ async def help_test_availability_when_connection_lost( async_fire_mqtt_message( hass, get_topic_tele_will(config), - get_state_online(config), + config_get_state_online(config), ) state = hass.states.get(f"{domain}.test") @@ -83,7 +115,7 @@ async def help_test_availability( async_fire_mqtt_message( hass, get_topic_tele_will(config), - get_state_online(config), + config_get_state_online(config), ) state = hass.states.get(f"{domain}.test") @@ -92,7 +124,7 @@ async def help_test_availability( async_fire_mqtt_message( hass, get_topic_tele_will(config), - get_state_offline(config), + config_get_state_offline(config), ) state = hass.states.get(f"{domain}.test") @@ -124,11 +156,11 @@ async def help_test_availability_discovery_update( 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) + offline1 = config_get_state_offline(config1) + offline2 = config_get_state_offline(config2) assert offline1 != offline2 - online1 = get_state_online(config1) - online2 = get_state_online(config2) + online1 = config_get_state_online(config1) + online2 = config_get_state_online(config2) assert online1 != online2 async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config1[CONF_MAC]}/config", data1) @@ -232,13 +264,12 @@ async def help_test_discovery_update_unchanged( assert discovery_update.called -async def help_test_discovery_device_remove(hass, mqtt_mock, domain, config): +async def help_test_discovery_device_remove(hass, mqtt_mock, domain, unique_id, 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) @@ -304,16 +335,17 @@ async def help_test_entity_id_update_discovery_update(hass, mqtt_mock, domain, c 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)) + async_fire_mqtt_message(hass, topic, config_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)) + async_fire_mqtt_message(hass, topic, config_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 hass.states.get(f"{domain}.milk") assert config[CONF_PREFIX][PREFIX_TELE] != "tele2" config[CONF_PREFIX][PREFIX_TELE] = "tele2" @@ -323,6 +355,6 @@ async def help_test_entity_id_update_discovery_update(hass, mqtt_mock, domain, c 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)) + async_fire_mqtt_message(hass, topic, config_get_state_online(config)) state = hass.states.get(f"{domain}.milk") assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 6fdbe0a15da..9af22636c53 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -2,34 +2,16 @@ 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 .conftest import setup_tasmota_helper +from .test_common import DEFAULT_CONFIG + 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): @@ -118,14 +100,14 @@ async def test_correct_config_discovery( # 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") + entity_entry = entity_reg.async_get("switch.test") assert entity_entry is not None - state = hass.states.get("switch.beer") + state = hass.states.get("switch.test") assert state is not None - assert state.name == "Beer" + assert state.name == "Test" - assert (mac, "switch", 0) in hass.data[ALREADY_DISCOVERED] + assert (mac, "switch", "relay", 0) in hass.data[ALREADY_DISCOVERED] async def test_device_discover( @@ -337,14 +319,14 @@ async def test_entity_duplicate_discovery(hass, mqtt_mock, caplog, setup_tasmota ) await hass.async_block_till_done() - state = hass.states.get("switch.beer") + state = hass.states.get("switch.test") state_duplicate = hass.states.get("binary_sensor.beer1") assert state is not None - assert state.name == "Beer" + assert state.name == "Test" assert state_duplicate is None assert ( - f"Entity already added, sending update: switch ('{mac}', 'switch', 0)" + f"Entity already added, sending update: switch ('{mac}', 'switch', 'relay', 0)" in caplog.text ) @@ -364,9 +346,9 @@ async def test_entity_duplicate_removal(hass, mqtt_mock, caplog, setup_tasmota): 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 + assert f"Removing entity: switch ('{mac}', 'switch', 'relay', 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 + assert "Removing entity: switch" not in caplog.text diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index 7eff464d837..9d5eb0c5985 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -1,14 +1,13 @@ -"""The tests for the MQTT switch platform.""" +"""The tests for the Tasmota 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 ( + DEFAULT_CONFIG, help_test_availability, help_test_availability_discovery_update, help_test_availability_when_connection_lost, @@ -23,29 +22,11 @@ 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) + config["rl"][0] = 1 mac = config["mac"] async_fire_mqtt_message( @@ -59,7 +40,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert state.state == "unavailable" assert not state.attributes.get(ATTR_ASSUMED_STATE) - async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "online") + 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) @@ -78,6 +59,7 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): """Test the sending MQTT commands.""" config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 mac = config["mac"] async_fire_mqtt_message( @@ -87,9 +69,12 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): ) await hass.async_block_till_done() - async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "online") + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") state = hass.states.get("switch.test") assert state.state == STATE_OFF + await hass.async_block_till_done() + await hass.async_block_till_done() + mqtt_mock.async_publish.reset_mock() # Turn the switch on and verify MQTT message is sent await common.async_turn_on(hass, "switch.test") @@ -116,26 +101,33 @@ async def test_availability_when_connection_lost( hass, mqtt_client_mock, mqtt_mock, setup_tasmota ): """Test availability after MQTT disconnection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 await help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_client_mock, mqtt_mock, switch.DOMAIN, config ) async def test_availability(hass, mqtt_mock, setup_tasmota): """Test availability.""" - await help_test_availability(hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG) + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + await help_test_availability(hass, mqtt_mock, switch.DOMAIN, config) async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): """Test availability discovery update.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 await help_test_availability_discovery_update( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock, switch.DOMAIN, config ) async def test_discovery_removal_switch(hass, mqtt_mock, caplog, setup_tasmota): """Test removal of discovered switch.""" config1 = copy.deepcopy(DEFAULT_CONFIG) + config1["rl"][0] = 1 config2 = copy.deepcopy(DEFAULT_CONFIG) config2["rl"][0] = 0 @@ -148,30 +140,39 @@ async def test_discovery_update_unchanged_switch( hass, mqtt_mock, caplog, setup_tasmota ): """Test update of discovered switch.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 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 + hass, mqtt_mock, caplog, switch.DOMAIN, config, discovery_update ) async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): """Test device registry remove.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 + unique_id = f"{DEFAULT_CONFIG['mac']}_switch_relay_0" await help_test_discovery_device_remove( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock, switch.DOMAIN, unique_id, config ) async def test_entity_id_update_subscriptions(hass, mqtt_mock, setup_tasmota): """Test MQTT subscriptions are managed when entity_id is updated.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock, switch.DOMAIN, config ) async def test_entity_id_update_discovery_update(hass, mqtt_mock, setup_tasmota): """Test MQTT discovery update when entity_id is updated.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"][0] = 1 await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock, switch.DOMAIN, config )