Move SmartBulb into SmartDevice (#874)

This commit is contained in:
Steven B 2024-04-29 18:19:44 +01:00 committed by GitHub
parent cb11b36511
commit d3544b4989
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 229 additions and 233 deletions

View File

@ -134,7 +134,6 @@ if TYPE_CHECKING:
from . import smart from . import smart
smart.SmartDevice("127.0.0.1") smart.SmartDevice("127.0.0.1")
smart.SmartBulb("127.0.0.1")
iot.IotDevice("127.0.0.1") iot.IotDevice("127.0.0.1")
iot.IotPlug("127.0.0.1") iot.IotPlug("127.0.0.1")
iot.IotBulb("127.0.0.1") iot.IotBulb("127.0.0.1")

View File

@ -5,8 +5,6 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import NamedTuple, Optional from typing import NamedTuple, Optional
from .device import Device
try: try:
from pydantic.v1 import BaseModel from pydantic.v1 import BaseModel
except ImportError: except ImportError:
@ -45,7 +43,7 @@ class BulbPreset(BaseModel):
mode: Optional[int] # noqa: UP007 mode: Optional[int] # noqa: UP007
class Bulb(Device, ABC): class Bulb(ABC):
"""Base class for TP-Link Bulb.""" """Base class for TP-Link Bulb."""
def _raise_for_invalid_brightness(self, value): def _raise_for_invalid_brightness(self, value):

View File

@ -40,7 +40,7 @@ from kasa.iot import (
IotWallSwitch, IotWallSwitch,
) )
from kasa.iot.modules import Usage from kasa.iot.modules import Usage
from kasa.smart import SmartBulb, SmartDevice from kasa.smart import SmartDevice
try: try:
from pydantic.v1 import ValidationError from pydantic.v1 import ValidationError
@ -88,7 +88,7 @@ TYPE_TO_CLASS = {
"iot.strip": IotStrip, "iot.strip": IotStrip,
"iot.lightstrip": IotLightStrip, "iot.lightstrip": IotLightStrip,
"smart.plug": SmartDevice, "smart.plug": SmartDevice,
"smart.bulb": SmartBulb, "smart.bulb": SmartDevice,
} }
ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType] ENCRYPT_TYPES = [encrypt_type.value for encrypt_type in EncryptType]

View File

@ -26,7 +26,7 @@ from .protocol import (
BaseProtocol, BaseProtocol,
BaseTransport, BaseTransport,
) )
from .smart import SmartBulb, SmartDevice from .smart import SmartDevice
from .smartprotocol import SmartProtocol from .smartprotocol import SmartProtocol
from .xortransport import XorTransport from .xortransport import XorTransport
@ -162,12 +162,12 @@ def get_device_class_from_family(device_type: str) -> type[Device] | None:
"""Return the device class from the type name.""" """Return the device class from the type name."""
supported_device_types: dict[str, type[Device]] = { supported_device_types: dict[str, type[Device]] = {
"SMART.TAPOPLUG": SmartDevice, "SMART.TAPOPLUG": SmartDevice,
"SMART.TAPOBULB": SmartBulb, "SMART.TAPOBULB": SmartDevice,
"SMART.TAPOSWITCH": SmartBulb, "SMART.TAPOSWITCH": SmartDevice,
"SMART.KASAPLUG": SmartDevice, "SMART.KASAPLUG": SmartDevice,
"SMART.TAPOHUB": SmartDevice, "SMART.TAPOHUB": SmartDevice,
"SMART.KASAHUB": SmartDevice, "SMART.KASAHUB": SmartDevice,
"SMART.KASASWITCH": SmartBulb, "SMART.KASASWITCH": SmartDevice,
"IOT.SMARTPLUGSWITCH": IotPlug, "IOT.SMARTPLUGSWITCH": IotPlug,
"IOT.SMARTBULB": IotBulb, "IOT.SMARTBULB": IotBulb,
} }

View File

@ -1,7 +1,6 @@
"""Package for supporting tapo-branded and newer kasa devices.""" """Package for supporting tapo-branded and newer kasa devices."""
from .smartbulb import SmartBulb
from .smartchilddevice import SmartChildDevice from .smartchilddevice import SmartChildDevice
from .smartdevice import SmartDevice from .smartdevice import SmartDevice
__all__ = ["SmartDevice", "SmartBulb", "SmartChildDevice"] __all__ = ["SmartDevice", "SmartChildDevice"]

View File

@ -1,189 +0,0 @@
"""Module for tapo-branded smart bulbs (L5**)."""
from __future__ import annotations
from typing import cast
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..exceptions import KasaException
from .modules import Brightness, ColorModule, ColorTemperatureModule
from .smartdevice import SmartDevice
AVAILABLE_EFFECTS = {
"L1": "Party",
"L2": "Relax",
}
class SmartBulb(SmartDevice, Bulb):
"""Representation of a TP-Link Tapo Bulb.
Documentation TBD. See :class:`~kasa.iot.Bulb` for now.
"""
@property
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
return "ColorModule" in self.modules
@property
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
return "Brightness" in self.modules
@property
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
return "ColorTemperatureModule" in self.modules
@property
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
if not self.is_variable_color_temp:
raise KasaException("Color temperature not supported")
return cast(
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).valid_temperature_range
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return "dynamic_light_effect_enable" in self._info
@property
def effect(self) -> dict:
"""Return effect state.
This follows the format used by SmartLightStrip.
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
# If no effect is active, dynamic_light_effect_id does not appear in info
current_effect = self._info.get("dynamic_light_effect_id", "")
data = {
"brightness": self.brightness,
"enable": current_effect != "",
"id": current_effect,
"name": AVAILABLE_EFFECTS.get(current_effect, ""),
}
return data
@property
def effect_list(self) -> list[str] | None:
"""Return built-in effects list.
Example:
['Party', 'Relax', ...]
"""
return list(AVAILABLE_EFFECTS.keys()) if self.has_effects else None
@property
def hsv(self) -> HSV:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""
if not self.is_color:
raise KasaException("Bulb does not support color.")
return cast(ColorModule, self.modules["ColorModule"]).hsv
@property
def color_temp(self) -> int:
"""Whether the bulb supports color temperature changes."""
if not self.is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
return cast(
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).color_temp
@property
def brightness(self) -> int:
"""Return the current brightness in percentage."""
if not self.is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return self._info.get("brightness", -1)
async def set_hsv(
self,
hue: int,
saturation: int,
value: int | None = None,
*,
transition: int | None = None,
) -> dict:
"""Set new HSV.
Note, transition is not supported and will be ignored.
:param int hue: hue in degrees
:param int saturation: saturation in percentage [0,100]
:param int value: value between 1 and 100
:param int transition: transition in milliseconds.
"""
if not self.is_color:
raise KasaException("Bulb does not support color.")
return await cast(ColorModule, self.modules["ColorModule"]).set_hsv(
hue, saturation, value
)
async def set_color_temp(
self, temp: int, *, brightness=None, transition: int | None = None
) -> dict:
"""Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored.
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
if not self.is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
return await cast(
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).set_color_temp(temp)
async def set_brightness(
self, brightness: int, *, transition: int | None = None
) -> dict:
"""Set the brightness in percentage.
Note, transition is not supported and will be ignored.
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
if not self.is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return await cast(Brightness, self.modules["Brightness"]).set_brightness(
brightness
)
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None:
"""Set an effect on the device."""
raise NotImplementedError()
@property
def presets(self) -> list[BulbPreset]:
"""Return a list of available bulb setting presets."""
return []

View File

@ -8,6 +8,7 @@ from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast from typing import TYPE_CHECKING, Any, Mapping, Sequence, cast
from ..aestransport import AesTransport from ..aestransport import AesTransport
from ..bulb import HSV, Bulb, BulbPreset, ColorTempRange
from ..device import Device, WifiNetwork from ..device import Device, WifiNetwork
from ..device_type import DeviceType from ..device_type import DeviceType
from ..deviceconfig import DeviceConfig from ..deviceconfig import DeviceConfig
@ -15,7 +16,16 @@ from ..emeterstatus import EmeterStatus
from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode from ..exceptions import AuthenticationError, DeviceError, KasaException, SmartErrorCode
from ..feature import Feature from ..feature import Feature
from ..smartprotocol import SmartProtocol from ..smartprotocol import SmartProtocol
from .modules import * # noqa: F403 from .modules import (
Brightness,
CloudModule,
ColorModule,
ColorTemperatureModule,
DeviceModule,
EnergyModule,
Firmware,
TimeModule,
)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,8 +38,13 @@ if TYPE_CHECKING:
# same issue, homekit perhaps? # same issue, homekit perhaps?
WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] # noqa: F405 WALL_SWITCH_PARENT_ONLY_MODULES = [DeviceModule, TimeModule, Firmware, CloudModule] # noqa: F405
AVAILABLE_BULB_EFFECTS = {
"L1": "Party",
"L2": "Relax",
}
class SmartDevice(Device):
class SmartDevice(Device, Bulb):
"""Base class to represent a SMART protocol based device.""" """Base class to represent a SMART protocol based device."""
def __init__( def __init__(
@ -404,6 +419,11 @@ class SmartDevice(Device):
"""Return if the device has emeter.""" """Return if the device has emeter."""
return "EnergyModule" in self.modules return "EnergyModule" in self.modules
@property
def is_dimmer(self) -> bool:
"""Whether the device acts as a dimmer."""
return self.is_dimmable
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if the device is on.""" """Return true if the device is on."""
@ -613,3 +633,172 @@ class SmartDevice(Device):
return DeviceType.WallSwitch return DeviceType.WallSwitch
_LOGGER.warning("Unknown device type, falling back to plug") _LOGGER.warning("Unknown device type, falling back to plug")
return DeviceType.Plug return DeviceType.Plug
# Bulb interface methods
@property
def is_color(self) -> bool:
"""Whether the bulb supports color changes."""
return "ColorModule" in self.modules
@property
def is_dimmable(self) -> bool:
"""Whether the bulb supports brightness changes."""
return "Brightness" in self.modules
@property
def is_variable_color_temp(self) -> bool:
"""Whether the bulb supports color temperature changes."""
return "ColorTemperatureModule" in self.modules
@property
def valid_temperature_range(self) -> ColorTempRange:
"""Return the device-specific white temperature range (in Kelvin).
:return: White temperature range in Kelvin (minimum, maximum)
"""
if not self.is_variable_color_temp:
raise KasaException("Color temperature not supported")
return cast(
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).valid_temperature_range
@property
def has_effects(self) -> bool:
"""Return True if the device supports effects."""
return "dynamic_light_effect_enable" in self._info
@property
def effect(self) -> dict:
"""Return effect state.
This follows the format used by SmartLightStrip.
Example:
{'brightness': 50,
'custom': 0,
'enable': 0,
'id': '',
'name': ''}
"""
# If no effect is active, dynamic_light_effect_id does not appear in info
current_effect = self._info.get("dynamic_light_effect_id", "")
data = {
"brightness": self.brightness,
"enable": current_effect != "",
"id": current_effect,
"name": AVAILABLE_BULB_EFFECTS.get(current_effect, ""),
}
return data
@property
def effect_list(self) -> list[str] | None:
"""Return built-in effects list.
Example:
['Party', 'Relax', ...]
"""
return list(AVAILABLE_BULB_EFFECTS.keys()) if self.has_effects else None
@property
def hsv(self) -> HSV:
"""Return the current HSV state of the bulb.
:return: hue, saturation and value (degrees, %, %)
"""
if not self.is_color:
raise KasaException("Bulb does not support color.")
return cast(ColorModule, self.modules["ColorModule"]).hsv
@property
def color_temp(self) -> int:
"""Whether the bulb supports color temperature changes."""
if not self.is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
return cast(
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).color_temp
@property
def brightness(self) -> int:
"""Return the current brightness in percentage."""
if not self.is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return cast(Brightness, self.modules["Brightness"]).brightness
async def set_hsv(
self,
hue: int,
saturation: int,
value: int | None = None,
*,
transition: int | None = None,
) -> dict:
"""Set new HSV.
Note, transition is not supported and will be ignored.
:param int hue: hue in degrees
:param int saturation: saturation in percentage [0,100]
:param int value: value between 1 and 100
:param int transition: transition in milliseconds.
"""
if not self.is_color:
raise KasaException("Bulb does not support color.")
return await cast(ColorModule, self.modules["ColorModule"]).set_hsv(
hue, saturation, value
)
async def set_color_temp(
self, temp: int, *, brightness=None, transition: int | None = None
) -> dict:
"""Set the color temperature of the device in kelvin.
Note, transition is not supported and will be ignored.
:param int temp: The new color temperature, in Kelvin
:param int transition: transition in milliseconds.
"""
if not self.is_variable_color_temp:
raise KasaException("Bulb does not support colortemp.")
return await cast(
ColorTemperatureModule, self.modules["ColorTemperatureModule"]
).set_color_temp(temp)
async def set_brightness(
self, brightness: int, *, transition: int | None = None
) -> dict:
"""Set the brightness in percentage.
Note, transition is not supported and will be ignored.
:param int brightness: brightness in percent
:param int transition: transition in milliseconds.
"""
if not self.is_dimmable: # pragma: no cover
raise KasaException("Bulb is not dimmable.")
return await cast(Brightness, self.modules["Brightness"]).set_brightness(
brightness
)
async def set_effect(
self,
effect: str,
*,
brightness: int | None = None,
transition: int | None = None,
) -> None:
"""Set an effect on the device."""
raise NotImplementedError()
@property
def presets(self) -> list[BulbPreset]:
"""Return a list of available bulb setting presets."""
return []

View File

@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from itertools import chain
import pytest import pytest
from kasa import ( from kasa import (
@ -11,7 +9,7 @@ from kasa import (
Discover, Discover,
) )
from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch from kasa.iot import IotBulb, IotDimmer, IotLightStrip, IotPlug, IotStrip, IotWallSwitch
from kasa.smart import SmartBulb, SmartDevice from kasa.smart import SmartDevice
from .fakeprotocol_iot import FakeIotProtocol from .fakeprotocol_iot import FakeIotProtocol
from .fakeprotocol_smart import FakeSmartProtocol from .fakeprotocol_smart import FakeSmartProtocol
@ -319,19 +317,7 @@ check_categories()
def device_for_fixture_name(model, protocol): def device_for_fixture_name(model, protocol):
if "SMART" in protocol: if "SMART" in protocol:
for d in chain( return SmartDevice
PLUGS_SMART,
SWITCHES_SMART,
STRIPS_SMART,
HUBS_SMART,
SENSORS_SMART,
THERMOSTATS_SMART,
):
if d in model:
return SmartDevice
for d in chain(BULBS_SMART, DIMMERS_SMART):
if d in model:
return SmartBulb
else: else:
for d in STRIPS_IOT: for d in STRIPS_IOT:
if d in model: if d in model:

View File

@ -7,9 +7,9 @@ from voluptuous import (
Schema, Schema,
) )
from kasa import Bulb, BulbPreset, DeviceType, KasaException from kasa import Bulb, BulbPreset, Device, DeviceType, KasaException
from kasa.iot import IotBulb from kasa.iot import IotBulb, IotDimmer
from kasa.smart import SmartBulb from kasa.smart import SmartDevice
from .conftest import ( from .conftest import (
bulb, bulb,
@ -30,7 +30,7 @@ from .test_iotdevice import SYSINFO_SCHEMA
@bulb @bulb
async def test_bulb_sysinfo(dev: Bulb): async def test_bulb_sysinfo(dev: Device):
assert dev.sys_info is not None assert dev.sys_info is not None
SYSINFO_SCHEMA_BULB(dev.sys_info) SYSINFO_SCHEMA_BULB(dev.sys_info)
@ -43,7 +43,7 @@ async def test_bulb_sysinfo(dev: Bulb):
@bulb @bulb
async def test_state_attributes(dev: Bulb): async def test_state_attributes(dev: Device):
assert "Cloud connection" in dev.state_information assert "Cloud connection" in dev.state_information
assert isinstance(dev.state_information["Cloud connection"], bool) assert isinstance(dev.state_information["Cloud connection"], bool)
@ -64,7 +64,8 @@ async def test_get_light_state(dev: IotBulb):
@color_bulb @color_bulb
@turn_on @turn_on
async def test_hsv(dev: Bulb, turn_on): async def test_hsv(dev: Device, turn_on):
assert isinstance(dev, Bulb)
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_color assert dev.is_color
@ -114,7 +115,8 @@ async def test_invalid_hsv(dev: Bulb, turn_on):
@color_bulb @color_bulb
@pytest.mark.skip("requires color feature") @pytest.mark.skip("requires color feature")
async def test_color_state_information(dev: Bulb): async def test_color_state_information(dev: Device):
assert isinstance(dev, Bulb)
assert "HSV" in dev.state_information assert "HSV" in dev.state_information
assert dev.state_information["HSV"] == dev.hsv assert dev.state_information["HSV"] == dev.hsv
@ -131,14 +133,16 @@ async def test_hsv_on_non_color(dev: Bulb):
@variable_temp @variable_temp
@pytest.mark.skip("requires colortemp module") @pytest.mark.skip("requires colortemp module")
async def test_variable_temp_state_information(dev: Bulb): async def test_variable_temp_state_information(dev: Device):
assert isinstance(dev, Bulb)
assert "Color temperature" in dev.state_information assert "Color temperature" in dev.state_information
assert dev.state_information["Color temperature"] == dev.color_temp assert dev.state_information["Color temperature"] == dev.color_temp
@variable_temp @variable_temp
@turn_on @turn_on
async def test_try_set_colortemp(dev: Bulb, turn_on): async def test_try_set_colortemp(dev: Device, turn_on):
assert isinstance(dev, Bulb)
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
await dev.set_color_temp(2700) await dev.set_color_temp(2700)
await dev.update() await dev.update()
@ -162,7 +166,7 @@ async def test_unknown_temp_range(dev: IotBulb, monkeypatch, caplog):
@variable_temp_smart @variable_temp_smart
async def test_smart_temp_range(dev: SmartBulb): async def test_smart_temp_range(dev: SmartDevice):
assert dev.valid_temperature_range assert dev.valid_temperature_range
@ -188,7 +192,8 @@ async def test_non_variable_temp(dev: Bulb):
@dimmable @dimmable
@turn_on @turn_on
async def test_dimmable_brightness(dev: Bulb, turn_on): async def test_dimmable_brightness(dev: Device, turn_on):
assert isinstance(dev, (Bulb, IotDimmer))
await handle_turn_on(dev, turn_on) await handle_turn_on(dev, turn_on)
assert dev.is_dimmable assert dev.is_dimmable

View File

@ -61,7 +61,16 @@ async def test_childdevice_properties(dev: SmartChildDevice):
# Skip emeter and time properties # Skip emeter and time properties
# TODO: needs API cleanup, emeter* should probably be removed in favor # TODO: needs API cleanup, emeter* should probably be removed in favor
# of access through features/modules, handling of time* needs decision. # of access through features/modules, handling of time* needs decision.
if name.startswith("emeter_") or name.startswith("time"): if (
name.startswith("emeter_")
or name.startswith("time")
or name.startswith("fan")
or name.startswith("color")
or name.startswith("brightness")
or name.startswith("valid_temperature_range")
or name.startswith("hsv")
or name.startswith("effect")
):
continue continue
try: try:
_ = getattr(first, name) _ = getattr(first, name)

View File

@ -11,7 +11,7 @@ from pytest_mock import MockerFixture
from kasa import KasaException from kasa import KasaException
from kasa.exceptions import SmartErrorCode from kasa.exceptions import SmartErrorCode
from kasa.smart import SmartBulb, SmartDevice from kasa.smart import SmartDevice
from .conftest import ( from .conftest import (
bulb_smart, bulb_smart,
@ -122,7 +122,7 @@ async def test_update_module_queries(dev: SmartDevice, mocker: MockerFixture):
@bulb_smart @bulb_smart
async def test_smartdevice_brightness(dev: SmartBulb): async def test_smartdevice_brightness(dev: SmartDevice):
"""Test brightness setter and getter.""" """Test brightness setter and getter."""
assert isinstance(dev, SmartDevice) assert isinstance(dev, SmartDevice)
assert "brightness" in dev._components assert "brightness" in dev._components