diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index 33b03109cd8..10e621e6668 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -96,7 +96,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device: SmartDevice = hass_data[entry.entry_id].device if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass_data.pop(entry.entry_id) - await device.protocol.close() # type: ignore[no-untyped-call] + await device.protocol.close() return unload_ok diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 4519ead4d09..3f8179f42f2 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -4,17 +4,19 @@ from __future__ import annotations import logging from typing import Any, cast -from kasa import SmartBulb +from kasa import SmartBulb, SmartLightStrip from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, + ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, COLOR_MODE_BRIGHTNESS, COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS, COLOR_MODE_ONOFF, + SUPPORT_EFFECT, SUPPORT_TRANSITION, LightEntity, ) @@ -42,7 +44,9 @@ async def async_setup_entry( """Set up switches.""" coordinator: TPLinkDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] device = cast(SmartBulb, coordinator.device) - if device.is_bulb or device.is_light_strip or device.is_dimmer: + if device.is_light_strip: + async_add_entities([TPLinkSmartLightStrip(device, coordinator)]) + elif device.is_bulb or device.is_dimmer: async_add_entities([TPLinkSmartBulb(device, coordinator)]) @@ -59,14 +63,14 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): """Initialize the switch.""" super().__init__(device, coordinator) # For backwards compat with pyHS100 - if self.device.is_dimmer: + if device.is_dimmer: # Dimmers used to use the switch format since # pyHS100 treated them as SmartPlug but the old code # created them as lights # https://github.com/home-assistant/core/blob/2021.9.7/homeassistant/components/tplink/common.py#L86 self._attr_unique_id = legacy_device_id(device) else: - self._attr_unique_id = self.device.mac.replace(":", "").upper() + self._attr_unique_id = device.mac.replace(":", "").upper() @async_refresh_after async def async_turn_on(self, **kwargs: Any) -> None: @@ -104,6 +108,11 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): await self.device.set_hsv(hue, sat, brightness, transition=transition) return + if ATTR_EFFECT in kwargs: + assert isinstance(self.device, SmartLightStrip) + await self.device.set_effect(kwargs[ATTR_EFFECT]) + return + # Fallback to adjusting brightness or turning the bulb on if brightness is not None: await self.device.set_brightness(brightness, transition=transition) @@ -175,3 +184,28 @@ class TPLinkSmartBulb(CoordinatedTPLinkEntity, LightEntity): return COLOR_MODE_COLOR_TEMP return COLOR_MODE_BRIGHTNESS + + +class TPLinkSmartLightStrip(TPLinkSmartBulb): + """Representation of a TPLink Smart Light Strip.""" + + device: SmartLightStrip + + @property + def supported_features(self) -> int: + """Flag supported features.""" + return super().supported_features | SUPPORT_EFFECT + + @property + def effect_list(self) -> list[str] | None: + """Return the list of available effects.""" + if effect_list := self.device.effect_list: + return cast(list[str], effect_list) + return None + + @property + def effect(self) -> str | None: + """Return the current effect.""" + if (effect := self.device.effect) and effect["enable"]: + return cast(str, effect["name"]) + return None diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 20f2e9dc171..ec72a3d7941 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -3,7 +3,7 @@ "name": "TP-Link Kasa Smart", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tplink", - "requirements": ["python-kasa==0.4.1"], + "requirements": ["python-kasa==0.4.2"], "codeowners": ["@rytilahti", "@thegardenmonkey"], "dependencies": ["network"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 013576a33db..0cb4a718af6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1891,7 +1891,7 @@ python-join-api==0.0.6 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa==0.4.1 +python-kasa==0.4.2 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 148384e0fdd..2e905d2a194 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1221,7 +1221,7 @@ python-izone==1.2.3 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa==0.4.1 +python-kasa==0.4.2 # homeassistant.components.xiaomi_miio python-miio==0.5.11 diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py index f9422d60669..4232d3e6909 100644 --- a/tests/components/tplink/__init__.py +++ b/tests/components/tplink/__init__.py @@ -2,7 +2,14 @@ from unittest.mock import AsyncMock, MagicMock, patch -from kasa import SmartBulb, SmartDevice, SmartDimmer, SmartPlug, SmartStrip +from kasa import ( + SmartBulb, + SmartDevice, + SmartDimmer, + SmartLightStrip, + SmartPlug, + SmartStrip, +) from kasa.exceptions import SmartDeviceException from kasa.protocol import TPLinkSmartHomeProtocol @@ -40,6 +47,10 @@ def _mocked_bulb() -> SmartBulb: bulb.is_strip = False bulb.is_plug = False bulb.is_dimmer = False + bulb.is_light_strip = False + bulb.has_effects = False + bulb.effect = None + bulb.effect_list = None bulb.hsv = (10, 30, 5) bulb.device_id = MAC_ADDRESS bulb.valid_temperature_range.min = 4000 @@ -54,6 +65,47 @@ def _mocked_bulb() -> SmartBulb: return bulb +class MockedSmartLightStrip(SmartLightStrip): + """Mock a SmartLightStrip.""" + + def __new__(cls, *args, **kwargs): + """Mock a SmartLightStrip that will pass an isinstance check.""" + return MagicMock(spec=cls) + + +def _mocked_smart_light_strip() -> SmartLightStrip: + strip = MockedSmartLightStrip() + strip.update = AsyncMock() + strip.mac = MAC_ADDRESS + strip.alias = ALIAS + strip.model = MODEL + strip.host = IP_ADDRESS + strip.brightness = 50 + strip.color_temp = 4000 + strip.is_color = True + strip.is_strip = False + strip.is_plug = False + strip.is_dimmer = False + strip.is_light_strip = True + strip.has_effects = True + strip.effect = {"name": "Effect1", "enable": 1} + strip.effect_list = ["Effect1", "Effect2"] + strip.hsv = (10, 30, 5) + strip.device_id = MAC_ADDRESS + strip.valid_temperature_range.min = 4000 + strip.valid_temperature_range.max = 9000 + strip.hw_info = {"sw_ver": "1.0.0", "hw_ver": "1.0.0"} + strip.turn_off = AsyncMock() + strip.turn_on = AsyncMock() + strip.set_brightness = AsyncMock() + strip.set_hsv = AsyncMock() + strip.set_color_temp = AsyncMock() + strip.set_effect = AsyncMock() + strip.set_custom_effect = AsyncMock() + strip.protocol = _mock_protocol() + return strip + + def _mocked_dimmer() -> SmartDimmer: dimmer = MagicMock(auto_spec=SmartDimmer, name="Mocked dimmer") dimmer.update = AsyncMock() @@ -67,6 +119,9 @@ def _mocked_dimmer() -> SmartDimmer: dimmer.is_strip = False dimmer.is_plug = False dimmer.is_dimmer = True + dimmer.is_light_strip = False + dimmer.effect = None + dimmer.effect_list = None dimmer.hsv = (10, 30, 5) dimmer.device_id = MAC_ADDRESS dimmer.valid_temperature_range.min = 4000 diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index c3b82045ef0..32394be790b 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -1,6 +1,7 @@ """Tests for light platform.""" from __future__ import annotations +from datetime import timedelta from unittest.mock import PropertyMock import pytest @@ -10,6 +11,8 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_MODE, ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_EFFECT_LIST, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, @@ -20,14 +23,21 @@ from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, ) from homeassistant.components.tplink.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from . import MAC_ADDRESS, _mocked_bulb, _patch_discovery, _patch_single_discovery +from . import ( + MAC_ADDRESS, + _mocked_bulb, + _mocked_smart_light_strip, + _patch_discovery, + _patch_single_discovery, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_light_unique_id(hass: HomeAssistant) -> None: @@ -375,3 +385,48 @@ async def test_dimmer_turn_on_fix(hass: HomeAssistant) -> None: ) bulb.turn_on.assert_called_once_with(transition=1) bulb.turn_on.reset_mock() + + +async def test_smart_strip_effects(hass: HomeAssistant) -> None: + """Test smart strip effects.""" + already_migrated_config_entry = MockConfigEntry( + domain=DOMAIN, data={}, unique_id=MAC_ADDRESS + ) + already_migrated_config_entry.add_to_hass(hass) + strip = _mocked_smart_light_strip() + + with _patch_discovery(device=strip), _patch_single_discovery(device=strip): + await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) + await hass.async_block_till_done() + + entity_id = "light.my_bulb" + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_EFFECT] == "Effect1" + assert state.attributes[ATTR_EFFECT_LIST] == ["Effect1", "Effect2"] + + await hass.services.async_call( + LIGHT_DOMAIN, + "turn_on", + {ATTR_ENTITY_ID: entity_id, ATTR_EFFECT: "Effect2"}, + blocking=True, + ) + strip.set_effect.assert_called_once_with("Effect2") + strip.set_effect.reset_mock() + + strip.effect = {"name": "Effect1", "enable": 0} + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert ATTR_EFFECT not in state.attributes + + strip.effect_list = None + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + assert state.attributes[ATTR_EFFECT_LIST] is None