From 6e84f9176204bf136eed84df0875e20b4256e5cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 7 Oct 2020 14:41:30 +0200 Subject: [PATCH] Add Tasmota binary sensor (#41380) --- .../components/tasmota/binary_sensor.py | 91 +++++++ homeassistant/components/tasmota/discovery.py | 1 + homeassistant/components/tasmota/mixins.py | 56 ++++- homeassistant/components/tasmota/switch.py | 54 +---- .../components/tasmota/test_binary_sensor.py | 222 ++++++++++++++++++ tests/components/tasmota/test_common.py | 3 +- 6 files changed, 370 insertions(+), 57 deletions(-) create mode 100644 homeassistant/components/tasmota/binary_sensor.py create mode 100644 tests/components/tasmota/test_binary_sensor.py diff --git a/homeassistant/components/tasmota/binary_sensor.py b/homeassistant/components/tasmota/binary_sensor.py new file mode 100644 index 00000000000..207fc4ec2dd --- /dev/null +++ b/homeassistant/components/tasmota/binary_sensor.py @@ -0,0 +1,91 @@ +"""Support for Tasmota binary sensors.""" +import logging + +from homeassistant.components import binary_sensor +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +import homeassistant.helpers.event as evt + +from .const import DOMAIN as TASMOTA_DOMAIN +from .discovery import TASMOTA_DISCOVERY_ENTITY_NEW, clear_discovery_hash +from .mixins import TasmotaAvailability, TasmotaDiscoveryUpdate + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Tasmota binary sensor dynamically through discovery.""" + + @callback + def async_discover(tasmota_entity, discovery_hash): + """Discover and add a Tasmota binary sensor.""" + try: + async_add_entities( + [ + TasmotaBinarySensor( + tasmota_entity=tasmota_entity, discovery_hash=discovery_hash + ) + ] + ) + except Exception: + clear_discovery_hash(hass, discovery_hash) + raise + + async_dispatcher_connect( + hass, + TASMOTA_DISCOVERY_ENTITY_NEW.format(binary_sensor.DOMAIN, TASMOTA_DOMAIN), + async_discover, + ) + + +class TasmotaBinarySensor( + TasmotaAvailability, + TasmotaDiscoveryUpdate, + BinarySensorEntity, +): + """Representation a Tasmota binary sensor.""" + + def __init__(self, **kwds): + """Initialize the Tasmota binary sensor.""" + self._delay_listener = None + self._state = None + + super().__init__( + discovery_update=self.discovery_update, + **kwds, + ) + + @callback + def off_delay_listener(self, now): + """Switch device off after a delay.""" + self._delay_listener = None + self._state = False + self.async_write_ha_state() + + @callback + def state_updated(self, state, **kwargs): + """Handle new MQTT state messages.""" + self._state = state + + if self._delay_listener is not None: + self._delay_listener() + self._delay_listener = None + + off_delay = self._tasmota_entity.off_delay + if self._state and off_delay is not None: + self._delay_listener = evt.async_call_later( + self.hass, off_delay, self.off_delay_listener + ) + + self.async_write_ha_state() + + @property + def force_update(self): + """Force update.""" + return True + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state diff --git a/homeassistant/components/tasmota/discovery.py b/homeassistant/components/tasmota/discovery.py index e6bde93bee8..37329dcf6aa 100644 --- a/homeassistant/components/tasmota/discovery.py +++ b/homeassistant/components/tasmota/discovery.py @@ -19,6 +19,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) SUPPORTED_PLATFORMS = [ + "binary_sensor", "switch", ] diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index c8099cfbb85..ec58eebd89d 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -1,4 +1,4 @@ -"""Tasnmota entity mixins.""" +"""Tasmota entity mixins.""" import logging from homeassistant.components.mqtt import ( @@ -6,6 +6,7 @@ from homeassistant.components.mqtt import ( is_connected as mqtt_connected, ) 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.entity import Entity @@ -25,7 +26,55 @@ class TasmotaEntity(Entity): def __init__(self, tasmota_entity) -> None: """Initialize.""" + self._state = None self._tasmota_entity = tasmota_entity + self._unique_id = tasmota_entity.unique_id + + async def async_added_to_hass(self): + """Subscribe to MQTT events.""" + self._tasmota_entity.set_on_state_callback(self.state_updated) + await self._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() + + 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() + + async def _subscribe_topics(self): + """(Re)Subscribe to topics.""" + await self._tasmota_entity.subscribe_topics() + + @callback + def state_updated(self, state, **kwargs): + """Handle new MQTT state messages.""" + self._state = state + self.async_write_ha_state() + + @property + def device_info(self): + """Return a device description for device registry.""" + return {"connections": {(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)}} + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._tasmota_entity.name + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id class TasmotaAvailability(TasmotaEntity): @@ -38,11 +87,11 @@ class TasmotaAvailability(TasmotaEntity): 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) ) + await super().async_added_to_hass() @callback def availability_updated(self, available: bool) -> None: @@ -76,8 +125,8 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): async def async_added_to_hass(self) -> None: """Subscribe to discovery updates.""" - await super().async_added_to_hass() self._removed_from_hass = False + await super().async_added_to_hass() async def discovery_callback(config): """Handle discovery update.""" @@ -109,3 +158,4 @@ class TasmotaDiscoveryUpdate(TasmotaEntity): if not self._removed_from_hass: clear_discovery_hash(self.hass, self._discovery_hash) self._removed_from_hass = True + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/tasmota/switch.py b/homeassistant/components/tasmota/switch.py index f087a30958c..2477d5888a7 100644 --- a/homeassistant/components/tasmota/switch.py +++ b/homeassistant/components/tasmota/switch.py @@ -4,7 +4,6 @@ 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 @@ -42,71 +41,20 @@ class TasmotaSwitch( ): """Representation of a Tasmota switch.""" - def __init__(self, tasmota_entity, **kwds): + def __init__(self, **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) diff --git a/tests/components/tasmota/test_binary_sensor.py b/tests/components/tasmota/test_binary_sensor.py new file mode 100644 index 00000000000..1b2c7757f3f --- /dev/null +++ b/tests/components/tasmota/test_binary_sensor.py @@ -0,0 +1,222 @@ +"""The tests for the Tasmota binary sensor platform.""" +import copy +from datetime import timedelta +import json + +from hatasmota.utils import ( + get_topic_stat_status, + get_topic_stat_switch, + get_topic_tele_sensor, + get_topic_tele_will, +) + +from homeassistant.components import binary_sensor +from homeassistant.components.tasmota.const import DEFAULT_PREFIX +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + EVENT_STATE_CHANGED, + STATE_OFF, + STATE_ON, +) +import homeassistant.core as ha +import homeassistant.util.dt as dt_util + +from .test_common import ( + DEFAULT_CONFIG, + 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, async_fire_time_changed + + +async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): + """Test state update via MQTT.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + 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("binary_sensor.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("binary_sensor.test") + assert state.state == STATE_OFF + assert not state.attributes.get(ATTR_ASSUMED_STATE) + + # Test normal state update + async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/SWITCH1", '{"STATE":"ON"}') + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/SWITCH1", '{"STATE":"OFF"}') + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + # Test periodic state update + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", '{"Switch1":"ON"}') + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", '{"Switch1":"OFF"}') + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + # Test polled state update + async_fire_mqtt_message( + hass, "tasmota_49A3BC/stat/STATUS8", '{"StatusSNS":{"Switch1":"ON"}}' + ) + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + async_fire_mqtt_message( + hass, "tasmota_49A3BC/stat/STATUS8", '{"StatusSNS":{"Switch1":"OFF"}}' + ) + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + +async def test_off_delay(hass, mqtt_mock, setup_tasmota): + """Test off_delay option.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 13 # PUSHON: 1s off_delay + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + events = [] + + @ha.callback + def callback(event): + """Verify event got called.""" + events.append(event.data["new_state"].state) + + hass.bus.async_listen(EVENT_STATE_CHANGED, callback) + + async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online") + await hass.async_block_till_done() + assert events == ["off"] + async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/SWITCH1", '{"STATE":"ON"}') + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + assert events == ["off", "on"] + + async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/SWITCH1", '{"STATE":"ON"}') + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + assert events == ["off", "on", "on"] + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + assert events == ["off", "on", "on", "off"] + + +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["swc"][0] = 1 + await help_test_availability_when_connection_lost( + hass, mqtt_client_mock, mqtt_mock, binary_sensor.DOMAIN, config + ) + + +async def test_availability(hass, mqtt_mock, setup_tasmota): + """Test availability.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + await help_test_availability(hass, mqtt_mock, binary_sensor.DOMAIN, config) + + +async def test_availability_discovery_update(hass, mqtt_mock, setup_tasmota): + """Test availability discovery update.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + await help_test_availability_discovery_update( + hass, mqtt_mock, binary_sensor.DOMAIN, config + ) + + +async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog, setup_tasmota): + """Test removal of discovered binary_sensor.""" + config1 = copy.deepcopy(DEFAULT_CONFIG) + config2 = copy.deepcopy(DEFAULT_CONFIG) + config1["swc"][0] = 1 + config2["swc"][0] = 0 + + await help_test_discovery_removal( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, config1, config2 + ) + + +async def test_discovery_update_unchanged_binary_sensor( + hass, mqtt_mock, caplog, setup_tasmota +): + """Test update of discovered binary_sensor.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["swc"][0] = 1 + with patch( + "homeassistant.components.tasmota.binary_sensor.TasmotaBinarySensor.discovery_update" + ) as discovery_update: + await help_test_discovery_update_unchanged( + hass, mqtt_mock, caplog, binary_sensor.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["swc"][0] = 1 + unique_id = f"{DEFAULT_CONFIG['mac']}_binary_sensor_switch_0" + await help_test_discovery_device_remove( + hass, mqtt_mock, binary_sensor.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["swc"][0] = 1 + topics = [ + get_topic_stat_switch(config, 0), + 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, binary_sensor.DOMAIN, config, topics + ) + + +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["swc"][0] = 1 + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, binary_sensor.DOMAIN, config + ) diff --git a/tests/components/tasmota/test_common.py b/tests/components/tasmota/test_common.py index 73a09a4844d..530d5aef8d6 100644 --- a/tests/components/tasmota/test_common.py +++ b/tests/components/tasmota/test_common.py @@ -301,7 +301,8 @@ 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() - topics = [get_topic_tele_state(config), get_topic_tele_will(config)] + 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")