From da58dee1d5013d7ebed631c4620e18518a1a4742 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 18 Aug 2023 20:29:45 +0300 Subject: [PATCH 1/9] Add preliminary support for tapo plugs --- kasa/__init__.py | 1 + kasa/cli.py | 2 + kasa/tapoplug.py | 174 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 kasa/tapoplug.py diff --git a/kasa/__init__.py b/kasa/__init__.py index 4ccf6286b..399debda5 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -28,6 +28,7 @@ from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug from kasa.smartstrip import SmartStrip +from kasa.tapoplug import TapoPlug __version__ = version("python-kasa") diff --git a/kasa/cli.py b/kasa/cli.py index 3bc779346..bdffddf29 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -41,8 +41,10 @@ def wrapper(message=None, *args, **kwargs): # echo is set to _do_echo so that it can be reset to _do_echo later after # --json has set it to _nop_echo echo = _do_echo + TapoPlug, TYPE_TO_CLASS = { + "tapoplug": TapoPlug, "plug": SmartPlug, "bulb": SmartBulb, "dimmer": SmartDimmer, diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py new file mode 100644 index 000000000..c1cda96c3 --- /dev/null +++ b/kasa/tapoplug.py @@ -0,0 +1,174 @@ +import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, Set + +from plugp100.api.plug_device import PlugDevice +from plugp100.api.tapo_client import TapoClient +from plugp100.responses.device_state import DeviceInfo, PlugDeviceState +from plugp100.responses.device_usage_info import DeviceUsageInfo +from plugp100.responses.energy_info import EnergyInfo +from plugp100.responses.power_info import PowerInfo + +from . import EmeterStatus +from .smartdevice import DeviceType, SmartDevice +from .smartplug import SmartPlug + +_LOGGER = logging.getLogger(__name__) + + +# TODO: there should be a baseclass for plugs that does not initialize modules etc. that are related only to some implementations +class TapoPlug(SmartPlug): + def __init__(self, host: str, *, port: Optional[int] = None) -> None: + # TODO: we are calling smartdevice here to avoid smartplug internal handling + SmartDevice.__init__(self, host, port=port) + # TODO: this is needed as we don't call smartplug ctor + self._device_type = DeviceType.Plug + env = os.environ + self._tapo_client = TapoClient( + env["KASA_TAPO_EMAIL"], env["KASA_TAPO_PASSWORD"] + ) + self._tapo_device = PlugDevice(self._tapo_client, self.host) + self._state = None + + async def update(self, update_children: bool = True): + if self._state is None: + await self._tapo_device.login() + + # TODO: check for success as + self._state = (await self._tapo_device.get_state()).value + self._info: DeviceInfo = self._state.info + + self._usage: DeviceUsageInfo = ( + await self._tapo_device.get_device_usage() + ).value + self._energy: EnergyInfo = (await self._tapo_device.get_energy_usage()).value + self._emeter: PowerInfo = (await self._tapo_device.get_current_power()).value + + self._last_update = self._data = { + "state": self._state, + "usage": self._usage, + "emeter": self._emeter, + "energy": self._energy, + } + + _LOGGER.debug("Got an update: %s", self._data) + + @property + def sys_info(self) -> Dict[str, Any]: + return self._state + + @property + def model(self) -> str: + return self._info.model + + @property + def alias(self) -> str: + return self._info.nickname + + @property + def time(self) -> datetime: + return None # TODO: make return value optional + + @property + def timezone(self) -> Dict: + return {"timezone": self._info.timezone, "timediff": self._info.time_difference} + + def has_emeter(self) -> bool: + return True + + @property + def emeter_realtime(self) -> EmeterStatus: + return EmeterStatus({"power_mw": self._energy.current_power}) + + @property + def emeter_today(self) -> Optional[float]: + return None + + @property + def emeter_this_month(self) -> Optional[float]: + return None + + @property + def hw_info(self) -> Dict: + # TODO: check that the keys match to kasa-infos + return { + "sw_ver": self._info.firmware_version, + "hw_ver": self._info.hardware_version, + "mac": self._info.mac, + "type": self._info.type, + "hwId": self._info.device_id, + "dev_name": self._info.nickname, + "oemId": self._info.oem_id, + } + + @property + def location(self) -> Dict: + loc = { + "latitude": self._info.latitude / 10_000, + "longitude": self._info.longitude / 10_000, + } + return loc + + @property + def rssi(self) -> Optional[int]: + return self._info.rssi + + @property + def mac(self) -> str: + return self._info.mac.replace("-", ":") + + @property + def device_id(self) -> str: + return self._info.device_id + + @property + def internal_state(self) -> Any: + return self._data + + @property + def is_on(self) -> bool: + return self._state.device_on + + async def turn_on(self, **kwargs): + return await self._tapo_device.on() + + async def turn_off(self, **kwargs): + return await self._tapo_device.off() + + async def _query_helper( + self, target: str, cmd: str, arg: Optional[Dict] = None, child_ids=None + ) -> Any: + res = await self._tapo_device.raw_command(cmd, arg) + if res.is_left(): + raise res.error + return res.value + + @property + def led(self) -> bool: + return None + + async def set_led(self, state: bool): + return await super().set_led(state) + + @property + def on_since(self) -> Optional[datetime]: + on_time = self._info.on_time + return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) + + @property + def state_information(self) -> Dict[str, Any]: + return { + "is_hw_v2": self._info.is_hardware_v2, + "overheated": self._info.overheated, + "signal_level": self._info.signal_level, + "auto_off": self._info.auto_off, + "auto_off_remaining": self._info.auto_off_time_remaining, + "On since": self.on_since, + "SSID": self._info.ssid, + } + + @property + def features(self) -> Set[str]: + # TODO: + return set() From f2095f4312c6464608490a3a29d2a09611cd933e Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Fri, 18 Aug 2023 22:17:14 +0300 Subject: [PATCH 2/9] Add support for reporting the current device time --- kasa/tapoplug.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index c1cda96c3..33aa50090 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -9,6 +9,7 @@ from plugp100.responses.device_usage_info import DeviceUsageInfo from plugp100.responses.energy_info import EnergyInfo from plugp100.responses.power_info import PowerInfo +from plugp100.responses.time_info import TimeInfo from . import EmeterStatus from .smartdevice import DeviceType, SmartDevice @@ -44,12 +45,14 @@ async def update(self, update_children: bool = True): ).value self._energy: EnergyInfo = (await self._tapo_device.get_energy_usage()).value self._emeter: PowerInfo = (await self._tapo_device.get_current_power()).value + self._time: TimeInfo = (await self._tapo_device.get_device_time()).value self._last_update = self._data = { "state": self._state, "usage": self._usage, "emeter": self._emeter, "energy": self._energy, + "time": self._time, } _LOGGER.debug("Got an update: %s", self._data) @@ -68,7 +71,7 @@ def alias(self) -> str: @property def time(self) -> datetime: - return None # TODO: make return value optional + return self._time.local_time() @property def timezone(self) -> Dict: From dc646d88fc4579f3ba81914ef086b0441b77aa4d Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 19 Aug 2023 17:21:48 +0300 Subject: [PATCH 3/9] Read on_time and auto_off from device state --- kasa/tapoplug.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index 33aa50090..649409104 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -46,7 +46,6 @@ async def update(self, update_children: bool = True): self._energy: EnergyInfo = (await self._tapo_device.get_energy_usage()).value self._emeter: PowerInfo = (await self._tapo_device.get_current_power()).value self._time: TimeInfo = (await self._tapo_device.get_device_time()).value - self._last_update = self._data = { "state": self._state, "usage": self._usage, @@ -156,7 +155,7 @@ async def set_led(self, state: bool): @property def on_since(self) -> Optional[datetime]: - on_time = self._info.on_time + on_time = self._state.on_time return datetime.now().replace(microsecond=0) - timedelta(seconds=on_time) @property @@ -165,8 +164,8 @@ def state_information(self) -> Dict[str, Any]: "is_hw_v2": self._info.is_hardware_v2, "overheated": self._info.overheated, "signal_level": self._info.signal_level, - "auto_off": self._info.auto_off, - "auto_off_remaining": self._info.auto_off_time_remaining, + "auto_off": self._state.auto_off, + "auto_off_remaining": self._state.auto_off_time_remaining, "On since": self.on_since, "SSID": self._info.ssid, } From 394a0bf769ba572bf960acfb83877ce6810781e0 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 26 Aug 2023 15:54:32 +0300 Subject: [PATCH 4/9] adapt to recent uptream api changes --- kasa/tapoplug.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index 649409104..28fb6f61e 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -10,6 +10,8 @@ from plugp100.responses.energy_info import EnergyInfo from plugp100.responses.power_info import PowerInfo from plugp100.responses.time_info import TimeInfo +from plugp100.common.credentials import AuthCredential + from . import EmeterStatus from .smartdevice import DeviceType, SmartDevice @@ -27,15 +29,12 @@ def __init__(self, host: str, *, port: Optional[int] = None) -> None: self._device_type = DeviceType.Plug env = os.environ self._tapo_client = TapoClient( - env["KASA_TAPO_EMAIL"], env["KASA_TAPO_PASSWORD"] + AuthCredential(username=env["KASA_TAPO_EMAIL"], password=env["KASA_TAPO_PASSWORD"]), self.host ) - self._tapo_device = PlugDevice(self._tapo_client, self.host) + self._tapo_device = PlugDevice(self._tapo_client) self._state = None async def update(self, update_children: bool = True): - if self._state is None: - await self._tapo_device.login() - # TODO: check for success as self._state = (await self._tapo_device.get_state()).value self._info: DeviceInfo = self._state.info From 57bad974e14819157c66313485da70f32449601a Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 26 Aug 2023 16:48:03 +0300 Subject: [PATCH 5/9] initialize tapoplug using new discovery protocol responses --- kasa/discover.py | 27 ++++++++++++++++++++++----- kasa/tapoplug.py | 8 +++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index 5b11bed5e..668528e59 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -21,6 +21,7 @@ from kasa.smartlightstrip import SmartLightStrip from kasa.smartplug import SmartPlug from kasa.smartstrip import SmartStrip +from kasa.tapoplug import TapoPlug _LOGGER = logging.getLogger(__name__) @@ -104,10 +105,15 @@ def datagram_received(self, data, addr) -> None: if port == self.discovery_port: info = json_loads(TPLinkSmartHomeProtocol.decrypt(data)) - _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) - elif port == Discover.DISCOVERY_PORT_2: info = json_loads(data[16:]) + else: + raise SmartDeviceException("Received response from unexpected port %s" % port) + + _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) + try: + device_class = Discover._get_device_class(info) + except UnsupportDeviceException as ex: self.unsupported_devices[ip] = info if self.on_unsupported is not None: asyncio.ensure_future(self.on_unsupported(info)) @@ -115,9 +121,6 @@ def datagram_received(self, data, addr) -> None: if self.discovered_event is not None: self.discovered_event.set() return - - try: - device_class = Discover._get_device_class(info) except SmartDeviceException as ex: _LOGGER.debug( "[DISCOVERY] Unable to find device type from %s: %s", info, ex @@ -131,6 +134,9 @@ def datagram_received(self, data, addr) -> None: ip, port=port, credentials=self.credentials, timeout=self.timeout ) device.update_from_discover_info(info) + # TODO: hack to force update on non-authenticated discoveries + # this is not optimal, and needs rewiring when authentication parts are in-place + asyncio.ensure_future(device.update()) self.discovered_devices[ip] = device @@ -377,6 +383,17 @@ async def connect_single( @staticmethod def _get_device_class(info: dict) -> Type[SmartDevice]: """Find SmartDevice subclass for device described by passed data.""" + # 1. Check for the discovery payloads from port 20002/udp + if "result" in info: + supported_device_types = { + "SMART.TAPOPLUG": TapoPlug, + } + if (device_type := info["result"].get("device_type")) in supported_device_types: + return supported_device_types[device_type] + else: + raise UnsupportDeviceException("Found unsupported device: %s" % info) + + # 2. Fallback to old 9999/udp discovery if "system" not in info or "get_sysinfo" not in info["system"]: raise SmartDeviceException("No 'system' or 'get_sysinfo' in response") diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index 28fb6f61e..238808808 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -13,7 +13,7 @@ from plugp100.common.credentials import AuthCredential -from . import EmeterStatus +from .emeterstatus import EmeterStatus from .smartdevice import DeviceType, SmartDevice from .smartplug import SmartPlug @@ -173,3 +173,9 @@ def state_information(self) -> Dict[str, Any]: def features(self) -> Set[str]: # TODO: return set() + + def update_from_discover_info(self, info): + """This can be used to update the state from discovery responses. + + As this works only on the unauthenticated discovery responses, we do nothing here. + """ From c924a20c928bf3c114a30c1d090925a8b5e2dc21 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 12 Sep 2023 01:26:50 +0300 Subject: [PATCH 6/9] Use passed credentials instead of environment variables --- kasa/tapoplug.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index 238808808..5d8a31d2a 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -22,14 +22,14 @@ # TODO: there should be a baseclass for plugs that does not initialize modules etc. that are related only to some implementations class TapoPlug(SmartPlug): - def __init__(self, host: str, *, port: Optional[int] = None) -> None: + def __init__(self, host: str, *, port: Optional[int] = None, credentials) -> None: # TODO: we are calling smartdevice here to avoid smartplug internal handling - SmartDevice.__init__(self, host, port=port) + SmartDevice.__init__(self, host, port=port, credentials=credentials) # TODO: this is needed as we don't call smartplug ctor self._device_type = DeviceType.Plug env = os.environ self._tapo_client = TapoClient( - AuthCredential(username=env["KASA_TAPO_EMAIL"], password=env["KASA_TAPO_PASSWORD"]), self.host + AuthCredential(username=credentials.username, password=credentials.password), self.host ) self._tapo_device = PlugDevice(self._tapo_client) self._state = None From 12208af79b03eb5ab33e8deb0d088a7e2a5ff6a2 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Sat, 16 Sep 2023 21:20:57 +0300 Subject: [PATCH 7/9] Raise AuthenticationException if no valid credentials are given --- kasa/cli.py | 2 +- kasa/discover.py | 8 ++++++-- kasa/tapoplug.py | 23 +++++++++++++++-------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index bdffddf29..dbb675d3f 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -19,6 +19,7 @@ SmartLightStrip, SmartPlug, SmartStrip, + TapoPlug, ) try: @@ -41,7 +42,6 @@ def wrapper(message=None, *args, **kwargs): # echo is set to _do_echo so that it can be reset to _do_echo later after # --json has set it to _nop_echo echo = _do_echo - TapoPlug, TYPE_TO_CLASS = { "tapoplug": TapoPlug, diff --git a/kasa/discover.py b/kasa/discover.py index 668528e59..506069d35 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -108,7 +108,9 @@ def datagram_received(self, data, addr) -> None: elif port == Discover.DISCOVERY_PORT_2: info = json_loads(data[16:]) else: - raise SmartDeviceException("Received response from unexpected port %s" % port) + raise SmartDeviceException( + "Received response from unexpected port %s" % port + ) _LOGGER.debug("[DISCOVERY] %s << %s", ip, info) try: @@ -388,7 +390,9 @@ def _get_device_class(info: dict) -> Type[SmartDevice]: supported_device_types = { "SMART.TAPOPLUG": TapoPlug, } - if (device_type := info["result"].get("device_type")) in supported_device_types: + if ( + device_type := info["result"].get("device_type") + ) in supported_device_types: return supported_device_types[device_type] else: raise UnsupportDeviceException("Found unsupported device: %s" % info) diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index 5d8a31d2a..cc4cc2ab9 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -5,15 +5,15 @@ from plugp100.api.plug_device import PlugDevice from plugp100.api.tapo_client import TapoClient +from plugp100.common.credentials import AuthCredential from plugp100.responses.device_state import DeviceInfo, PlugDeviceState from plugp100.responses.device_usage_info import DeviceUsageInfo from plugp100.responses.energy_info import EnergyInfo from plugp100.responses.power_info import PowerInfo from plugp100.responses.time_info import TimeInfo -from plugp100.common.credentials import AuthCredential - from .emeterstatus import EmeterStatus +from .exceptions import AuthenticationException from .smartdevice import DeviceType, SmartDevice from .smartplug import SmartPlug @@ -27,15 +27,22 @@ def __init__(self, host: str, *, port: Optional[int] = None, credentials) -> Non SmartDevice.__init__(self, host, port=port, credentials=credentials) # TODO: this is needed as we don't call smartplug ctor self._device_type = DeviceType.Plug - env = os.environ - self._tapo_client = TapoClient( - AuthCredential(username=credentials.username, password=credentials.password), self.host - ) - self._tapo_device = PlugDevice(self._tapo_client) + self._tapo_client = None + self._tapo_device = None self._state = None async def update(self, update_children: bool = True): - # TODO: check for success as + if self.credentials is None or self.credentials.username is None: + raise AuthenticationException("Tapo plug requires authentication.") + + if self._tapo_client is None: + user = self.credentials.username + pw = self.credentials.password + self._tapo_client = TapoClient( + AuthCredential(username=user, password=pw), self.host + ) + self._tapo_device = PlugDevice(self._tapo_client) + self._state = (await self._tapo_device.get_state()).value self._info: DeviceInfo = self._state.info From 4e4ce752f7b62e99180eb906d7b0337f216e7470 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Tue, 21 Nov 2023 15:03:56 +0100 Subject: [PATCH 8/9] Initialize the client as it's not done automatically anymore --- kasa/tapoplug.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index cc4cc2ab9..ca0029e13 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -41,6 +41,7 @@ async def update(self, update_children: bool = True): self._tapo_client = TapoClient( AuthCredential(username=user, password=pw), self.host ) + await self._tapo_client.initialize() self._tapo_device = PlugDevice(self._tapo_client) self._state = (await self._tapo_device.get_state()).value From 676e4a42fa9abb0367ef8b415c14ac49faf4f064 Mon Sep 17 00:00:00 2001 From: Teemu Rytilahti Date: Wed, 22 Nov 2023 19:56:29 +0100 Subject: [PATCH 9/9] Disable time&timezone for now --- kasa/tapoplug.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/kasa/tapoplug.py b/kasa/tapoplug.py index ca0029e13..55b958a03 100644 --- a/kasa/tapoplug.py +++ b/kasa/tapoplug.py @@ -77,10 +77,28 @@ def alias(self) -> str: @property def time(self) -> datetime: + """Disabled as 'region' is '' on my test device + File "/x/python-kasa/kasa/tapoplug.py", line 80, in time + return self._time.local_time() + ^^^^^^^^^^^^^^^^^^^^^^^ + File "x/plugp100/responses/time_info.py", line 19, in local_time + return datetime.fromtimestamp(self.timestamp, tz=ZoneInfo(self.region)) + ^^^^^^^^^^^^^^^^^^^^^ + """ + return None return self._time.local_time() @property def timezone(self) -> Dict: + """Disabled as 'region' is '' on my test device + File "/x/python-kasa/kasa/tapoplug.py", line 80, in time + return self._time.local_time() + ^^^^^^^^^^^^^^^^^^^^^^^ + File "x/plugp100/responses/time_info.py", line 19, in local_time + return datetime.fromtimestamp(self.timestamp, tz=ZoneInfo(self.region)) + ^^^^^^^^^^^^^^^^^^^^^ + """ + return None return {"timezone": self._info.timezone, "timediff": self._info.time_difference} def has_emeter(self) -> bool: