From 84cb00bb4e6b6ffc67a0e7c55dda96d36076a59e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 12 Oct 2020 07:27:06 +0200 Subject: [PATCH] Add Tasmota sensor (#41483) * Add Tasmota sensor * Remove useless try-except * Bump hatasmota to 0.0.11 * Apply suggestions from code review Co-authored-by: Paulus Schoutsen * Sort dict constants Co-authored-by: Paulus Schoutsen --- homeassistant/components/tasmota/discovery.py | 34 ++- .../components/tasmota/manifest.json | 2 +- homeassistant/components/tasmota/sensor.py | 161 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_common.py | 185 ++++++++++--- tests/components/tasmota/test_sensor.py | 254 ++++++++++++++++++ 7 files changed, 604 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/tasmota/sensor.py create mode 100644 tests/components/tasmota/test_sensor.py diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index 37329dcf6aa..c8b00910b34 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -11,7 +11,9 @@ from hatasmota.discovery import ( unique_id_from_hash, ) +import homeassistant.components.sensor as sensor from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity_registry import async_entries_for_device from homeassistant.helpers.typing import HomeAssistantType from .const import DOMAIN @@ -117,8 +119,38 @@ async def async_start( for (tasmota_entity_config, discovery_hash) in tasmota_entities: await _discover_entity(tasmota_entity_config, discovery_hash, platform) + async def async_sensors_discovered(sensors, mac): + """Handle discovery of (additional) sensors.""" + platform = sensor.DOMAIN + await _load_platform(platform) + + device_registry = await hass.helpers.device_registry.async_get_registry() + entity_registry = await hass.helpers.entity_registry.async_get_registry() + device = device_registry.async_get_device(set(), {("mac", mac)}) + + if device is None: + _LOGGER.warning("Got sensors for unknown device mac: %s", mac) + return + + orphaned_entities = { + entry.unique_id + for entry in async_entries_for_device(entity_registry, device.id) + if entry.domain == sensor.DOMAIN and entry.platform == DOMAIN + } + for (tasmota_sensor_config, discovery_hash) in sensors: + if tasmota_sensor_config: + orphaned_entities.discard(tasmota_sensor_config.unique_id) + await _discover_entity(tasmota_sensor_config, discovery_hash, platform) + for unique_id in orphaned_entities: + entity_id = entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) + if entity_id: + _LOGGER.debug("Removing entity: %s %s", platform, entity_id) + entity_registry.async_remove(entity_id) + 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, None) + await tasmota_discovery.start_discovery( + async_device_discovered, async_sensors_discovered + ) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 58c40209da5..20e21003019 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.10"], + "requirements": ["hatasmota==0.0.11"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"] diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py new file mode 100644 index 00000000000..5dd161ab18d --- /dev/null +++ b/homeassistant/components/tasmota/sensor.py @@ -0,0 +1,161 @@ +"""Support for Tasmota sensors.""" +import logging +from typing import Optional + +from hatasmota.const import ( + SENSOR_AMBIENT, + SENSOR_APPARENT_POWERUSAGE, + SENSOR_BATTERY, + SENSOR_CCT, + SENSOR_CO2, + SENSOR_COLOR_BLUE, + SENSOR_COLOR_GREEN, + SENSOR_COLOR_RED, + SENSOR_CURRENT, + SENSOR_DEWPOINT, + SENSOR_DISTANCE, + SENSOR_ECO2, + SENSOR_FREQUENCY, + SENSOR_HUMIDITY, + SENSOR_ILLUMINANCE, + SENSOR_MOISTURE, + SENSOR_PB0_3, + SENSOR_PB0_5, + SENSOR_PB1, + SENSOR_PB2_5, + SENSOR_PB5, + SENSOR_PB10, + SENSOR_PM1, + SENSOR_PM2_5, + SENSOR_PM10, + SENSOR_POWERFACTOR, + SENSOR_POWERUSAGE, + SENSOR_PRESSURE, + SENSOR_PRESSUREATSEALEVEL, + SENSOR_PROXIMITY, + SENSOR_REACTIVE_POWERUSAGE, + SENSOR_TEMPERATURE, + SENSOR_TODAY, + SENSOR_TOTAL, + SENSOR_TOTAL_START_TIME, + SENSOR_TVOC, + SENSOR_VOLTAGE, + SENSOR_WEIGHT, + SENSOR_YESTERDAY, +) + +from homeassistant.components import sensor +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESSURE, + DEVICE_CLASS_TEMPERATURE, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN as TASMOTA_DOMAIN +from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW +from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate + +_LOGGER = logging.getLogger(__name__) + +SENSOR_DEVICE_CLASS_MAP = { + SENSOR_AMBIENT: DEVICE_CLASS_ILLUMINANCE, + SENSOR_APPARENT_POWERUSAGE: DEVICE_CLASS_POWER, + SENSOR_BATTERY: DEVICE_CLASS_BATTERY, + SENSOR_HUMIDITY: DEVICE_CLASS_HUMIDITY, + SENSOR_ILLUMINANCE: DEVICE_CLASS_ILLUMINANCE, + SENSOR_POWERUSAGE: DEVICE_CLASS_POWER, + SENSOR_PRESSURE: DEVICE_CLASS_PRESSURE, + SENSOR_PRESSUREATSEALEVEL: DEVICE_CLASS_PRESSURE, + SENSOR_REACTIVE_POWERUSAGE: DEVICE_CLASS_POWER, + SENSOR_TEMPERATURE: DEVICE_CLASS_TEMPERATURE, + SENSOR_TODAY: DEVICE_CLASS_POWER, + SENSOR_TOTAL: DEVICE_CLASS_POWER, + SENSOR_YESTERDAY: DEVICE_CLASS_POWER, +} + +SENSOR_ICON_MAP = { + SENSOR_CCT: "mdi:temperature-kelvin", + SENSOR_CO2: "mdi:molecule-co2", + SENSOR_COLOR_BLUE: "mdi:palette", + SENSOR_COLOR_GREEN: "mdi:palette", + SENSOR_COLOR_RED: "mdi:palette", + SENSOR_CURRENT: "mdi:alpha-a-circle-outline", + SENSOR_DEWPOINT: "mdi:weather-rainy", + SENSOR_DISTANCE: "mdi:leak", + SENSOR_ECO2: "mdi:molecule-co2", + SENSOR_FREQUENCY: "mdi:current-ac", + SENSOR_MOISTURE: "mdi:cup-water", + SENSOR_PB0_3: "mdi:flask", + SENSOR_PB0_5: "mdi:flask", + SENSOR_PB10: "mdi:flask", + SENSOR_PB1: "mdi:flask", + SENSOR_PB2_5: "mdi:flask", + SENSOR_PB5: "mdi:flask", + SENSOR_PM10: "mdi:air-filter", + SENSOR_PM1: "mdi:air-filter", + SENSOR_PM2_5: "mdi:air-filter", + SENSOR_POWERFACTOR: "mdi:alpha-f-circle-outline", + SENSOR_PROXIMITY: "mdi:ruler", + SENSOR_TOTAL_START_TIME: "mdi:progress-clock", + SENSOR_TVOC: "mdi:air-filter", + SENSOR_VOLTAGE: "mdi:alpha-v-circle-outline", + SENSOR_WEIGHT: "mdi:scale", +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Tasmota sensor dynamically through discovery.""" + + async def async_discover_sensor(tasmota_entity, discovery_hash): + """Discover and add a Tasmota sensor.""" + async_add_entities( + [ + TasmotaSensor( + tasmota_entity=tasmota_entity, discovery_hash=discovery_hash + ) + ] + ) + + async_dispatcher_connect( + hass, + TASMOTA_DISCOVERY_ENTITY_NEW.format(sensor.DOMAIN, TASMOTA_DOMAIN), + async_discover_sensor, + ) + + +class TasmotaSensor(TasmotaAvailability, TasmotaDiscoveryUpdate, Entity): + """Representation of a Tasmota sensor.""" + + def __init__(self, **kwds): + """Initialize the Tasmota sensor.""" + self._state = False + + super().__init__( + discovery_update=self.discovery_update, + **kwds, + ) + + @property + def device_class(self) -> Optional[str]: + """Return the device class of the sensor.""" + return SENSOR_DEVICE_CLASS_MAP.get(self._tasmota_entity.quantity) + + @property + def icon(self): + """Return the icon.""" + return SENSOR_ICON_MAP.get(self._tasmota_entity.quantity) + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return self._tasmota_entity.unit diff --git a/requirements_all.txt b/requirements_all.txt index 58a9b8138ba..4799d848f2c 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.10 +hatasmota==0.0.11 # homeassistant.components.jewish_calendar hdate==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8895391d46..b3cbdf548bb 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.10 +hatasmota==0.0.11 # 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 bb424a01175..c246699dab5 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -57,7 +57,13 @@ DEFAULT_CONFIG = { async def help_test_availability_when_connection_lost( - hass, mqtt_client_mock, mqtt_mock, domain, config + hass, + mqtt_client_mock, + mqtt_mock, + domain, + config, + sensor_config=None, + entity_id="test", ): """Test availability after MQTT disconnection. @@ -69,6 +75,13 @@ async def help_test_availability_when_connection_lost( json.dumps(config), ) await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() # Device online async_fire_mqtt_message( @@ -76,7 +89,8 @@ async def help_test_availability_when_connection_lost( get_topic_tele_will(config), config_get_state_online(config), ) - state = hass.states.get(f"{domain}.test") + + state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE # Disconnected from MQTT server -> state changed to unavailable @@ -85,7 +99,7 @@ async def help_test_availability_when_connection_lost( 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") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE # Reconnected to MQTT server -> state still unavailable @@ -94,7 +108,7 @@ async def help_test_availability_when_connection_lost( 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") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE # Receive LWT again @@ -103,7 +117,7 @@ async def help_test_availability_when_connection_lost( get_topic_tele_will(config), config_get_state_online(config), ) - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE @@ -112,6 +126,8 @@ async def help_test_availability( mqtt_mock, domain, config, + sensor_config=None, + entity_id="test", ): """Test availability. @@ -123,8 +139,15 @@ async def help_test_availability( json.dumps(config), ) await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message( @@ -133,7 +156,7 @@ async def help_test_availability( config_get_state_online(config), ) - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message( @@ -142,7 +165,7 @@ async def help_test_availability( config_get_state_offline(config), ) - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE @@ -151,6 +174,8 @@ async def help_test_availability_discovery_update( mqtt_mock, domain, config, + sensor_config=None, + entity_id="test", ): """Test update of discovered TasmotaAvailability. @@ -180,16 +205,23 @@ async def help_test_availability_discovery_update( async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config1[CONF_MAC]}/config", data1) await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, online1) - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, availability_topic1, offline1) - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE # Change availability settings @@ -200,17 +232,24 @@ async def help_test_availability_discovery_update( 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") + state = hass.states.get(f"{domain}.{entity_id}") 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") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE async def help_test_availability_poll_state( - hass, mqtt_client_mock, mqtt_mock, domain, config, poll_topic, poll_payload + hass, + mqtt_client_mock, + mqtt_mock, + domain, + config, + poll_topic, + poll_payload, + sensor_config=None, ): """Test polling of state when device is available. @@ -222,6 +261,13 @@ async def help_test_availability_poll_state( json.dumps(config), ) await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() mqtt_mock.async_publish.reset_mock() # Device online, verify poll for state @@ -265,7 +311,16 @@ async def help_test_availability_poll_state( async def help_test_discovery_removal( - hass, mqtt_mock, caplog, domain, config1, config2 + hass, + mqtt_mock, + caplog, + domain, + config1, + config2, + sensor_config1=None, + sensor_config2=None, + entity_id="test", + name="Test", ): """Test removal of discovered entity.""" device_reg = await hass.helpers.device_registry.async_get_registry() @@ -277,34 +332,56 @@ async def help_test_discovery_removal( async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config1[CONF_MAC]}/config", data1) await hass.async_block_till_done() + if sensor_config1: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config1[CONF_MAC]}/sensors", + json.dumps(sensor_config1), + ) + 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") + entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") assert entity_entry is not None # Verify state is added - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state is not None - assert state.name == "Test" + assert state.name == name async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config2[CONF_MAC]}/config", data2) await hass.async_block_till_done() + if sensor_config1: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config2[CONF_MAC]}/sensors", + json.dumps(sensor_config2), + ) + 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") + entity_entry = entity_reg.async_get(f"{domain}.{entity_id}") assert entity_entry is None # Verify state is removed - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state is None async def help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, domain, config, discovery_update + hass, + mqtt_mock, + caplog, + domain, + config, + discovery_update, + sensor_config=None, + entity_id="test", + name="Test", ): """Test update of discovered component without changes. @@ -313,18 +390,33 @@ async def help_test_discovery_update_unchanged( config1 = copy.deepcopy(config) config2 = copy.deepcopy(config) config2[CONF_PREFIX][PREFIX_CMND] = "cmnd2" + config2[CONF_PREFIX][PREFIX_TELE] = "tele2" 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() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state is not None - assert state.name == "Test" + assert state.name == name async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data1) await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() assert not discovery_update.called @@ -334,7 +426,9 @@ async def help_test_discovery_update_unchanged( assert discovery_update.called -async def help_test_discovery_device_remove(hass, mqtt_mock, domain, unique_id, config): +async def help_test_discovery_device_remove( + hass, mqtt_mock, domain, unique_id, config, sensor_config=None +): """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() @@ -344,6 +438,13 @@ async def help_test_discovery_device_remove(hass, mqtt_mock, domain, unique_id, data = json.dumps(config) async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data) await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() device = device_reg.async_get_device(set(), {("mac", config[CONF_MAC])}) assert device is not None @@ -358,7 +459,7 @@ async def help_test_discovery_device_remove(hass, mqtt_mock, domain, unique_id, async def help_test_entity_id_update_subscriptions( - hass, mqtt_mock, domain, config, topics=None + hass, mqtt_mock, domain, config, topics=None, sensor_config=None, entity_id="test" ): """Test MQTT subscriptions are managed when entity_id is updated.""" entity_reg = await hass.helpers.entity_registry.async_get_registry() @@ -370,22 +471,31 @@ async def help_test_entity_id_update_subscriptions( async_fire_mqtt_message(hass, f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/config", data) await hass.async_block_till_done() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() if not topics: topics = [get_topic_tele_state(config), get_topic_tele_will(config)] assert len(topics) > 0 - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") 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") + entity_reg.async_update_entity( + f"{domain}.{entity_id}", new_entity_id=f"{domain}.milk" + ) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state is None state = hass.states.get(f"{domain}.milk") @@ -394,7 +504,9 @@ async def help_test_entity_id_update_subscriptions( 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): +async def help_test_entity_id_update_discovery_update( + hass, mqtt_mock, domain, config, sensor_config=None, entity_id="test" +): """Test MQTT discovery update after entity_id is updated.""" entity_reg = await hass.helpers.entity_registry.async_get_registry() @@ -405,16 +517,25 @@ 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() + if sensor_config: + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{config[CONF_MAC]}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() async_fire_mqtt_message(hass, topic, config_get_state_online(config)) - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state != STATE_UNAVAILABLE async_fire_mqtt_message(hass, topic, config_get_state_offline(config)) - state = hass.states.get(f"{domain}.test") + state = hass.states.get(f"{domain}.{entity_id}") assert state.state == STATE_UNAVAILABLE - entity_reg.async_update_entity(f"{domain}.test", new_entity_id=f"{domain}.milk") + entity_reg.async_update_entity( + f"{domain}.{entity_id}", new_entity_id=f"{domain}.milk" + ) await hass.async_block_till_done() assert hass.states.get(f"{domain}.milk") diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py new file mode 100644 index 00000000000..296d9e4878e --- /dev/null +++ b/tests/components/tasmota/test_sensor.py @@ -0,0 +1,254 @@ +"""The tests for the Tasmota sensor platform.""" +import copy +import json + +from hatasmota.utils import ( + get_topic_stat_status, + get_topic_tele_sensor, + get_topic_tele_will, +) + +from homeassistant.components import sensor +from homeassistant.components.tasmota.const import DEFAULT_PREFIX +from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNKNOWN + +from .test_common import ( + DEFAULT_CONFIG, + help_test_availability, + help_test_availability_discovery_update, + help_test_availability_poll_state, + 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 + +DEFAULT_SENSOR_CONFIG = { + "sn": { + "Time": "2020-09-25T12:47:15", + "DHT11": {"Temperature": None}, + "TempUnit": "C", + } +} + + +async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): + """Test state update via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_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, + f"{DEFAULT_PREFIX}/{mac}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.dht11_temperature") + 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("sensor.dht11_temperature") + assert state.state == STATE_UNKNOWN + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Test periodic state update + async_fire_mqtt_message( + hass, "tasmota_49A3BC/tele/SENSOR", '{"DHT11":{"Temperature":20.5}}' + ) + state = hass.states.get("sensor.dht11_temperature") + assert state.state == "20.5" + + # Test polled state update + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS8", + '{"StatusSNS":{"DHT11":{"Temperature":20.0}}}', + ) + state = hass.states.get("sensor.dht11_temperature") + assert state.state == "20.0" + + +async def test_attributes(hass, mqtt_mock, setup_tasmota): + """Test correct attributes for sensors.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = { + "sn": { + "DHT11": {"Temperature": None}, + "Beer": {"CarbonDioxide": None}, + "TempUnit": "C", + } + } + 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, + f"{DEFAULT_PREFIX}/{mac}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.dht11_temperature") + assert state.attributes.get("device_class") == "temperature" + assert state.attributes.get("friendly_name") == "DHT11 Temperature" + assert state.attributes.get("icon") is None + assert state.attributes.get("unit_of_measurement") == "C" + + state = hass.states.get("sensor.beer_CarbonDioxide") + assert state.attributes.get("device_class") is None + assert state.attributes.get("friendly_name") == "Beer CarbonDioxide" + assert state.attributes.get("icon") == "mdi:molecule-co2" + assert state.attributes.get("unit_of_measurement") == "ppm" + + +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) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_availability_when_connection_lost( + hass, + mqtt_client_mock, + mqtt_mock, + sensor.DOMAIN, + config, + sensor_config, + "dht11_temperature", + ) + + +async def test_availability(hass, mqtt_mock, setup_tasmota): + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_availability( + hass, mqtt_mock, sensor.DOMAIN, config, sensor_config, "dht11_temperature" + ) + + +async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): + """Test availability discovery update.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_availability_discovery_update( + hass, mqtt_mock, sensor.DOMAIN, config, sensor_config, "dht11_temperature" + ) + + +async def test_availability_poll_state( + hass, mqtt_client_mock, mqtt_mock, setup_tasmota +): + """Test polling after MQTT connection (re)established.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + poll_topic = "tasmota_49A3BC/cmnd/STATUS" + await help_test_availability_poll_state( + hass, + mqtt_client_mock, + mqtt_mock, + sensor.DOMAIN, + config, + poll_topic, + "8", + sensor_config, + ) + + +async def test_discovery_removal_sensor(hass, mqtt_mock, caplog, setup_tasmota): + """Test removal of discovered sensor.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config1 = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + + await help_test_discovery_removal( + hass, + mqtt_mock, + caplog, + sensor.DOMAIN, + config, + config, + sensor_config1, + {}, + "dht11_temperature", + "DHT11 Temperature", + ) + + +async def test_discovery_update_unchanged_sensor( + hass, mqtt_mock, caplog, setup_tasmota +): + """Test update of discovered sensor.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + with patch( + "homeassistant.components.tasmota.sensor.TasmotaSensor.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, + mqtt_mock, + caplog, + sensor.DOMAIN, + config, + discovery_update, + sensor_config, + "dht11_temperature", + "DHT11 Temperature", + ) + + +async def test_discovery_device_remove(hass, mqtt_mock, setup_tasmota): + """Test device registry remove.""" + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + unique_id = f"{DEFAULT_CONFIG['mac']}_sensor_sensor_DHT11_Temperature" + await help_test_discovery_device_remove( + hass, mqtt_mock, sensor.DOMAIN, unique_id, config, sensor_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) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + topics = [ + get_topic_tele_sensor(config), + get_topic_stat_status(config, 8), + get_topic_tele_will(config), + ] + await help_test_entity_id_update_subscriptions( + hass, + mqtt_mock, + sensor.DOMAIN, + config, + topics, + sensor_config, + "dht11_temperature", + ) + + +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) + sensor_config = copy.deepcopy(DEFAULT_SENSOR_CONFIG) + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, sensor.DOMAIN, config, sensor_config, "dht11_temperature" + )