From 14acc8550e2ad342602c4423141f5ccd32fc6b4a Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jan 2024 15:28:30 +0000 Subject: [PATCH 01/35] Rename base TPLinkProtocol to BaseProtocol (#669) --- kasa/__init__.py | 4 ++-- kasa/device_factory.py | 6 +++--- kasa/iotprotocol.py | 4 ++-- kasa/protocol.py | 4 ++-- kasa/smartbulb.py | 4 ++-- kasa/smartdevice.py | 6 +++--- kasa/smartdimmer.py | 4 ++-- kasa/smartlightstrip.py | 4 ++-- kasa/smartplug.py | 4 ++-- kasa/smartprotocol.py | 4 ++-- kasa/smartstrip.py | 4 ++-- kasa/tapo/tapodevice.py | 4 ++-- kasa/tapo/tapoplug.py | 4 ++-- kasa/tests/test_protocol.py | 4 ++-- 14 files changed, 30 insertions(+), 30 deletions(-) diff --git a/kasa/__init__.py b/kasa/__init__.py index 3465147aa..a8101ae3e 100755 --- a/kasa/__init__.py +++ b/kasa/__init__.py @@ -29,7 +29,7 @@ UnsupportedDeviceException, ) from kasa.iotprotocol import IotProtocol -from kasa.protocol import TPLinkProtocol, TPLinkSmartHomeProtocol +from kasa.protocol import BaseProtocol, TPLinkSmartHomeProtocol from kasa.smartbulb import SmartBulb, SmartBulbPreset, TurnOnBehavior, TurnOnBehaviors from kasa.smartdevice import DeviceType, SmartDevice from kasa.smartdimmer import SmartDimmer @@ -44,7 +44,7 @@ __all__ = [ "Discover", "TPLinkSmartHomeProtocol", - "TPLinkProtocol", + "BaseProtocol", "IotProtocol", "SmartProtocol", "SmartBulb", diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 757f0c337..83db093f4 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -9,8 +9,8 @@ from .iotprotocol import IotProtocol from .klaptransport import KlapTransport, KlapTransportV2 from .protocol import ( + BaseProtocol, BaseTransport, - TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport, ) @@ -141,14 +141,14 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice] def get_protocol( config: DeviceConfig, -) -> Optional[TPLinkProtocol]: +) -> Optional[BaseProtocol]: """Return the protocol from the connection name.""" protocol_name = config.connection_type.device_family.value.split(".")[0] protocol_transport_key = ( protocol_name + "." + config.connection_type.encryption_type.value ) supported_device_protocols: Dict[ - str, Tuple[Type[TPLinkProtocol], Type[BaseTransport]] + str, Tuple[Type[BaseProtocol], Type[BaseTransport]] ] = { "IOT.XOR": (TPLinkSmartHomeProtocol, _XorTransport), "IOT.KLAP": (IotProtocol, KlapTransport), diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index 9f72bbc0a..c58cc8802 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -11,12 +11,12 @@ TimeoutException, ) from .json import dumps as json_dumps -from .protocol import BaseTransport, TPLinkProtocol +from .protocol import BaseProtocol, BaseTransport _LOGGER = logging.getLogger(__name__) -class IotProtocol(TPLinkProtocol): +class IotProtocol(BaseProtocol): """Class for the legacy TPLink IOT KASA Protocol.""" BACKOFF_SECONDS_AFTER_TIMEOUT = 1 diff --git a/kasa/protocol.py b/kasa/protocol.py index 74023e017..a63250fac 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -79,7 +79,7 @@ async def close(self) -> None: """Close the transport. Abstract method to be overriden.""" -class TPLinkProtocol(ABC): +class BaseProtocol(ABC): """Base class for all TP-Link Smart Home communication.""" def __init__( @@ -140,7 +140,7 @@ async def close(self) -> None: """Close the transport. Abstract method to be overriden.""" -class TPLinkSmartHomeProtocol(TPLinkProtocol): +class TPLinkSmartHomeProtocol(BaseProtocol): """Implementation of the TP-Link Smart Home protocol.""" INITIALIZATION_VECTOR = 171 diff --git a/kasa/smartbulb.py b/kasa/smartbulb.py index 8897ceceb..5b5ae573f 100644 --- a/kasa/smartbulb.py +++ b/kasa/smartbulb.py @@ -11,7 +11,7 @@ from .deviceconfig import DeviceConfig from .modules import Antitheft, Cloud, Countdown, Emeter, Schedule, Time, Usage -from .protocol import TPLinkProtocol +from .protocol import BaseProtocol from .smartdevice import DeviceType, SmartDevice, SmartDeviceException, requires_update @@ -222,7 +222,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Bulb diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 54e7c4492..08a6bfb65 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -25,7 +25,7 @@ from .emeterstatus import EmeterStatus from .exceptions import SmartDeviceException from .modules import Emeter, Module -from .protocol import TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport +from .protocol import BaseProtocol, TPLinkSmartHomeProtocol, _XorTransport _LOGGER = logging.getLogger(__name__) @@ -196,7 +196,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: """Create a new SmartDevice instance. @@ -204,7 +204,7 @@ def __init__( """ if config and protocol: protocol._transport._config = config - self.protocol: TPLinkProtocol = protocol or TPLinkSmartHomeProtocol( + self.protocol: BaseProtocol = protocol or TPLinkSmartHomeProtocol( transport=_XorTransport(config=config or DeviceConfig(host=host)), ) _LOGGER.debug("Initializing %s of type %s", self.host, type(self)) diff --git a/kasa/smartdimmer.py b/kasa/smartdimmer.py index ca0960f11..97738cc43 100644 --- a/kasa/smartdimmer.py +++ b/kasa/smartdimmer.py @@ -4,7 +4,7 @@ from kasa.deviceconfig import DeviceConfig from kasa.modules import AmbientLight, Motion -from kasa.protocol import TPLinkProtocol +from kasa.protocol import BaseProtocol from kasa.smartdevice import DeviceType, SmartDeviceException, requires_update from kasa.smartplug import SmartPlug @@ -70,7 +70,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Dimmer diff --git a/kasa/smartlightstrip.py b/kasa/smartlightstrip.py index 27ebf8381..103ecfa88 100644 --- a/kasa/smartlightstrip.py +++ b/kasa/smartlightstrip.py @@ -3,7 +3,7 @@ from .deviceconfig import DeviceConfig from .effects import EFFECT_MAPPING_V1, EFFECT_NAMES_V1 -from .protocol import TPLinkProtocol +from .protocol import BaseProtocol from .smartbulb import SmartBulb from .smartdevice import DeviceType, SmartDeviceException, requires_update @@ -48,7 +48,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.LightStrip diff --git a/kasa/smartplug.py b/kasa/smartplug.py index d9ac0c863..e8251b689 100644 --- a/kasa/smartplug.py +++ b/kasa/smartplug.py @@ -4,7 +4,7 @@ from kasa.deviceconfig import DeviceConfig from kasa.modules import Antitheft, Cloud, Schedule, Time, Usage -from kasa.protocol import TPLinkProtocol +from kasa.protocol import BaseProtocol from kasa.smartdevice import DeviceType, SmartDevice, requires_update _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index c50c511f9..c28db948e 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -24,12 +24,12 @@ TimeoutException, ) from .json import dumps as json_dumps -from .protocol import BaseTransport, TPLinkProtocol, md5 +from .protocol import BaseProtocol, BaseTransport, md5 _LOGGER = logging.getLogger(__name__) -class SmartProtocol(TPLinkProtocol): +class SmartProtocol(BaseProtocol): """Class for the new TPLink SMART protocol.""" BACKOFF_SECONDS_AFTER_TIMEOUT = 1 diff --git a/kasa/smartstrip.py b/kasa/smartstrip.py index 793931325..b1e967c45 100755 --- a/kasa/smartstrip.py +++ b/kasa/smartstrip.py @@ -16,7 +16,7 @@ from .deviceconfig import DeviceConfig from .modules import Antitheft, Countdown, Emeter, Schedule, Time, Usage -from .protocol import TPLinkProtocol +from .protocol import BaseProtocol _LOGGER = logging.getLogger(__name__) @@ -87,7 +87,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self.emeter_type = "emeter" diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 8edec611c..ff8bdaea8 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -9,7 +9,7 @@ from ..emeterstatus import EmeterStatus from ..exceptions import AuthenticationException, SmartDeviceException from ..modules import Emeter -from ..protocol import TPLinkProtocol +from ..protocol import BaseProtocol from ..smartdevice import SmartDevice, WifiNetwork from ..smartprotocol import SmartProtocol @@ -24,7 +24,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: _protocol = protocol or SmartProtocol( transport=AesTransport(config=config or DeviceConfig(host=host)), diff --git a/kasa/tapo/tapoplug.py b/kasa/tapo/tapoplug.py index bb20f5cc5..1bd90fd37 100644 --- a/kasa/tapo/tapoplug.py +++ b/kasa/tapo/tapoplug.py @@ -4,7 +4,7 @@ from typing import Any, Dict, Optional, cast from ..deviceconfig import DeviceConfig -from ..protocol import TPLinkProtocol +from ..protocol import BaseProtocol from ..smartdevice import DeviceType from .tapodevice import TapoDevice @@ -19,7 +19,7 @@ def __init__( host: str, *, config: Optional[DeviceConfig] = None, - protocol: Optional[TPLinkProtocol] = None, + protocol: Optional[BaseProtocol] = None, ) -> None: super().__init__(host=host, config=config, protocol=protocol) self._device_type = DeviceType.Plug diff --git a/kasa/tests/test_protocol.py b/kasa/tests/test_protocol.py index 563b8176f..f623b597d 100644 --- a/kasa/tests/test_protocol.py +++ b/kasa/tests/test_protocol.py @@ -16,8 +16,8 @@ from ..exceptions import SmartDeviceException from ..klaptransport import KlapTransport, KlapTransportV2 from ..protocol import ( + BaseProtocol, BaseTransport, - TPLinkProtocol, TPLinkSmartHomeProtocol, _XorTransport, ) @@ -345,7 +345,7 @@ def _get_subclasses(of_class): @pytest.mark.parametrize( - "class_name_obj", _get_subclasses(TPLinkProtocol), ids=lambda t: t[0] + "class_name_obj", _get_subclasses(BaseProtocol), ids=lambda t: t[0] ) def test_protocol_init_signature(class_name_obj): params = list(inspect.signature(class_name_obj[1].__init__).parameters.values()) From 6b0a72d5a73483e9618ad52cc9aafff8bacf1619 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jan 2024 16:45:19 +0000 Subject: [PATCH 02/35] Add protocol and transport documentation (#663) * Add protocol and transport documentation * Update post review --- docs/source/design.rst | 83 ++++++++++++++++++++++++++++++++++++++++++ kasa/protocol.py | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/docs/source/design.rst b/docs/source/design.rst index 6538c8b80..419c60569 100644 --- a/docs/source/design.rst +++ b/docs/source/design.rst @@ -1,5 +1,6 @@ .. py:module:: kasa.modules + .. _library_design: Library Design & Modules @@ -46,6 +47,7 @@ While the properties are designed to provide a nice API to use for common use ca you may sometimes want to access the raw, cached data as returned by the device. This can be done using the :attr:`~kasa.SmartDevice.internal_state` property. + .. _modules: Modules @@ -61,6 +63,42 @@ You can get the list of supported modules for a given device instance using :att If you only need some module-specific information, you can call the wanted method on the module to avoid using :meth:`~kasa.SmartDevice.update`. +Protocols and Transports +************************ + +The library supports two different TP-Link protocols, ``IOT`` and ``SMART``. +``IOT`` is the original Kasa protocol and ``SMART`` is the newer protocol supported by TAPO devices and newer KASA devices. +The original protocol has a ``target``, ``command``, ``args`` interface whereas the new protocol uses a different set of +commands and has a ``method``, ``parameters`` interface. +Confusingly TP-Link originally called the Kasa line "Kasa Smart" and hence this library used "Smart" in a lot of the +module and class names but actually they were built to work with the ``IOT`` protocol. + +In 2021 TP-Link started updating the underlying communication transport used by Kasa devices to make them more secure. +It switched from a TCP connection with static XOR type of encryption to a transport called ``KLAP`` which communicates +over http and uses handshakes to negotiate a dynamic encryption cipher. +This automatic update was put on hold and only seemed to affect UK HS100 models. + +In 2023 TP-Link started updating the underlying communication transport used by Tapo devices to make them more secure. +It switched from AES encryption via public key exchange to use ``KLAP`` encryption and negotiation due to concerns +around impersonation with AES. +The encryption cipher is the same as for Kasa KLAP but the handshake seeds are slightly different. +Also in 2023 TP-Link started releasing newer Kasa branded devices using the ``SMART`` protocol. +This appears to be driven by hardware version rather than firmware. + + +In order to support these different configurations the library migrated from a single :class:`TPLinkSmartHomeProtocol ` +to support pluggable transports and protocols. +The classes providing this functionality are: + +- :class:`BaseProtocol ` +- :class:`IotProtocol ` +- :class:`SmartProtocol ` + +- :class:`BaseTransport ` +- :class:`AesTransport ` +- :class:`KlapTransport ` +- :class:`KlapTransportV2 ` + API documentation for modules ***************************** @@ -70,3 +108,48 @@ API documentation for modules :members: :inherited-members: :undoc-members: + + + +API documentation for protocols and transports +********************************************** + +.. autoclass:: kasa.protocol.BaseProtocol + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.iotprotocol.IotProtocol + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.smartprotocol.SmartProtocol + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.protocol.BaseTransport + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.klaptransport.KlapTransport + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.klaptransport.KlapTransportV2 + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.aestransport.AesTransport + :members: + :inherited-members: + :undoc-members: + +.. autoclass:: kasa.protocol.TPLinkSmartHomeProtocol + :members: + :inherited-members: + :undoc-members: diff --git a/kasa/protocol.py b/kasa/protocol.py index a63250fac..bbdd81fdf 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -168,7 +168,7 @@ async def query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: :param str host: host name or ip address of the device :param request: command to send to the device (can be either dict or - json string) + json string) :param retry_count: how many retries to do in case of failure :return: response dict """ From ee487ad837f51cea7803009dede3e91ffb9cf54f Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Mon, 22 Jan 2024 17:25:23 +0000 Subject: [PATCH 03/35] Sleep between discovery packets (#656) * Sleep between discovery packets * Add tests --- kasa/discover.py | 39 +++++++---- kasa/tests/test_discovery.py | 121 ++++++++++++++++++++++++++++++++++- 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/kasa/discover.py b/kasa/discover.py index fca578a31..8b58d4bd1 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -49,6 +49,7 @@ def __init__( on_discovered: Optional[OnDiscoveredCallable] = None, target: str = "255.255.255.255", discovery_packets: int = 3, + discovery_timeout: int = 5, interface: Optional[str] = None, on_unsupported: Optional[ Callable[[UnsupportedDeviceException], Awaitable[None]] @@ -65,7 +66,8 @@ def __init__( self.port = port self.discovery_port = port or Discover.DISCOVERY_PORT - self.target = (target, self.discovery_port) + self.target = target + self.target_1 = (target, self.discovery_port) self.target_2 = (target, Discover.DISCOVERY_PORT_2) self.discovered_devices = {} @@ -75,7 +77,9 @@ def __init__( self.discovered_event = discovered_event self.credentials = credentials self.timeout = timeout + self.discovery_timeout = discovery_timeout self.seen_hosts: Set[str] = set() + self.discover_task: Optional[asyncio.Task] = None def connection_made(self, transport) -> None: """Set socket options for broadcasting.""" @@ -93,16 +97,21 @@ def connection_made(self, transport) -> None: socket.SOL_SOCKET, socket.SO_BINDTODEVICE, self.interface.encode() ) - self.do_discover() + self.discover_task = asyncio.create_task(self.do_discover()) - def do_discover(self) -> None: + async def do_discover(self) -> None: """Send number of discovery datagrams.""" req = json_dumps(Discover.DISCOVERY_QUERY) _LOGGER.debug("[DISCOVERY] %s >> %s", self.target, Discover.DISCOVERY_QUERY) encrypted_req = TPLinkSmartHomeProtocol.encrypt(req) - for _i in range(self.discovery_packets): - self.transport.sendto(encrypted_req[4:], self.target) # type: ignore + sleep_between_packets = self.discovery_timeout / self.discovery_packets + for i in range(self.discovery_packets): + if self.target in self.seen_hosts: # Stop sending for discover_single + break + self.transport.sendto(encrypted_req[4:], self.target_1) # type: ignore self.transport.sendto(Discover.DISCOVERY_QUERY_2, self.target_2) # type: ignore + if i < self.discovery_packets - 1: + await asyncio.sleep(sleep_between_packets) def datagram_received(self, data, addr) -> None: """Handle discovery responses.""" @@ -132,14 +141,12 @@ def datagram_received(self, data, addr) -> None: self.unsupported_device_exceptions[ip] = udex if self.on_unsupported is not None: asyncio.ensure_future(self.on_unsupported(udex)) - if self.discovered_event is not None: - self.discovered_event.set() + self._handle_discovered_event() return except SmartDeviceException as ex: _LOGGER.debug(f"[DISCOVERY] Unable to find device type for {ip}: {ex}") self.invalid_device_exceptions[ip] = ex - if self.discovered_event is not None: - self.discovered_event.set() + self._handle_discovered_event() return self.discovered_devices[ip] = device @@ -147,15 +154,23 @@ def datagram_received(self, data, addr) -> None: if self.on_discovered is not None: asyncio.ensure_future(self.on_discovered(device)) + self._handle_discovered_event() + + def _handle_discovered_event(self): + """If discovered_event is available set it and cancel discover_task.""" if self.discovered_event is not None: + if self.discover_task: + self.discover_task.cancel() self.discovered_event.set() def error_received(self, ex): """Handle asyncio.Protocol errors.""" _LOGGER.error("Got error: %s", ex) - def connection_lost(self, ex): - """NOP implementation of connection lost.""" + def connection_lost(self, ex): # pragma: no cover + """Cancel the discover task if running.""" + if self.discover_task: + self.discover_task.cancel() class Discover: @@ -260,6 +275,7 @@ async def discover( on_unsupported=on_unsupported, credentials=credentials, timeout=timeout, + discovery_timeout=discovery_timeout, port=port, ), local_addr=("0.0.0.0", 0), # noqa: S104 @@ -334,6 +350,7 @@ async def discover_single( discovered_event=event, credentials=credentials, timeout=timeout, + discovery_timeout=discovery_timeout, ), local_addr=("0.0.0.0", 0), # noqa: S104 ) diff --git a/kasa/tests/test_discovery.py b/kasa/tests/test_discovery.py index 071a65035..2916e60ad 100644 --- a/kasa/tests/test_discovery.py +++ b/kasa/tests/test_discovery.py @@ -1,10 +1,13 @@ # type: ignore +import asyncio import logging import re import socket +from unittest.mock import MagicMock import aiohttp import pytest # type: ignore # https://github.com/pytest-dev/pytest/issues/3342 +from async_timeout import timeout as asyncio_timeout from kasa import ( Credentials, @@ -12,6 +15,7 @@ Discover, SmartDevice, SmartDeviceException, + TPLinkSmartHomeProtocol, protocol, ) from kasa.deviceconfig import ( @@ -198,9 +202,9 @@ async def test_discover_send(mocker): """Test discovery parameters.""" proto = _DiscoverProtocol() assert proto.discovery_packets == 3 - assert proto.target == ("255.255.255.255", 9999) + assert proto.target_1 == ("255.255.255.255", 9999) transport = mocker.patch.object(proto, "transport") - proto.do_discover() + await proto.do_discover() assert transport.sendto.call_count == proto.discovery_packets * 2 @@ -341,3 +345,116 @@ async def test_discover_http_client(discovery_mock, mocker): assert x.protocol._transport._http_client.client != http_client x.config.http_client = http_client assert x.protocol._transport._http_client.client == http_client + + +LEGACY_DISCOVER_DATA = { + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "0.0", + "mac": "00:00:00:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS100(UK)", + "sw_ver": "1.1.0 Build 201016 Rel.175121", + "updating": 0, + } + } +} + + +class FakeDatagramTransport(asyncio.DatagramTransport): + GHOST_PORT = 8888 + + def __init__(self, dp, port, do_not_reply_count, unsupported=False): + self.dp = dp + self.port = port + self.do_not_reply_count = do_not_reply_count + self.send_count = 0 + if port == 9999: + self.datagram = TPLinkSmartHomeProtocol.encrypt( + json_dumps(LEGACY_DISCOVER_DATA) + )[4:] + elif port == 20002: + discovery_data = UNSUPPORTED if unsupported else AUTHENTICATION_DATA_KLAP + self.datagram = ( + b"\x02\x00\x00\x01\x01[\x00\x00\x00\x00\x00\x00W\xcev\xf8" + + json_dumps(discovery_data).encode() + ) + else: + self.datagram = {"foo": "bar"} + + def get_extra_info(self, name, default=None): + return MagicMock() + + def sendto(self, data, addr=None): + ip, port = addr + if port == self.port or self.port == self.GHOST_PORT: + self.send_count += 1 + if self.send_count > self.do_not_reply_count: + self.dp.datagram_received(self.datagram, (ip, self.port)) + + +@pytest.mark.parametrize("port", [9999, 20002]) +@pytest.mark.parametrize("do_not_reply_count", [0, 1, 2, 3, 4]) +async def test_do_discover_drop_packets(mocker, port, do_not_reply_count): + """Make sure that discover_single handles authenticating devices correctly.""" + host = "127.0.0.1" + discovery_timeout = 1 + + event = asyncio.Event() + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + discovered_event=event, + ) + ft = FakeDatagramTransport(dp, port, do_not_reply_count) + dp.connection_made(ft) + + timed_out = False + try: + async with asyncio_timeout(discovery_timeout): + await event.wait() + except asyncio.TimeoutError: + timed_out = True + + await asyncio.sleep(0) + assert ft.send_count == do_not_reply_count + 1 + assert dp.discover_task.done() + assert timed_out is False + + +@pytest.mark.parametrize( + "port, will_timeout", + [(FakeDatagramTransport.GHOST_PORT, True), (20002, False)], + ids=["unknownport", "unsupporteddevice"], +) +async def test_do_discover_invalid(mocker, port, will_timeout): + """Make sure that discover_single handles authenticating devices correctly.""" + host = "127.0.0.1" + discovery_timeout = 1 + + event = asyncio.Event() + dp = _DiscoverProtocol( + target=host, + discovery_timeout=discovery_timeout, + discovery_packets=5, + discovered_event=event, + ) + ft = FakeDatagramTransport(dp, port, 0, unsupported=True) + dp.connection_made(ft) + + timed_out = False + try: + async with asyncio_timeout(15): + await event.wait() + except asyncio.TimeoutError: + timed_out = True + + await asyncio.sleep(0) + assert dp.discover_task.done() + assert timed_out is will_timeout From 37f522c7630ff3dfd37819970b1612708eedc754 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 19:24:08 -1000 Subject: [PATCH 04/35] Add L530E(US) fixture (#674) --- .../fixtures/smart/L530E(US)_2.0_1.1.0.json | 439 ++++++++++++++++++ 1 file changed, 439 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json diff --git a/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json new file mode 100644 index 000000000..59cbf04e4 --- /dev/null +++ b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json @@ -0,0 +1,439 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "light_effect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L530E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "5C-62-8B-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp": 2700, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "dynamic_light_effect_enable": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.0 Build 230721 Rel.224802", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "5C-62-8B-00-00-00", + "model": "L530", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -43, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705976485 + }, + "get_device_usage": { + "power_usage": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "saved_power": { + "past30": 2, + "past7": 2, + "today": 2 + }, + "time_usage": { + "past30": 3, + "past7": 3, + "today": 3 + } + }, + "get_dynamic_light_effect_rules": { + "enable": false, + "max_count": 2, + "rule_list": [ + { + "change_mode": "direct", + "change_time": 1000, + "color_status_list": [ + [ + 100, + 0, + 0, + 2700 + ], + [ + 100, + 321, + 99, + 0 + ], + [ + 100, + 196, + 99, + 0 + ], + [ + 100, + 6, + 97, + 0 + ], + [ + 100, + 160, + 100, + 0 + ], + [ + 100, + 274, + 95, + 0 + ], + [ + 100, + 48, + 100, + 0 + ], + [ + 100, + 242, + 99, + 0 + ] + ], + "id": "L1", + "scene_name": "" + }, + { + "change_mode": "bln", + "change_time": 2000, + "color_status_list": [ + [ + 100, + 54, + 6, + 0 + ], + [ + 100, + 19, + 39, + 0 + ], + [ + 100, + 194, + 52, + 0 + ], + [ + 100, + 324, + 24, + 0 + ], + [ + 100, + 170, + 34, + 0 + ], + [ + 100, + 276, + 27, + 0 + ], + [ + 100, + 56, + 46, + 0 + ], + [ + 100, + 221, + 36, + 0 + ] + ], + "id": "L2", + "scene_name": "" + } + ], + "start_index": 0, + "sum": 2 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.0 Build 230721 Rel.224802", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "states": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 277, + "saturation": 86 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 60, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 300, + "saturation": 100 + } + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L530", + "device_type": "SMART.TAPOBULB" + } + } +} From 65c47a937306538c428bbad60e24fb7c31c808f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 21:12:54 -1000 Subject: [PATCH 05/35] Update fixtures from test devices (#679) * Update fixtures from test devices * move l920 to another pr --- kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json | 90 ++++ kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json | 93 ++++ kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json | 59 +++ kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json | 42 ++ .../fixtures/smart/EP25(US)_2.6_1.0.2.json | 414 ++++++++++++++++++ .../fixtures/smart/L530E(US)_2.0_1.1.0.json | 34 +- .../fixtures/smart/P125M(US)_1.0_1.1.0.json | 22 +- 7 files changed, 726 insertions(+), 28 deletions(-) create mode 100644 kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json create mode 100644 kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json create mode 100644 kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json create mode 100644 kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json create mode 100644 kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json diff --git a/kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json b/kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json new file mode 100644 index 000000000..bdab432e2 --- /dev/null +++ b/kasa/tests/fixtures/HS300(US)_2.0_1.0.12.json @@ -0,0 +1,90 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 6, + "err_code": 0, + "power_mw": 277, + "slot_id": 0, + "total_wh": 62, + "voltage_mv": 120110 + } + }, + "system": { + "get_sysinfo": { + "alias": "#MASKED_NAME#", + "child_num": 6, + "children": [ + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D00", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D01", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D02", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D03", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D04", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + }, + { + "alias": "#MASKED_NAME#", + "id": "8006A0F1D01120C3F93794F7AACACDBE1EAD246D05", + "next_action": { + "type": -1 + }, + "on_time": 710216, + "state": 1 + } + ], + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "C0:06:C3:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "HS300(US)", + "oemId": "00000000000000000000000000000000", + "rssi": -44, + "status": "new", + "sw_ver": "1.0.12 Build 220121 Rel.175814", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json b/kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json new file mode 100644 index 000000000..b098dbda1 --- /dev/null +++ b/kasa/tests/fixtures/KL125(US)_4.0_1.0.5.json @@ -0,0 +1,93 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 0, + "total_wh": 0 + } + }, + "smartlife.iot.smartbulb.lightingservice": { + "get_light_state": { + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "err_code": 0, + "on_off": 0 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Smart Wi-Fi LED Bulb with Color Changing", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "4.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "light_state": { + "dft_on_state": { + "brightness": 84, + "color_temp": 0, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "on_off": 0 + }, + "longitude_i": 0, + "mic_mac": "5091E3000000", + "mic_type": "IOT.SMARTBULB", + "model": "KL125(US)", + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "preferred_state": [ + { + "brightness": 50, + "color_temp": 2700, + "hue": 0, + "index": 0, + "saturation": 0 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "index": 1, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "index": 2, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "index": 3, + "saturation": 100 + } + ], + "rssi": -37, + "status": "new", + "sw_ver": "1.0.5 Build 230613 Rel.151643" + } + } +} diff --git a/kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json b/kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json new file mode 100644 index 000000000..cf54d6ebf --- /dev/null +++ b/kasa/tests/fixtures/KL430(US)_2.0_1.0.11.json @@ -0,0 +1,59 @@ +{ + "smartlife.iot.common.emeter": { + "get_realtime": { + "err_code": 0, + "power_mw": 600, + "total_wh": 0 + } + }, + "system": { + "get_sysinfo": { + "LEF": 1, + "active_mode": "none", + "alias": "#MASKED_NAME#", + "ctrl_protocols": { + "name": "Linkie", + "version": "1.0" + }, + "description": "Kasa Smart Light Strip, Multicolor", + "dev_state": "normal", + "deviceId": "0000000000000000000000000000000000000000", + "disco_ver": "1.0", + "err_code": 0, + "hwId": "00000000000000000000000000000000", + "hw_ver": "2.0", + "is_color": 1, + "is_dimmable": 1, + "is_factory": false, + "is_variable_color_temp": 1, + "latitude_i": 0, + "length": 16, + "light_state": { + "dft_on_state": { + "brightness": 100, + "color_temp": 9000, + "hue": 9, + "mode": "normal", + "saturation": 67 + }, + "on_off": 0 + }, + "lighting_effect_state": { + "brightness": 70, + "custom": 0, + "enable": 0, + "id": "joqVjlaTsgzmuQQBAlHRkkPAqkBUiqeb", + "name": "Icicle" + }, + "longitude_i": 0, + "mic_mac": "E8:48:B8:00:00:00", + "mic_type": "IOT.SMARTBULB", + "model": "KL430(US)", + "oemId": "00000000000000000000000000000000", + "preferred_state": [], + "rssi": -43, + "status": "new", + "sw_ver": "1.0.11 Build 220812 Rel.153345" + } + } +} diff --git a/kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json b/kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json new file mode 100644 index 000000000..f073e7923 --- /dev/null +++ b/kasa/tests/fixtures/KP115(US)_1.0_1.0.21.json @@ -0,0 +1,42 @@ +{ + "emeter": { + "get_realtime": { + "current_ma": 0, + "err_code": 0, + "power_mw": 0, + "total_wh": 0, + "voltage_mv": 120652 + } + }, + "system": { + "get_sysinfo": { + "active_mode": "none", + "alias": "#MASKED_NAME#", + "dev_name": "Smart Wi-Fi Plug Mini", + "deviceId": "0000000000000000000000000000000000000000", + "err_code": 0, + "feature": "TIM:ENE", + "hwId": "00000000000000000000000000000000", + "hw_ver": "1.0", + "icon_hash": "", + "latitude_i": 0, + "led_off": 0, + "longitude_i": 0, + "mac": "54:AF:97:00:00:00", + "mic_type": "IOT.SMARTPLUGSWITCH", + "model": "KP115(US)", + "next_action": { + "type": -1 + }, + "ntc_state": 0, + "obd_src": "tplink", + "oemId": "00000000000000000000000000000000", + "on_time": 0, + "relay_state": 0, + "rssi": -60, + "status": "new", + "sw_ver": "1.0.21 Build 231129 Rel.171238", + "updating": 0 + } + } +} diff --git a/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json new file mode 100644 index 000000000..2d3e2e5ea --- /dev/null +++ b/kasa/tests/fixtures/smart/EP25(US)_2.6_1.0.2.json @@ -0,0 +1,414 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "energy_monitoring", + "ver_code": 2 + }, + { + "id": "power_protection", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "current_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "EP25(US)", + "device_type": "SMART.KASAPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_current_power": { + "current_power": 0 + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 231108 Rel.163012", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "2.6", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "EP25", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 155838, + "overcurrent_status": "normal", + "overheated": false, + "power_protection_status": "normal", + "region": "America/Chicago", + "rssi": -56, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -360, + "type": "SMART.KASAPLUG" + }, + "get_device_time": { + "region": "America/Chicago", + "time_diff": -360, + "timestamp": 1705991903 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 41789, + "past7": 8678, + "today": 38 + }, + "time_usage": { + "past30": 41789, + "past7": 8678, + "today": 38 + } + }, + "get_electricity_price_config": { + "constant_price": 0, + "time_of_use_config": { + "summer": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + }, + "winter": { + "midpeak": 0, + "offpeak": 0, + "onpeak": 0, + "period": [ + 0, + 0, + 0, + 0 + ], + "weekday_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "weekend_config": [ + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ] + } + }, + "type": "constant" + }, + "get_energy_usage": { + "current_power": 0, + "electricity_charge": [ + 0, + 0, + 0 + ], + "local_time": "2024-01-23 00:38:23", + "month_energy": 0, + "month_runtime": 31709, + "today_energy": 0, + "today_runtime": 38 + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.2 Build 231108 Rel.163012", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 436, + "night_mode_type": "sunrise_sunset", + "start_time": 1072, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_max_power": { + "max_power": 1885 + }, + "get_next_event": {}, + "get_protection_power": { + "enabled": false, + "protection_power": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "EP25", + "device_type": "SMART.KASAPLUG", + "is_klap": false + } + } +} diff --git a/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json index 59cbf04e4..6dac10489 100644 --- a/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json +++ b/kasa/tests/fixtures/smart/L530E(US)_2.0_1.1.0.json @@ -145,7 +145,7 @@ "get_device_info": { "avatar": "bulb", "brightness": 100, - "color_temp": 2700, + "color_temp": 0, "color_temp_range": [ 2500, 6500 @@ -154,19 +154,19 @@ "re_power_type": "always_on", "state": { "brightness": 100, - "color_temp": 2700, - "hue": 0, - "saturation": 100 + "color_temp": 0, + "hue": 12, + "saturation": 45 }, "type": "last_states" }, "device_id": "0000000000000000000000000000000000000000", - "device_on": true, + "device_on": false, "dynamic_light_effect_enable": false, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.1.0 Build 230721 Rel.224802", "has_set_location_info": true, - "hue": 0, + "hue": 12, "hw_id": "00000000000000000000000000000000", "hw_ver": "2.0", "ip": "127.0.0.123", @@ -179,8 +179,8 @@ "oem_id": "00000000000000000000000000000000", "overheated": false, "region": "Pacific/Honolulu", - "rssi": -43, - "saturation": 100, + "rssi": -41, + "saturation": 45, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -190,23 +190,23 @@ "get_device_time": { "region": "Pacific/Honolulu", "time_diff": -600, - "timestamp": 1705976485 + "timestamp": 1705991895 }, "get_device_usage": { "power_usage": { - "past30": 1, - "past7": 1, - "today": 1 - }, - "saved_power": { "past30": 2, "past7": 2, "today": 2 }, + "saved_power": { + "past30": 8, + "past7": 8, + "today": 8 + }, "time_usage": { - "past30": 3, - "past7": 3, - "today": 3 + "past30": 10, + "past7": 10, + "today": 10 } }, "get_dynamic_light_effect_rules": { diff --git a/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json b/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json index 812cd1ea1..78e876d73 100644 --- a/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json +++ b/kasa/tests/fixtures/smart/P125M(US)_1.0_1.1.0.json @@ -86,7 +86,7 @@ "factory_default": false, "ip": "127.0.0.123", "is_support_iot_cloud": true, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "mgt_encrypt_schm": { "encrypt_type": "KLAP", "http_port": 80, @@ -130,21 +130,21 @@ "device_on": true, "fw_id": "00000000000000000000000000000000", "fw_ver": "1.1.0 Build 231009 Rel.155831", - "has_set_location_info": false, + "has_set_location_info": true, "hw_id": "00000000000000000000000000000000", "hw_ver": "1.0", "ip": "127.0.0.123", "lang": "en_US", "latitude": 0, "longitude": 0, - "mac": "00-00-00-00-00-00", + "mac": "48-22-54-00-00-00", "model": "P125M", "nickname": "I01BU0tFRF9OQU1FIw==", "oem_id": "00000000000000000000000000000000", - "on_time": 76, + "on_time": 189479, "overheated": false, "region": "Pacific/Honolulu", - "rssi": -49, + "rssi": -43, "signal_level": 3, "specs": "", "ssid": "I01BU0tFRF9TU0lEIw==", @@ -154,13 +154,13 @@ "get_device_time": { "region": "Pacific/Honolulu", "time_diff": -600, - "timestamp": 1704406945 + "timestamp": 1705991899 }, "get_device_usage": { "time_usage": { - "past30": 16892, - "past7": 4, - "today": 4 + "past30": 3163, + "past7": 3163, + "today": 1238 } }, "get_fw_download_state": { @@ -184,9 +184,9 @@ "led_rule": "always", "led_status": true, "night_mode": { - "end_time": 420, + "end_time": 427, "night_mode_type": "sunrise_sunset", - "start_time": 1140, + "start_time": 1092, "sunrise_offset": 0, "sunset_offset": 0 } From abd3ee0768b9fb2ea5057d4681ecbad2d26e00a0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 22 Jan 2024 21:31:19 -1000 Subject: [PATCH 06/35] Add P135 fixture (#673) * Add P135 fixture This device is a dimmer but we currently treat it as a on/off * add to conftest --- kasa/tests/conftest.py | 4 +- .../fixtures/smart/P135(US)_1.0_1.0.5.json | 317 ++++++++++++++++++ 2 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 4aae40356..205715499 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -98,7 +98,9 @@ "KP401", "KS200M", } -PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M"} +# P135 supports dimming, but its not currently support +# by the library +PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M", "P135"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json b/kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json new file mode 100644 index 000000000..9f6c3b034 --- /dev/null +++ b/kasa/tests/fixtures/smart/P135(US)_1.0_1.0.5.json @@ -0,0 +1,317 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P135(US)", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "F0-A7-31-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "plug", + "brightness": 100, + "default_states": { + "re_power_type": "always_off", + "re_power_type_capability": [ + "last_states", + "always_on", + "always_off" + ], + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 230801 Rel.095702", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "F0-A7-31-00-00-00", + "model": "P135", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "Pacific/Honolulu", + "rssi": -43, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705975451 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.0.5 Build 230801 Rel.095702", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false, + "night_mode": { + "end_time": 427, + "night_mode_type": "sunrise_sunset", + "start_time": 1092, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 2 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "P135", + "device_type": "SMART.TAPOPLUG" + } + } +} From d5fdf05ed264adf76d51723fe33263a6a9ba8694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 00:12:24 -1000 Subject: [PATCH 07/35] Add P100 test fixture (#683) --- kasa/tests/conftest.py | 2 +- .../fixtures/smart/P100_1.0.0_1.3.7.json | 197 ++++++++++++++++++ 2 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 205715499..12f9c2769 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -100,7 +100,7 @@ } # P135 supports dimming, but its not currently support # by the library -PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M", "P135"} +PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json b/kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json new file mode 100644 index 000000000..cdddc72e0 --- /dev/null +++ b/kasa/tests/fixtures/smart/P100_1.0.0_1.3.7.json @@ -0,0 +1,197 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "P100", + "device_type": "SMART.TAPOPLUG", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "CC-32-E5-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "plug", + "default_states": { + "state": {}, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.3.7 Build 20230711 Rel. 61904", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "location": "bedroom", + "longitude": 0, + "mac": "CC-32-E5-00-00-00", + "model": "P100", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -48, + "signal_level": 3, + "specs": "US", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOPLUG" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705995478 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 10, + "status": 0, + "upgrade_time": 0 + }, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.3.7 Build 20230711 Rel. 61904", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_led_info": { + "led_rule": "always", + "led_status": false + }, + "get_next_event": { + "action": -1, + "e_time": 0, + "id": "0", + "s_time": 0, + "type": 0 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 20, + "start_index": 0, + "sum": 0 + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "P100", + "device_type": "SMART.TAPOPLUG" + } + } +} From c1f2f8fe670a466bee053fe7967761ecda5aff77 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 11:14:59 +0100 Subject: [PATCH 08/35] Check README for supported models (#684) * Check README for supported models * Use poetry for running due to imports * Update README --- .github/workflows/ci.yml | 4 ++++ README.md | 36 +++++++++++++++++++--------- devtools/check_readme_vs_fixtures.py | 8 +++++++ 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dcd091eb..761ed8baa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: - name: "Run check-ast" run: | poetry run pre-commit run check-ast --all-files + - name: "Check README for supported models" + run: | + poetry run python -m devtools.check_readme_vs_fixtures + tests: name: Python ${{ matrix.python-version}} on ${{ matrix.os }}${{ fromJSON('[" (extras)", ""]')[matrix.extras == ''] }} diff --git a/README.md b/README.md index fdc9a4b83..eeb5e7e2a 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ Note, that this works currently only on kasa-branded devices which use port 9999 In principle, most kasa-branded devices that are locally controllable using the official Kasa mobile app work with this library. The following lists the devices that have been manually verified to work. -**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).** +**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).** ### Plugs @@ -228,10 +228,10 @@ The following lists the devices that have been manually verified to work. * KP105 * KP115 * KP125 -* KP125M [See note below](#tapo-and-newer-kasa-branded-devices) +* KP125M [See note below](#newer-kasa-branded-devices) * KP401 * EP10 -* EP25 [See note below](#tapo-and-newer-kasa-branded-devices) +* EP25 [See note below](#newer-kasa-branded-devices) ### Power Strips @@ -273,18 +273,29 @@ The following lists the devices that have been manually verified to work. * KL420L5 * KL430 -### Tapo and newer Kasa branded devices +### Tapo branded devices The library has recently added a limited supported for devices that carry Tapo branding. At the moment, the following devices have been confirmed to work: -* Tapo P110 (plug) -* Tapo L530E (bulb) -* Tapo L900-5 (led strip) -* Tapo L900-10 (led strip) -* Kasa KS205 (Wifi/Matter Wall Switch) -* Kasa KS225 (Wifi/Matter Wall Dimmer Switch) +#### Plugs + +* Tapo P110 +* Tapo P135 (dimming not yet supported) + +#### Bulbs + +* Tapo L510B +* Tapo L530E + +#### Light strips + +* Tapo L900-5 +* Tapo L900-10 +* Tapo L920-5 + +### Newer Kasa branded devices Some newer hardware versions of Kasa branded devices are now using the same protocol as Tapo branded devices. Support for these devices is currently limited as per TAPO branded @@ -292,8 +303,11 @@ devices: * Kasa EP25 (plug) hw_version 2.6 * Kasa KP125M (plug) +* Kasa KS205 (Wifi/Matter Wall Switch) +* Kasa KS225 (Wifi/Matter Wall Dimmer Switch) + -**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `devtools/dump_devinfo.py` to generate one).** +**If your device is unlisted but working, please open a pull request to update the list and add a fixture file (use `python -m devtools.dump_devinfo` to generate one).** ## Resources diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index 1f55eea87..f7a2f2c39 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,4 +1,6 @@ """Script that checks if README.md is missing devices that have fixtures.""" +import sys + from kasa.tests.conftest import ( ALL_DEVICES, BULBS, @@ -28,6 +30,12 @@ def _get_device_type(dev, typemap): return "Unknown type" +found_unlisted = False for dev in ALL_DEVICES: if dev not in readme: print(f"{dev} not listed in {_get_device_type(dev, typemap)}") + found_unlisted = True + + +if found_unlisted: + sys.exit(-1) From 1db955b05ee10d60b80a65649cdba84f058e04a7 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 10:33:07 +0000 Subject: [PATCH 09/35] Make request batch size configurable and avoid multiRequest if 1 (#681) --- devtools/dump_devinfo.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 85ad01502..6a8240ef6 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -15,7 +15,7 @@ from collections import defaultdict, namedtuple from pathlib import Path from pprint import pprint -from typing import Dict, List +from typing import Dict, List, Union import asyncclick as click @@ -106,10 +106,10 @@ def default_to_regular(d): return d -async def handle_device(basedir, autosave, device: SmartDevice): +async def handle_device(basedir, autosave, device: SmartDevice, batch_size: int): """Create a fixture for a single device instance.""" if isinstance(device, TapoDevice): - filename, copy_folder, final = await get_smart_fixture(device) + filename, copy_folder, final = await get_smart_fixture(device, batch_size) else: filename, copy_folder, final = await get_legacy_fixture(device) @@ -156,8 +156,11 @@ async def handle_device(basedir, autosave, device: SmartDevice): ) @click.option("--basedir", help="Base directory for the git repository", default=".") @click.option("--autosave", is_flag=True, default=False, help="Save without prompting") +@click.option( + "--batch-size", default=5, help="Number of batched requests to send at once" +) @click.option("-d", "--debug", is_flag=True) -async def cli(host, target, basedir, autosave, debug, username, password): +async def cli(host, target, basedir, autosave, debug, username, password, batch_size): """Generate devinfo files for devices. Use --host (for a single device) or --target (for a complete network). @@ -169,7 +172,7 @@ async def cli(host, target, basedir, autosave, debug, username, password): if host is not None: click.echo("Host given, performing discovery on %s." % host) device = await Discover.discover_single(host, credentials=credentials) - await handle_device(basedir, autosave, device) + await handle_device(basedir, autosave, device, batch_size) else: click.echo( "No --host given, performing discovery on %s. Use --target to override." @@ -178,7 +181,7 @@ async def cli(host, target, basedir, autosave, debug, username, password): devices = await Discover.discover(target=target, credentials=credentials) click.echo("Detected %s devices" % len(devices)) for dev in devices.values(): - await handle_device(basedir, autosave, dev) + await handle_device(basedir, autosave, dev, batch_size) async def get_legacy_fixture(device): @@ -252,17 +255,23 @@ async def get_legacy_fixture(device): async def _make_requests_or_exit( - device: SmartDevice, requests: List[SmartRequest], name: str + device: SmartDevice, + requests: List[SmartRequest], + name: str, + batch_size: int, ) -> Dict[str, Dict]: final = {} try: end = len(requests) - step = 5 # Break the requests down as there seems to be a size limit + step = batch_size # Break the requests down as there seems to be a size limit for i in range(0, end, step): x = i requests_step = requests[x : x + step] + request: Union[List[SmartRequest], SmartRequest] = ( + requests_step[0] if len(requests_step) == 1 else requests_step + ) responses = await device.protocol.query( - SmartRequest._create_request_dict(requests_step) + SmartRequest._create_request_dict(request) ) for method, result in responses.items(): final[method] = result @@ -283,7 +292,7 @@ async def _make_requests_or_exit( exit(1) -async def get_smart_fixture(device: TapoDevice): +async def get_smart_fixture(device: TapoDevice, batch_size: int): """Get fixture for new TAPO style protocol.""" extra_test_calls = [ SmartCall( @@ -314,7 +323,7 @@ async def get_smart_fixture(device: TapoDevice): click.echo("Testing component_nego call ..", nl=False) responses = await _make_requests_or_exit( - device, [SmartRequest.component_nego()], "component_nego call" + device, [SmartRequest.component_nego()], "component_nego call", batch_size ) component_info_response = responses["component_nego"] click.echo(click.style("OK", fg="green")) @@ -383,7 +392,9 @@ async def get_smart_fixture(device: TapoDevice): for succ in successes: requests.append(succ.request) - final = await _make_requests_or_exit(device, requests, "all successes at once") + final = await _make_requests_or_exit( + device, requests, "all successes at once", batch_size + ) # Need to recreate a DiscoverResult here because we don't want the aliases # in the fixture, we want the actual field names as returned by the device. From cfbdf7c64adb06ee74eaf14c4f35c4bf1824dfb2 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 13:24:17 +0100 Subject: [PATCH 10/35] Show discovery data for state with verbose (#678) * Show discovery data for state with verbose * Remove duplicate discovery printout on discovery, add a newline for readability --- kasa/cli.py | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 282273162..d1cb72765 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -390,7 +390,6 @@ async def discover(ctx): target = ctx.parent.params["target"] username = ctx.parent.params["username"] password = ctx.parent.params["password"] - verbose = ctx.parent.params["verbose"] discovery_timeout = ctx.parent.params["discovery_timeout"] timeout = ctx.parent.params["timeout"] port = ctx.parent.params["port"] @@ -429,9 +428,6 @@ async def print_discovered(dev: SmartDevice): discovered[dev.host] = dev.internal_state ctx.parent.obj = dev await ctx.parent.invoke(state) - if verbose: - echo() - _echo_discovery_info(dev._discovery_info) echo() await Discover.discover( @@ -473,21 +469,20 @@ def _echo_discovery_info(discovery_info): return echo("\t[bold]== Discovery Result ==[/bold]") - echo(f"\tDevice Type: {dr.device_type}") - echo(f"\tDevice Model: {dr.device_model}") - echo(f"\tIP: {dr.ip}") - echo(f"\tMAC: {dr.mac}") - echo(f"\tDevice Id (hash): {dr.device_id}") - echo(f"\tOwner (hash): {dr.owner}") - echo(f"\tHW Ver: {dr.hw_ver}") - echo(f"\tIs Support IOT Cloud: {dr.is_support_iot_cloud})") - echo(f"\tOBD Src: {dr.obd_src}") - echo(f"\tFactory Default: {dr.factory_default}") - echo("\t\t== Encryption Scheme ==") - echo(f"\t\tEncrypt Type: {dr.mgt_encrypt_schm.encrypt_type}") - echo(f"\t\tIs Support HTTPS: {dr.mgt_encrypt_schm.is_support_https}") - echo(f"\t\tHTTP Port: {dr.mgt_encrypt_schm.http_port}") - echo(f"\t\tLV (Login Level): {dr.mgt_encrypt_schm.lv}") + echo(f"\tDevice Type: {dr.device_type}") + echo(f"\tDevice Model: {dr.device_model}") + echo(f"\tIP: {dr.ip}") + echo(f"\tMAC: {dr.mac}") + echo(f"\tDevice Id (hash): {dr.device_id}") + echo(f"\tOwner (hash): {dr.owner}") + echo(f"\tHW Ver: {dr.hw_ver}") + echo(f"\tSupports IOT Cloud: {dr.is_support_iot_cloud}") + echo(f"\tOBD Src: {dr.obd_src}") + echo(f"\tFactory Default: {dr.factory_default}") + echo(f"\tEncrypt Type: {dr.mgt_encrypt_schm.encrypt_type}") + echo(f"\tSupports HTTPS: {dr.mgt_encrypt_schm.is_support_https}") + echo(f"\tHTTP Port: {dr.mgt_encrypt_schm.http_port}") + echo(f"\tLV (Login Level): {dr.mgt_encrypt_schm.lv}") async def find_host_from_alias(alias, target="255.255.255.255", timeout=1, attempts=3): @@ -562,6 +557,8 @@ async def state(ctx, dev: SmartDevice): echo(f"\tDevice ID: {dev.device_id}") for feature in dev.features: echo(f"\tFeature: {feature}") + echo() + _echo_discovery_info(dev._discovery_info) return dev.internal_state From c8ac3a29c771546ec38202516ca40902e5f87455 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 14:26:47 +0100 Subject: [PATCH 11/35] Add reboot and factory_reset to tapodevice (#686) * Add reboot and factory_reset to tapodevice * Add test for reboot command * Fix mocking as different protocols use different methods for comms.. --- kasa/tapo/tapodevice.py | 15 +++++++++++++++ kasa/tests/test_cli.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index ff8bdaea8..156a61d1a 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -339,3 +339,18 @@ async def update_credentials(self, username: str, password: str): "time": t, } return await self.protocol.query({"set_qs_info": payload}) + + async def reboot(self, delay: int = 1) -> None: + """Reboot the device. + + Note that giving a delay of zero causes this to block, + as the device reboots immediately without responding to the call. + """ + await self.protocol.query({"device_reboot": {"delay": delay}}) + + async def factory_reset(self) -> None: + """Reset device back to factory settings. + + Note, this does not downgrade the firmware. + """ + await self.protocol.query("device_reset") diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index b1db15e19..3aad37dda 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -21,6 +21,7 @@ cli, emeter, raw_command, + reboot, state, sysinfo, toggle, @@ -103,6 +104,21 @@ async def test_raw_command(dev): assert "Usage" in res.output +@device_smart +async def test_reboot(dev, mocker): + """Test that reboot works on SMART devices.""" + runner = CliRunner() + query_mock = mocker.patch.object(dev.protocol, "query") + + res = await runner.invoke( + reboot, + obj=dev, + ) + + query_mock.assert_called() + assert res.exit_code == 0 + + @device_smart async def test_wifi_scan(dev): runner = CliRunner() From 718983c401ecb180641124bed3a2a78e6caa72dc Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 14:44:32 +0000 Subject: [PATCH 12/35] Try default tapo credentials for klap and aes (#685) * Try default tapo credentials for klap and aes * Add tests --- kasa/aestransport.py | 49 ++++++++++++++++++++------- kasa/klaptransport.py | 45 ++++++++++++------------- kasa/protocol.py | 16 ++++++++- kasa/tests/test_aestransport.py | 60 ++++++++++++++++++++++++++++++++- kasa/tests/test_klapprotocol.py | 6 ++-- 5 files changed, 134 insertions(+), 42 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 65b0045df..cd810b8ff 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -30,7 +30,7 @@ from .httpclient import HttpClient from .json import dumps as json_dumps from .json import loads as json_loads -from .protocol import BaseTransport +from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials _LOGGER = logging.getLogger(__name__) @@ -69,12 +69,12 @@ def __init__( ) and not self._credentials_hash: self._credentials = Credentials() if self._credentials: - self._login_params = self._get_login_params() + self._login_params = self._get_login_params(self._credentials) else: self._login_params = json_loads( base64.b64decode(self._credentials_hash.encode()).decode() # type: ignore[union-attr] ) - + self._default_credentials: Optional[Credentials] = None self._http_client: HttpClient = HttpClient(config) self._handshake_done = False @@ -98,26 +98,27 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(json_dumps(self._login_params).encode()).decode() - def _get_login_params(self): + def _get_login_params(self, credentials): """Get the login parameters based on the login_version.""" - un, pw = self.hash_credentials(self._login_version == 2) + un, pw = self.hash_credentials(self._login_version == 2, credentials) password_field_name = "password2" if self._login_version == 2 else "password" return {password_field_name: pw, "username": un} - def hash_credentials(self, login_v2): + @staticmethod + def hash_credentials(login_v2, credentials): """Hash the credentials.""" if login_v2: un = base64.b64encode( - _sha1(self._credentials.username.encode()).encode() + _sha1(credentials.username.encode()).encode() ).decode() pw = base64.b64encode( - _sha1(self._credentials.password.encode()).encode() + _sha1(credentials.password.encode()).encode() ).decode() else: un = base64.b64encode( - _sha1(self._credentials.username.encode()).encode() + _sha1(credentials.username.encode()).encode() ).decode() - pw = base64.b64encode(self._credentials.password.encode()).decode() + pw = base64.b64encode(credentials.password.encode()).decode() return un, pw def _handle_response_error_code(self, resp_dict: dict, msg: str): @@ -173,10 +174,28 @@ async def send_secure_passthrough(self, request: str): async def perform_login(self): """Login to the device.""" + try: + await self.try_login(self._login_params) + except AuthenticationException as ex: + if ex.error_code != SmartErrorCode.LOGIN_ERROR: + raise ex + if self._default_credentials is None: + self._default_credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPO"] + ) + await self.perform_handshake() + await self.try_login(self._get_login_params(self._default_credentials)) + _LOGGER.debug( + "%s: logged in with default credentials", + self._host, + ) + + async def try_login(self, login_params): + """Try to login with supplied login_params.""" self._login_token = None login_request = { "method": "login_device", - "params": self._login_params, + "params": login_params, "request_time_milis": round(time.time() * 1000), } request = json_dumps(login_request) @@ -260,7 +279,13 @@ async def send(self, request: str): if not self._handshake_done or self._handshake_session_expired(): await self.perform_handshake() if not self._login_token: - await self.perform_login() + try: + await self.perform_login() + # After a login failure handshake needs to + # be redone or a 9999 error is received. + except AuthenticationException as ex: + self._handshake_done = False + raise ex return await self.send_secure_passthrough(request) diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 92d6fd2b3..5411314a3 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -58,7 +58,7 @@ from .exceptions import AuthenticationException, SmartDeviceException from .httpclient import HttpClient from .json import loads as json_loads -from .protocol import BaseTransport, md5 +from .protocol import DEFAULT_CREDENTIALS, BaseTransport, get_default_credentials, md5 _LOGGER = logging.getLogger(__name__) @@ -85,9 +85,6 @@ class KlapTransport(BaseTransport): DEFAULT_PORT: int = 80 DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} - - KASA_SETUP_EMAIL = "kasa@tp-link.net" - KASA_SETUP_PASSWORD = "kasaSetup" # noqa: S105 SESSION_COOKIE_NAME = "TP_SESSIONID" def __init__( @@ -108,7 +105,7 @@ def __init__( self._local_auth_owner = self.generate_owner_hash(self._credentials).hex() else: self._local_auth_hash = base64.b64decode(self._credentials_hash.encode()) # type: ignore[union-attr] - self._kasa_setup_auth_hash = None + self._default_credentials_auth_hash: Dict[str, bytes] = {} self._blank_auth_hash = None self._handshake_lock = asyncio.Lock() self._query_lock = asyncio.Lock() @@ -183,27 +180,27 @@ async def perform_handshake1(self) -> Tuple[bytes, bytes, bytes]: _LOGGER.debug("handshake1 hashes match with expected credentials") return local_seed, remote_seed, self._local_auth_hash # type: ignore - # Now check against the default kasa setup credentials - if not self._kasa_setup_auth_hash: - kasa_setup_creds = Credentials( - username=self.KASA_SETUP_EMAIL, - password=self.KASA_SETUP_PASSWORD, - ) - self._kasa_setup_auth_hash = self.generate_auth_hash(kasa_setup_creds) - - kasa_setup_seed_auth_hash = self.handshake1_seed_auth_hash( - local_seed, - remote_seed, - self._kasa_setup_auth_hash, # type: ignore - ) + # Now check against the default setup credentials + for key, value in DEFAULT_CREDENTIALS.items(): + if key not in self._default_credentials_auth_hash: + default_credentials = get_default_credentials(value) + self._default_credentials_auth_hash[key] = self.generate_auth_hash( + default_credentials + ) - if kasa_setup_seed_auth_hash == server_hash: - _LOGGER.debug( - "Server response doesn't match our expected hash on ip %s" - + " but an authentication with kasa setup credentials matched", - self._host, + default_credentials_seed_auth_hash = self.handshake1_seed_auth_hash( + local_seed, + remote_seed, + self._default_credentials_auth_hash[key], # type: ignore ) - return local_seed, remote_seed, self._kasa_setup_auth_hash # type: ignore + + if default_credentials_seed_auth_hash == server_hash: + _LOGGER.debug( + "Server response doesn't match our expected hash on ip %s" + + f" but an authentication with {key} default credentials matched", + self._host, + ) + return local_seed, remote_seed, self._default_credentials_auth_hash[key] # type: ignore # Finally check against blank credentials if not already blank blank_creds = Credentials() diff --git a/kasa/protocol.py b/kasa/protocol.py index bbdd81fdf..59fea4a84 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -10,6 +10,7 @@ http://www.apache.org/licenses/LICENSE-2.0 """ import asyncio +import base64 import contextlib import errno import logging @@ -17,13 +18,14 @@ import struct from abc import ABC, abstractmethod from pprint import pformat as pf -from typing import Dict, Generator, Optional, Union +from typing import Dict, Generator, Optional, Tuple, Union # When support for cpython older than 3.11 is dropped # async_timeout can be replaced with asyncio.timeout from async_timeout import timeout as asyncio_timeout from cryptography.hazmat.primitives import hashes +from .credentials import Credentials from .deviceconfig import DeviceConfig from .exceptions import SmartDeviceException from .json import dumps as json_dumps @@ -361,6 +363,18 @@ def decrypt(ciphertext: bytes) -> str: ).decode() +def get_default_credentials(tuple: Tuple[str, str]) -> Credentials: + """Return decoded default credentials.""" + un = base64.b64decode(tuple[0].encode()).decode() + pw = base64.b64decode(tuple[1].encode()).decode() + return Credentials(un, pw) + + +DEFAULT_CREDENTIALS = { + "KASA": ("a2FzYUB0cC1saW5rLm5ldA==", "a2FzYVNldHVw"), + "TAPO": ("dGVzdEB0cC1saW5rLm5ldA==", "dGVzdA=="), +} + # Try to load the kasa_crypt module and if it is available try: from kasa_crypt import decrypt, encrypt diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 774aaf943..748dae9ae 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -16,6 +16,7 @@ from ..exceptions import ( SMART_RETRYABLE_ERRORS, SMART_TIMEOUT_ERRORS, + AuthenticationException, SmartDeviceException, SmartErrorCode, ) @@ -91,6 +92,53 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat assert transport._login_token == mock_aes_device.token +@pytest.mark.parametrize( + "inner_error_codes, expectation, call_count", + [ + ([SmartErrorCode.LOGIN_ERROR, 0, 0, 0], does_not_raise(), 4), + ( + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.LOGIN_ERROR], + pytest.raises(AuthenticationException), + 3, + ), + ( + [SmartErrorCode.LOGIN_FAILED_ERROR], + pytest.raises(AuthenticationException), + 1, + ), + ], + ids=("LOGIN_ERROR-success", "LOGIN_ERROR-LOGIN_ERROR", "LOGIN_FAILED_ERROR"), +) +async def test_login_errors(mocker, inner_error_codes, expectation, call_count): + host = "127.0.0.1" + mock_aes_device = MockAesDevice(host, 200, 0, inner_error_codes) + post_mock = mocker.patch.object( + aiohttp.ClientSession, "post", side_effect=mock_aes_device.post + ) + + transport = AesTransport( + config=DeviceConfig(host, credentials=Credentials("foo", "bar")) + ) + transport._handshake_done = True + transport._session_expire_at = time.time() + 86400 + transport._encryption_session = mock_aes_device.encryption_session + + assert transport._login_token is None + + request = { + "method": "get_device_info", + "params": None, + "request_time_milis": round(time.time() * 1000), + "requestID": 1, + "terminal_uuid": "foobar", + } + + with expectation: + await transport.send(json_dumps(request)) + assert transport._login_token == mock_aes_device.token + assert post_mock.call_count == call_count # Login, Handshake, Login + + @status_parameters async def test_send(mocker, status_code, error_code, inner_error_code, expectation): host = "127.0.0.1" @@ -166,8 +214,16 @@ def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): self.host = host self.status_code = status_code self.error_code = error_code - self.inner_error_code = inner_error_code + self._inner_error_code = inner_error_code self.http_client = HttpClient(DeviceConfig(self.host)) + self.inner_call_count = 0 + + @property + def inner_error_code(self): + if isinstance(self._inner_error_code, list): + return self._inner_error_code[self.inner_call_count] + else: + return self._inner_error_code async def post(self, url, params=None, json=None, *_, **__): return await self._post(url, json) @@ -215,8 +271,10 @@ async def _return_secure_passthrough_response(self, url, json): async def _return_login_response(self, url, json): result = {"result": {"token": self.token}, "error_code": self.inner_error_code} + self.inner_call_count += 1 return self._mock_response(self.status_code, result) async def _return_send_response(self, url, json): result = {"result": {"method": None}, "error_code": self.inner_error_code} + self.inner_call_count += 1 return self._mock_response(self.status_code, result) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 8ae32e3f7..54f4a4bed 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -28,6 +28,7 @@ KlapTransportV2, _sha256, ) +from ..protocol import DEFAULT_CREDENTIALS, get_default_credentials from ..smartprotocol import SmartProtocol DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -241,10 +242,7 @@ def test_encrypt_unicode(): (Credentials("foo", "bar"), does_not_raise()), (Credentials(), does_not_raise()), ( - Credentials( - KlapTransport.KASA_SETUP_EMAIL, - KlapTransport.KASA_SETUP_PASSWORD, - ), + get_default_credentials(DEFAULT_CREDENTIALS["KASA"]), does_not_raise(), ), ( From e233e377ad8748dc5a4ec8d706ad5f70209825f5 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 15:29:27 +0000 Subject: [PATCH 13/35] Generate AES KeyPair lazily (#687) * Generate AES KeyPair lazily * Fix coverage * Update post-review * Fix pragma * Make json dumps consistent between python and orjson * Add comment * Add comments re json parameter in HttpClient --- kasa/aestransport.py | 52 +++++++++++++++++++++------------ kasa/httpclient.py | 17 +++++++++-- kasa/json.py | 6 +++- kasa/tests/test_aestransport.py | 5 +++- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index cd810b8ff..14a9ee6a1 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -8,7 +8,7 @@ import hashlib import logging import time -from typing import Dict, Optional, cast +from typing import TYPE_CHECKING, AsyncGenerator, Dict, Optional, cast from cryptography.hazmat.primitives import padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding @@ -55,6 +55,8 @@ class AesTransport(BaseTransport): "requestByApp": "true", "Accept": "application/json", } + CONTENT_LENGTH = "Content-Length" + KEY_PAIR_CONTENT_LENGTH = 314 def __init__( self, @@ -86,6 +88,8 @@ def __init__( self._login_token = None + self._key_pair: Optional[KeyPair] = None + _LOGGER.debug("Created AES transport for %s", self._host) @property @@ -204,34 +208,44 @@ async def try_login(self, login_params): self._handle_response_error_code(resp_dict, "Error logging in") self._login_token = resp_dict["result"]["token"] - async def perform_handshake(self): - """Perform the handshake.""" - _LOGGER.debug("Will perform handshaking...") - _LOGGER.debug("Generating keypair") - - self._handshake_done = False - self._session_expire_at = None - self._session_cookie = None - - url = f"http://{self._host}/app" - key_pair = KeyPair.create_key_pair() + async def _generate_key_pair_payload(self) -> AsyncGenerator: + """Generate the request body and return an ascyn_generator. + This prevents the key pair being generated unless a connection + can be made to the device. + """ + _LOGGER.debug("Generating keypair") + self._key_pair = KeyPair.create_key_pair() pub_key = ( "-----BEGIN PUBLIC KEY-----\n" - + key_pair.get_public_key() + + self._key_pair.get_public_key() # type: ignore[union-attr] + "\n-----END PUBLIC KEY-----\n" ) handshake_params = {"key": pub_key} _LOGGER.debug(f"Handshake params: {handshake_params}") - request_body = {"method": "handshake", "params": handshake_params} - _LOGGER.debug(f"Request {request_body}") + yield json_dumps(request_body).encode() + async def perform_handshake(self): + """Perform the handshake.""" + _LOGGER.debug("Will perform handshaking...") + + self._key_pair = None + self._handshake_done = False + self._session_expire_at = None + self._session_cookie = None + + url = f"http://{self._host}/app" + # Device needs the content length or it will response with 500 + headers = { + **self.COMMON_HEADERS, + self.CONTENT_LENGTH: str(self.KEY_PAIR_CONTENT_LENGTH), + } status_code, resp_dict = await self._http_client.post( url, - json=request_body, - headers=self.COMMON_HEADERS, + json=self._generate_key_pair_payload(), + headers=headers, cookies_dict=self._session_cookie, ) @@ -259,8 +273,10 @@ async def perform_handshake(self): self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} self._session_expire_at = time.time() + 86400 + if TYPE_CHECKING: + assert self._key_pair is not None # pragma: no cover self._encryption_session = AesEncyptionSession.create_from_keypair( - handshake_key, key_pair + handshake_key, self._key_pair ) self._handshake_done = True diff --git a/kasa/httpclient.py b/kasa/httpclient.py index a4bd84a33..28a19e8bd 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -41,14 +41,25 @@ async def post( *, params: Optional[Dict[str, Any]] = None, data: Optional[bytes] = None, - json: Optional[Dict] = None, + json: Optional[Union[Dict, Any]] = None, headers: Optional[Dict[str, str]] = None, cookies_dict: Optional[Dict[str, str]] = None, ) -> Tuple[int, Optional[Union[Dict, bytes]]]: - """Send an http post request to the device.""" + """Send an http post request to the device. + + If the request is provided via the json parameter json will be returned. + """ response_data = None self._last_url = url self.client.cookie_jar.clear() + return_json = bool(json) + # If json is not a dict send as data. + # This allows the json parameter to be used to pass other + # types of data such as async_generator and still have json + # returned. + if json and not isinstance(json, Dict): + data = json + json = None try: resp = await self.client.post( url, @@ -62,7 +73,7 @@ async def post( async with resp: if resp.status == 200: response_data = await resp.read() - if json: + if return_json: response_data = json_loads(response_data.decode()) except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: diff --git a/kasa/json.py b/kasa/json.py index 4acc865f5..aed8cd56d 100755 --- a/kasa/json.py +++ b/kasa/json.py @@ -11,5 +11,9 @@ def dumps(obj, *, default=None): except ImportError: import json - dumps = json.dumps + def dumps(obj, *, default=None): + """Dump JSON.""" + # Separators specified for consistency with orjson + return json.dumps(obj, separators=(",", ":")) + loads = json.loads diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 748dae9ae..4694e3631 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -225,7 +225,10 @@ def inner_error_code(self): else: return self._inner_error_code - async def post(self, url, params=None, json=None, *_, **__): + async def post(self, url, params=None, json=None, data=None, *_, **__): + if data: + async for item in data: + json = json_loads(item.decode()) return await self._post(url, json) async def _post(self, url, json): From f045696ebe7dbed55ab928559446506b8fe5aad9 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 21:51:07 +0000 Subject: [PATCH 14/35] Fix P100 error getting conn closed when trying default login after login failure (#690) --- kasa/aestransport.py | 33 +++++++++++++++++++++------------ kasa/tests/test_aestransport.py | 12 +++++++++++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 14a9ee6a1..018176adc 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -180,19 +180,28 @@ async def perform_login(self): """Login to the device.""" try: await self.try_login(self._login_params) - except AuthenticationException as ex: - if ex.error_code != SmartErrorCode.LOGIN_ERROR: - raise ex - if self._default_credentials is None: - self._default_credentials = get_default_credentials( - DEFAULT_CREDENTIALS["TAPO"] + except AuthenticationException as aex: + try: + if aex.error_code != SmartErrorCode.LOGIN_ERROR: + raise aex + if self._default_credentials is None: + self._default_credentials = get_default_credentials( + DEFAULT_CREDENTIALS["TAPO"] + ) + await self.perform_handshake() + await self.try_login(self._get_login_params(self._default_credentials)) + _LOGGER.debug( + "%s: logged in with default credentials", + self._host, ) - await self.perform_handshake() - await self.try_login(self._get_login_params(self._default_credentials)) - _LOGGER.debug( - "%s: logged in with default credentials", - self._host, - ) + except AuthenticationException: + raise + except Exception as ex: + raise AuthenticationException( + "Unable to login and trying default " + + "login raised another exception: %s", + ex, + ) from ex async def try_login(self, login_params): """Try to login with supplied login_params.""" diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 4694e3631..c58aad4eb 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -106,8 +106,18 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat pytest.raises(AuthenticationException), 1, ), + ( + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SESSION_TIMEOUT_ERROR], + pytest.raises(SmartDeviceException), + 3, + ), ], - ids=("LOGIN_ERROR-success", "LOGIN_ERROR-LOGIN_ERROR", "LOGIN_FAILED_ERROR"), + ids=( + "LOGIN_ERROR-success", + "LOGIN_ERROR-LOGIN_ERROR", + "LOGIN_FAILED_ERROR", + "LOGIN_ERROR-SESSION_TIMEOUT_ERROR", + ), ) async def test_login_errors(mocker, inner_error_codes, expectation, call_count): host = "127.0.0.1" From e576fcdb463b602d4ae9042a02cc36a32902be26 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 23 Jan 2024 22:58:57 +0100 Subject: [PATCH 15/35] Allow raw-command and wifi without update (#688) * Allow raw-command and wifi without update * Call update always but on wifi&raw-command * Add tests * Skip update also if device_family was defined, as device factory performs an update --- kasa/cli.py | 4 +++- kasa/tests/test_cli.py | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index d1cb72765..1b20303d4 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -317,7 +317,6 @@ def _nop_echo(*args, **kwargs): if type is not None: dev = TYPE_TO_CLASS[type](host) - await dev.update() elif device_family and encrypt_type: ctype = ConnectionType( DeviceFamilyType(device_family), @@ -339,6 +338,9 @@ def _nop_echo(*args, **kwargs): port=port, credentials=credentials, ) + + # Skip update for wifi & raw-command, and if factory was used to connect + if ctx.invoked_subcommand not in ["wifi", "raw-command"] and not device_family: await dev.update() ctx.obj = dev diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index 3aad37dda..fa2d5c69e 100644 --- a/kasa/tests/test_cli.py +++ b/kasa/tests/test_cli.py @@ -34,6 +34,27 @@ from .conftest import device_iot, device_smart, handle_turn_on, new_discovery, turn_on +async def test_update_called_by_cli(dev, mocker): + """Test that device update is called on main.""" + runner = CliRunner() + update = mocker.patch.object(dev, "update") + mocker.patch("kasa.discover.Discover.discover_single", return_value=dev) + + res = await runner.invoke( + cli, + [ + "--host", + "127.0.0.1", + "--username", + "foo", + "--password", + "bar", + ], + ) + assert res.exit_code == 0 + update.assert_called() + + @device_iot async def test_sysinfo(dev): runner = CliRunner() @@ -86,8 +107,9 @@ async def test_alias(dev): await dev.set_alias(old_alias) -async def test_raw_command(dev): +async def test_raw_command(dev, mocker): runner = CliRunner() + update = mocker.patch.object(dev, "update") from kasa.tapo import TapoDevice if isinstance(dev, TapoDevice): @@ -96,6 +118,10 @@ async def test_raw_command(dev): params = ["system", "get_sysinfo"] res = await runner.invoke(raw_command, params, obj=dev) + # Make sure that update was not called for wifi + with pytest.raises(AssertionError): + update.assert_called() + assert res.exit_code == 0 assert dev.model in res.output @@ -129,14 +155,19 @@ async def test_wifi_scan(dev): @device_smart -async def test_wifi_join(dev): +async def test_wifi_join(dev, mocker): runner = CliRunner() + update = mocker.patch.object(dev, "update") res = await runner.invoke( wifi, ["join", "FOOBAR", "--keytype", "wpa_psk", "--password", "foobar"], obj=dev, ) + # Make sure that update was not called for wifi + with pytest.raises(AssertionError): + update.assert_called() + assert res.exit_code == 0 assert "Asking the device to connect to FOOBAR" in res.output From 1788c5014637f8bb0d60212111f29e65dfc3267e Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Tue, 23 Jan 2024 22:15:18 +0000 Subject: [PATCH 16/35] Update transport close/reset behaviour (#689) Co-authored-by: J. Nick Koston --- kasa/aestransport.py | 8 +++++--- kasa/httpclient.py | 8 ++++++-- kasa/iotprotocol.py | 16 +++++----------- kasa/klaptransport.py | 7 ++++++- kasa/protocol.py | 26 ++++++++++++++++---------- kasa/smartdevice.py | 4 ++++ kasa/smartprotocol.py | 16 +++++----------- kasa/tests/conftest.py | 11 +++++++++-- kasa/tests/newfakes.py | 3 +++ kasa/tests/test_aestransport.py | 1 + kasa/tests/test_device_factory.py | 2 ++ kasa/tests/test_httpclient.py | 4 ++-- kasa/tests/test_klapprotocol.py | 3 ++- 13 files changed, 66 insertions(+), 43 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 018176adc..73d02b0ee 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -315,10 +315,12 @@ async def send(self, request: str): return await self.send_secure_passthrough(request) async def close(self) -> None: - """Mark the handshake and login as not done. + """Close the http client and reset internal state.""" + await self.reset() + await self._http_client.close() - Since we likely lost the connection. - """ + async def reset(self) -> None: + """Reset internal handshake and login state.""" self._handshake_done = False self._login_token = None diff --git a/kasa/httpclient.py b/kasa/httpclient.py index 28a19e8bd..7fe0b2c39 100644 --- a/kasa/httpclient.py +++ b/kasa/httpclient.py @@ -5,7 +5,11 @@ import aiohttp from .deviceconfig import DeviceConfig -from .exceptions import ConnectionException, SmartDeviceException, TimeoutException +from .exceptions import ( + ConnectionException, + SmartDeviceException, + TimeoutException, +) from .json import loads as json_loads @@ -78,7 +82,7 @@ async def post( except (aiohttp.ServerDisconnectedError, aiohttp.ClientOSError) as ex: raise ConnectionException( - f"Unable to connect to the device: {self._config.host}: {ex}", ex + f"Device connection error: {self._config.host}: {ex}", ex ) from ex except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as ex: raise TimeoutException( diff --git a/kasa/iotprotocol.py b/kasa/iotprotocol.py index c58cc8802..ed926101c 100755 --- a/kasa/iotprotocol.py +++ b/kasa/iotprotocol.py @@ -45,32 +45,31 @@ async def _query(self, request: str, retry_count: int = 3) -> Dict: try: return await self._execute_query(request, retry) except ConnectionException as sdex: - await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue except AuthenticationException as auex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) raise auex except RetryableException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except SmartDeviceException as ex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to query the device: %s, not retrying: %s", self._host, @@ -85,10 +84,5 @@ async def _execute_query(self, request: str, retry_count: int) -> Dict: return await self._transport.send(request) async def close(self) -> None: - """Close the underlying transport. - - Some transports may close the connection, and some may - use this as a hint that they need to reconnect, or - reauthenticate. - """ + """Close the underlying transport.""" await self._transport.close() diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index 5411314a3..c678e4483 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -348,7 +348,12 @@ async def send(self, request: str): return json_payload async def close(self) -> None: - """Mark the handshake as not done since we likely lost the connection.""" + """Close the http client and reset internal state.""" + await self.reset() + await self._http_client.close() + + async def reset(self) -> None: + """Reset internal handshake state.""" self._handshake_done = False @staticmethod diff --git a/kasa/protocol.py b/kasa/protocol.py index 59fea4a84..ae8eb89b1 100755 --- a/kasa/protocol.py +++ b/kasa/protocol.py @@ -80,6 +80,10 @@ async def send(self, request: str) -> Dict: async def close(self) -> None: """Close the transport. Abstract method to be overriden.""" + @abstractmethod + async def reset(self) -> None: + """Reset internal state.""" + class BaseProtocol(ABC): """Base class for all TP-Link Smart Home communication.""" @@ -139,7 +143,10 @@ async def send(self, request: str) -> Dict: return {} async def close(self) -> None: - """Close the transport. Abstract method to be overriden.""" + """Close the transport.""" + + async def reset(self) -> None: + """Reset internal state..""" class TPLinkSmartHomeProtocol(BaseProtocol): @@ -233,9 +240,9 @@ def close_without_wait(self) -> None: if writer: writer.close() - def _reset(self) -> None: - """Clear any varibles that should not survive between loops.""" - self.reader = self.writer = None + async def reset(self) -> None: + """Reset the transport.""" + await self.close() async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: """Try to query a device.""" @@ -252,12 +259,12 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: try: await self._connect(timeout) except ConnectionRefusedError as ex: - await self.close() + await self.reset() raise SmartDeviceException( f"Unable to connect to the device: {self._host}:{self._port}: {ex}" ) from ex except OSError as ex: - await self.close() + await self.reset() if ex.errno in _NO_RETRY_ERRORS or retry >= retry_count: raise SmartDeviceException( f"Unable to connect to the device:" @@ -265,7 +272,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: ) from ex continue except Exception as ex: - await self.close() + await self.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( @@ -290,7 +297,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: async with asyncio_timeout(timeout): return await self._execute_query(request) except Exception as ex: - await self.close() + await self.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise SmartDeviceException( @@ -312,7 +319,7 @@ async def _query(self, request: str, retry_count: int, timeout: int) -> Dict: raise # make mypy happy, this should never be reached.. - await self.close() + await self.reset() raise SmartDeviceException("Query reached somehow to unreachable") def __del__(self) -> None: @@ -322,7 +329,6 @@ def __del__(self) -> None: # or in another thread so we need to make sure the call to # close is called safely with call_soon_threadsafe self.loop.call_soon_threadsafe(self.writer.close) - self._reset() @staticmethod def _xor_payload(unencrypted: bytes) -> Generator[int, None, None]: diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 08a6bfb65..31418afcc 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -806,6 +806,10 @@ def config(self) -> DeviceConfig: """Return the device configuration.""" return self.protocol.config + async def disconnect(self): + """Disconnect and close any underlying connection resources.""" + await self.protocol.close() + @staticmethod async def connect( *, diff --git a/kasa/smartprotocol.py b/kasa/smartprotocol.py index c28db948e..6f0648ea0 100644 --- a/kasa/smartprotocol.py +++ b/kasa/smartprotocol.py @@ -66,32 +66,31 @@ async def _query(self, request: Union[str, Dict], retry_count: int = 3) -> Dict: try: return await self._execute_query(request, retry) except ConnectionException as sdex: - await self.close() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise sdex continue except AuthenticationException as auex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to authenticate with %s, not retrying", self._host ) raise auex except RetryableException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex continue except TimeoutException as ex: - await self.close() + await self._transport.reset() if retry >= retry_count: _LOGGER.debug("Giving up on %s after %s retries", self._host, retry) raise ex await asyncio.sleep(self.BACKOFF_SECONDS_AFTER_TIMEOUT) continue except SmartDeviceException as ex: - await self.close() + await self._transport.reset() _LOGGER.debug( "Unable to query the device: %s, not retrying: %s", self._host, @@ -167,12 +166,7 @@ def _handle_response_error_code(self, resp_dict: dict): raise SmartDeviceException(msg, error_code=error_code) async def close(self) -> None: - """Close the underlying transport. - - Some transports may close the connection, and some may - use this as a hint that they need to reconnect, or - reauthenticate. - """ + """Close the underlying transport.""" await self._transport.close() diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 12f9c2769..7addbe72a 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -15,6 +15,7 @@ Credentials, Discover, SmartBulb, + SmartDevice, SmartDimmer, SmartLightStrip, SmartPlug, @@ -416,9 +417,15 @@ async def dev(request): IP_MODEL_CACHE[ip] = model = d.model if model not in file: pytest.skip(f"skipping file {file}") - return d if d else await _discover_update_and_close(ip, username, password) + dev: SmartDevice = ( + d if d else await _discover_update_and_close(ip, username, password) + ) + else: + dev: SmartDevice = await get_device_for_file(file, protocol) + + yield dev - return await get_device_for_file(file, protocol) + await dev.disconnect() @pytest.fixture diff --git a/kasa/tests/newfakes.py b/kasa/tests/newfakes.py index 78bea3340..625a4994c 100644 --- a/kasa/tests/newfakes.py +++ b/kasa/tests/newfakes.py @@ -377,6 +377,9 @@ def _send_request(self, request_dict: dict): async def close(self) -> None: pass + async def reset(self) -> None: + pass + class FakeTransportProtocol(TPLinkSmartHomeProtocol): def __init__(self, info): diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index c58aad4eb..cfd292845 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -147,6 +147,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): await transport.send(json_dumps(request)) assert transport._login_token == mock_aes_device.token assert post_mock.call_count == call_count # Login, Handshake, Login + await transport.close() @status_parameters diff --git a/kasa/tests/test_device_factory.py b/kasa/tests/test_device_factory.py index 25a13aea5..8e3e2ed60 100644 --- a/kasa/tests/test_device_factory.py +++ b/kasa/tests/test_device_factory.py @@ -69,6 +69,8 @@ async def test_connect( assert dev.config == config + await dev.disconnect() + @pytest.mark.parametrize("custom_port", [123, None]) async def test_connect_custom_port(all_fixture_data: dict, mocker, custom_port): diff --git a/kasa/tests/test_httpclient.py b/kasa/tests/test_httpclient.py index 0a6c2beba..e178b8189 100644 --- a/kasa/tests/test_httpclient.py +++ b/kasa/tests/test_httpclient.py @@ -19,12 +19,12 @@ ( aiohttp.ServerDisconnectedError(), ConnectionException, - "Unable to connect to the device: ", + "Device connection error: ", ), ( aiohttp.ClientOSError(), ConnectionException, - "Unable to connect to the device: ", + "Device connection error: ", ), ( aiohttp.ServerTimeoutError(), diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 54f4a4bed..09ceccaef 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -54,9 +54,10 @@ async def read(self): [ (Exception("dummy exception"), False), (aiohttp.ServerTimeoutError("dummy exception"), True), + (aiohttp.ServerDisconnectedError("dummy exception"), True), (aiohttp.ClientOSError("dummy exception"), True), ], - ids=("Exception", "SmartDeviceException", "ConnectError"), + ids=("Exception", "ServerTimeoutError", "ServerDisconnectedError", "ClientOSError"), ) @pytest.mark.parametrize("transport_class", [AesTransport, KlapTransport]) @pytest.mark.parametrize("protocol_class", [IotProtocol, SmartProtocol]) From 52c3fb4d524d7e475936bdb62ffa08bc1b845bb3 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 00:12:01 +0100 Subject: [PATCH 17/35] Add 1003 (TRANSPORT_UNKNOWN_CREDENTIALS_ERROR) (#667) --- kasa/exceptions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kasa/exceptions.py b/kasa/exceptions.py index c0ef23b6a..fb86ef14c 100644 --- a/kasa/exceptions.py +++ b/kasa/exceptions.py @@ -53,6 +53,8 @@ class SmartErrorCode(IntEnum): HTTP_TRANSPORT_FAILED_ERROR = 1112 LOGIN_FAILED_ERROR = 1111 HAND_SHAKE_FAILED_ERROR = 1100 + #: Real description unknown, seen after an encryption-changing fw upgrade + TRANSPORT_UNKNOWN_CREDENTIALS_ERROR = 1003 TRANSPORT_NOT_AVAILABLE_ERROR = 1002 CMD_COMMAND_CANCEL_ERROR = 1001 NULL_TRANSPORT_ERROR = 1000 @@ -111,6 +113,7 @@ class SmartErrorCode(IntEnum): SmartErrorCode.LOGIN_FAILED_ERROR, SmartErrorCode.AES_DECODE_FAIL_ERROR, SmartErrorCode.HAND_SHAKE_FAILED_ERROR, + SmartErrorCode.TRANSPORT_UNKNOWN_CREDENTIALS_ERROR, ] SMART_TIMEOUT_ERRORS = [ From 5907dc763a059b4e0a124a81360ebc0283d9c68b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 20:59:39 -1000 Subject: [PATCH 18/35] Add fixtures for L510E (#693) * Add fixtures for L510E * mac --- kasa/tests/conftest.py | 2 +- .../fixtures/smart/L510E(US)_3.0_1.0.5.json | 295 ++++++++++++++++++ .../fixtures/smart/L510E(US)_3.0_1.1.2.json | 267 ++++++++++++++++ 3 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json create mode 100644 kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 7addbe72a..28f209589 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -47,7 +47,7 @@ BULBS_SMART_VARIABLE_TEMP = {"L530E"} BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} -BULBS_SMART_DIMMABLE = {"KS225", "L510B"} +BULBS_SMART_DIMMABLE = {"KS225", "L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) .union(BULBS_SMART_DIMMABLE) diff --git a/kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json new file mode 100644 index 000000000..15b85d085 --- /dev/null +++ b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.0.5.json @@ -0,0 +1,295 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "00-00-00-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp_range": [ + 2700, + 2700 + ], + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "always_on" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 230529 Rel.113426", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "", + "latitude": 0, + "longitude": 0, + "mac": "00-00-00-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -69, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706060153 + }, + "get_device_usage": { + "power_usage": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "saved_power": { + "past30": 4, + "past7": 4, + "today": 4 + }, + "time_usage": { + "past30": 5, + "past7": 5, + "today": 5 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-12-04", + "release_note": "Modifications and Bug Fixes:\n1. Added the support for setting the Fade In time manually.\n2. Optimized Wi-Fi connection stability\n3. Enhanced local communication security.\n4. Fixed some minor bugs.", + "type": 1 + }, + "get_next_event": {}, + "get_preset_rules": { + "brightness": [ + 1, + 25, + 50, + 75, + 100 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa2_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L510", + "device_type": "SMART.TAPOBULB" + } + } +} diff --git a/kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json new file mode 100644 index 000000000..055674d28 --- /dev/null +++ b/kasa/tests/fixtures/smart/L510E(US)_3.0_1.1.2.json @@ -0,0 +1,267 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 3 + }, + { + "id": "localSmart", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L510E(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "bulb", + "brightness": 100, + "color_temp_range": [ + 2700, + 2700 + ], + "default_states": { + "brightness": { + "type": "last_states", + "value": 100 + }, + "re_power_type": "always_on" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "3.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "L510", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -69, + "signal_level": 2, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706060527 + }, + "get_device_usage": { + "power_usage": { + "past30": 1, + "past7": 1, + "today": 1 + }, + "saved_power": { + "past30": 9, + "past7": 9, + "today": 9 + }, + "time_usage": { + "past30": 10, + "past7": 10, + "today": 10 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.2 Build 231120 Rel.201048", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + }, + "on_state": { + "duration": 2, + "enable": false, + "max_duration": 60 + } + }, + "get_preset_rules": { + "brightness": [ + 1, + 25, + 50, + 75, + 100 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L510", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 8b566757c303f62f2dbaa7845765cadfb3ef421f Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 09:10:55 +0100 Subject: [PATCH 19/35] Add new cli command 'command' to execute arbitrary commands (#692) * Add new cli command 'command' to execute arbitrary commands This deprecates 'raw-command', which requires positional argument for module, in favor of new 'command' that accepts '--module' option for IOT devices. * Pull block list to the module level --- kasa/cli.py | 23 +++++++++++++++++++---- kasa/modules/emeter.py | 2 +- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 1b20303d4..5f726be05 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -1,4 +1,5 @@ """python-kasa cli tool.""" +import ast import asyncio import json import logging @@ -69,6 +70,9 @@ def wrapper(message=None, *args, **kwargs): device_family_type.value for device_family_type in DeviceFamilyType ] +# Block list of commands which require no update +SKIP_UPDATE_COMMANDS = ["wifi", "raw-command", "command"] + click.anyio_backend = "asyncio" pass_dev = click.make_pass_decorator(SmartDevice) @@ -339,8 +343,9 @@ def _nop_echo(*args, **kwargs): credentials=credentials, ) - # Skip update for wifi & raw-command, and if factory was used to connect - if ctx.invoked_subcommand not in ["wifi", "raw-command"] and not device_family: + # Skip update on specific commands, or if device factory, + # that performs an update was used for the device. + if ctx.invoked_subcommand not in SKIP_UPDATE_COMMANDS and not device_family: await dev.update() ctx.obj = dev @@ -592,13 +597,23 @@ async def alias(dev, new_alias, index): @cli.command() @pass_dev +@click.pass_context @click.argument("module") @click.argument("command") @click.argument("parameters", default=None, required=False) -async def raw_command(dev: SmartDevice, module, command, parameters): +async def raw_command(ctx, dev: SmartDevice, module, command, parameters): """Run a raw command on the device.""" - import ast + logging.warning("Deprecated, use 'kasa command --module %s %s'", module, command) + return await ctx.forward(cmd_command) + +@cli.command(name="command") +@pass_dev +@click.option("--module", required=False, help="Module for IOT protocol.") +@click.argument("command") +@click.argument("parameters", default=None, required=False) +async def cmd_command(dev: SmartDevice, module, command, parameters): + """Run a raw command on the device.""" if parameters is not None: parameters = ast.literal_eval(parameters) diff --git a/kasa/modules/emeter.py b/kasa/modules/emeter.py index a205396ed..11eed48f8 100644 --- a/kasa/modules/emeter.py +++ b/kasa/modules/emeter.py @@ -63,7 +63,7 @@ def _convert_stat_data( self, data: List[Dict[str, Union[int, float]]], entry_key: str, - kwh: bool=True, + kwh: bool = True, key: Optional[int] = None, ) -> Dict[Union[int, float], Union[int, float]]: """Return emeter information keyed with the day/month. From eb217a748ce38586cd72061ca2e6507f54e784ad Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Jan 2024 08:20:44 +0000 Subject: [PATCH 20/35] Fix test_klapprotocol test duration (#698) --- kasa/tests/test_klapprotocol.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kasa/tests/test_klapprotocol.py b/kasa/tests/test_klapprotocol.py index 09ceccaef..4d711f034 100644 --- a/kasa/tests/test_klapprotocol.py +++ b/kasa/tests/test_klapprotocol.py @@ -6,6 +6,7 @@ import sys import time from contextlib import nullcontext as does_not_raise +from unittest.mock import PropertyMock import aiohttp import pytest @@ -67,6 +68,7 @@ async def test_protocol_retries_via_client_session( ): host = "127.0.0.1" conn = mocker.patch.object(aiohttp.ClientSession, "post", side_effect=error) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): @@ -95,6 +97,7 @@ async def test_protocol_retries_via_httpclient( ): host = "127.0.0.1" conn = mocker.patch.object(HttpClient, "post", side_effect=error) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): @@ -117,6 +120,7 @@ async def test_protocol_no_retry_on_connection_error( "post", side_effect=AuthenticationException("foo"), ) + mocker.patch.object(protocol_class, "BACKOFF_SECONDS_AFTER_TIMEOUT", 0) config = DeviceConfig(host) with pytest.raises(SmartDeviceException): await protocol_class(transport=transport_class(config=config)).query( From 3f40410db3b2d0069da9d5d4d248299b92f2f205 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 09:36:45 +0100 Subject: [PATCH 21/35] Update readme fixture checker and readme (#699) --- README.md | 4 +++- devtools/check_readme_vs_fixtures.py | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eeb5e7e2a..6e99268e0 100644 --- a/README.md +++ b/README.md @@ -282,11 +282,13 @@ At the moment, the following devices have been confirmed to work: #### Plugs * Tapo P110 +* Tapo P125M * Tapo P135 (dimming not yet supported) #### Bulbs * Tapo L510B +* Tapo L510E * Tapo L530E #### Light strips @@ -334,7 +336,7 @@ use it directly you should expect it could break in future releases until this s Other TAPO libraries are: * [PyTapo - Python library for communication with Tapo Cameras](https://github.com/JurajNyiri/pytapo) -* [Tapo P100 (Tapo P105/P100 plugs, Tapo L510E bulbs)](https://github.com/fishbigger/TapoP100) +* [Tapo P100 (Tapo plugs, Tapo bulbs)](https://github.com/fishbigger/TapoP100) * [Home Assistant integration](https://github.com/fishbigger/HomeAssistant-Tapo-P100-Control) * [plugp100, another tapo library](https://github.com/petretiandrea/plugp100) * [Home Assistant integration](https://github.com/petretiandrea/home-assistant-tapo-p100) diff --git a/devtools/check_readme_vs_fixtures.py b/devtools/check_readme_vs_fixtures.py index f7a2f2c39..88663621a 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,4 +1,5 @@ """Script that checks if README.md is missing devices that have fixtures.""" +import re import sys from kasa.tests.conftest import ( @@ -32,10 +33,11 @@ def _get_device_type(dev, typemap): found_unlisted = False for dev in ALL_DEVICES: - if dev not in readme: + regex = rf"^\*.*\s{dev}" + match = re.search(regex, readme, re.MULTILINE) + if match is None: print(f"{dev} not listed in {_get_device_type(dev, typemap)}") found_unlisted = True - if found_unlisted: sys.exit(-1) From 24c645746e51e58c87ffa6163fc6bcec75c76c2d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 22:50:25 -1000 Subject: [PATCH 22/35] Refactor aestransport to use a state enum (#691) --- kasa/aestransport.py | 80 ++++++++++++++++++--------------- kasa/tests/test_aestransport.py | 10 ++--- pyproject.toml | 11 ++++- 3 files changed, 58 insertions(+), 43 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 73d02b0ee..5269d185c 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -8,7 +8,8 @@ import hashlib import logging import time -from typing import TYPE_CHECKING, AsyncGenerator, Dict, Optional, cast +from enum import Enum, auto +from typing import TYPE_CHECKING, Any, AsyncGenerator, Dict, Optional, Tuple, cast from cryptography.hazmat.primitives import padding, serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding @@ -41,6 +42,14 @@ def _sha1(payload: bytes) -> str: return sha1_algo.hexdigest() +class TransportState(Enum): + """Enum for AES state.""" + + HANDSHAKE_REQUIRED = auto() # Handshake needed + LOGIN_REQUIRED = auto() # Login needed + ESTABLISHED = auto() # Ready to send requests + + class AesTransport(BaseTransport): """Implementation of the AES encryption protocol. @@ -79,21 +88,21 @@ def __init__( self._default_credentials: Optional[Credentials] = None self._http_client: HttpClient = HttpClient(config) - self._handshake_done = False + self._state = TransportState.HANDSHAKE_REQUIRED self._encryption_session: Optional[AesEncyptionSession] = None self._session_expire_at: Optional[float] = None self._session_cookie: Optional[Dict[str, str]] = None - self._login_token = None + self._login_token: Optional[str] = None self._key_pair: Optional[KeyPair] = None _LOGGER.debug("Created AES transport for %s", self._host) @property - def default_port(self): + def default_port(self) -> int: """Default port for the transport.""" return self.DEFAULT_PORT @@ -102,30 +111,25 @@ def credentials_hash(self) -> str: """The hashed credentials used by the transport.""" return base64.b64encode(json_dumps(self._login_params).encode()).decode() - def _get_login_params(self, credentials): + def _get_login_params(self, credentials: Credentials) -> Dict[str, str]: """Get the login parameters based on the login_version.""" un, pw = self.hash_credentials(self._login_version == 2, credentials) password_field_name = "password2" if self._login_version == 2 else "password" return {password_field_name: pw, "username": un} @staticmethod - def hash_credentials(login_v2, credentials): + def hash_credentials(login_v2: bool, credentials: Credentials) -> Tuple[str, str]: """Hash the credentials.""" + un = base64.b64encode(_sha1(credentials.username.encode()).encode()).decode() if login_v2: - un = base64.b64encode( - _sha1(credentials.username.encode()).encode() - ).decode() pw = base64.b64encode( _sha1(credentials.password.encode()).encode() ).decode() else: - un = base64.b64encode( - _sha1(credentials.username.encode()).encode() - ).decode() pw = base64.b64encode(credentials.password.encode()).decode() return un, pw - def _handle_response_error_code(self, resp_dict: dict, msg: str): + def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: error_code = SmartErrorCode(resp_dict.get("error_code")) # type: ignore[arg-type] if error_code == SmartErrorCode.SUCCESS: return @@ -135,12 +139,11 @@ def _handle_response_error_code(self, resp_dict: dict, msg: str): if error_code in SMART_RETRYABLE_ERRORS: raise RetryableException(msg, error_code=error_code) if error_code in SMART_AUTHENTICATION_ERRORS: - self._handshake_done = False - self._login_token = None + self._state = TransportState.HANDSHAKE_REQUIRED raise AuthenticationException(msg, error_code=error_code) raise SmartDeviceException(msg, error_code=error_code) - async def send_secure_passthrough(self, request: str): + async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: """Send encrypted message as passthrough.""" url = f"http://{self._host}/app" if self._login_token: @@ -165,16 +168,17 @@ async def send_secure_passthrough(self, request: str): + f"status code {status_code} to passthrough" ) - resp_dict = cast(Dict, resp_dict) self._handle_response_error_code( resp_dict, "Error sending secure_passthrough message" ) - response = self._encryption_session.decrypt( # type: ignore - resp_dict["result"]["response"].encode() - ) - resp_dict = json_loads(response) - return resp_dict + if TYPE_CHECKING: + resp_dict = cast(Dict[str, Any], resp_dict) + assert self._encryption_session is not None + + raw_response: str = resp_dict["result"]["response"] + response = self._encryption_session.decrypt(raw_response.encode()) + return json_loads(response) # type: ignore[return-value] async def perform_login(self): """Login to the device.""" @@ -182,7 +186,7 @@ async def perform_login(self): await self.try_login(self._login_params) except AuthenticationException as aex: try: - if aex.error_code != SmartErrorCode.LOGIN_ERROR: + if aex.error_code is not SmartErrorCode.LOGIN_ERROR: raise aex if self._default_credentials is None: self._default_credentials = get_default_credentials( @@ -203,9 +207,8 @@ async def perform_login(self): ex, ) from ex - async def try_login(self, login_params): + async def try_login(self, login_params: Dict[str, Any]) -> None: """Try to login with supplied login_params.""" - self._login_token = None login_request = { "method": "login_device", "params": login_params, @@ -216,6 +219,7 @@ async def try_login(self, login_params): resp_dict = await self.send_secure_passthrough(request) self._handle_response_error_code(resp_dict, "Error logging in") self._login_token = resp_dict["result"]["token"] + self._state = TransportState.ESTABLISHED async def _generate_key_pair_payload(self) -> AsyncGenerator: """Generate the request body and return an ascyn_generator. @@ -236,12 +240,11 @@ async def _generate_key_pair_payload(self) -> AsyncGenerator: _LOGGER.debug(f"Request {request_body}") yield json_dumps(request_body).encode() - async def perform_handshake(self): + async def perform_handshake(self) -> None: """Perform the handshake.""" _LOGGER.debug("Will perform handshaking...") self._key_pair = None - self._handshake_done = False self._session_expire_at = None self._session_cookie = None @@ -258,7 +261,7 @@ async def perform_handshake(self): cookies_dict=self._session_cookie, ) - _LOGGER.debug(f"Device responded with: {resp_dict}") + _LOGGER.debug("Device responded with: %s", resp_dict) if status_code != 200: raise SmartDeviceException( @@ -268,6 +271,9 @@ async def perform_handshake(self): self._handle_response_error_code(resp_dict, "Unable to complete handshake") + if TYPE_CHECKING: + resp_dict = cast(Dict[str, Any], resp_dict) + handshake_key = resp_dict["result"]["key"] if ( @@ -283,12 +289,12 @@ async def perform_handshake(self): self._session_expire_at = time.time() + 86400 if TYPE_CHECKING: - assert self._key_pair is not None # pragma: no cover + assert self._key_pair is not None self._encryption_session = AesEncyptionSession.create_from_keypair( handshake_key, self._key_pair ) - self._handshake_done = True + self._state = TransportState.LOGIN_REQUIRED _LOGGER.debug("Handshake with %s complete", self._host) @@ -299,17 +305,20 @@ def _handshake_session_expired(self): or self._session_expire_at - time.time() <= 0 ) - async def send(self, request: str): + async def send(self, request: str) -> Dict[str, Any]: """Send the request.""" - if not self._handshake_done or self._handshake_session_expired(): + if ( + self._state is TransportState.HANDSHAKE_REQUIRED + or self._handshake_session_expired() + ): await self.perform_handshake() - if not self._login_token: + if self._state is not TransportState.ESTABLISHED: try: await self.perform_login() # After a login failure handshake needs to # be redone or a 9999 error is received. except AuthenticationException as ex: - self._handshake_done = False + self._state = TransportState.HANDSHAKE_REQUIRED raise ex return await self.send_secure_passthrough(request) @@ -321,8 +330,7 @@ async def close(self) -> None: async def reset(self) -> None: """Reset internal handshake and login state.""" - self._handshake_done = False - self._login_token = None + self._state = TransportState.HANDSHAKE_REQUIRED class AesEncyptionSession: diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index cfd292845..086f6ea60 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -10,7 +10,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import padding as asymmetric_padding -from ..aestransport import AesEncyptionSession, AesTransport +from ..aestransport import AesEncyptionSession, AesTransport, TransportState from ..credentials import Credentials from ..deviceconfig import DeviceConfig from ..exceptions import ( @@ -66,11 +66,11 @@ async def test_handshake( ) assert transport._encryption_session is None - assert transport._handshake_done is False + assert transport._state is TransportState.HANDSHAKE_REQUIRED with expectation: await transport.perform_handshake() assert transport._encryption_session is not None - assert transport._handshake_done is True + assert transport._state is TransportState.LOGIN_REQUIRED @status_parameters @@ -82,7 +82,7 @@ async def test_login(mocker, status_code, error_code, inner_error_code, expectat transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) ) - transport._handshake_done = True + transport._state = TransportState.LOGIN_REQUIRED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session @@ -129,7 +129,7 @@ async def test_login_errors(mocker, inner_error_codes, expectation, call_count): transport = AesTransport( config=DeviceConfig(host, credentials=Credentials("foo", "bar")) ) - transport._handshake_done = True + transport._state = TransportState.LOGIN_REQUIRED transport._session_expire_at = time.time() + 86400 transport._encryption_session = mock_aes_device.encryption_session diff --git a/pyproject.toml b/pyproject.toml index 6bd81a900..206565559 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,9 +65,16 @@ omit = ["kasa/tests/*"] [tool.coverage.report] exclude_lines = [ - # ignore abstract methods + # Don't complain if tests don't hit defensive assertion code: + "raise AssertionError", "raise NotImplementedError", - "def __repr__" + # Don't complain about missing debug-only code: + "def __repr__", + # Have to re-enable the standard pragma + "pragma: no cover", + # TYPE_CHECKING and @overload blocks are never executed during pytest run + "if TYPE_CHECKING:", + "@overload" ] [tool.pytest.ini_options] From bab40d43e6973912f97c9ccc1c59ecfbf0eedf6f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 Jan 2024 23:11:27 -1000 Subject: [PATCH 23/35] Renew the handshake session 20 minutes before we think it will expire (#697) * Renew the KLAP handshake session 20 minutes before we think it will expire Currently we assumed the clocks were perfectly aligned and the handshake session lasted 20 hours. We now add a 20 minute buffer * use timeout cookie when available --- kasa/aestransport.py | 23 +++++++++++++++++------ kasa/klaptransport.py | 19 ++++++++++++++----- 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 5269d185c..412dbbf22 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -36,6 +36,10 @@ _LOGGER = logging.getLogger(__name__) +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + def _sha1(payload: bytes) -> str: sha1_algo = hashlib.sha1() # noqa: S324 sha1_algo.update(payload) @@ -59,6 +63,7 @@ class AesTransport(BaseTransport): DEFAULT_PORT: int = 80 SESSION_COOKIE_NAME = "TP_SESSIONID" + TIMEOUT_COOKIE_NAME = "TIMEOUT" COMMON_HEADERS = { "Content-Type": "application/json", "requestByApp": "true", @@ -254,7 +259,9 @@ async def perform_handshake(self) -> None: **self.COMMON_HEADERS, self.CONTENT_LENGTH: str(self.KEY_PAIR_CONTENT_LENGTH), } - status_code, resp_dict = await self._http_client.post( + http_client = self._http_client + + status_code, resp_dict = await http_client.post( url, json=self._generate_key_pair_payload(), headers=headers, @@ -277,17 +284,21 @@ async def perform_handshake(self) -> None: handshake_key = resp_dict["result"]["key"] if ( - cookie := self._http_client.get_cookie( # type: ignore + cookie := http_client.get_cookie( # type: ignore self.SESSION_COOKIE_NAME ) ) or ( - cookie := self._http_client.get_cookie( # type: ignore - "SESSIONID" - ) + cookie := http_client.get_cookie("SESSIONID") # type: ignore ): self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} - self._session_expire_at = time.time() + 86400 + timeout = int( + http_client.get_cookie(self.TIMEOUT_COOKIE_NAME) or ONE_DAY_SECONDS + ) + # There is a 24 hour timeout on the session cookie + # but the clock on the device is not always accurate + # so we set the expiry to 24 hours from now minus a buffer + self._session_expire_at = time.time() + timeout - SESSION_EXPIRE_BUFFER_SECONDS if TYPE_CHECKING: assert self._key_pair is not None self._encryption_session = AesEncyptionSession.create_from_keypair( diff --git a/kasa/klaptransport.py b/kasa/klaptransport.py index c678e4483..cd0e3de6b 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -63,6 +63,10 @@ _LOGGER = logging.getLogger(__name__) +ONE_DAY_SECONDS = 86400 +SESSION_EXPIRE_BUFFER_SECONDS = 60 * 20 + + def _sha256(payload: bytes) -> bytes: digest = hashes.Hash(hashes.SHA256()) # noqa: S303 digest.update(payload) @@ -86,6 +90,7 @@ class KlapTransport(BaseTransport): DEFAULT_PORT: int = 80 DISCOVERY_QUERY = {"system": {"get_sysinfo": None}} SESSION_COOKIE_NAME = "TP_SESSIONID" + TIMEOUT_COOKIE_NAME = "TIMEOUT" def __init__( self, @@ -271,14 +276,18 @@ async def perform_handshake(self) -> Any: self._session_cookie = None local_seed, remote_seed, auth_hash = await self.perform_handshake1() - if cookie := self._http_client.get_cookie( # type: ignore - self.SESSION_COOKIE_NAME - ): + http_client = self._http_client + if cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME): # type: ignore self._session_cookie = {self.SESSION_COOKIE_NAME: cookie} # The device returns a TIMEOUT cookie on handshake1 which # it doesn't like to get back so we store the one we want - - self._session_expire_at = time.time() + 86400 + timeout = int( + http_client.get_cookie(self.TIMEOUT_COOKIE_NAME) or ONE_DAY_SECONDS + ) + # There is a 24 hour timeout on the session cookie + # but the clock on the device is not always accurate + # so we set the expiry to 24 hours from now minus a buffer + self._session_expire_at = time.time() + timeout - SESSION_EXPIRE_BUFFER_SECONDS self._encryption_session = await self.perform_handshake2( local_seed, remote_seed, auth_hash ) From f7c04bcef84bb71b55c7018764791fe138104917 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Wed, 24 Jan 2024 09:40:36 +0000 Subject: [PATCH 24/35] Add --batch-size hint to timeout errors in dump_devinfo (#696) * Add --batch-size hint to timeout errors in dump_devinfo * Add _echo_error function for displaying critical errors --- devtools/dump_devinfo.py | 57 ++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 6a8240ef6..e9ec56b7b 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -20,7 +20,14 @@ import asyncclick as click from devtools.helpers.smartrequests import COMPONENT_REQUESTS, SmartRequest -from kasa import AuthenticationException, Credentials, Discover, SmartDevice +from kasa import ( + AuthenticationException, + Credentials, + Discover, + SmartDevice, + SmartDeviceException, + TimeoutException, +) from kasa.discover import DiscoveryResult from kasa.exceptions import SmartErrorCode from kasa.tapo.tapodevice import TapoDevice @@ -227,11 +234,7 @@ async def get_legacy_fixture(device): try: final = await device.protocol.query(final_query) except Exception as ex: - click.echo( - click.style( - f"Unable to query all successes at once: {ex}", bold=True, fg="red" - ) - ) + _echo_error(f"Unable to query all successes at once: {ex}", bold=True, fg="red") if device._discovery_info and not device._discovery_info.get("system"): # Need to recreate a DiscoverResult here because we don't want the aliases @@ -254,6 +257,16 @@ async def get_legacy_fixture(device): return save_filename, copy_folder, final +def _echo_error(msg: str): + click.echo( + click.style( + msg, + bold=True, + fg="red", + ) + ) + + async def _make_requests_or_exit( device: SmartDevice, requests: List[SmartRequest], @@ -277,17 +290,25 @@ async def _make_requests_or_exit( final[method] = result return final except AuthenticationException as ex: - click.echo( - click.style( - f"Unable to query the device due to an authentication error: {ex}", - bold=True, - fg="red", - ) + _echo_error( + f"Unable to query the device due to an authentication error: {ex}", + ) + exit(1) + except SmartDeviceException as ex: + _echo_error( + f"Unable to query {name} at once: {ex}", ) + if ( + isinstance(ex, TimeoutException) + or ex.error_code == SmartErrorCode.SESSION_TIMEOUT_ERROR + ): + _echo_error( + "Timeout, try reducing the batch size via --batch-size option.", + ) exit(1) except Exception as ex: - click.echo( - click.style(f"Unable to query {name} at once: {ex}", bold=True, fg="red") + _echo_error( + f"Unexpected exception querying {name} at once: {ex}", ) exit(1) @@ -361,12 +382,8 @@ async def get_smart_fixture(device: TapoDevice, batch_size: int): SmartRequest._create_request_dict(test_call.request) ) except AuthenticationException as ex: - click.echo( - click.style( - f"Unable to query the device due to an authentication error: {ex}", - bold=True, - fg="red", - ) + _echo_error( + f"Unable to query the device due to an authentication error: {ex}", ) exit(1) except Exception as ex: From aecf0ecd8a6aa3d7dde2f3d2a5f20cf0ba7465a4 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 13:21:37 +0100 Subject: [PATCH 25/35] Do not crash on missing geolocation (#701) If 'has_set_location_info' is false, the geolocation is missing. --- kasa/tapo/tapodevice.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/kasa/tapo/tapodevice.py b/kasa/tapo/tapodevice.py index 156a61d1a..86967b69d 100644 --- a/kasa/tapo/tapodevice.py +++ b/kasa/tapo/tapodevice.py @@ -147,8 +147,8 @@ def hw_info(self) -> Dict: def location(self) -> Dict: """Return the device location.""" loc = { - "latitude": cast(float, self._info.get("latitude")) / 10_000, - "longitude": cast(float, self._info.get("longitude")) / 10_000, + "latitude": cast(float, self._info.get("latitude", 0)) / 10_000, + "longitude": cast(float, self._info.get("longitude", 0)) / 10_000, } return loc From 3df837cc825d64038e0fe4699184bd43757fb10f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 09:43:42 -1000 Subject: [PATCH 26/35] Ensure login token is only sent if aes state is ESTABLISHED (#702) --- kasa/aestransport.py | 7 +++---- kasa/tests/test_aestransport.py | 18 ++++++++++++------ 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/kasa/aestransport.py b/kasa/aestransport.py index 412dbbf22..4e1ccb7d6 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -151,7 +151,7 @@ def _handle_response_error_code(self, resp_dict: Any, msg: str) -> None: async def send_secure_passthrough(self, request: str) -> Dict[str, Any]: """Send encrypted message as passthrough.""" url = f"http://{self._host}/app" - if self._login_token: + if self._state is TransportState.ESTABLISHED and self._login_token: url += f"?token={self._login_token}" encrypted_payload = self._encryption_session.encrypt(request.encode()) # type: ignore @@ -250,6 +250,7 @@ async def perform_handshake(self) -> None: _LOGGER.debug("Will perform handshaking...") self._key_pair = None + self._login_token = None self._session_expire_at = None self._session_cookie = None @@ -284,9 +285,7 @@ async def perform_handshake(self) -> None: handshake_key = resp_dict["result"]["key"] if ( - cookie := http_client.get_cookie( # type: ignore - self.SESSION_COOKIE_NAME - ) + cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME) # type: ignore ) or ( cookie := http_client.get_cookie("SESSIONID") # type: ignore ): diff --git a/kasa/tests/test_aestransport.py b/kasa/tests/test_aestransport.py index 086f6ea60..9fe5cabd4 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -1,9 +1,12 @@ import base64 import json +import random +import string import time from contextlib import nullcontext as does_not_raise from json import dumps as json_dumps from json import loads as json_loads +from typing import Any, Dict, Optional import aiohttp import pytest @@ -219,7 +222,6 @@ async def read(self): return json_dumps(self._json).encode() encryption_session = AesEncyptionSession(KEY_IV[:16], KEY_IV[16:]) - token = "test_token" # noqa def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): self.host = host @@ -228,6 +230,7 @@ def __init__(self, host, status_code=200, error_code=0, inner_error_code=0): self._inner_error_code = inner_error_code self.http_client = HttpClient(DeviceConfig(self.host)) self.inner_call_count = 0 + self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 @property def inner_error_code(self): @@ -242,7 +245,7 @@ async def post(self, url, params=None, json=None, data=None, *_, **__): json = json_loads(item.decode()) return await self._post(url, json) - async def _post(self, url, json): + async def _post(self, url: str, json: Dict[str, Any]): if json["method"] == "handshake": return await self._return_handshake_response(url, json) elif json["method"] == "securePassthrough": @@ -253,7 +256,7 @@ async def _post(self, url, json): assert url == f"http://{self.host}/app?token={self.token}" return await self._return_send_response(url, json) - async def _return_handshake_response(self, url, json): + async def _return_handshake_response(self, url: str, json: Dict[str, Any]): start = len("-----BEGIN PUBLIC KEY-----\n") end = len("\n-----END PUBLIC KEY-----\n") client_pub_key = json["params"]["key"][start:-end] @@ -266,7 +269,7 @@ async def _return_handshake_response(self, url, json): self.status_code, {"result": {"key": key_64}, "error_code": self.error_code} ) - async def _return_secure_passthrough_response(self, url, json): + async def _return_secure_passthrough_response(self, url: str, json: Dict[str, Any]): encrypted_request = json["params"]["request"] decrypted_request = self.encryption_session.decrypt(encrypted_request.encode()) decrypted_request_dict = json_loads(decrypted_request) @@ -283,12 +286,15 @@ async def _return_secure_passthrough_response(self, url, json): } return self._mock_response(self.status_code, result) - async def _return_login_response(self, url, json): + async def _return_login_response(self, url: str, json: Dict[str, Any]): + if "token=" in url: + raise Exception("token should not be in url for a login request") + self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 result = {"result": {"token": self.token}, "error_code": self.inner_error_code} self.inner_call_count += 1 return self._mock_response(self.status_code, result) - async def _return_send_response(self, url, json): + async def _return_send_response(self, url: str, json: Dict[str, Any]): result = {"result": {"method": None}, "error_code": self.inner_error_code} self.inner_call_count += 1 return self._mock_response(self.status_code, result) From ae6a31463ef5840276e4d4e7ce8d3a63060a9e03 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 11:53:28 -1000 Subject: [PATCH 27/35] Fix overly greedy _strip_rich_formatting (#703) --- kasa/cli.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/kasa/cli.py b/kasa/cli.py index 5f726be05..86aea4367 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -37,6 +37,9 @@ try: from rich import print as _do_echo except ImportError: + # Remove 7-bit C1 ANSI sequences + # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python + ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") def _strip_rich_formatting(echo_func): """Strip rich formatting from messages.""" @@ -44,7 +47,7 @@ def _strip_rich_formatting(echo_func): @wraps(echo_func) def wrapper(message=None, *args, **kwargs): if message is not None: - message = re.sub(r"\[/?.+?]", "", message) + message = ansi_escape.sub("", message) echo_func(message, *args, **kwargs) return wrapper From 2d8b966e5bc86d69f157ac1afec0cf43af915c65 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 24 Jan 2024 23:09:27 +0100 Subject: [PATCH 28/35] Document authenticated provisioning (#634) --- docs/source/cli.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/source/cli.rst b/docs/source/cli.rst index b75cc85b2..c1570bc0c 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -51,6 +51,14 @@ You can provision your device without any extra apps by using the ``kasa wifi`` As with all other commands, you can also pass ``--help`` to both ``join`` and ``scan`` commands to see the available options. +.. note:: + + For devices requiring authentication, the device-stored credentials can be changed using + the ``update-credentials`` commands, for example, to match with other cloud-connected devices. + However, note that communications with devices provisioned using this method will stop working + when connected to the cloud. + + ``kasa --help`` *************** From 3235ba620d68bf10d43f15b03963391a28173b7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 12:29:55 -1000 Subject: [PATCH 29/35] Add updated L920 fixture (#680) * Add updated L920 fixture * Fix overly greedy _strip_rich_formatting --------- Co-authored-by: Teemu R --- .../fixtures/smart/L920-5(US)_1.0_1.1.3.json | 415 ++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json diff --git a/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json b/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json new file mode 100644 index 000000000..5463944dd --- /dev/null +++ b/kasa/tests/fixtures/smart/L920-5(US)_1.0_1.1.3.json @@ -0,0 +1,415 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 3 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L920-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "34-60-F9-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 100, + "color_temp": 0, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 250, + "saturation": 85 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "has_set_location_info": false, + "hue": 250, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "mac": "34-60-F9-00-00-00", + "model": "L920", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -32, + "saturation": 85, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705991901 + }, + "get_device_usage": { + "power_usage": { + "past30": 8, + "past7": 7, + "today": 0 + }, + "saved_power": { + "past30": 110, + "past7": 101, + "today": 14 + }, + "time_usage": { + "past30": 118, + "past7": 108, + "today": 14 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.3 Build 231229 Rel.164316", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_lighting_effect": { + "brightness": 50, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 0, + "expansion_strategy": 2, + "id": "", + "name": "station", + "repeat_times": 1, + "segment_length": 1, + "sequence": [ + [ + 300, + 100, + 50 + ], + [ + 240, + 100, + 50 + ], + [ + 180, + 100, + 50 + ], + [ + 120, + 100, + 50 + ], + [ + 60, + 100, + 50 + ], + [ + 0, + 100, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ] + ], + "spread": 16, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 24, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L920", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From 8947ffbc9439b8973ed763ba97a413edb5f19299 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 12:31:01 -1000 Subject: [PATCH 30/35] Add L930-5 fixture (#694) * Add L930-5 fixture * Mark L930-5 as variable temp * Update readme * Fix overly greedy _strip_rich_formatting --------- Co-authored-by: Teemu Rytilahti --- README.md | 1 + kasa/tests/conftest.py | 4 +- .../fixtures/smart/L930-5(US)_1.0_1.1.2.json | 429 ++++++++++++++++++ 3 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json diff --git a/README.md b/README.md index 6e99268e0..d4cebe488 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ At the moment, the following devices have been confirmed to work: * Tapo L900-5 * Tapo L900-10 * Tapo L920-5 +* Tapo L930-5 ### Newer Kasa branded devices diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index 28f209589..c043b18c8 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -44,8 +44,8 @@ SUPPORTED_DEVICES = SUPPORTED_IOT_DEVICES + SUPPORTED_SMART_DEVICES # Tapo bulbs -BULBS_SMART_VARIABLE_TEMP = {"L530E"} -BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5"} +BULBS_SMART_VARIABLE_TEMP = {"L530E", "L930-5"} +BULBS_SMART_LIGHT_STRIP = {"L900-5", "L900-10", "L920-5", "L930-5"} BULBS_SMART_COLOR = {"L530E", *BULBS_SMART_LIGHT_STRIP} BULBS_SMART_DIMMABLE = {"KS225", "L510B", "L510E"} BULBS_SMART = ( diff --git a/kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json b/kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json new file mode 100644 index 000000000..de7ae2c79 --- /dev/null +++ b/kasa/tests/fixtures/smart/L930-5(US)_1.0_1.1.2.json @@ -0,0 +1,429 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 3 + }, + { + "id": "bulb_quick_control", + "ver_code": 1 + }, + { + "id": "localSmart", + "ver_code": 1 + }, + { + "id": "homekit", + "ver_code": 2 + }, + { + "id": "segment", + "ver_code": 1 + }, + { + "id": "segment_effect", + "ver_code": 1 + }, + { + "id": "auto_light", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L930-5(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "KLAP", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_light_info": { + "enable": false, + "mode": "light_track" + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 100, + "color_temp": 4500, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.1.2 Build 231212 Rel.210005", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "L930", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -37, + "saturation": 100, + "segment_effect": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_segment": { + "segment": 50 + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706061664 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 6, + "past7": 6, + "today": 6 + }, + "time_usage": { + "past30": 6, + "past7": 6, + "today": 6 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 0, + "fw_ver": "1.1.2 Build 231212 Rel.210005", + "hw_id": "", + "need_to_upgrade": false, + "oem_id": "", + "release_date": "", + "release_note": "", + "type": 0 + }, + "get_lighting_effect": { + "brightness": 50, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 0, + "expansion_strategy": 2, + "id": "", + "name": "station", + "repeat_times": 1, + "segment_length": 1, + "sequence": [ + [ + 300, + 100, + 50 + ], + [ + 240, + 100, + 50 + ], + [ + 180, + 100, + 50 + ], + [ + 120, + 100, + 50 + ], + [ + 60, + 100, + 50 + ], + [ + 0, + 100, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ] + ], + "spread": 16, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 4500, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_segment_effect_rule": { + "brightness": 0, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "get_wireless_scan_info": { + "ap_list": [], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "L930", + "device_type": "SMART.TAPOBULB", + "is_klap": true + } + } +} From fa6bc59b29c148b56499ee4e9afc8459373d6ad0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 21:49:26 -1000 Subject: [PATCH 31/35] Replace rich formatting stripper (#706) * Revert "Fix overly greedy _strip_rich_formatting (#703)" This reverts commit ae6a31463ef5840276e4d4e7ce8d3a63060a9e03. * Improve rich formatter stripper reverts and replaces #703 --- kasa/cli.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/kasa/cli.py b/kasa/cli.py index 86aea4367..42b13b9bb 100755 --- a/kasa/cli.py +++ b/kasa/cli.py @@ -37,9 +37,11 @@ try: from rich import print as _do_echo except ImportError: - # Remove 7-bit C1 ANSI sequences - # https://stackoverflow.com/questions/14693701/how-can-i-remove-the-ansi-escape-sequences-from-a-string-in-python - ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") + # Strip out rich formatting if rich is not installed + # but only lower case tags to avoid stripping out + # raw data from the device that is printed from + # the device state. + rich_formatting = re.compile(r"\[/?[a-z]+]") def _strip_rich_formatting(echo_func): """Strip rich formatting from messages.""" @@ -47,7 +49,7 @@ def _strip_rich_formatting(echo_func): @wraps(echo_func) def wrapper(message=None, *args, **kwargs): if message is not None: - message = ansi_escape.sub("", message) + message = rich_formatting.sub("", message) echo_func(message, *args, **kwargs) return wrapper From fa94548723d40bd588c08aedd60d59c274ebaded Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 21:53:43 -1000 Subject: [PATCH 32/35] Add additional L900-10 fixture (#707) --- .../smart/L900-10(US)_1.0_1.0.11.json | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json diff --git a/kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json b/kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json new file mode 100644 index 000000000..8665c8f31 --- /dev/null +++ b/kasa/tests/fixtures/smart/L900-10(US)_1.0_1.0.11.json @@ -0,0 +1,428 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "light_strip", + "ver_code": 1 + }, + { + "id": "light_strip_lighting_effect", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 1 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "color_temperature", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 3 + }, + { + "id": "color", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "music_rhythm", + "ver_code": 2 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "L900-10(US)", + "device_type": "SMART.TAPOBULB", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "54-AF-97-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": true, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "light_strip", + "brightness": 100, + "color_temp": 9000, + "color_temp_range": [ + 9000, + 9000 + ], + "default_states": { + "state": { + "brightness": 100, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": true, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.11 Build 220119 Rel.221258", + "has_set_location_info": true, + "hue": 0, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "lighting_effect": { + "brightness": 50, + "custom": 0, + "display_colors": [], + "enable": 0, + "id": "", + "name": "station" + }, + "longitude": 0, + "mac": "54-AF-97-00-00-00", + "model": "L900", + "music_rhythm_enable": false, + "music_rhythm_mode": "single_lamp", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -42, + "saturation": 100, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706141011 + }, + "get_device_usage": { + "power_usage": { + "past30": 0, + "past7": 0, + "today": 0 + }, + "saved_power": { + "past30": 3, + "past7": 3, + "today": 3 + }, + "time_usage": { + "past30": 3, + "past7": 3, + "today": 3 + } + }, + "get_fw_download_state": { + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 230905 Rel.184939", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-13", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced the local communication security.", + "type": 2 + }, + "get_lighting_effect": { + "brightness": 50, + "custom": 0, + "direction": 1, + "display_colors": [], + "duration": 0, + "enable": 0, + "expansion_strategy": 2, + "id": "", + "name": "station", + "repeat_times": 1, + "segment_length": 1, + "sequence": [ + [ + 300, + 100, + 50 + ], + [ + 240, + 100, + 50 + ], + [ + 180, + 100, + 50 + ], + [ + 120, + 100, + 50 + ], + [ + 60, + 100, + 50 + ], + [ + 0, + 100, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ], + [ + 0, + 0, + 50 + ] + ], + "spread": 16, + "transition": 400, + "type": "sequence" + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "enable": false + }, + "get_preset_rules": { + "start_index": 0, + "states": [ + { + "brightness": 50, + "color_temp": 9000, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 240, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 0, + "saturation": 100 + }, + { + "brightness": 100, + "color_temp": 0, + "hue": 120, + "saturation": 100 + } + ], + "sum": 7 + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 16, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + } + ], + "extra_info": { + "device_model": "L900", + "device_type": "SMART.TAPOBULB" + } + } +} From cba073ebde251d5f3e6eda342e31758f46981cb0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 21:54:56 -1000 Subject: [PATCH 33/35] Add support for tapo wall switches (S500D) (#704) * Add support for the S500D * tweak * Update README.md --- README.md | 4 + kasa/device_factory.py | 1 + kasa/deviceconfig.py | 1 + kasa/tests/conftest.py | 9 +- .../fixtures/smart/S500D(US)_1.0_1.0.5.json | 317 ++++++++++++++++++ 5 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json diff --git a/README.md b/README.md index d4cebe488..0300677f9 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,10 @@ At the moment, the following devices have been confirmed to work: * Tapo L920-5 * Tapo L930-5 +#### Wall switches + +* Tapo S500D + ### Newer Kasa branded devices Some newer hardware versions of Kasa branded devices are now using the same protocol as diff --git a/kasa/device_factory.py b/kasa/device_factory.py index 83db093f4..d216e0ef9 100755 --- a/kasa/device_factory.py +++ b/kasa/device_factory.py @@ -131,6 +131,7 @@ def get_device_class_from_family(device_type: str) -> Optional[Type[SmartDevice] supported_device_types: Dict[str, Type[SmartDevice]] = { "SMART.TAPOPLUG": TapoPlug, "SMART.TAPOBULB": TapoBulb, + "SMART.TAPOSWITCH": TapoBulb, "SMART.KASAPLUG": TapoPlug, "SMART.KASASWITCH": TapoBulb, "IOT.SMARTPLUGSWITCH": SmartPlug, diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 58d33661b..77ce6df40 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -30,6 +30,7 @@ class DeviceFamilyType(Enum): SmartKasaSwitch = "SMART.KASASWITCH" SmartTapoPlug = "SMART.TAPOPLUG" SmartTapoBulb = "SMART.TAPOBULB" + SmartTapoSwitch = "SMART.TAPOSWITCH" def _dataclass_from_dict(klass, in_val): diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index c043b18c8..eb7b53f3d 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -111,7 +111,7 @@ STRIPS = {*STRIPS_IOT, *STRIPS_SMART} DIMMERS_IOT = {"ES20M", "HS220", "KS220M", "KS230", "KP405"} -DIMMERS_SMART: Set[str] = set() +DIMMERS_SMART = {"S500D"} DIMMERS = { *DIMMERS_IOT, *DIMMERS_SMART, @@ -240,6 +240,9 @@ def parametrize(desc, devices, protocol_filter=None, ids=None): plug_smart = parametrize("plug devices smart", PLUGS_SMART, protocol_filter={"SMART"}) bulb_smart = parametrize("bulb devices smart", BULBS_SMART, protocol_filter={"SMART"}) +dimmers_smart = parametrize( + "dimmer devices smart", DIMMERS_SMART, protocol_filter={"SMART"} +) device_smart = parametrize( "devices smart", ALL_DEVICES_SMART, protocol_filter={"SMART"} ) @@ -300,6 +303,7 @@ def check_categories(): + lightstrip.args[1] + plug_smart.args[1] + bulb_smart.args[1] + + dimmers_smart.args[1] ) diff = set(SUPPORTED_DEVICES) - set(categorized_fixtures) if diff: @@ -331,6 +335,9 @@ def device_for_file(model, protocol): for d in BULBS_SMART: if d in model: return TapoBulb + for d in DIMMERS_SMART: + if d in model: + return TapoBulb else: for d in STRIPS_IOT: if d in model: diff --git a/kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json b/kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json new file mode 100644 index 000000000..a141e7003 --- /dev/null +++ b/kasa/tests/fixtures/smart/S500D(US)_1.0_1.0.5.json @@ -0,0 +1,317 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "brightness", + "ver_code": 1 + }, + { + "id": "preset", + "ver_code": 1 + }, + { + "id": "on_off_gradually", + "ver_code": 2 + }, + { + "id": "dimmer_calibration", + "ver_code": 1 + }, + { + "id": "overheat_protection", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S500D(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "48-22-54-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "avatar": "switch_s500d", + "brightness": 46, + "default_states": { + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.5 Build 221014 Rel.112003", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "48-22-54-00-00-00", + "model": "S500D", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheat_status": "normal", + "region": "Pacific/Honolulu", + "rssi": -31, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706136515 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_inherit_info": null, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 230906 Rel.141935", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-10-07", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced local communication security.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 426, + "night_mode_type": "sunrise_sunset", + "start_time": 1093, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_on_off_gradually_info": { + "off_state": { + "duration": 2, + "enable": true + }, + "on_state": { + "duration": 3, + "enable": true + } + }, + "get_preset_rules": { + "brightness": [ + 100, + 75, + 50, + 25, + 1 + ] + }, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "S500D", + "device_type": "SMART.TAPOSWITCH" + } + } +} From 716b1f82d9ffd6e288d8522c3eaf20eba767983a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 24 Jan 2024 22:07:01 -1000 Subject: [PATCH 34/35] Add support for the S500 (#705) * Add support for the S500D * tweak * Add S505 --- README.md | 1 + kasa/tests/conftest.py | 2 +- .../fixtures/smart/S505(US)_1.0_1.0.2.json | 309 ++++++++++++++++++ 3 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json diff --git a/README.md b/README.md index 0300677f9..42b1c99d1 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,7 @@ At the moment, the following devices have been confirmed to work: #### Wall switches * Tapo S500D +* Tapo S505 ### Newer Kasa branded devices diff --git a/kasa/tests/conftest.py b/kasa/tests/conftest.py index eb7b53f3d..9b5731866 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -101,7 +101,7 @@ } # P135 supports dimming, but its not currently support # by the library -PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135"} +PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135", "S505"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, diff --git a/kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json b/kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json new file mode 100644 index 000000000..c9c63cd7f --- /dev/null +++ b/kasa/tests/fixtures/smart/S505(US)_1.0_1.0.2.json @@ -0,0 +1,309 @@ +{ + "component_nego": { + "component_list": [ + { + "id": "device", + "ver_code": 2 + }, + { + "id": "firmware", + "ver_code": 2 + }, + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "time", + "ver_code": 1 + }, + { + "id": "wireless", + "ver_code": 1 + }, + { + "id": "schedule", + "ver_code": 2 + }, + { + "id": "countdown", + "ver_code": 2 + }, + { + "id": "antitheft", + "ver_code": 1 + }, + { + "id": "account", + "ver_code": 1 + }, + { + "id": "synchronize", + "ver_code": 1 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "led", + "ver_code": 1 + }, + { + "id": "cloud_connect", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "device_local_time", + "ver_code": 1 + }, + { + "id": "default_states", + "ver_code": 1 + }, + { + "id": "auto_off", + "ver_code": 2 + }, + { + "id": "matter", + "ver_code": 1 + }, + { + "id": "delay_action", + "ver_code": 1 + } + ] + }, + "discovery_result": { + "device_id": "00000000000000000000000000000000", + "device_model": "S505(US)", + "device_type": "SMART.TAPOSWITCH", + "factory_default": false, + "ip": "127.0.0.123", + "is_support_iot_cloud": true, + "mac": "3C-52-A1-00-00-00", + "mgt_encrypt_schm": { + "encrypt_type": "AES", + "http_port": 80, + "is_support_https": false, + "lv": 2 + }, + "obd_src": "tplink", + "owner": "00000000000000000000000000000000" + }, + "get_antitheft_rules": { + "antitheft_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_auto_off_config": { + "delay_min": 120, + "enable": false + }, + "get_auto_update_info": { + "enable": false, + "random_range": 120, + "time": 180 + }, + "get_connect_cloud_state": { + "status": 0 + }, + "get_countdown_rules": { + "countdown_rule_max_count": 1, + "enable": false, + "rule_list": [] + }, + "get_device_info": { + "auto_off_remain_time": 0, + "auto_off_status": "off", + "avatar": "switch_s500", + "default_states": { + "state": { + "on": false + }, + "type": "custom" + }, + "device_id": "0000000000000000000000000000000000000000", + "device_on": false, + "fw_id": "00000000000000000000000000000000", + "fw_ver": "1.0.2 Build 230313 Rel.101023", + "has_set_location_info": true, + "hw_id": "00000000000000000000000000000000", + "hw_ver": "1.0", + "ip": "127.0.0.123", + "lang": "en_US", + "latitude": 0, + "longitude": 0, + "mac": "3C-52-A1-00-00-00", + "model": "S505", + "nickname": "I01BU0tFRF9OQU1FIw==", + "oem_id": "00000000000000000000000000000000", + "on_time": 0, + "overheated": false, + "region": "Pacific/Honolulu", + "rssi": -37, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOSWITCH" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1706137970 + }, + "get_device_usage": { + "time_usage": { + "past30": 0, + "past7": 0, + "today": 0 + } + }, + "get_fw_download_state": { + "auto_upgrade": false, + "download_progress": 0, + "reboot_time": 5, + "status": 0, + "upgrade_time": 5 + }, + "get_latest_fw": { + "fw_size": 786432, + "fw_ver": "1.1.0 Build 231024 Rel.201030", + "hw_id": "00000000000000000000000000000000", + "need_to_upgrade": true, + "oem_id": "00000000000000000000000000000000", + "release_date": "2023-11-24", + "release_note": "Modifications and Bug Fixes:\n1. Enhanced stability and performance.\n2. Enhanced local communication security.\n3. Fixed some minor bugs.", + "type": 2 + }, + "get_led_info": { + "led_rule": "always", + "led_status": true, + "night_mode": { + "end_time": 426, + "night_mode_type": "sunrise_sunset", + "start_time": 1093, + "sunrise_offset": 0, + "sunset_offset": 0 + } + }, + "get_next_event": {}, + "get_schedule_rules": { + "enable": false, + "rule_list": [], + "schedule_rule_max_count": 32, + "start_index": 0, + "sum": 0 + }, + "get_wireless_scan_info": { + "ap_list": [ + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 3, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 2, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + }, + { + "bssid": "000000000000", + "channel": 0, + "cipher_type": 2, + "key_type": "wpa_psk", + "signal_level": 1, + "ssid": "I01BU0tFRF9TU0lEIw==" + } + ], + "wep_supported": false + }, + "qs_component_nego": { + "component_list": [ + { + "id": "quick_setup", + "ver_code": 3 + }, + { + "id": "sunrise_sunset", + "ver_code": 1 + }, + { + "id": "ble_whole_setup", + "ver_code": 1 + }, + { + "id": "matter", + "ver_code": 1 + }, + { + "id": "iot_cloud", + "ver_code": 1 + }, + { + "id": "inherit", + "ver_code": 1 + }, + { + "id": "firmware", + "ver_code": 2 + } + ], + "extra_info": { + "device_model": "S505", + "device_type": "SMART.TAPOSWITCH" + } + } +} From c01c3c679c681bafa0152e283ce61459fe1b0a21 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Thu, 25 Jan 2024 09:32:45 +0100 Subject: [PATCH 35/35] Prepare 0.6.1 (#709) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.1...0.6.1) Release highlights: * Support for tapo wall switches * Support for unprovisioned devices * Performance and stability improvements **Implemented enhancements:** - Add support for tapo wall switches \(S500D\) [\#704](https://github.com/python-kasa/python-kasa/pull/704) (@bdraco) - Add new cli command 'command' to execute arbitrary commands [\#692](https://github.com/python-kasa/python-kasa/pull/692) (@rytilahti) - Allow raw-command and wifi without update [\#688](https://github.com/python-kasa/python-kasa/pull/688) (@rytilahti) - Generate AES KeyPair lazily [\#687](https://github.com/python-kasa/python-kasa/pull/687) (@sdb9696) - Add reboot and factory\_reset to tapodevice [\#686](https://github.com/python-kasa/python-kasa/pull/686) (@rytilahti) - Try default tapo credentials for klap and aes [\#685](https://github.com/python-kasa/python-kasa/pull/685) (@sdb9696) - Sleep between discovery packets [\#656](https://github.com/python-kasa/python-kasa/pull/656) (@sdb9696) **Fixed bugs:** - Do not crash on missing geolocation [\#701](https://github.com/python-kasa/python-kasa/pull/701) (@rytilahti) - Fix P100 error getting conn closed when trying default login after login failure [\#690](https://github.com/python-kasa/python-kasa/pull/690) (@sdb9696) **Documentation updates:** - Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) - Document authenticated provisioning [\#634](https://github.com/python-kasa/python-kasa/pull/634) (@rytilahti) **Closed issues:** - Consider handshake as still valid on ServerDisconnectedError [\#676](https://github.com/python-kasa/python-kasa/issues/676) - AES Transport creates the key even if the device is offline [\#675](https://github.com/python-kasa/python-kasa/issues/675) - how to provision new Tapo plug devices? [\#565](https://github.com/python-kasa/python-kasa/issues/565) - Space out discovery requests [\#229](https://github.com/python-kasa/python-kasa/issues/229) **Merged pull requests:** - Add additional L900-10 fixture [\#707](https://github.com/python-kasa/python-kasa/pull/707) (@bdraco) - Replace rich formatting stripper [\#706](https://github.com/python-kasa/python-kasa/pull/706) (@bdraco) - Add support for the S500 [\#705](https://github.com/python-kasa/python-kasa/pull/705) (@bdraco) - Fix overly greedy \_strip\_rich\_formatting [\#703](https://github.com/python-kasa/python-kasa/pull/703) (@bdraco) - Ensure login token is only sent if aes state is ESTABLISHED [\#702](https://github.com/python-kasa/python-kasa/pull/702) (@bdraco) - Update readme fixture checker and readme [\#699](https://github.com/python-kasa/python-kasa/pull/699) (@rytilahti) - Fix test\_klapprotocol test duration [\#698](https://github.com/python-kasa/python-kasa/pull/698) (@sdb9696) - Renew the handshake session 20 minutes before we think it will expire [\#697](https://github.com/python-kasa/python-kasa/pull/697) (@bdraco) - Add --batch-size hint to timeout errors in dump\_devinfo [\#696](https://github.com/python-kasa/python-kasa/pull/696) (@sdb9696) - Add L930-5 fixture [\#694](https://github.com/python-kasa/python-kasa/pull/694) (@bdraco) - Add fixtures for L510E [\#693](https://github.com/python-kasa/python-kasa/pull/693) (@bdraco) - Refactor aestransport to use a state enum [\#691](https://github.com/python-kasa/python-kasa/pull/691) (@bdraco) - Update transport close/reset behaviour [\#689](https://github.com/python-kasa/python-kasa/pull/689) (@sdb9696) - Check README for supported models [\#684](https://github.com/python-kasa/python-kasa/pull/684) (@rytilahti) - Add P100 test fixture [\#683](https://github.com/python-kasa/python-kasa/pull/683) (@bdraco) - Make dump\_devinfo request batch size configurable [\#681](https://github.com/python-kasa/python-kasa/pull/681) (@sdb9696) - Add updated L920 fixture [\#680](https://github.com/python-kasa/python-kasa/pull/680) (@bdraco) - Update fixtures from test devices [\#679](https://github.com/python-kasa/python-kasa/pull/679) (@bdraco) - Show discovery data for state with verbose [\#678](https://github.com/python-kasa/python-kasa/pull/678) (@rytilahti) - Add L530E\(US\) fixture [\#674](https://github.com/python-kasa/python-kasa/pull/674) (@bdraco) - Add P135 fixture [\#673](https://github.com/python-kasa/python-kasa/pull/673) (@bdraco) - Rename base TPLinkProtocol to BaseProtocol [\#669](https://github.com/python-kasa/python-kasa/pull/669) (@sdb9696) - Add 1003 \(TRANSPORT\_UNKNOWN\_CREDENTIALS\_ERROR\) [\#667](https://github.com/python-kasa/python-kasa/pull/667) (@rytilahti) --- CHANGELOG.md | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de52be989..2a2fe8d4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,67 @@ # Changelog +## [0.6.1](https://github.com/python-kasa/python-kasa/tree/0.6.1) (2024-01-25) + +[Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0.1...0.6.1) + +Release highlights: +* Support for tapo wall switches +* Support for unprovisioned devices +* Performance and stability improvements + +**Implemented enhancements:** + +- Add support for tapo wall switches \(S500D\) [\#704](https://github.com/python-kasa/python-kasa/pull/704) (@bdraco) +- Add new cli command 'command' to execute arbitrary commands [\#692](https://github.com/python-kasa/python-kasa/pull/692) (@rytilahti) +- Allow raw-command and wifi without update [\#688](https://github.com/python-kasa/python-kasa/pull/688) (@rytilahti) +- Generate AES KeyPair lazily [\#687](https://github.com/python-kasa/python-kasa/pull/687) (@sdb9696) +- Add reboot and factory\_reset to tapodevice [\#686](https://github.com/python-kasa/python-kasa/pull/686) (@rytilahti) +- Try default tapo credentials for klap and aes [\#685](https://github.com/python-kasa/python-kasa/pull/685) (@sdb9696) +- Sleep between discovery packets [\#656](https://github.com/python-kasa/python-kasa/pull/656) (@sdb9696) + +**Fixed bugs:** + +- Do not crash on missing geolocation [\#701](https://github.com/python-kasa/python-kasa/pull/701) (@rytilahti) +- Fix P100 error getting conn closed when trying default login after login failure [\#690](https://github.com/python-kasa/python-kasa/pull/690) (@sdb9696) + +**Documentation updates:** + +- Add protocol and transport documentation [\#663](https://github.com/python-kasa/python-kasa/pull/663) (@sdb9696) +- Document authenticated provisioning [\#634](https://github.com/python-kasa/python-kasa/pull/634) (@rytilahti) + +**Closed issues:** + +- Consider handshake as still valid on ServerDisconnectedError [\#676](https://github.com/python-kasa/python-kasa/issues/676) +- AES Transport creates the key even if the device is offline [\#675](https://github.com/python-kasa/python-kasa/issues/675) +- how to provision new Tapo plug devices? [\#565](https://github.com/python-kasa/python-kasa/issues/565) +- Space out discovery requests [\#229](https://github.com/python-kasa/python-kasa/issues/229) + +**Merged pull requests:** + +- Add additional L900-10 fixture [\#707](https://github.com/python-kasa/python-kasa/pull/707) (@bdraco) +- Replace rich formatting stripper [\#706](https://github.com/python-kasa/python-kasa/pull/706) (@bdraco) +- Add support for the S500 [\#705](https://github.com/python-kasa/python-kasa/pull/705) (@bdraco) +- Fix overly greedy \_strip\_rich\_formatting [\#703](https://github.com/python-kasa/python-kasa/pull/703) (@bdraco) +- Ensure login token is only sent if aes state is ESTABLISHED [\#702](https://github.com/python-kasa/python-kasa/pull/702) (@bdraco) +- Update readme fixture checker and readme [\#699](https://github.com/python-kasa/python-kasa/pull/699) (@rytilahti) +- Fix test\_klapprotocol test duration [\#698](https://github.com/python-kasa/python-kasa/pull/698) (@sdb9696) +- Renew the handshake session 20 minutes before we think it will expire [\#697](https://github.com/python-kasa/python-kasa/pull/697) (@bdraco) +- Add --batch-size hint to timeout errors in dump\_devinfo [\#696](https://github.com/python-kasa/python-kasa/pull/696) (@sdb9696) +- Add L930-5 fixture [\#694](https://github.com/python-kasa/python-kasa/pull/694) (@bdraco) +- Add fixtures for L510E [\#693](https://github.com/python-kasa/python-kasa/pull/693) (@bdraco) +- Refactor aestransport to use a state enum [\#691](https://github.com/python-kasa/python-kasa/pull/691) (@bdraco) +- Update transport close/reset behaviour [\#689](https://github.com/python-kasa/python-kasa/pull/689) (@sdb9696) +- Check README for supported models [\#684](https://github.com/python-kasa/python-kasa/pull/684) (@rytilahti) +- Add P100 test fixture [\#683](https://github.com/python-kasa/python-kasa/pull/683) (@bdraco) +- Make dump\_devinfo request batch size configurable [\#681](https://github.com/python-kasa/python-kasa/pull/681) (@sdb9696) +- Add updated L920 fixture [\#680](https://github.com/python-kasa/python-kasa/pull/680) (@bdraco) +- Update fixtures from test devices [\#679](https://github.com/python-kasa/python-kasa/pull/679) (@bdraco) +- Show discovery data for state with verbose [\#678](https://github.com/python-kasa/python-kasa/pull/678) (@rytilahti) +- Add L530E\(US\) fixture [\#674](https://github.com/python-kasa/python-kasa/pull/674) (@bdraco) +- Add P135 fixture [\#673](https://github.com/python-kasa/python-kasa/pull/673) (@bdraco) +- Rename base TPLinkProtocol to BaseProtocol [\#669](https://github.com/python-kasa/python-kasa/pull/669) (@sdb9696) +- Add 1003 \(TRANSPORT\_UNKNOWN\_CREDENTIALS\_ERROR\) [\#667](https://github.com/python-kasa/python-kasa/pull/667) (@rytilahti) + ## [0.6.0.1](https://github.com/python-kasa/python-kasa/tree/0.6.0.1) (2024-01-21) [Full Changelog](https://github.com/python-kasa/python-kasa/compare/0.6.0...0.6.0.1) @@ -17,6 +79,7 @@ A patch release to improve the protocol handling. **Merged pull requests:** +- Release 0.6.0.1 [\#666](https://github.com/python-kasa/python-kasa/pull/666) (@rytilahti) - Add l900-5 1.1.0 fixture [\#664](https://github.com/python-kasa/python-kasa/pull/664) (@rytilahti) - Add fixtures with new MAC mask [\#661](https://github.com/python-kasa/python-kasa/pull/661) (@sdb9696) - Make close behaviour consistent across new protocols and transports [\#660](https://github.com/python-kasa/python-kasa/pull/660) (@sdb9696) diff --git a/pyproject.toml b/pyproject.toml index 206565559..f6092024a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "python-kasa" -version = "0.6.0.1" +version = "0.6.1" description = "Python API for TP-Link Kasa Smarthome devices" license = "GPL-3.0-or-later" authors = ["python-kasa developers"]