diff --git a/.gitignore b/.gitignore index 9da1e235..573a4c08 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ venv .venv /build +docs/build diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 603c7f48..b75cc85b 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -7,6 +7,11 @@ To see what is being sent to and received from the device, specify option ``--de To avoid discovering the devices when executing commands its type can be passed as an option (e.g., ``--type plug`` for plugs, ``--type bulb`` for bulbs, ..). If no type is manually given, its type will be discovered automatically which causes a short delay. +Note that the ``--type`` parameter only works for legacy devices using port 9999. + +To avoid discovering the devices for newer KASA or TAPO devices using port 20002 for discovery the ``--device-family``, ``-encrypt-type`` and optional +``-login-version`` options can be passed and the devices will probably require authentication via ``--username`` and ``--password``. +Refer to ``kasa --help`` for detailed usage. If no command is given, the ``state`` command will be executed to query the device state. @@ -20,7 +25,12 @@ Discovery ********* The tool can automatically discover supported devices using a broadcast-based discovery protocol. -This works by sending an UDP datagram on port 9999 to the broadcast address (defaulting to ``255.255.255.255``). +This works by sending an UDP datagram on ports 9999 and 20002 to the broadcast address (defaulting to ``255.255.255.255``). + +Newer devices that respond on port 20002 will require TP-Link cloud credentials to be passed (unless they have never been connected +to the TP-Link cloud) or they will report as having failed authentication when trying to query the device. +Use ``--username`` and ``--password`` options to specify credentials. +These values can also be set as environment variables via ``KASA_USERNAME`` and ``KASA_PASSWORD``. On multihomed systems, you can use ``--target`` option to specify the broadcast target. For example, if your devices reside in network ``10.0.0.0/24`` you can use ``kasa --target 10.0.0.255 discover`` to discover them. diff --git a/docs/source/conf.py b/docs/source/conf.py index cc7a725e..01724943 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,6 +58,7 @@ html_theme = "sphinx_rtd_theme" html_static_path = ["_static"] todo_include_todos = True +myst_heading_anchors = 3 def setup(app): diff --git a/docs/source/deviceconfig.rst b/docs/source/deviceconfig.rst deleted file mode 100644 index 25bf077b..00000000 --- a/docs/source/deviceconfig.rst +++ /dev/null @@ -1,18 +0,0 @@ -DeviceConfig -============ - -.. contents:: Contents - :local: - -.. note:: - - Feel free to open a pull request to improve the documentation! - - -API documentation -***************** - -.. autoclass:: kasa.DeviceConfig - :members: - :inherited-members: - :undoc-members: diff --git a/docs/source/discover.rst b/docs/source/discover.rst index 87b14ee7..b89178a3 100644 --- a/docs/source/discover.rst +++ b/docs/source/discover.rst @@ -1,9 +1,59 @@ +.. py:module:: kasa.discover + Discovering devices =================== .. contents:: Contents :local: +Discovery +********* + +Discovery works by sending broadcast UDP packets to two known TP-link discovery ports, 9999 and 20002. +Port 9999 is used for legacy devices that do not use strong encryption and 20002 is for newer devices that use different +levels of encryption. +If a device uses port 20002 for discovery you will obtain some basic information from the device via discovery, but you +will need to await :func:`SmartDevice.update() ` to get full device information. +Credentials will most likely be required for port 20002 devices although if the device has never been connected to the tplink +cloud it may work without credentials. + +To query or update the device requires authentication via :class:`Credentials ` and if this is invalid or not provided it +will raise an :class:`AuthenticationException `. + +If discovery encounters an unsupported device when calling via :meth:`Discover.discover_single() ` +it will raise a :class:`UnsupportedDeviceException `. +If discovery encounters a device when calling :meth:`Discover.discover() `, +you can provide a callback to the ``on_unsupported`` parameter +to handle these. + +Example: + +.. code-block:: python + + import asyncio + from kasa import Discover, Credentials + + async def main(): + device = await Discover.discover_single( + "127.0.0.1", + credentials=Credentials("myusername", "mypassword"), + discovery_timeout=10 + ) + + await device.update() # Request the update + print(device.alias) # Print out the alias + + devices = await Discover.discover( + credentials=Credentials("myusername", "mypassword"), + discovery_timeout=10 + ) + for ip, device in devices.items(): + await device.update() + print(device.alias) + + if __name__ == "__main__": + asyncio.run(main()) + API documentation ***************** diff --git a/docs/source/index.rst b/docs/source/index.rst index 16e7cbd0..346c53d0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -15,4 +15,3 @@ smartdimmer smartstrip smartlightstrip - deviceconfig diff --git a/docs/source/smartbulb.rst b/docs/source/smartbulb.rst index 5f8fd7ee..aa0e27e5 100644 --- a/docs/source/smartbulb.rst +++ b/docs/source/smartbulb.rst @@ -67,7 +67,7 @@ API documentation :members: :undoc-members: -.. autoclass:: kasa.BehaviorMode +.. autoclass:: kasa.smartbulb.BehaviorMode :members: .. autoclass:: kasa.TurnOnBehaviors diff --git a/docs/source/smartdevice.rst b/docs/source/smartdevice.rst index d8ef58b2..2a29a8d9 100644 --- a/docs/source/smartdevice.rst +++ b/docs/source/smartdevice.rst @@ -26,7 +26,7 @@ These methods will return the device response, which can be useful for some use Errors are raised as :class:`SmartDeviceException` instances for the library user to handle. -Simple example script showing some functionality: +Simple example script showing some functionality for legacy devices: .. code-block:: python @@ -45,6 +45,31 @@ Simple example script showing some functionality: if __name__ == "__main__": asyncio.run(main()) +If you are connecting to a newer KASA or TAPO device you can get the device via discovery or +connect directly with :class:`DeviceConfig`: + +.. code-block:: python + + import asyncio + from kasa import Discover, Credentials + + async def main(): + device = await Discover.discover_single( + "127.0.0.1", + credentials=Credentials("myusername", "mypassword"), + discovery_timeout=10 + ) + + config = device.config # DeviceConfig.to_dict() can be used to store for later + + # To connect directly later without discovery + + later_device = await SmartDevice.connect(config=config) + + await later_device.update() + + print(later_device.alias) # Print out the alias + If you want to perform updates in a loop, you need to make sure that the device accesses are done in the same event loop: .. code-block:: python @@ -67,6 +92,22 @@ Refer to device type specific classes for more examples: :class:`SmartPlug`, :class:`SmartBulb`, :class:`SmartStrip`, :class:`SmartDimmer`, :class:`SmartLightStrip`. +DeviceConfig class +****************** + +The :class:`DeviceConfig` class can be used to initialise devices with parameters to allow them to be connected to without using +discovery. +This is required for newer KASA and TAPO devices that use different protocols for communication and will not respond +on port 9999 but instead use different encryption protocols over http port 80. +Currently there are three known types of encryption for TP-Link devices and two different protocols. +Devices with automatic firmware updates enabled may update to newer versions of the encryption without separate notice, +so discovery can be helpful to determine the correct config. + +To connect directly pass a :class:`DeviceConfig` object to :meth:`SmartDevice.connect()`. + +A :class:`DeviceConfig` can be constucted manually if you know the :attr:`DeviceConfig.connection_type` values for the device or +alternatively the config can be retrieved from :attr:`SmartDevice.config` post discovery and then re-used. + Energy Consumption and Usage Statistics *************************************** @@ -103,3 +144,25 @@ API documentation .. autoclass:: SmartDevice :members: :undoc-members: + +.. autoclass:: DeviceConfig + :members: + :inherited-members: + :undoc-members: + :member-order: bysource + +.. autoclass:: Credentials + :members: + :undoc-members: + +.. autoclass:: SmartDeviceException + :members: + :undoc-members: + +.. autoclass:: AuthenticationException + :members: + :undoc-members: + +.. autoclass:: UnsupportedDeviceException + :members: + :undoc-members: diff --git a/kasa/deviceconfig.py b/kasa/deviceconfig.py index 5235868f..f317ccb1 100644 --- a/kasa/deviceconfig.py +++ b/kasa/deviceconfig.py @@ -126,20 +126,30 @@ class DeviceConfig: """Class to represent paramaters that determine how to connect to devices.""" DEFAULT_TIMEOUT = 5 - + #: IP address or hostname host: str + #: Timeout for querying the device timeout: Optional[int] = DEFAULT_TIMEOUT + #: Override the default 9999 port to support port forwarding port_override: Optional[int] = None + #: Credentials for devices requiring authentication credentials: Optional[Credentials] = None + #: Credentials hash for devices requiring authentication. + #: If credentials are also supplied they take precendence over credentials_hash. + #: Credentials hash can be retrieved from :attr:`SmartDevice.credentials_hash` credentials_hash: Optional[str] = None + #: The protocol specific type of connection. Defaults to the legacy type. connection_type: ConnectionType = field( default_factory=lambda: ConnectionType( DeviceFamilyType.IotSmartPlugSwitch, EncryptType.Xor, 1 ) ) - + #: True if the device uses http. Consumers should retrieve rather than set this + #: in order to determine whether they should pass a custom http client if desired. uses_http: bool = False + # compare=False will be excluded from the serialization and object comparison. + #: Set a custom http_client for the device to use. http_client: Optional[httpx.AsyncClient] = field(default=None, compare=False) def __post_init__(self): @@ -154,7 +164,7 @@ class DeviceConfig: credentials_hash: Optional[str] = None, exclude_credentials: bool = False, ) -> Dict[str, Dict[str, str]]: - """Convert connection params to dict.""" + """Convert device config to dict.""" if credentials_hash or exclude_credentials: self.credentials = None if credentials_hash: @@ -163,5 +173,5 @@ class DeviceConfig: @staticmethod def from_dict(cparam_dict: Dict[str, Dict[str, str]]) -> "DeviceConfig": - """Return connection parameters from dict.""" + """Return device config from dict.""" return _dataclass_from_dict(DeviceConfig, cparam_dict) diff --git a/kasa/discover.py b/kasa/discover.py index 8fbd6ff0..9bdae46b 100755 --- a/kasa/discover.py +++ b/kasa/discover.py @@ -171,10 +171,15 @@ class Discover: device object. :func:`discover_single()` can be used to initialize a single device given its - IP address. If the type of the device and its IP address is already known, - you can initialize the corresponding device class directly without this. + IP address. If the :class:`DeviceConfig` of the device is already known, + you can initialize the corresponding device class directly without discovery. - The protocol uses UDP broadcast datagrams on port 9999 for discovery. + The protocol uses UDP broadcast datagrams on port 9999 and 20002 for discovery. + Legacy devices support discovery on port 9999 and newer devices on 20002. + + Newer devices that respond on port 20002 will most likely require TP-Link cloud + credentials to be passed if queries or updates are to be performed on the returned + devices. Examples: Discovery returns a list of discovered devices: @@ -222,7 +227,8 @@ class Discover: ) -> DeviceDict: """Discover supported devices. - Sends discovery message to 255.255.255.255:9999 in order + Sends discovery message to 255.255.255.255:9999 and + 255.255.255.255:20002 in order to detect available supported devices in the local network, and waits for given timeout for answers from devices. If you have multiple interfaces, @@ -239,9 +245,13 @@ class Discover: :param target: The target address where to send the broadcast discovery queries if multi-homing (e.g. 192.168.xxx.255). :param on_discovered: coroutine to execute on discovery - :param timeout: How long to wait for responses, defaults to 5 + :param discovery_timeout: Seconds to wait for responses, defaults to 5 :param discovery_packets: Number of discovery packets to broadcast :param interface: Bind to specific interface + :param on_unsupported: Optional callback when unsupported devices are discovered + :param credentials: Credentials for devices requiring authentication + :param port: Override the discovery port for devices listening on 9999 + :param timeout: Query timeout in seconds for devices returned by discovery :return: dictionary with discovered devices """ loop = asyncio.get_event_loop() @@ -282,13 +292,14 @@ class Discover: """Discover a single device by the given IP address. It is generally preferred to avoid :func:`discover_single()` and - use :func:`connect_single()` instead as it should perform better when + use :meth:`SmartDevice.connect()` instead as it should perform better when the WiFi network is congested or the device is not responding to discovery requests. :param host: Hostname of device to query - :param port: Optionally set a different port for the device - :param timeout: Timeout for discovery + :param discovery_timeout: Timeout in seconds for discovery + :param port: Optionally set a different port for legacy devices using port 9999 + :param timeout: Timeout in seconds device for devices queries :param credentials: Credentials for devices that require authentication :rtype: SmartDevice :return: Object for querying/controlling found device. diff --git a/kasa/smartdevice.py b/kasa/smartdevice.py index 912f7cd9..144c2894 100755 --- a/kasa/smartdevice.py +++ b/kasa/smartdevice.py @@ -247,7 +247,7 @@ class SmartDevice: @property def credentials_hash(self) -> Optional[str]: - """Return the connection parameters the device is using.""" + """The protocol specific hash of the credentials the device is using.""" return self.protocol._transport.credentials_hash def add_module(self, name: str, module: Module): @@ -804,7 +804,7 @@ class SmartDevice: @property def config(self) -> DeviceConfig: - """Return the connection parameters the device is using.""" + """Return the device configuration.""" return self.protocol.config @staticmethod