diff --git a/homeassistant/components/tasmota/cover.py b/homeassistant/components/tasmota/cover.py index 172e460c29b..4b851a86406 100644 --- a/homeassistant/components/tasmota/cover.py +++ b/homeassistant/components/tasmota/cover.py @@ -9,6 +9,7 @@ from hatasmota.models import DiscoveryHashType from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, CoverEntity, CoverEntityFeature, @@ -55,23 +56,32 @@ class TasmotaCover( ): """Representation of a Tasmota cover.""" - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.STOP - | CoverEntityFeature.SET_POSITION - ) _tasmota_entity: tasmota_shutter.TasmotaShutter def __init__(self, **kwds: Any) -> None: """Initialize the Tasmota cover.""" self._direction: int | None = None self._position: int | None = None + self._tilt_position: int | None = None super().__init__( **kwds, ) + self._attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + if self._tasmota_entity.supports_tilt: + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" self._tasmota_entity.set_on_state_callback(self.cover_state_updated) @@ -82,6 +92,7 @@ class TasmotaCover( """Handle state updates.""" self._direction = kwargs["direction"] self._position = kwargs["position"] + self._tilt_position = kwargs["tilt"] self.async_write_ha_state() @property @@ -92,6 +103,14 @@ class TasmotaCover( """ return self._position + @property + def current_cover_tilt_position(self) -> int | None: + """Return current tilt position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return self._tilt_position + @property def is_opening(self) -> bool: """Return if the cover is opening or not.""" @@ -125,3 +144,20 @@ class TasmotaCover( async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._tasmota_entity.stop() + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + await self._tasmota_entity.open_tilt() + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close cover tilt.""" + await self._tasmota_entity.close_tilt() + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + tilt = kwargs[ATTR_TILT_POSITION] + await self._tasmota_entity.set_tilt_position(tilt) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + await self._tasmota_entity.stop() diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 6a743683d94..772105043fe 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -3,7 +3,7 @@ "name": "Tasmota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tasmota", - "requirements": ["hatasmota==0.4.1"], + "requirements": ["hatasmota==0.5.0"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"], diff --git a/requirements_all.txt b/requirements_all.txt index b572dca4f6f..79ee89d3cb7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -792,7 +792,7 @@ hass-nabucasa==0.54.0 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.4.1 +hatasmota==0.5.0 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0f5459d43a..85077aa295a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -565,7 +565,7 @@ hangups==0.4.18 hass-nabucasa==0.54.0 # homeassistant.components.tasmota -hatasmota==0.4.1 +hatasmota==0.5.0 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index e88df08a80c..843fd72ecf1 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -30,6 +30,19 @@ from .test_common import ( from tests.common import async_fire_mqtt_message +COVER_SUPPORT = ( + cover.SUPPORT_OPEN + | cover.SUPPORT_CLOSE + | cover.SUPPORT_STOP + | cover.SUPPORT_SET_POSITION +) +TILT_SUPPORT = ( + cover.SUPPORT_OPEN_TILT + | cover.SUPPORT_CLOSE_TILT + | cover.SUPPORT_STOP_TILT + | cover.SUPPORT_SET_TILT_POSITION +) + async def test_missing_relay(hass, mqtt_mock, setup_tasmota): """Test no cover is discovered if relays are missing.""" @@ -64,11 +77,46 @@ async def test_multiple_covers( assert len(hass.states.async_all("cover")) == num_covers +async def test_tilt_support(hass, mqtt_mock, setup_tasmota): + """Test tilt support detection.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config["rl"] = [3, 3, 3, 3, 3, 3, 3, 3] + config["sht"] = [ + [0, 0, 0], # Default settings, no tilt + [-90, 90, 24], # Tilt configured + [-90, 90, 0], # Duration 0, no tilt + [-90, -90, 24], # min+max same, no tilt + ] + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + assert len(hass.states.async_all("cover")) == 4 + + state = hass.states.get("cover.tasmota_cover_1") + assert state.attributes["supported_features"] == COVER_SUPPORT + + state = hass.states.get("cover.tasmota_cover_2") + assert state.attributes["supported_features"] == COVER_SUPPORT | TILT_SUPPORT + + state = hass.states.get("cover.tasmota_cover_3") + assert state.attributes["supported_features"] == COVER_SUPPORT + + state = hass.states.get("cover.tasmota_cover_4") + assert state.attributes["supported_features"] == COVER_SUPPORT + + 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] = 3 config["rl"][1] = 3 + config["sht"] = [[-90, 90, 24]] mac = config["mac"] async_fire_mqtt_message( @@ -86,40 +134,39 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): await hass.async_block_till_done() state = hass.states.get("cover.tasmota_cover_1") assert state.state == STATE_UNKNOWN - assert ( - state.attributes["supported_features"] - == cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_STOP - | cover.SUPPORT_SET_POSITION - ) + assert state.attributes["supported_features"] == COVER_SUPPORT | TILT_SUPPORT assert not state.attributes.get(ATTR_ASSUMED_STATE) # Periodic updates async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":54,"Direction":-1}}', + '{"Shutter1":{"Position":54,"Direction":-1,"Tilt":-90}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" assert state.attributes["current_position"] == 54 + assert state.attributes["current_tilt_position"] == 0 async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":100,"Direction":1}}', + '{"Shutter1":{"Position":100,"Direction":1,"Tilt":90}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" assert state.attributes["current_position"] == 100 + assert state.attributes["current_tilt_position"] == 100 async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"Shutter1":{"Position":0,"Direction":0}}' + hass, + "tasmota_49A3BC/tele/SENSOR", + '{"Shutter1":{"Position":0,"Direction":0,"Tilt":0}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" assert state.attributes["current_position"] == 0 + assert state.attributes["current_tilt_position"] == 50 async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", '{"Shutter1":{"Position":1,"Direction":0}}' @@ -141,29 +188,32 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":54,"Direction":-1}}}', + '{"StatusSNS":{"Shutter1":{"Position":54,"Direction":-1,"Tilt":-90}}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" assert state.attributes["current_position"] == 54 + assert state.attributes["current_tilt_position"] == 0 async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":1}}}', + '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":1,"Tilt":90}}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" assert state.attributes["current_position"] == 100 + assert state.attributes["current_tilt_position"] == 100 async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":0,"Direction":0}}}', + '{"StatusSNS":{"Shutter1":{"Position":0,"Direction":0,"Tilt":0}}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" assert state.attributes["current_position"] == 0 + assert state.attributes["current_tilt_position"] == 50 async_fire_mqtt_message( hass, @@ -187,27 +237,32 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":54,"Direction":-1}}', + '{"Shutter1":{"Position":54,"Direction":-1,"Tilt":-90}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" assert state.attributes["current_position"] == 54 + assert state.attributes["current_tilt_position"] == 0 async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":100,"Direction":1}}', + '{"Shutter1":{"Position":100,"Direction":1,"Tilt":90}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" assert state.attributes["current_position"] == 100 + assert state.attributes["current_tilt_position"] == 100 async_fire_mqtt_message( - hass, "tasmota_49A3BC/stat/RESULT", '{"Shutter1":{"Position":0,"Direction":0}}' + hass, + "tasmota_49A3BC/stat/RESULT", + '{"Shutter1":{"Position":0,"Direction":0,"Tilt":0}}', ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" assert state.attributes["current_position"] == 0 + assert state.attributes["current_tilt_position"] == 50 async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", '{"Shutter1":{"Position":1,"Direction":0}}' @@ -249,14 +304,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot await hass.async_block_till_done() state = hass.states.get("cover.tasmota_cover_1") assert state.state == STATE_UNKNOWN - assert ( - state.attributes["supported_features"] - == cover.SUPPORT_OPEN - | cover.SUPPORT_CLOSE - | cover.SUPPORT_STOP - | cover.SUPPORT_SET_POSITION - ) - assert not state.attributes.get(ATTR_ASSUMED_STATE) + assert state.attributes["supported_features"] == COVER_SUPPORT # Periodic updates async_fire_mqtt_message( @@ -405,6 +453,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): config["dn"] = "Test" config["rl"][0] = 3 config["rl"][1] = 3 + config["sht"] = [[-90, 90, 24]] mac = config["mac"] async_fire_mqtt_message( @@ -461,6 +510,45 @@ async def test_sending_mqtt_commands(hass, mqtt_mock, setup_tasmota): ) mqtt_mock.async_publish.reset_mock() + # Close the cover tilt and verify MQTT message is sent + await call_service(hass, "cover.test_cover_1", "close_cover_tilt") + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/ShutterTilt1", "CLOSE", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Open the cover tilt and verify MQTT message is sent + await call_service(hass, "cover.test_cover_1", "open_cover_tilt") + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/ShutterTilt1", "OPEN", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Stop the cover tilt and verify MQTT message is sent + await call_service(hass, "cover.test_cover_1", "stop_cover_tilt") + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/ShutterStop1", "", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Set tilt position and verify MQTT message is sent + await call_service( + hass, "cover.test_cover_1", "set_cover_tilt_position", tilt_position=0 + ) + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/ShutterTilt1", "-90", 0, False + ) + mqtt_mock.async_publish.reset_mock() + + # Set tilt position and verify MQTT message is sent + await call_service( + hass, "cover.test_cover_1", "set_cover_tilt_position", tilt_position=100 + ) + mqtt_mock.async_publish.assert_called_once_with( + "tasmota_49A3BC/cmnd/ShutterTilt1", "90", 0, False + ) + mqtt_mock.async_publish.reset_mock() + async def test_sending_mqtt_commands_inverted(hass, mqtt_mock, setup_tasmota): """Test the sending MQTT commands."""