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/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/README.md b/README.md index fdc9a4b83..42b1c99d1 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,37 @@ 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 P125M +* Tapo P135 (dimming not yet supported) + +#### Bulbs + +* Tapo L510B +* Tapo L510E +* Tapo L530E + +#### Light strips + +* Tapo L900-5 +* Tapo L900-10 +* Tapo L920-5 +* Tapo L930-5 + +#### Wall switches + +* Tapo S500D +* Tapo S505 + +### 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 +311,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 @@ -320,7 +342,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 1f55eea87..88663621a 100644 --- a/devtools/check_readme_vs_fixtures.py +++ b/devtools/check_readme_vs_fixtures.py @@ -1,4 +1,7 @@ """Script that checks if README.md is missing devices that have fixtures.""" +import re +import sys + from kasa.tests.conftest import ( ALL_DEVICES, BULBS, @@ -28,6 +31,13 @@ def _get_device_type(dev, typemap): return "Unknown type" +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) diff --git a/devtools/dump_devinfo.py b/devtools/dump_devinfo.py index 85ad01502..e9ec56b7b 100644 --- a/devtools/dump_devinfo.py +++ b/devtools/dump_devinfo.py @@ -15,12 +15,19 @@ 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 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 @@ -106,10 +113,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 +163,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 +179,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 +188,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): @@ -224,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 @@ -251,39 +257,63 @@ 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], 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 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) -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 +344,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")) @@ -352,12 +382,8 @@ async def get_smart_fixture(device: TapoDevice): 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: @@ -383,7 +409,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. 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`` *************** 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/__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/aestransport.py b/kasa/aestransport.py index 65b0045df..4e1ccb7d6 100644 --- a/kasa/aestransport.py +++ b/kasa/aestransport.py @@ -8,7 +8,8 @@ import hashlib import logging import time -from typing import 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 @@ -30,17 +31,29 @@ 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__) +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) 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. @@ -50,11 +63,14 @@ class AesTransport(BaseTransport): DEFAULT_PORT: int = 80 SESSION_COOKIE_NAME = "TP_SESSIONID" + TIMEOUT_COOKIE_NAME = "TIMEOUT" COMMON_HEADERS = { "Content-Type": "application/json", "requestByApp": "true", "Accept": "application/json", } + CONTENT_LENGTH = "Content-Length" + KEY_PAIR_CONTENT_LENGTH = 314 def __init__( self, @@ -69,27 +85,29 @@ 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 + 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 @@ -98,29 +116,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): + 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) + 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: 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(self._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() - ).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): + 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 @@ -130,15 +144,14 @@ 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: + 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 @@ -160,23 +173,50 @@ 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.""" - self._login_token = None + try: + await self.try_login(self._login_params) + except AuthenticationException as aex: + try: + if aex.error_code is not 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, + ) + 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: Dict[str, Any]) -> None: + """Try to login with supplied login_params.""" login_request = { "method": "login_device", - "params": self._login_params, + "params": login_params, "request_time_milis": round(time.time() * 1000), } request = json_dumps(login_request) @@ -184,39 +224,52 @@ async def perform_login(self): 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 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() - status_code, resp_dict = await self._http_client.post( + async def perform_handshake(self) -> None: + """Perform the handshake.""" + _LOGGER.debug("Will perform handshaking...") + + self._key_pair = None + self._login_token = None + 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), + } + http_client = self._http_client + + status_code, resp_dict = await http_client.post( url, - json=request_body, - headers=self.COMMON_HEADERS, + json=self._generate_key_pair_payload(), + headers=headers, 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( @@ -226,25 +279,32 @@ 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 ( - cookie := self._http_client.get_cookie( # type: ignore - self.SESSION_COOKIE_NAME - ) + cookie := http_client.get_cookie(self.SESSION_COOKIE_NAME) # type: ignore ) 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( - handshake_key, key_pair + handshake_key, self._key_pair ) - self._handshake_done = True + self._state = TransportState.LOGIN_REQUIRED _LOGGER.debug("Handshake with %s complete", self._host) @@ -255,22 +315,32 @@ 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: - await self.perform_login() + 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._state = TransportState.HANDSHAKE_REQUIRED + raise ex 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. - """ - self._handshake_done = False - self._login_token = None + async def reset(self) -> None: + """Reset internal handshake and login state.""" + self._state = TransportState.HANDSHAKE_REQUIRED class AesEncyptionSession: diff --git a/kasa/cli.py b/kasa/cli.py index 282273162..42b13b9bb 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 @@ -36,6 +37,11 @@ try: from rich import print as _do_echo except ImportError: + # 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.""" @@ -43,7 +49,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 = rich_formatting.sub("", message) echo_func(message, *args, **kwargs) return wrapper @@ -69,6 +75,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) @@ -317,7 +326,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 +347,10 @@ def _nop_echo(*args, **kwargs): port=port, credentials=credentials, ) + + # 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 @@ -390,7 +402,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 +440,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 +481,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 +569,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 @@ -593,13 +602,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/device_factory.py b/kasa/device_factory.py index 757f0c337..d216e0ef9 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, ) @@ -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, @@ -141,14 +142,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/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/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/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 = [ diff --git a/kasa/httpclient.py b/kasa/httpclient.py index a4bd84a33..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 @@ -41,14 +45,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,12 +77,12 @@ 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: 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 9f72bbc0a..ed926101c 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 @@ -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/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/klaptransport.py b/kasa/klaptransport.py index 92d6fd2b3..cd0e3de6b 100644 --- a/kasa/klaptransport.py +++ b/kasa/klaptransport.py @@ -58,11 +58,15 @@ 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__) +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) @@ -85,10 +89,8 @@ 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" + TIMEOUT_COOKIE_NAME = "TIMEOUT" def __init__( self, @@ -108,7 +110,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 +185,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() @@ -274,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 ) @@ -351,7 +357,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/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. diff --git a/kasa/protocol.py b/kasa/protocol.py index 74023e017..ae8eb89b1 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 @@ -78,8 +80,12 @@ 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 TPLinkProtocol(ABC): +class BaseProtocol(ABC): """Base class for all TP-Link Smart Home communication.""" def __init__( @@ -137,10 +143,13 @@ 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(TPLinkProtocol): + +class TPLinkSmartHomeProtocol(BaseProtocol): """Implementation of the TP-Link Smart Home protocol.""" INITIALIZATION_VECTOR = 171 @@ -168,7 +177,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 """ @@ -231,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.""" @@ -250,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:" @@ -263,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( @@ -288,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( @@ -310,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: @@ -320,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]: @@ -361,6 +369,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/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..31418afcc 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)) @@ -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/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..6f0648ea0 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 @@ -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/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..86967b69d 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)), @@ -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 @@ -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/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/conftest.py b/kasa/tests/conftest.py index 4aae40356..9b5731866 100644 --- a/kasa/tests/conftest.py +++ b/kasa/tests/conftest.py @@ -15,6 +15,7 @@ Credentials, Discover, SmartBulb, + SmartDevice, SmartDimmer, SmartLightStrip, SmartPlug, @@ -43,10 +44,10 @@ 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"} +BULBS_SMART_DIMMABLE = {"KS225", "L510B", "L510E"} BULBS_SMART = ( BULBS_SMART_VARIABLE_TEMP.union(BULBS_SMART_COLOR) .union(BULBS_SMART_DIMMABLE) @@ -98,7 +99,9 @@ "KP401", "KS200M", } -PLUGS_SMART = {"P110", "KP125M", "EP25", "KS205", "P125M"} +# P135 supports dimming, but its not currently support +# by the library +PLUGS_SMART = {"P100", "P110", "KP125M", "EP25", "KS205", "P125M", "P135", "S505"} PLUGS = { *PLUGS_IOT, *PLUGS_SMART, @@ -108,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, @@ -237,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"} ) @@ -297,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: @@ -328,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: @@ -414,9 +424,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/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/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 + } + } +} 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..6dac10489 --- /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": 0, + "color_temp_range": [ + 2500, + 6500 + ], + "default_states": { + "re_power_type": "always_on", + "state": { + "brightness": 100, + "color_temp": 0, + "hue": 12, + "saturation": 45 + }, + "type": "last_states" + }, + "device_id": "0000000000000000000000000000000000000000", + "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": 12, + "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": -41, + "saturation": 45, + "signal_level": 3, + "specs": "", + "ssid": "I01BU0tFRF9TU0lEIw==", + "time_diff": -600, + "type": "SMART.TAPOBULB" + }, + "get_device_time": { + "region": "Pacific/Honolulu", + "time_diff": -600, + "timestamp": 1705991895 + }, + "get_device_usage": { + "power_usage": { + "past30": 2, + "past7": 2, + "today": 2 + }, + "saved_power": { + "past30": 8, + "past7": 8, + "today": 8 + }, + "time_usage": { + "past30": 10, + "past7": 10, + "today": 10 + } + }, + "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" + } + } +} 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" + } + } +} 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 + } + } +} 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 + } + } +} 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" + } + } +} 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 } 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" + } + } +} 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" + } + } +} 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" + } + } +} 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 774aaf943..9fe5cabd4 100644 --- a/kasa/tests/test_aestransport.py +++ b/kasa/tests/test_aestransport.py @@ -1,21 +1,25 @@ 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 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 ( SMART_RETRYABLE_ERRORS, SMART_TIMEOUT_ERRORS, + AuthenticationException, SmartDeviceException, SmartErrorCode, ) @@ -65,11 +69,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 @@ -81,7 +85,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 @@ -91,6 +95,64 @@ 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, + ), + ( + [SmartErrorCode.LOGIN_ERROR, SmartErrorCode.SESSION_TIMEOUT_ERROR], + pytest.raises(SmartDeviceException), + 3, + ), + ], + 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" + 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._state = TransportState.LOGIN_REQUIRED + 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 + await transport.close() + + @status_parameters async def test_send(mocker, status_code, error_code, inner_error_code, expectation): host = "127.0.0.1" @@ -160,19 +222,30 @@ 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 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 + self.token = "".join(random.choices(string.ascii_uppercase, k=32)) # noqa: S311 + + @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, *_, **__): + 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): + 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": @@ -183,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] @@ -196,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) @@ -213,10 +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) diff --git a/kasa/tests/test_cli.py b/kasa/tests/test_cli.py index b1db15e19..fa2d5c69e 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, @@ -33,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() @@ -85,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): @@ -95,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 @@ -103,6 +130,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() @@ -113,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 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_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 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 8ae32e3f7..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 @@ -28,6 +29,7 @@ KlapTransportV2, _sha256, ) +from ..protocol import DEFAULT_CREDENTIALS, get_default_credentials from ..smartprotocol import SmartProtocol DUMMY_QUERY = {"foobar": {"foo": "bar", "bar": "foo"}} @@ -53,9 +55,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]) @@ -65,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): @@ -93,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): @@ -115,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( @@ -241,10 +247,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(), ), ( 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()) diff --git a/pyproject.toml b/pyproject.toml index 6bd81a900..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"] @@ -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]